この記事で学ぶこと
#4 でGoからDBに接続してデータを取得(SELECT)しました。今回は データを書き換える(UPDATE)・消す(DELETE) を実装します。
読むだけなら事故は起きませんが、書き換えは違います。一行間違えると本番データが全部壊れます。 この記事では書き方だけでなく「どうやって事故を防ぐか」まで扱います。
この記事を終えると以下ができるようになります。
-
QueryとExecを正しく使い分けられる - 「本当に更新できたか」を
RowsAffectedで確認できる - プレースホルダでSQLインジェクションを防げる
-
WHERE忘れという典型的な事故を理解し、防げる - 物理削除と論理削除を使い分けられる
この回はGo編です。Pythonで進めている方は「#10 - PythonでUPDATE/DELETEを実装する」をご覧ください。
準備
前回までに使ったPostgreSQLコンテナを起動します。
docker start pg-practice
docker exec -it pg-practice psql -U postgres -d testdb
練習用のテーブルを用意します。
DROP TABLE IF EXISTS users;
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL
);
INSERT INTO users (name, email) VALUES
('田中', 'tanaka@example.com'),
('鈴木', 'suzuki@example.com'),
('佐藤', 'sato@example.com');
確認します。
SELECT * FROM users;
id | name | email
----+------+--------------------
1 | 田中 | tanaka@example.com
2 | 鈴木 | suzuki@example.com
3 | 佐藤 | sato@example.com
\q でpsqlを抜けます。
Query と Exec の違い
Goの database/sql には、SQLを実行するメソッドが2種類あります。ここを間違えると動きません。
| メソッド | 使う場面 | 返ってくるもの |
|---|---|---|
Query / QueryRow
|
SELECT | 行データ(*sql.Rows) |
Exec |
INSERT / UPDATE / DELETE | 実行結果(sql.Result) |
判断基準はひとつだけです。「行が返ってくるか、返ってこないか」。
SELECTは「データをください」なので行が返ります。だから Query。
UPDATEやDELETEは「書き換えておいて」なので行は返りません。返るのは「何件処理したか」といった結果情報だけです。だから Exec。
// SELECT → Query
rows, err := db.Query("SELECT id, name FROM users")
// UPDATE → Exec
result, err := db.Exec("UPDATE users SET email = $1 WHERE id = $2", newEmail, id)
【手を動かす①】UPDATE を実装する
main.go を作ります。
package main
import (
"database/sql"
"fmt"
"log"
_ "github.com/lib/pq"
)
func main() {
db, err := sql.Open("postgres",
"host=localhost port=5432 user=postgres password=postgres dbname=testdb sslmode=disable")
if err != nil {
log.Fatal("接続設定に失敗:", err)
}
defer db.Close()
// 実際に通信して疎通を確認する
if err := db.Ping(); err != nil {
log.Fatal("DBに到達できません:", err)
}
// UPDATE を実行する
result, err := db.Exec(
"UPDATE users SET email = $1 WHERE name = $2",
"tanaka-new@example.com", "田中",
)
if err != nil {
log.Fatal("UPDATE失敗:", err)
}
// 何件更新されたかを取得する
affected, err := result.RowsAffected()
if err != nil {
log.Fatal("件数取得に失敗:", err)
}
fmt.Printf("%d件更新しました\n", affected)
}
実行します。
go mod init update-practice
go get github.com/lib/pq
go run main.go
1件更新しました
psqlで確認すると、田中さんのemailが変わっています。
$1 $2 は何か
プレースホルダといいます。「ここに値が入ります」という穴です。実際の値は Exec の第2引数以降で渡します。
db.Exec("UPDATE users SET email = $1 WHERE name = $2", "新しいアドレス", "田中")
// ↑1つ目 ↑2つ目 ↑ここが$1 ↑ここが$2
PostgreSQLは $1, $2, ... という番号形式です。MySQLでは ? を使います。ドライバによって違うので、使うDBに合わせます。
なぜ文字列で組み立ててはいけないのかは後半で扱います。ここが今回いちばん重要な話です。
sql.Result と RowsAffected
Exec が返す sql.Result から、何件の行が影響を受けたかを取れます。
affected, err := result.RowsAffected()
これは飾りではありません。極めて重要な安全確認です。
「該当0件」はエラーにならない
ここが最大の落とし穴です。次のコードを見てください。
result, err := db.Exec(
"UPDATE users SET email = $1 WHERE name = $2",
"new@example.com", "存在しない人",
)
if err != nil {
log.Fatal(err) // ← ここには入らない
}
fmt.Println("更新完了") // ← こっちが実行される
存在しない人 というユーザーはいません。しかし err は nil のままで、「更新完了」と表示されます。
実際に確かめた結果がこちらです。
=== 該当行がある UPDATE ===
影響を受けた行数: 1
=== 該当行が無い UPDATE ===
影響を受けた行数: 0 ← エラーではなく0件
SQLとしては「条件に合う行を探した。0件だった。以上」で、正常終了です。文法エラーでも接続エラーでもないので、DBは何も問題を報告しません。
つまり err == nil を確認しただけでは、「更新できたつもり」のまま処理が進みます。 ユーザーには「保存しました」と表示されるのに、実際には何も変わっていない。この手のバグは動作確認をすり抜けやすく、本番で発覚します。
正しい書き方
result, err := db.Exec(
"UPDATE users SET email = $1 WHERE id = $2",
newEmail, userID,
)
if err != nil {
return fmt.Errorf("更新処理に失敗しました: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("更新件数の取得に失敗しました: %w", err)
}
if affected == 0 {
// 対象が存在しなかった。呼び出し元が「見つからない」と判断できるようにする
return fmt.Errorf("id=%d のユーザーが見つかりません", userID)
}
err のチェックと RowsAffected のチェックは別物です。両方必要です。
-
err != nil… SQLが実行できなかった(構文エラー、接続断など) -
affected == 0… SQLは実行できたが対象がいなかった
APIを作るなら、前者は500、後者は404に対応します。
【手を動かす②】DELETE を実装する
DELETEも構造は同じです。Exec を使い、RowsAffected で確認します。
result, err := db.Exec("DELETE FROM users WHERE name = $1", "佐藤")
if err != nil {
log.Fatal("DELETE失敗:", err)
}
affected, _ := result.RowsAffected()
if affected == 0 {
fmt.Println("削除対象が見つかりませんでした")
} else {
fmt.Printf("%d件削除しました\n", affected)
}
DELETEも同じく、存在しない行を消そうとしてもエラーになりません。0件が返るだけです。
プレースホルダとSQLインジェクション
先ほど「文字列で組み立ててはいけない」と書きました。理由を説明します。
やってはいけない書き方
// 絶対にダメ
query := "DELETE FROM users WHERE name = '" + userInput + "'"
db.Exec(query)
一見動きます。userInput が 佐藤 なら、こうなります。
DELETE FROM users WHERE name = '佐藤'
問題は、悪意のある入力が来たときです。userInput に次の文字列を入れられたとします。
' OR '1'='1
組み立てられるSQLはこうなります。
DELETE FROM users WHERE name = '' OR '1'='1'
'1'='1' は常に真です。つまり 全ユーザーが削除されます。
これが SQLインジェクション です。攻撃者が入力欄を通じて、SQLの構造そのものを書き換えてしまう攻撃です。
プレースホルダなら防げる
db.Exec("DELETE FROM users WHERE name = $1", userInput)
この形なら、userInput に何が入っていても値としてしか扱われません。' OR '1'='1 という入力が来たら、「' OR '1'='1 という名前のユーザー」を探しにいきます。当然そんな人はいないので0件です。
なぜ安全なのか:プレースホルダを使うと、SQL文の構造とデータが別々にDBへ送られます。DBは先に「この構造のSQLを実行する」と決めてから値を当てはめるので、値の中身がSQLの構造を変えることが原理的にできません。
入力値をSQLに埋め込むときは、必ずプレースホルダを使う。 例外はありません。
WHERE 忘れという事故
もうひとつ、初心者もベテランもやる事故があります。
UPDATE users SET email = 'test@example.com';
WHERE がありません。この場合 全行が書き換わります。
実際に試した結果です。
=== WHERE を忘れた UPDATE ===
影響を受けた行数: 3 ← 全件
('田中', 'DANGER@example.com')
('鈴木', 'DANGER@example.com')
('佐藤', 'DANGER@example.com')
DELETE FROM users; なら全件削除です。本番でこれをやると復旧作業になります。
防ぎ方
① 先にSELECTで確認する
UPDATEやDELETEを書いたら、まず WHERE 以降をそのままSELECTに置き換えて実行します。
-- まずこれを実行して、対象を目で見る
SELECT * FROM users WHERE name = '田中';
-- 意図通りなら、同じ条件でUPDATEする
UPDATE users SET email = 'new@example.com' WHERE name = '田中';
② トランザクションで囲む
#9 で学んだトランザクションを使うと、確認してから確定できます。
BEGIN;
UPDATE users SET email = 'new@example.com' WHERE name = '田中';
SELECT * FROM users; -- 結果を確認する
-- 問題なければ COMMIT; おかしければ ROLLBACK;
③ Goのコードでは RowsAffected を必ず見る
想定より多い件数が返ってきたら、条件が間違っている可能性があります。
if affected > 1 {
// 1件のはずが複数件更新された = 条件がおかしい
return fmt.Errorf("想定外の更新件数です: %d件", affected)
}
物理削除と論理削除
DELETE は行を本当に消します。これを 物理削除 といいます。
しかし業務システムでは、消したデータを後から参照したい場面が頻繁にあります。
- 「退会したユーザーの過去の注文履歴を見たい」
- 「誤って削除したので戻したい」
- 「監査で削除の記録を求められた」
物理削除するとこれらに対応できません。そこで 論理削除 を使います。
論理削除の実装
「消したフラグ」を立てるだけで、行自体は残します。
ALTER TABLE users ADD COLUMN deleted_at TIMESTAMP DEFAULT NULL;
削除は「消す」ではなく「日時を入れる」になります。
// 削除(実際はUPDATE)
result, err := db.Exec(
"UPDATE users SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL",
userID,
)
そして取得時に必ず除外します。
rows, err := db.Query("SELECT id, name FROM users WHERE deleted_at IS NULL")
| 物理削除 | 論理削除 | |
|---|---|---|
| SQL | DELETE |
UPDATE で deleted_at を設定 |
| 復旧 | できない |
deleted_at を NULL に戻すだけ |
| 履歴 | 消える | 残る |
| 取得時 | 通常通り | 必ず WHERE deleted_at IS NULL が必要 |
使い分けの基準:後から「なぜ消したのか」「誰が消したのか」を説明する必要があるデータは論理削除にします。金銭や人の判断が絡むものはほぼ論理削除です。
論理削除の弱点は、取得時の除外条件を書き忘れると削除済みデータが見えてしまうことです。これはアプリ全体で徹底する必要があります。
確認テスト
知識問題
Q1. Query と Exec はどう使い分けますか。判断基準を答えてください。
Q2. 次のコードには問題があります。何が起きうるか説明してください。
result, err := db.Exec("UPDATE users SET email = $1 WHERE id = $2", email, id)
if err != nil {
return err
}
return nil // 成功
Q3. プレースホルダを使うとSQLインジェクションを防げるのはなぜですか。「値として扱われるから」の一歩先まで説明してください。
Q4. 物理削除ではなく論理削除を選ぶべきなのはどういうデータですか。理由も答えてください。
実技問題
Q5. users テーブルで、id を指定してユーザーを1件更新する関数を書いてください。以下を満たすこと。
- プレースホルダを使う
- 対象が存在しなかった場合、呼び出し元が区別できる形でエラーを返す
- 想定外に複数件更新された場合も検知する
確認テスト 解答・解説
Q1
行が返ってくるかどうかで判断します。
- SELECT → 行が返る →
Query/QueryRow - INSERT / UPDATE / DELETE → 行が返らない(結果情報だけ) →
Exec
Exec の返り値 sql.Result からは行データは取れず、RowsAffected()(影響行数)や LastInsertId()(採番されたID。PostgreSQLでは非対応でRETURNINGを使う)が取れます。
Q2
該当する行が0件でも err は nil になるため、更新できていないのに「成功」を返してしまいます。
id が存在しない場合、SQLとしては正常に実行され「0件該当した」という結果になります。文法エラーでも接続エラーでもないので err は nil です。
結果として、ユーザーには「保存しました」と表示されるのに実際には何も変わっていない、という状態になります。
修正するには RowsAffected() を確認します。
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return fmt.Errorf("id=%d は存在しません", id)
}
Q3
プレースホルダを使うと、SQL文の構造と値が別々にDBへ送られるためです。
DBは先に「UPDATE users SET email = ? WHERE id = ? という構造のSQLを実行する」と決めます。その後で値を当てはめます。構造が先に確定しているので、値の中身がSQLの構造を後から変えることが原理的にできません。
一方、文字列連結で組み立てると、値と構造が混ざった1本の文字列としてDBに届きます。DBには「どこまでが開発者の意図した構造で、どこからがユーザー入力か」を区別する手段がありません。だから ' OR '1'='1 のような入力でWHERE句の意味を書き換えられてしまいます。
Q4
後から「なぜ・いつ・誰が消したのか」を説明する必要があるデータです。
具体的には、金銭が絡むもの(注文、決済、請求)、人の判断が絡むもの(審査結果、承認履歴)、法令で保存が求められるものなどです。
理由は3つあります。
- 説明責任:「なぜこの請求を取り消したのか」を後から問われたとき、行が消えていると答えられない
- 復旧可能性:誤操作による削除を戻せる
- 関連データの整合性:ユーザーを物理削除すると、そのユーザーに紐づく注文履歴が参照先を失う
逆に、一時的なキャッシュやセッション情報など、消えても誰も困らず後から問われないデータは物理削除で構いません。
Q5
func UpdateUserEmail(db *sql.DB, id int, email string) error {
result, err := db.Exec(
"UPDATE users SET email = $1 WHERE id = $2",
email, id,
)
if err != nil {
return fmt.Errorf("ユーザーの更新に失敗しました: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("更新件数の取得に失敗しました: %w", err)
}
switch {
case affected == 0:
// 対象が存在しない。呼び出し元が404に変換できるようにする
return fmt.Errorf("id=%d のユーザーが見つかりません", id)
case affected > 1:
// 主キー指定なのに複数件 = 条件かデータがおかしい
return fmt.Errorf("想定外の更新件数です(id=%d, %d件)", id, affected)
}
return nil
}
ポイント
- プレースホルダ
$1$2を使い、文字列連結をしていない -
errとaffected == 0を別々に扱っている(前者は500、後者は404に対応する) -
%wでエラーをラップし、呼び出し元でerrors.Is/errors.Asにより原因を判別できるようにしている -
affected > 1も検知している。主キー指定で複数件更新されるのは異常事態なので、黙って通さない
まとめ
-
Execは書き換え系、Queryは取得系。行が返るかで判断する -
err == nilは「更新できた」を意味しない。RowsAffected()で件数を必ず確認する - プレースホルダは必須。SQL文の構造と値を分離することでインジェクションを防ぐ
-
WHERE忘れは全件更新・全件削除になる。SELECTで先に確認し、トランザクションで囲む - 後から説明を求められるデータは論理削除。ただし取得時の除外条件を徹底する
次回は #11 Goでトランザクションとエラーハンドリング です。#9 で学んだトランザクションを、実際にGoのコードで実装します。複数のUPDATEをまとめて成功・失敗させる方法を扱います。