02.1 リレーショナルモデル — テーブルとキーの基礎¶
Module 1 まではデータをメモリ上の構造体(struct)で持っていました。ここからは、
データをデータベースのテーブルとして持つ世界に入ります。
このレッスンでは、まず「テーブルとは何か」「テーブルの行を一意に識別する仕組み(キー)」 というリレーショナルモデルの基礎を学びます。そのうえで、キーの設計を誤ると何が起きるかを、 「顧客情報と注文情報を1つのテーブルに詰め込んだ」実データで再現します。
このレッスンのゴール:
- 「リレーション(関係)」「タプル」「属性」という言葉でテーブルを説明できる
- 主キーと候補キーの違いを説明できる
- 非正規化されたテーブルで「1件の変更が何行に波及するか」を実測できる
- 関係代数の基本操作(選択・射影・結合)を概念として説明できる
注意: このレッスンでは「問題を観察する」ところまでを扱います。 「どう直すか(正規化)」は 02.4 で学びます。
1. リレーション(関係)とキー¶
データベースの世界では、テーブルのことを リレーション(relation, 関係) と呼びます。 リレーショナルモデルは、次の3つの要素で説明できます。
- リレーション(relation): テーブルそのもの。「行の集合」
- タプル(tuple): 1行。1件の事実(例: 1件の注文明細)
- 属性(attribute): 1列。データの1つの性質(例: 顧客名、価格)
ここで最も重要な約束事が、「各タプル(行)は一意に識別できなければならない」というものです。 もし2つの行が完全に同じ内容だったら、それが同じ事実を指しているのか、別々の事実がたまたま 同じ値になっただけなのかを区別できなくなります。
この「一意に識別する」役割を担うのが キー です。
- 候補キー(candidate key): 「その列(または列の組み合わせ)の値が重複しない」という条件を 満たす列(の組)。1つのテーブルに候補キーは複数存在しうる
- 主キー(primary key): 候補キーの中から「これをテーブルの識別子として使う」と選んだ1つ。 テーブルにつき1つだけ選ぶ
たとえば注文テーブルなら、order_id(注文ごとに自動採番される番号)は重複しないので候補キーに
なれます。もし「メールアドレスは1人1つしか持てない」という業務ルールが保証されているなら、
customer_email も候補キーになりえます。そのうち実際にテーブル定義で PRIMARY KEY に
指定したものが主キーです。
2. 非自明な点: 「今のデータで重複していない」だけでは候補キーと言い切れない¶
いま手元のデータで customer_email の値が重複していなかったとしても、それだけでは
「候補キーである」とは言い切れません。候補キーは「業務ルールとして重複しないと保証されているか」
で決まるものであり、「たまたま今のデータで重複していない」は根拠になりません。
たとえば「メールアドレスは自由記入で、将来同じアドレスを複数の顧客が使うかもしれない」なら、
customer_email は候補キーとして使うべきではありません。このレッスンでは、実データに対して
「重複しているかどうか」を測定する方法を学びますが、それはあくまで「今のデータでは重複していない」
という事実確認であり、「業務ルールとして将来も保証される」こととは別問題だという点に注意してください。
import (
"database/sql"
"errors"
"fmt"
"strings"
"github.com/janpfeifer/gonb/gonbui"
_ "modernc.org/sqlite"
)
// ErrUnanswered は、練習問題が未回答のときにプレースホルダ関数が返す特別なエラー。
var ErrUnanswered = errors.New("未回答: この関数はまだ実装されていません")
// openCourseDB は、このコース共通の SQLite 接続を開く。
// GoNB はセル単位で実行されるため、DB は「1セル完結」(open→defer Close→操作)で使う。
// セルをまたいだ状態共有は Go 変数ではなく、同じパスのファイルを介して行う。
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.db"
3. 動かしてみる: 顧客情報と注文情報を1つのテーブルに詰め込む¶
「テーブル設計をサボる」とどうなるかを見るために、1行が「注文の明細1件」を表し、
顧客の名前・メール・住所と、商品情報が注文のたびに繰り返し書き込まれるテーブル
orders_flat を作ります。
DROP → CREATE → INSERT を1つのセルにまとめているので、このセルは何度実行しても同じ初期状態に 戻ります(再実行・カーネル再起動に強い書き方です)。
%%
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人ぶん)
4. 重複を実測する¶
佐藤さん(customer_id=1)は3回注文しているので、customer_name・customer_email・
customer_address がまったく同じ値のまま3行にコピーされています。これを実測しましょう。
%%
db := openCourseDB(flatDBPath)
defer db.Close()
var total, distinctCustomers int
db.QueryRow(`SELECT COUNT(*) FROM orders_flat`).Scan(&total)
db.QueryRow(`SELECT COUNT(DISTINCT customer_id) FROM orders_flat`).Scan(&distinctCustomers)
fmt.Printf("orders_flat の全行数: %d 行\n", total)
fmt.Printf("customer_id の異なり数(実際の顧客数): %d 人\n", distinctCustomers)
fmt.Printf("重複行数(同じ顧客の情報が複数行に書かれている数): %d 行\n", total-distinctCustomers)
orders_flat の全行数: 6 行 customer_id の異なり数(実際の顧客数): 3 人 重複行数(同じ顧客の情報が複数行に書かれている数): 3 行
5. 更新異常を実測する(住所変更)¶
佐藤さんが引っ越して、住所(customer_address)が変わったとします。orders_flat では
佐藤さんが登場する行すべてを UPDATE しないと、「同じ人なのに行によって住所が違う」という
矛盾が残ってしまいます。
観察点: 1人の住所変更で何行直す必要があるかを、RowsAffected() で実測します。
%%
db := openCourseDB(flatDBPath)
defer db.Close()
res, err := db.Exec(
`UPDATE orders_flat SET customer_address = ? WHERE customer_id = ?`,
"東京都新宿区9-9", 1,
)
if err != nil {
panic(err)
}
n, _ := res.RowsAffected()
fmt.Printf("佐藤さんの住所変更で更新された行数: %d 行\n", n)
fmt.Println("→ 佐藤さんは3回注文しているので、たった1つの事実(住所)を直すのに3行触る必要があった")
佐藤さんの住所変更で更新された行数: 3 行 → 佐藤さんは3回注文しているので、たった1つの事実(住所)を直すのに3行触る必要があった
この結果を、あとで解答ノートブックの練習問題で使う HTML 表としてまとめます。
func renderAnomalySummary(rows [][3]string) string {
var b strings.Builder
b.WriteString(`<table border="1" cellpadding="4" style="border-collapse:collapse">`)
b.WriteString(`<tr><th>項目</th><th>実測値</th><th>観察点</th></tr>`)
for _, r := range rows {
b.WriteString(fmt.Sprintf(`<tr><td>%s</td><td>%s</td><td>%s</td></tr>`, r[0], r[1], r[2]))
}
b.WriteString(`</table>`)
return b.String()
}
%%
gonbui.DisplayHTML(renderAnomalySummary([][3]string{
{"orders_flat の全行数", "6 行", "顧客は3人だけなのに行数はそれより多い"},
{"customer_id の重複行数", "3 行", "同じ顧客の情報が複数行にコピーされている"},
{"住所変更(customer_id=1)で更新された行数", "3 行", "1つの事実を直すのに3行触る必要があった"},
}))
gonbui.Sync()
| 項目 | 実測値 | 観察点 |
|---|---|---|
| orders_flat の全行数 | 6 行 | 顧客は3人だけなのに行数はそれより多い |
| customer_id の重複行数 | 3 行 | 同じ顧客の情報が複数行にコピーされている |
| 住所変更(customer_id=1)で更新された行数 | 3 行 | 1つの事実を直すのに3行触る必要があった |
6. キーの候補を実データで検証する¶
理論に戻ります。orders_flat の主キーは order_id です(テーブル定義で
PRIMARY KEY に指定したとおり、1行1行を一意に識別します)。
では他の列は候補キーになれるでしょうか? 「その列の値が重複しないか」を、
COUNT(*)(全行数)と COUNT(DISTINCT 列名)(異なる値の数)を比べることで確認できます。
2つが一致すれば重複が無い = 候補キーの条件を満たす、一致しなければ重複がある、と判定できます。
func renderKeyCheck(rows [][3]string) string {
var b strings.Builder
b.WriteString(`<table border="1" cellpadding="4" style="border-collapse:collapse">`)
b.WriteString(`<tr><th>列名</th><th>全行数 / 異なる値の数</th><th>候補キーか</th></tr>`)
for _, r := range rows {
b.WriteString(fmt.Sprintf(`<tr><td>%s</td><td>%s</td><td>%s</td></tr>`, r[0], r[1], r[2]))
}
b.WriteString(`</table>`)
return b.String()
}
%%
db := openCourseDB(flatDBPath)
defer db.Close()
columns := []string{"order_id", "customer_id", "customer_email"}
var rows [][3]string
for _, col := range columns {
query := fmt.Sprintf(`SELECT COUNT(*), COUNT(DISTINCT %s) FROM orders_flat`, col)
var total, distinct int
if err := db.QueryRow(query).Scan(&total, &distinct); err != nil {
panic(err)
}
verdict := "❌ 候補キーではない(重複あり)"
if total == distinct {
verdict = "✅ 候補キーの条件を満たす"
}
rows = append(rows, [3]string{col, fmt.Sprintf("%d / %d", total, distinct), verdict})
}
gonbui.DisplayHTML(renderKeyCheck(rows))
gonbui.Sync()
| 列名 | 全行数 / 異なる値の数 | 候補キーか |
|---|---|---|
| order_id | 6 / 6 | ✅ 候補キーの条件を満たす |
| customer_id | 6 / 3 | ❌ 候補キーではない(重複あり) |
| customer_email | 6 / 3 | ❌ 候補キーではない(重複あり) |
表を見てください。order_id だけが「全行数 = 異なる値の数」になり、候補キーの条件を満たします
(これが実際に主キーとして選ばれています)。customer_id と customer_email は、同じ顧客が
複数回注文しているぶんだけ重複しており、単独では候補キーになれません。
補足:
customer_id単独ではキーになれませんが、customersという「顧客だけ」のテーブルを 別に作れば、そのテーブルの中ではcustomer_idは重複しない主キーになります。これがまさに 02.4 で行う「テーブルを分割する」という発想です。
7. 関係代数の考え方: 選択・射影・結合¶
SQL の SELECT 文は、実は関係代数(relational algebra)という数学的な操作の組み合わせです。
代表的な3つの操作を、概念として押さえておきましょう。
- 選択(selection, σ): 行を絞り込む操作。SQL の
WHEREに対応する(例:「customer_id=1 の行だけ」) - 射影(projection, π): 列を絞り込む操作。SQL の
SELECT 列名, ...に対応する (例:「商品名と価格の列だけ」) - 結合(join, ⋈): 複数のリレーションを、共通のキーでつなぎ合わせる操作。
たとえば
customersテーブルとorder_itemsテーブルをcustomer_idでつなげば、 「分割したテーブルをもう一度1つのビューとして見る」ことができる(02.3 で詳しく扱います)
orders_flat はまだ1つのテーブルしか無いので結合はできませんが、選択と射影は今すぐ試せます。
次のコードは「佐藤さん(customer_id=1)の、商品名と価格だけ」を選択・射影で取り出します。
%%
db := openCourseDB(flatDBPath)
defer db.Close()
// WHERE customer_id = ? が「選択」、SELECT product_name, price が「射影」に対応する。
rows, err := db.Query(
`SELECT product_name, price FROM orders_flat WHERE customer_id = ?`, 1,
)
if err != nil {
panic(err)
}
defer rows.Close()
fmt.Println("佐藤さんの注文(商品名・価格のみ):")
for rows.Next() {
var name string
var price int
if err := rows.Scan(&name, &price); err != nil {
panic(err)
}
fmt.Printf(" - %s: %d円\n", name, price)
}
佐藤さんの注文(商品名・価格のみ): - マグカップ: 800円 - ノート: 300円 - ペン: 150円
customer_name や customer_address の列は結果に出てきません。射影で「必要な列だけ」に
絞り込んでいることが分かります。同様に、customer_id = ? の条件によって選択で「必要な行だけ」
に絞り込まれています。
8. 直感・類推: 同じチラシを配ってから誤字に気づく¶
orders_flat の状態は、同じチラシ(顧客情報付き)を、注文のたびに新しく印刷して配ってしまった
ようなものです。
1回だけ配るなら問題ありません。しかし佐藤さんに3回チラシを配ってしまうと、住所の誤字(変更)に 気づいたとき、「佐藤さんに渡した3枚のチラシを全部回収して、全部書き直す」必要が出てきます。 1枚だけ直して残り2枚を放置すれば、「佐藤さんの住所」について矛盾した情報が同時に存在することに なります。
もし最初から「顧客情報は名簿に1回だけ書き、注文のたびには『どの顧客か』という番号だけを渡す」 方式にしていれば、直すのは名簿の1行だけで済みます。この「情報を1箇所に集約する」設計が、 次の 02.4 で学ぶ正規化です。
練習問題 2.1: isCandidateKey(候補キー判定)を実装しよう¶
isCandidateKey を実装してください。この関数は、orders_flat の指定した列が
「候補キーの条件(値が重複しない)を満たすかどうか」を判定するだけ(非破壊)の関数です。
セクション6で手作業でやった「全行数 と 異なる値の数を比べる」を、再利用できる形にします。
仕様:
func isCandidateKey(db *sql.DB, column string) (bool, error)
orders_flatについて、COUNT(*)(全行数)とCOUNT(DISTINCT column)(異なる値の数)を比較する- 一致すれば
true, nil(候補キーの条件を満たす) - 一致しなければ
false, nil(重複がある = 候補キーではない) - SQL エラーが起きたら
false, <そのエラー>を返す
注意: column は列名(SQLの識別子)なので、値のプレースホルダ ? では埋め込めません
(? は値専用です)。セクション6のコードのように fmt.Sprintf で組み立てる必要がありますが、
この関数に外部から自由な文字列(ユーザー入力など)を渡してはいけません(SQLインジェクションに
つながります)。このノートブックでは常に固定の既知の列名だけを渡します。
ヒント: セクション6のループの中身を、1列ぶんだけ関数として切り出すイメージです。
// YOUR CODE HERE
// func isCandidateKey(db *sql.DB, column string) (bool, error) を実装してください。
// (未実装のままチェックセルを実行すると「未回答」と表示されます)
func isCandidateKey(db *sql.DB, column string) (bool, error) {
return false, ErrUnanswered
}
チェックのためのヘルパー¶
答え合わせに使う小さなヘルパー mustEqual を定義します。
(GoNB はローカルパッケージを import できないため、各ノートブックにこの定義を置いています)
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))
}
%%
// このチェックは SELECT のみで DB を変更しないため、何度実行しても同じ結果になる。
db := openCourseDB(flatDBPath)
defer db.Close()
ok1, err1 := isCandidateKey(db, "order_id")
if errors.Is(err1, ErrUnanswered) {
fmt.Println("⚠️ 未回答: 練習問題を解いてから、このセルを再度実行してください")
} else {
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("🎉 すべてのチェックが通りました")
}
⚠️ 未回答: 練習問題を解いてから、このセルを再度実行してください
まとめ¶
- リレーション(テーブル)は「一意に識別できるタプル(行)の集合」
- 主キーは、候補キー(重複しない列・列の組)の中から選んだ1つ
- 「今のデータで重複していない」だけでは候補キーの根拠にならない(業務ルールの保証が必要)
- 顧客情報と注文情報を1つのテーブルに詰め込むと、1件の事実の変更が複数行に波及する (このレッスンでは住所変更で3行に波及することを実測した)
- SQL の
SELECTは、選択(WHERE)・射影(SELECT 列)・結合(JOIN)という 関係代数の操作の組み合わせ
答え合わせは 02.1-relational-model-solutions.ipynb で行ってください。
次は 02.2 で SQL の CRUD(CREATE/INSERT/SELECT/UPDATE/DELETE)を学びます。
このレッスンで見た「更新異常」の直し方(テーブル分割)は 02.4 で扱います。