02.2 SQL の CRUD操作 — CREATE・INSERT・SELECT・UPDATE・DELETE¶
このレッスンでは、SQL の基本操作(CRUD: Create/Read/Update/Delete)を、 テーブルの中身が操作の前後でどう変わるかを見ながら学びます。
このレッスンのゴール:
- DDL(
CREATE TABLE)と DML(INSERT/SELECT/UPDATE/DELETE)の違いを説明できる - 各操作の前後でテーブルの行・列がどう変化するかを実際に確認できる
RowsAffected()で「何行変わったか」を数値として取得できる
非自明ポイント1: DDL と DML は「何を変えるか」が違う¶
- DDL(Data Definition Language): テーブルの構造を変える。
CREATE TABLE/DROP TABLE/ALTER TABLE - DML(Data Manipulation Language): テーブルの中身(行)を変える。
INSERT/SELECT/UPDATE/DELETE
SELECT は中身を変えませんが、データを取り出す操作として DML に分類されます。
非自明ポイント2: SQL は「宣言型」 — 手続き型のループ思考とは違う¶
Go のような手続き型言語に慣れていると、「stock が 10 未満の行を集める」処理を、
つい次のように考えてしまいます。
// 手続き型の発想: 「どうやって」集めるかを1ステップずつ指示する
var result []Item
for _, row := range allRows { // 1. 全部の行を順番に見る
if row.Stock < 10 { // 2. 条件を確認する
result = append(result, row) // 3. 条件に合えば集める
}
}
これは「どうやって(HOW)」欲しい結果を得るかを、手順として命令する書き方です。
SQL の SELECT は発想が根本的に違います。
SELECT * FROM inventory WHERE stock < 10;
この 1 文は「何が欲しいか(WHAT)」だけを宣言しています。「1行ずつ順番に見て条件を確認する」
という手順は、この SQL 文のどこにも書かれていません —— それは SQLite(データベースエンジン)の
内部が決めることです。同じ WHERE stock < 10 でも、エンジンは全行を舐めるかもしれませんし
(フルスキャン)、索引があれば索引だけを辿るかもしれません(実行計画は 02.5 で扱います)。
書き手は「条件」だけを書き、「どう実行するか」はエンジンに任せる —— これが SQL が
「宣言型(declarative)言語」と呼ばれる理由です。
このレッスンで書く UPDATE/DELETE も同じです。「対象の行を1つずつ探して回って書き換える」
という手順は書きません。「WHERE に一致する行を書き換えよ(消せ)」という条件だけを書きます。
import (
"database/sql"
"errors"
"fmt"
"strings"
"github.com/janpfeifer/gonb/gonbui"
_ "modernc.org/sqlite"
)
// ErrUnanswered は、練習問題が未回答のときにプレースホルダ関数が返す特別なエラー。
var ErrUnanswered = errors.New("未回答: この関数はまだ実装されていません")
// 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
}
func renderTable(headers []string, rows [][]string) string {
var b strings.Builder
b.WriteString(`<table border="1" cellpadding="4" style="border-collapse:collapse"><tr>`)
for _, h := range headers {
b.WriteString(fmt.Sprintf("<th>%s</th>", h))
}
b.WriteString("</tr>")
for _, row := range rows {
b.WriteString("<tr>")
for _, cell := range row {
b.WriteString(fmt.Sprintf("<td>%s</td>", cell))
}
b.WriteString("</tr>")
}
b.WriteString("</table>")
return b.String()
}
1. CREATE — テーブルの構造を定義する(DDL)¶
「在庫」テーブル inventory を作ります。シードは DROP→CREATE→INSERT を1セルにまとめ、
このセルを再実行すればいつでも初期状態(apple=10, banana=5, cherry=20)に戻せます。
const dbPath = "_02_2_sql_crud.db"
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)`)
}
func dumpInventory(db *sql.DB, label string) {
rows, err := db.Query("SELECT id, item, stock FROM inventory ORDER BY id")
if err != nil {
panic(err)
}
defer rows.Close()
var table [][]string
for rows.Next() {
var id, stock int
var item string
if err := rows.Scan(&id, &item, &stock); err != nil {
panic(err)
}
table = append(table, []string{fmt.Sprintf("%d", id), item, fmt.Sprintf("%d", stock)})
}
gonbui.DisplayHTML(fmt.Sprintf("<p><b>%s</b></p>", label) + renderTable([]string{"id", "item", "stock"}, table))
gonbui.Sync()
}
%%
seedInventory()
db := openCourseDB(dbPath)
defer db.Close()
dumpInventory(db, "CREATE + INSERT 直後")
CREATE + INSERT 直後
| id | item | stock |
|---|---|---|
| 1 | apple | 10 |
| 2 | banana | 5 |
| 3 | cherry | 20 |
2. INSERT — 行を追加する¶
新しい商品 durian を追加します。追加前と後のテーブルを並べて表示します。
%%
db := openCourseDB(dbPath)
defer db.Close()
dumpInventory(db, "INSERT 前")
result, err := db.Exec("INSERT INTO inventory (item, stock) VALUES ('durian', 3)")
if err != nil {
panic(err)
}
affected, _ := result.RowsAffected()
fmt.Printf("INSERT: %d 行追加\n", affected)
dumpInventory(db, "INSERT 後")
INSERT 前
| id | item | stock |
|---|---|---|
| 1 | apple | 10 |
| 2 | banana | 5 |
| 3 | cherry | 20 |
INSERT: 1 行追加
INSERT 後
| id | item | stock |
|---|---|---|
| 1 | apple | 10 |
| 2 | banana | 5 |
| 3 | cherry | 20 |
| 4 | durian | 3 |
3. UPDATE — 行を書き換える¶
apple の在庫を 10 → 15 に更新します。UPDATE は「同じ値を書く」形にしてあるので、
このセルを何度実行しても結果は変わりません(冪等)。
%%
db := openCourseDB(dbPath)
defer db.Close()
dumpInventory(db, "UPDATE 前")
result, err := db.Exec("UPDATE inventory SET stock = 15 WHERE item = 'apple'")
if err != nil {
panic(err)
}
affected, _ := result.RowsAffected()
fmt.Printf("UPDATE: %d 行変更\n", affected)
dumpInventory(db, "UPDATE 後")
UPDATE 前
| id | item | stock |
|---|---|---|
| 1 | apple | 10 |
| 2 | banana | 5 |
| 3 | cherry | 20 |
| 4 | durian | 3 |
UPDATE: 1 行変更
UPDATE 後
| id | item | stock |
|---|---|---|
| 1 | apple | 15 |
| 2 | banana | 5 |
| 3 | cherry | 20 |
| 4 | durian | 3 |
4. DELETE — 行を削除する¶
durian を削除します。DELETE は特定の item を対象にしているので冪等です
(2回目実行時は対象行が既に無いので RowsAffected() が 0 になります)。
%%
db := openCourseDB(dbPath)
defer db.Close()
dumpInventory(db, "DELETE 前")
result, err := db.Exec("DELETE FROM inventory WHERE item = 'durian'")
if err != nil {
panic(err)
}
affected, _ := result.RowsAffected()
fmt.Printf("DELETE: %d 行削除\n", affected)
dumpInventory(db, "DELETE 後")
DELETE 前
| id | item | stock |
|---|---|---|
| 1 | apple | 15 |
| 2 | banana | 5 |
| 3 | cherry | 20 |
| 4 | durian | 3 |
DELETE: 1 行削除
DELETE 後
| id | item | stock |
|---|---|---|
| 1 | apple | 15 |
| 2 | banana | 5 |
| 3 | cherry | 20 |
5. 直感・類推: ノートの書き換え¶
- CREATE = 新しいノートを買って罫線を引く(構造を作る)
- INSERT = ノートに1行書き足す
- SELECT = ノートを読む(ノート自体は変わらない)
- UPDATE = 既存の行を消しゴムで消して書き直す
- DELETE = 行を消しゴムで消す(何も書かない)
「構造を作る(CREATE)」と「中身を書き換える(INSERT/UPDATE/DELETE)」は別の操作だと 意識すると、SQL のエラーメッセージも読みやすくなります (例: 「テーブルが無い」は CREATE 忘れ、「値が入らない」は INSERT/UPDATE の問題)。
練習問題 2.2: 在庫を安全に減らす¶
次の関数を実装してください。
// decreaseStock は item の在庫を amount だけ減らす。
// 在庫が amount 未満ならエラーを返し、在庫は変更しない。
// 成功したら更新後の在庫数を返す。
func decreaseStock(db *sql.DB, item string, amount int) (int, error)
ヒント: まず SELECT stock FROM inventory WHERE item = ? で現在の在庫を確認し、
不足していればエラーを返してください(UPDATE を実行する前に判定するのが大切です)。
// YOUR CODE HERE
func decreaseStock(db *sql.DB, item string, amount int) (int, error) {
return 0, ErrUnanswered
}
チェックのためのヘルパー¶
答え合わせに使う mustEqual を定義します。
import "reflect"
import "fmt"
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))
}
%%
// チェック用に在庫を既知の状態へ戻す(このセルは何度実行しても同じ結果になる)
seedInventory()
db := openCourseDB(dbPath)
defer db.Close()
n1, err1 := decreaseStock(db, "banana", 3)
if errors.Is(err1, ErrUnanswered) {
fmt.Println("⚠️ 未回答: 練習問題を解いてから、このセルを再度実行してください")
} else {
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("🎉 すべてのチェックが通りました")
}
⚠️ 未回答: 練習問題を解いてから、このセルを再度実行してください
まとめ¶
- DDL(
CREATE TABLE)はテーブルの構造、DML(INSERT/SELECT/UPDATE/DELETE)は中身を変える - 各操作の前後でテーブルを見比べると、何行・何列がどう変わったかが具体的にわかる
RowsAffected()で「実際に何行変わったか」を数値で確認できる- 不正な変更を防ぐには、UPDATE の前に条件を確認してから実行する(
decreaseStockのパターン)
答え合わせは 02.2-sql-crud-solutions.ipynb で行ってください。
次は 02.3 で複数テーブルの集計・JOIN を見ます。