0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

42日でバックエンドエンジニアの基礎を完全に理解する

0
Posted at

この記事で学ぶこと

#4 でGoからDBに接続してデータを取得(SELECT)しました。今回は データを書き換える(UPDATE)・消す(DELETE) を実装します。

読むだけなら事故は起きませんが、書き換えは違います。一行間違えると本番データが全部壊れます。 この記事では書き方だけでなく「どうやって事故を防ぐか」まで扱います。

この記事を終えると以下ができるようになります。

  • QueryExec を正しく使い分けられる
  • 「本当に更新できたか」を 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("更新完了")   // ← こっちが実行される

存在しない人 というユーザーはいません。しかし errnil のままで、「更新完了」と表示されます。

実際に確かめた結果がこちらです。

=== 該当行がある 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 UPDATEdeleted_at を設定
復旧 できない deleted_atNULL に戻すだけ
履歴 消える 残る
取得時 通常通り 必ず WHERE deleted_at IS NULL が必要

使い分けの基準:後から「なぜ消したのか」「誰が消したのか」を説明する必要があるデータは論理削除にします。金銭や人の判断が絡むものはほぼ論理削除です。

論理削除の弱点は、取得時の除外条件を書き忘れると削除済みデータが見えてしまうことです。これはアプリ全体で徹底する必要があります。


確認テスト

知識問題

Q1. QueryExec はどう使い分けますか。判断基準を答えてください。

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件でも errnil になるため、更新できていないのに「成功」を返してしまいます。

id が存在しない場合、SQLとしては正常に実行され「0件該当した」という結果になります。文法エラーでも接続エラーでもないので errnil です。

結果として、ユーザーには「保存しました」と表示されるのに実際には何も変わっていない、という状態になります。

修正するには 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つあります。

  1. 説明責任:「なぜこの請求を取り消したのか」を後から問われたとき、行が消えていると答えられない
  2. 復旧可能性:誤操作による削除を戻せる
  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 を使い、文字列連結をしていない
  • erraffected == 0別々に扱っている(前者は500、後者は404に対応する)
  • %w でエラーをラップし、呼び出し元で errors.Is / errors.As により原因を判別できるようにしている
  • affected > 1 も検知している。主キー指定で複数件更新されるのは異常事態なので、黙って通さない

まとめ

  • Exec は書き換え系、Query は取得系。行が返るかで判断する
  • err == nil は「更新できた」を意味しないRowsAffected() で件数を必ず確認する
  • プレースホルダは必須。SQL文の構造と値を分離することでインジェクションを防ぐ
  • WHERE 忘れは全件更新・全件削除になる。SELECTで先に確認し、トランザクションで囲む
  • 後から説明を求められるデータは論理削除。ただし取得時の除外条件を徹底する

次回は #11 Goでトランザクションとエラーハンドリング です。#9 で学んだトランザクションを、実際にGoのコードで実装します。複数のUPDATEをまとめて成功・失敗させる方法を扱います。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?