← レッスン一覧に戻る

解答 02.2 — SQL の CRUD操作(decreaseStock)¶

このノートブックは 02.2-sql-crud.ipynb の練習問題の解答です。 まず問題の前提コード(inventory テーブルの定義・シード)を再掲し、次に解答、最後にチェックを実行します。

先に自分の力で解いてから、答え合わせに使ってください。

前提コード(問題ノートブックと同じ定義・シード)¶

In [1]:
import (
	"database/sql"
	"fmt"

	_ "modernc.org/sqlite"
)

// openCourseDB はこのノートブック用の SQLite 接続を開く。
// GoNB のセルは1つの関数として実行されるため、db は各セル内で開いて同じセル内で閉じる。
func openCourseDB(path string) *sql.DB {
	db, err := sql.Open("sqlite", path)
	if err != nil {
		panic(err)
	}
	db.SetMaxOpenConns(1)
	return db
}

const dbPath = "file:_02_2_sql_crud_solutions.db"

// seedInventory はテーブルを消して作り直し、初期データを入れる(問題ノートブックと同じ内容)。
// 何度実行しても同じ初期状態に戻る(冪等)。
func seedInventory() {
	db := openCourseDB(dbPath)
	defer db.Close()

	db.Exec("DROP TABLE IF EXISTS inventory")
	db.Exec(`CREATE TABLE inventory (
		id INTEGER PRIMARY KEY,
		item TEXT UNIQUE,
		stock INTEGER
	)`)
	db.Exec(`INSERT INTO inventory (item, stock) VALUES
		('apple', 10), ('banana', 5), ('cherry', 20)`)
}

解答: decreaseStock¶

在庫を減らす前に、まず現在の在庫を SELECT で確認します。「不足なら UPDATE を実行しない」 のが要点です(先に確認 → 条件を満たせば UPDATE、の順序を守ることで、在庫がマイナスにならない という不変条件を守れます)。

In [2]:
// decreaseStock は item の在庫を amount だけ減らす。
// 在庫が amount 未満ならエラーを返し、在庫は変更しない。
// 成功したら更新後の在庫数を返す。
func decreaseStock(db *sql.DB, item string, amount int) (int, error) {
	var stock int
	if err := db.QueryRow("SELECT stock FROM inventory WHERE item = ?", item).Scan(&stock); err != nil {
		return 0, err
	}
	if stock < amount {
		return 0, fmt.Errorf("insufficient stock: item=%s, stock=%d, amount=%d", item, stock, amount)
	}
	newStock := stock - amount
	if _, err := db.Exec("UPDATE inventory SET stock = ? WHERE item = ?", newStock, item); err != nil {
		return 0, err
	}
	return newStock, nil
}

チェック(問題ノートブックと同じ期待値)¶

答え合わせ用のヘルパー mustEqual を定義します。

In [3]:
import "reflect"

func mustEqual(got, want any, name string) {
	if reflect.DeepEqual(got, want) {
		fmt.Printf("✅ Passed: %s\n", name)
		return
	}
	panic(fmt.Sprintf("❌ %s\n  got  = %v (%T)\n  want = %v (%T)", name, got, got, want, want))
}
In [4]:
%%
// チェックの前に必ず初期状態へ再シードする(このセルを何度実行しても同じ結果になるように)。
seedInventory()

db := openCourseDB(dbPath)
defer db.Close()

n1, err1 := decreaseStock(db, "banana", 3)
mustEqual(err1, nil, "decreaseStock(banana, 3) はエラーなし")
mustEqual(n1, 2, "banana の在庫が 5 → 2")

_, err2 := decreaseStock(db, "banana", 100)
mustEqual(err2 != nil, true, "在庫不足(100)はエラー")

var stockAfterErr int
db.QueryRow("SELECT stock FROM inventory WHERE item = 'banana'").Scan(&stockAfterErr)
mustEqual(stockAfterErr, 2, "エラー時は在庫が変わらない(2のまま)")

fmt.Println("🎉 すべてのチェックが通りました")
✅ Passed: decreaseStock(banana, 3) はエラーなし
✅ Passed: banana の在庫が 5 → 2
✅ Passed: 在庫不足(100)はエラー
✅ Passed: エラー時は在庫が変わらない(2のまま)
🎉 すべてのチェックが通りました

解説¶

  1. 確認してから変更する順序: SELECT で現在の在庫を読み、amount 未満ならUPDATE を 実行せずにエラーを返します。この順序が逆(先に引いてからマイナスをチェック)だと、一瞬でも 不正な状態が DB に書き込まれてしまいます
  2. エラー時は状態を変えない: decreaseStock(db, "banana", 100) はエラーを返しますが、その後の SELECT で在庫が 2 のまま(変更されていない)ことを確認しています。これは 01.1 の Withdraw/MonthlyFee と同じ「不正は先に弾く → 成功操作は最後に」という設計です
  3. amount == stock は成功として扱う: stock < amount という条件なので、ちょうど在庫と同じ数 を減らす場合はエラーになりません(在庫が 0 になるのは正常な操作です)
  4. 🔴 発展: この実装は「同時アクセス」には安全ではない: SELECT で読んでから UPDATE するまでの 間に、別の接続が同じ item を同時に減らすと、両方が同じ古い在庫を見て両方成功してしまう 可能性があります(read-then-write の競合)。このノートブックは1人が順番にセルを実行する前提 なので実害はありませんが、複数リクエストが同時に来る本番のWebサーバーでは、 UPDATE inventory SET stock = stock - ? WHERE item = ? AND stock >= ? のように条件付き UPDATEを1回のSQL文で行い、RowsAffected()が0なら失敗とする実装にすると、チェックと変更が データベース側で不可分(atomic)になり安全になります。04章(API設計)で改めて扱います。

次は 02.3 で「集計・JOIN」を見ます。