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