← レッスン一覧に戻る

解答 02.1 — リレーショナルモデル(isCandidateKey)¶

このノートブックは 02.1-relational-model.ipynb の練習問題の解答です。 まず問題の前提コードを再掲し、次に解答、最後にチェックを実行します。

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

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

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

	_ "modernc.org/sqlite"
)

func openCourseDB(path string) *sql.DB {
	db, err := sql.Open("sqlite", path)
	if err != nil {
		panic(err)
	}
	db.SetMaxOpenConns(1)
	return db
}

const flatDBPath = "file:_02_1_relational_solutions.db"
In [2]:
%%
db := openCourseDB(flatDBPath)
defer db.Close()

db.Exec(`DROP TABLE IF EXISTS orders_flat`)
db.Exec(`CREATE TABLE orders_flat (
	order_id INTEGER PRIMARY KEY,
	customer_id INTEGER,
	customer_name TEXT,
	customer_email TEXT,
	customer_address TEXT,
	product_name TEXT,
	price INTEGER,
	qty INTEGER
)`)

seed := []struct {
	customerID                       int
	customerName, email, address     string
	productName                      string
	price, qty                       int
}{
	{1, "佐藤", "sato@example.com", "東京都渋谷区1-1", "マグカップ", 800, 2},
	{1, "佐藤", "sato@example.com", "東京都渋谷区1-1", "ノート", 300, 1},
	{1, "佐藤", "sato@example.com", "東京都渋谷区1-1", "ペン", 150, 5},
	{2, "鈴木", "suzuki@example.com", "大阪府大阪市2-2", "マグカップ", 800, 3},
	{2, "鈴木", "suzuki@example.com", "大阪府大阪市2-2", "ノート", 300, 2},
	{3, "田中", "tanaka@example.com", "愛知県名古屋市3-3", "ペン", 150, 1},
}
for _, s := range seed {
	_, err := db.Exec(
		`INSERT INTO orders_flat (customer_id, customer_name, customer_email, customer_address, product_name, price, qty)
		 VALUES (?, ?, ?, ?, ?, ?, ?)`,
		s.customerID, s.customerName, s.email, s.address, s.productName, s.price, s.qty)
	if err != nil {
		panic(err)
	}
}
fmt.Println("✅ orders_flat をシードしました(6行、顧客3人ぶん)")
✅ orders_flat をシードしました(6行、顧客3人ぶん)

解答: isCandidateKey¶

セクション6(本編)でやった「COUNT(*) と COUNT(DISTINCT 列名) を比較する」を、 1列ぶんの判定として関数に切り出すだけです。

In [3]:
func isCandidateKey(db *sql.DB, column string) (bool, error) {
	// column は列名(SQLの識別子)なので、値のプレースホルダ(?)では埋め込めない。
	// fmt.Sprintf で組み立てるが、外部入力(ユーザーが送ってきた文字列)を
	// そのまま渡してはいけない(SQLインジェクションになる)。このノートブックでは
	// 常に固定の既知の列名だけを渡す前提。
	query := fmt.Sprintf(`SELECT COUNT(*), COUNT(DISTINCT %s) FROM orders_flat`, column)
	var total, distinct int
	if err := db.QueryRow(query).Scan(&total, &distinct); err != nil {
		return false, err
	}
	return total == distinct, nil
}

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

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

In [4]:
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 [5]:
%%
// このチェックは SELECT のみで DB を変更しないため、何度実行しても同じ結果になる。
db := openCourseDB(flatDBPath)
defer db.Close()

ok1, err1 := isCandidateKey(db, "order_id")
mustEqual(err1, nil, "order_id はエラーなし")
mustEqual(ok1, true, "order_id は候補キーの条件を満たす(重複なし)")

ok2, err2 := isCandidateKey(db, "customer_id")
mustEqual(err2, nil, "customer_id はエラーなし")
mustEqual(ok2, false, "customer_id は候補キーではない(重複あり)")

ok3, err3 := isCandidateKey(db, "customer_email")
mustEqual(err3, nil, "customer_email はエラーなし")
mustEqual(ok3, false, "customer_email も候補キーではない(重複あり)")

fmt.Println("🎉 すべてのチェックが通りました")
✅ Passed: order_id はエラーなし
✅ Passed: order_id は候補キーの条件を満たす(重複なし)
✅ Passed: customer_id はエラーなし
✅ Passed: customer_id は候補キーではない(重複あり)
✅ Passed: customer_email はエラーなし
✅ Passed: customer_email も候補キーではない(重複あり)
🎉 すべてのチェックが通りました

解説¶

  1. COUNT(*) と COUNT(DISTINCT column) の比較が候補キー判定の本質: 全行数と 「その列の異なる値の数」が一致すれば、その列だけで各行を一意に区別できる = 候補キーの 条件を満たします。一致しなければ、どこかの行で同じ値が重複しているということです
  2. order_id は主キーそのものなので当然 true: テーブル定義で PRIMARY KEY に 指定した列は、SQLite が重複を許さないため、必ず候補キーの条件も満たします
  3. customer_id と customer_email が false になる理由は同じ: どちらも 「同じ顧客が複数回注文すると、同じ値が複数行に現れる」という構造上の理由で重複します。 これは 02.1 本編で見た「更新異常」(住所変更が3行に波及する)とまったく同じ原因です
  4. 列名はプレースホルダで埋め込めない: SQL のプレースホルダ ? は「値」を安全に 埋め込むためのものであり、「列名」のような識別子には使えません。列名を動的に組み立てる 関数は、渡せる値を固定の既知リストに限定するなど、呼び出し側で安全性を保証する必要があります