← レッスン一覧に戻る

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 は候補キーとして使うべきではありません。このレッスンでは、実データに対して 「重複しているかどうか」を測定する方法を学びますが、それはあくまで「今のデータでは重複していない」 という事実確認であり、「業務ルールとして将来も保証される」こととは別問題だという点に注意してください。

In [1]:
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つのセルにまとめているので、このセルは何度実行しても同じ初期状態に 戻ります(再実行・カーネル再起動に強い書き方です)。

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人ぶん)

4. 重複を実測する¶

佐藤さん(customer_id=1)は3回注文しているので、customer_name・customer_email・ customer_address がまったく同じ値のまま3行にコピーされています。これを実測しましょう。

In [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() で実測します。

In [4]:
%%
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 表としてまとめます。

In [5]:
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()
}
In [6]:
%%
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つが一致すれば重複が無い = 候補キーの条件を満たす、一致しなければ重複がある、と判定できます。

In [7]:
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()
}
In [8]:
%%
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_id6 / 6✅ 候補キーの条件を満たす
customer_id6 / 3❌ 候補キーではない(重複あり)
customer_email6 / 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)の、商品名と価格だけ」を選択・射影で取り出します。

In [9]:
%%
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列ぶんだけ関数として切り出すイメージです。

In [10]:
// 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 できないため、各ノートブックにこの定義を置いています)

In [11]:
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 [12]:
%%
// このチェックは 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 で扱います。