解答 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のまま) 🎉 すべてのチェックが通りました
解説¶
- 確認してから変更する順序:
SELECTで現在の在庫を読み、amount未満ならUPDATEを 実行せずにエラーを返します。この順序が逆(先に引いてからマイナスをチェック)だと、一瞬でも 不正な状態が DB に書き込まれてしまいます - エラー時は状態を変えない:
decreaseStock(db, "banana", 100)はエラーを返しますが、その後のSELECTで在庫が 2 のまま(変更されていない)ことを確認しています。これは 01.1 のWithdraw/MonthlyFeeと同じ「不正は先に弾く → 成功操作は最後に」という設計です amount == stockは成功として扱う:stock < amountという条件なので、ちょうど在庫と同じ数 を減らす場合はエラーになりません(在庫が 0 になるのは正常な操作です)- 🔴 発展: この実装は「同時アクセス」には安全ではない:
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」を見ます。