1対1 — ユーザーとプロフィールを INNER JOIN し、行数が変わらないことを確認する
カーディナリティ(多重度)とは、テーブル間の「1行が相手テーブルの何行と対応するか」を表す関係性です。JOIN 前後の行数変化を予測するために不可欠な概念です。
最もシンプルな関係が 1対1(1:1)です。テーブルAの1行がテーブルBのちょうど1行と対応する関係で、結合しても行数は増減しません。
-- 1:1 の例: users 1行 ↔ user_profiles 1行 SELECT u.name, p.email FROM users AS u INNER JOIN user_profiles AS p ON u.user_id = p.user_id; -- users: 3行 → JOIN後: 3行(行数変化なし)
users テーブルと user_profiles テーブルを user_id で INNER JOIN し、ユーザーの名前とメールアドレスを取得してください。
| user_id | name |
|---|---|
| 1 | 田中 |
| 2 | 佐藤 |
| 3 | 山田 |
| profile_id | user_id | |
|---|---|---|
| 1 | 1 | tanaka@example.com |
| 2 | 2 | sato@example.com |
| 3 | 3 | yamada@example.com |
※ user_profiles.user_id には UNIQUE 制約があり、各ユーザーにプロフィールは1つだけです。
| name | |
|---|---|
| 田中 | tanaka@example.com |
| 佐藤 | sato@example.com |
| 山田 | yamada@example.com |
SELECT u.name, p.email FROM users AS u INNER JOIN user_profiles AS p ON u.user_id = p.user_id -- 両方のテーブルで user_id は一意 ORDER BY u.user_id; /* 実行順序: 1. FROM users AS u → 行を読み込む 2. INNER JOIN user_profiles → 結合(一致行のみ) 3. SELECT u.name, p.email → 列を評価 4. ORDER BY u.user_id → 並び替えて出力 */
LEGEND
① FROM
FROM users AS uusers テーブル(3行)を読み込みます。このテーブルの user_id は主キーで、値は一意です。| user_id | name |
|---|---|
| 1 | 田中 |
| 2 | 佐藤 |
| 3 | 山田 |
u = users p = user_profiles 3行 → 3行
MIN(左テーブルのマッチ行数, 右テーブルのマッチ行数) です。全行がマッチする場合は行数が一切変わらないため、集計時に重複カウントのリスクがありません。次の問題(1:N)で行数が増える場面を学び、その違いを実感してください。1対N — 部門テーブルと社員テーブルを JOIN し、行数が展開される仕組みを理解する
1対N(1:N)は最も頻出の関係性です。テーブルAの1行がテーブルBの複数行と対応する関係で、JOIN すると「1」側の行が「N」側の行数分だけ展開(増加)します。
-- 1:N の例: 1部門 → N人の社員 SELECT d.dept_name, e.name FROM departments AS d INNER JOIN employees AS e ON d.dept_id = e.dept_id; -- departments: 3行 → JOIN後: 5行(社員数に展開される)
1:N の JOIN では「1」側のデータ(例: dept_name)が N 行分コピーされます。この展開された状態で SUM や COUNT をすると二重・三重カウントの原因になります。展開を意識し、集計対象を正しく選ぶことが重要です。
departments テーブルと employees テーブルを INNER JOIN し、各社員の名前(emp_name)と所属部門名(dept_name)を取得してください。
| dept_id | dept_name |
|---|---|
| 1 | 営業部 |
| 2 | 開発部 |
| 3 | 人事部 |
| emp_id | name | dept_id |
|---|---|---|
| 1 | 田中 | 1 |
| 2 | 佐藤 | 1 |
| 3 | 山田 | 2 |
| 4 | 鈴木 | 2 |
| 5 | 高橋 | 2 |
| emp_name | dept_name |
|---|---|
| 田中 | 営業部 |
| 佐藤 | 営業部 |
| 山田 | 開発部 |
| 鈴木 | 開発部 |
| 高橋 | 開発部 |
SELECT e.name AS emp_name, d.dept_name FROM departments AS d INNER JOIN employees AS e ON d.dept_id = e.dept_id -- 1部門 → N社員(1:N で行が展開される) ORDER BY d.dept_id, e.emp_id; /* 実行順序: 1. FROM departments AS d → 行を読み込む 2. INNER JOIN employees AS e → 結合(一致行のみ) 3. SELECT e.name, d.dept_name → 列を評価 4. ORDER BY → 並び替えて出力 */
LEGEND
① FROM
FROM departments AS ddepartments テーブル(3行)を読み込みます。dept_id は主キーで一意です。この「1」側テーブルの行が、JOINでどう変化するかに注目してください。| dept_id | dept_name |
|---|---|
| 1 | 営業部 |
| 2 | 開発部 |
| 3 | 人事部 |
d = departments(1側) e = employees(N側) 3行 → 5行
SUM(d.budget) を全体で取ると、営業部の予算が2回、開発部の予算が3回カウントされます。集計は「N」側の列に対して行うか、展開前に「1」側を先に集計しておく必要があります。LEFT JOIN を使い、COUNT(e.emp_id) で 0 件を正しくカウントします。1対N + GROUP BY — JOIN で展開された行を集計し、元の行数に集約する
1:N JOIN による行数展開を GROUP BY で集約するパターンです。JOIN で N 行に展開された結果を、元の「1」側のキーでグループ化し、COUNT や SUM で集計します。
-- 行数変化の流れ: 展開 → 集約 customers(3行) → INNER JOIN orders → 6行 に展開(1:N) → GROUP BY customer_id → 3行 に集約
① JOIN:
「1」側の行数 → 「N」側のマッチ行数 に展開② GROUP BY:
展開された行数 → グループ数(= 「1」側の元の行数)に集約この「展開→集約」の往復を理解することが、正しい集計クエリを書く基盤になります。
customers と orders を INNER JOIN し、顧客ごとの注文件数(order_count)と合計金額(total_amount)を取得してください。注文件数の多い順で出力します。
| customer_id | name |
|---|---|
| 1 | 田中 |
| 2 | 佐藤 |
| 3 | 山田 |
| order_id | customer_id | amount |
|---|---|---|
| 1 | 1 | 3000 |
| 2 | 1 | 1500 |
| 3 | 2 | 5000 |
| 4 | 2 | 2000 |
| 5 | 2 | 800 |
| 6 | 3 | 4000 |
| name | order_count | total_amount |
|---|---|---|
| 佐藤 | 3 | 7800 |
| 田中 | 2 | 4500 |
| 山田 | 1 | 4000 |
SELECT c.name, COUNT(o.order_id) AS order_count, SUM(o.amount) AS total_amount FROM customers AS c INNER JOIN orders AS o ON c.customer_id = o.customer_id GROUP BY c.customer_id, c.name -- 展開された行を元の顧客単位に集約 ORDER BY order_count DESC; /* 実行順序: 1. FROM customers AS c → 行を読み込む 2. INNER JOIN orders AS o → 結合(一致行のみ) 3. GROUP BY c.customer_id → グループ化 4. 集計関数を評価 → COUNT, SUM 5. SELECT → 列を評価 6. ORDER BY order_count DESC → 並び替えて出力 */
LEGEND
① FROM
FROM customers AS ccustomers テーブル(3行)を読み込みます。customer_id は主キー(1側)です。| customer_id | name |
|---|---|
| 1 | 田中 |
| 2 | 佐藤 |
| 3 | 山田 |
3行(元) → 6行(JOIN展開) → 3行(GROUP BY集約)
SELECT c.name, o.amount FROM customers c JOIN orders o ... のように GROUP BY なしで書くと、佐藤が3行出力されます。1:N JOIN 後に集計する場合は必ず GROUP BY を書きましょう。SELECT c.name ... GROUP BY c.customer_id とだけ書くと c.name がエラーになります。SELECT に書いた集計関数以外の列はすべて GROUP BY にも書く必要があります(主キーの関数従属性を除く)。SELECT COUNT(*) で JOIN 後の行数を確認する習慣をつけましょう。N対N — 中間テーブルを介して学生と講座を結合し、多対多の関係を理解する
N対N(多対多)は「1人の学生が複数の講座を受講」かつ「1つの講座に複数の学生が参加」する関係です。RDBMS では中間テーブル(Junction Table)を使って表現します。
-- N:N の構造: students ←(1:N)→ enrollments ←(N:1)→ courses FROM students AS s INNER JOIN enrollments AS e ON s.student_id = e.student_id INNER JOIN courses AS c ON e.course_id = c.course_id;
中間テーブル(enrollments)は students と courses の両方の外部キーを持ちます。
students →(1:N)→ enrollments と courses →(1:N)→ enrollments という2つの 1:N関係で構成されます。JOIN 後の行数は中間テーブルの行数に一致します。students、enrollments(中間テーブル)、courses を2回の INNER JOIN で結合し、各学生が受講している講座名を一覧で取得してください。
| student_id | name |
|---|---|
| 1 | 田中 |
| 2 | 佐藤 |
| 3 | 山田 |
| enrollment_id | student_id | course_id |
|---|---|---|
| 1 | 1 | 1 |
| 2 | 1 | 2 |
| 3 | 2 | 1 |
| 4 | 2 | 3 |
| 5 | 3 | 2 |
| course_id | course_name |
|---|---|
| 1 | SQL基礎 |
| 2 | Python入門 |
| 3 | データ分析 |
| student_name | course_name |
|---|---|
| 田中 | SQL基礎 |
| 田中 | Python入門 |
| 佐藤 | SQL基礎 |
| 佐藤 | データ分析 |
| 山田 | Python入門 |
SELECT s.name AS student_name, c.course_name FROM students AS s INNER JOIN enrollments AS e -- 1回目: students →(1:N)→ enrollments ON s.student_id = e.student_id INNER JOIN courses AS c -- 2回目: enrollments →(N:1)→ courses ON e.course_id = c.course_id ORDER BY s.student_id, c.course_id; /* 実行順序: 1. FROM students AS s → students を読み込む 2. INNER JOIN enrollments AS e → 結合(1:N で展開) 3. INNER JOIN courses AS c → 結合(N:1) 4. SELECT s.name, c.course_name → 2列を射影 5. ORDER BY → student_id・course_id でソート */
LEGEND
① FROM
FROM students AS sstudents テーブル(3行)を読み込みます。この後、中間テーブル enrollments を経由して courses に辿り着きます。| student_id | name |
|---|---|
| 1 | 田中 |
| 2 | 佐藤 |
| 3 | 山田 |
s = students e = enrollments(中間) c = courses
UNIQUE(student_id, course_id) の複合ユニーク制約を設定します。これにより「同じ学生が同じ講座に二重登録」することを防ぎ、意図しない行数膨張を防止できます。course_ids = '1,2' のようにカンマ区切りで格納する設計は、JOIN・検索・集計がすべて困難になるアンチパターンです。中間テーブルによる正規化が RDB の正しい N:N 表現です。ファントラップ — 2つの 1:N を同時に JOIN すると発生する重複集計の罠と回避策
ファントラップ(Fan Trap)は、1つのテーブルから2つの 1:N テーブルを同時に JOINしたときに発生する行の意図しない増殖(直積)です。最も頻出する JOIN 事故であり、カーディナリティ理解の集大成です。
-- [Warning] 間違い: 2つの 1:N テーブルを同時にJOIN → 直積が発生 FROM users AS u INNER JOIN orders AS o ON u.user_id = o.user_id -- 1:N (1) INNER JOIN reviews AS r ON u.user_id = r.user_id -- 1:N (2) -- 田中: orders 2行 × reviews 2行 = 4行に膨張!
orders と reviews に直接の結合キーがないため、ユーザー「田中」の orders 2行 と reviews 2行のすべての組み合わせ(2×2=4行)が生成されます。
この状態で
SUM(o.amount) すると各金額が reviews の行数分だけ重複カウントされ、誤った集計結果になります。各ユーザーの合計注文金額(total_amount)と平均レビュー評価(avg_rating)を取得してください。ファントラップを回避するため、各 1:N を個別に集計してから JOIN する正しいアプローチで解いてください。
| user_id | name |
|---|---|
| 1 | 田中 |
| 2 | 佐藤 |
| order_id | user_id | amount |
|---|---|---|
| 1 | 1 | 3000 |
| 2 | 1 | 1500 |
| 3 | 2 | 5000 |
| review_id | user_id | rating |
|---|---|---|
| 1 | 1 | 5 |
| 2 | 1 | 3 |
| 3 | 2 | 4 |
| name | total_amount | avg_rating |
|---|---|---|
| 田中 | 4500 | 4.0 |
| 佐藤 | 5000 | 4.0 |
-- ✓ 正しいアプローチ: 各 1:N を個別に集計 → 1:1 で安全に JOIN SELECT u.name, o_agg.total_amount, r_agg.avg_rating FROM users AS u INNER JOIN ( SELECT user_id, SUM(amount) AS total_amount FROM orders GROUP BY user_id ) AS o_agg ON u.user_id = o_agg.user_id INNER JOIN ( SELECT user_id, ROUND(AVG(rating), 1) AS avg_rating FROM reviews GROUP BY user_id ) AS r_agg ON u.user_id = r_agg.user_id ORDER BY u.user_id; /* 実行順序: 1. サブクエリ① → orders を集計(o_agg) 2. サブクエリ② → reviews を集計(r_agg) 3. FROM users AS u → users を読み込む 4. INNER JOIN o_agg → user_id で結合 5. INNER JOIN r_agg → user_id で結合 6. SELECT → 結果を出力 */
LEGEND
① 派生テーブル①: orders 集計
SELECT user_id, SUM(amount) FROM orders GROUP BY user_idまず orders を user_id で集計し、派生テーブル o_agg を生成します。user_id が一意になるため、後の JOIN は 1:1 になります。| user_id | amount |
|---|---|
| 1 | 3000 |
| 1 | 1500 |
| 2 | 5000 |
u = users o_agg = orders集計 r_agg = reviews集計
SELECT DISTINCT で重複行を除去しても、集計関数(SUM・AVG)の値は直積の膨張を反映した間違った値のままです。DISTINCT は見た目の行を減らすだけで、集計の二重カウントは修正しません。SELECT COUNT(*) を実行し、想定より行数が多い場合はファントラップを疑ってください。特に同じテーブルに対して2つ以上の 1:N テーブルを JOIN するときは必ず起きます。「合計額がやけに大きい」も典型的なサインです。WITH 句(CTE)で可読性を高める方式、③ ウィンドウ関数を使う方式。いずれも核心は「先に集計してカーディナリティを 1:1 に揃える」ことです。