N : M は中間テーブルに分解する
素の N:M 線は設計未完了のサイン。必ず中間テーブル(交差表)に分解する。
素の N : M 線は「設計の未完了マーク」
「学生 ── 講義」を N : M のまま線 1 本で結んだ ER 図は、概念を捉えている途中の下書きでしかありません。リレーショナルモデルには 2 つのテーブルを直接 N : M で結ぶ仕組みが無く、必ず第 3 のテーブル(中間テーブル/交差表/junction table)を介します。
- 片方に FK 列を持たせると多値になり、第 1 正規形(1NF)を満たせない
- 「誰が何を取っているか」を問い合わせる SQL が書けない(CSV 文字列で持つ、JSON 配列で持つ等は別の闇)
- 後から「履修日」や「成績」といった属性が出てくると、置き場所が無く詰む
ER 図のレビューで N : M を見かけたら、そこで必ず中間テーブルに分解するまでがレビューの仕事です。
実例 — タグ付け・ロール・履修登録
「N:M を中間テーブルに分解する」はセオリーですが、中間テーブルに属性を乗せるかどうかで設計が二段階に分かれます。実例でパターンを覚えると判断が速くなります。
| 業務例 | 中間テーブル | 属性の有無 | 扱い |
|---|---|---|---|
| 記事にタグを付ける | article_tags (article_id, tag_id) | なし(純粋な N:M) | 複合 PK の中間テーブルで十分。関連実体に昇格させない |
| ユーザーにロールを割り当て | user_roles (user_id, role_id, assigned_at, assigned_by) | あり(誰がいつ割当) | 関連実体に昇格。role_assignments に改名して履歴管理 |
| 学生が講義を履修 | enrollments (student_id, course_id, enrolled_at, grade) | あり(履修日・成績) | 最初から enrollments として命名。代理 id + UNIQUE(student_id, course_id) が定石 |
| 商品を注文に入れる | order_items (order_id, product_id, quantity, unit_price) | あり(数量・単価) | 事実上の「明細」。代理 id を必ず持つ(他テーブルから参照される) |
記事-タグのような「純粋な紐付け」は複合 PK のままで、ユーザー-ロールや注文-商品のように紐付いた瞬間の情報(いつ・誰が・いくらで)を残したいものは関連実体に昇格 ― という判断軸です。迷ったら「レコード単位で参照・更新・削除したいか」を問うと決まります。
中間テーブルの基本形
中間テーブルは 両側のエンティティの PK を FK として 2 列並べる だけが基本形です。
複合 PK (student_id, course_id) にすることで、「同じ学生が同じ講義に 2 回登録される」を DB レベルで防げます。片方だけ UNIQUE を付けたり、何も付けないで運用すると、必ず重複レコードが紛れ込みます。
CREATE TABLE student_courses (
student_id BIGINT NOT NULL REFERENCES students(id),
course_id BIGINT NOT NULL REFERENCES courses(id),
PRIMARY KEY (student_id, course_id)
);関連実体への昇格 — 属性が乗るケース
中間テーブルに属性(列)が乗り始めたら、それはもはや単なる交差表ではなく、独立した「関連実体(association entity)」に昇格しています。
enrolled_at(いつ履修登録したか)grade(成績)assigned_by(誰が割り当てたか)
こうなると、テーブル名も enrollments(履修)のように業務用語に沿った名前に改名するのが自然です。ER 図の書き方も「学生 ── 講義」の線ではなく「学生 ─ 履修 ─ 講義」の 2 段関係に変わります。
※ 属性が乗る = 関連そのものが業務上の「モノ」として扱われている、というシグナル。改名・整理のタイミングを逃すと、後から SQL 側で「最新の履修」「成績付き履修」などを書くときにコードが散らかります。
複合 PK vs 代理 id + UNIQUE
中間テーブルの PK には 2 つの流派があります。
| 設計 | PK | 向く場面 | 弱点 |
|---|---|---|---|
| 複合 PK(古典派) | PRIMARY KEY (a_id, b_id) | 純粋な交差表。属性が乗らない・乗ってもせいぜい 1〜2 列 | MySQL InnoDB では全セカンダリ索引に PK 2 列が埋め込まれて太る |
| 代理 id + UNIQUE(現代派) | PRIMARY KEY (id) + UNIQUE (a_id, b_id) | 関連実体に昇格したテーブル。他テーブルから参照される/ORM で扱う | PK が業務的意味を持たない列になるため、列が 1 本増える |
迷ったら「他のテーブルから FK で参照されるか」で決めます。されるなら代理 id、されないなら複合 PK で十分。詳しい根拠は 代理キー vs 自然キー を参照。
両方向インデックスも忘れずに
中間テーブルは両方向から検索されるのが普通です。PK (student_id, course_id) だけだと「この講義を取っている学生一覧」のクエリで左端欠けとなり、索引が使われません。
CREATE INDEX ON student_courses (course_id, student_id) のような逆方向の索引を最初から貼るのが定石です。詳細は 外部キーとリレーション列の索引設計 を参照してください。
