← レッスン一覧に戻る

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 に一致する行を書き換えよ(消せ)」という条件だけを書きます。

In [1]:
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)に戻せます。

In [2]:
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()
}
In [3]:
%%
seedInventory()
db := openCourseDB(dbPath)
defer db.Close()
dumpInventory(db, "CREATE + INSERT 直後")

CREATE + INSERT 直後

iditemstock
1apple10
2banana5
3cherry20

2. INSERT — 行を追加する¶

新しい商品 durian を追加します。追加前と後のテーブルを並べて表示します。

In [4]:
%%
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 前

iditemstock
1apple10
2banana5
3cherry20
INSERT: 1 行追加

INSERT 後

iditemstock
1apple10
2banana5
3cherry20
4durian3

3. UPDATE — 行を書き換える¶

apple の在庫を 10 → 15 に更新します。UPDATE は「同じ値を書く」形にしてあるので、 このセルを何度実行しても結果は変わりません(冪等)。

In [5]:
%%
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 前

iditemstock
1apple10
2banana5
3cherry20
4durian3
UPDATE: 1 行変更

UPDATE 後

iditemstock
1apple15
2banana5
3cherry20
4durian3

4. DELETE — 行を削除する¶

durian を削除します。DELETE は特定の item を対象にしているので冪等です (2回目実行時は対象行が既に無いので RowsAffected() が 0 になります)。

In [6]:
%%
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 前

iditemstock
1apple15
2banana5
3cherry20
4durian3
DELETE: 1 行削除

DELETE 後

iditemstock
1apple15
2banana5
3cherry20

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 を実行する前に判定するのが大切です)。

In [7]:
// YOUR CODE HERE
func decreaseStock(db *sql.DB, item string, amount int) (int, error) {
	return 0, ErrUnanswered
}

チェックのためのヘルパー¶

答え合わせに使う mustEqual を定義します。

In [8]:
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))
}
In [9]:
%%
// チェック用に在庫を既知の状態へ戻す(このセルは何度実行しても同じ結果になる)
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 を見ます。