複合キーJOIN — 複数の条件を組み合わせて正確なレコードを結合する
結合キーが1つだけではレコードが一意に定まらない場合、ON句で AND を使って複数の列を結合条件(複合キー)にします。履歴データやテナントIDが存在するマルチテナント設計など、実務では極めて頻出のパターンです。
FROM table_a AS a INNER JOIN table_b AS b ON a.key1 = b.key1 AND a.key2 = b.key2;
出退勤履歴(attendance)とシフト予定(shifts)の2つのテーブルがあります。これらを user_id と work_date の両方が一致する条件で結合し、誰が・いつ・どんなシフトで・何時に出勤したかを取得してください。
| user_id | work_date | check_in |
|---|---|---|
| 1 | 10-01 | 08:50 |
| 1 | 10-02 | 09:05 |
| 2 | 10-01 | 08:55 |
| user_id | work_date | shift_type |
|---|---|---|
| 1 | 10-01 | 早番 |
| 1 | 10-02 | 通常 |
| 2 | 10-01 | 通常 |
| 2 | 10-02 | 早番 |
| user_id | work_date | shift_type | check_in |
|---|---|---|---|
| 1 | 10-01 | 早番 | 08:50 |
| 1 | 10-02 | 通常 | 09:05 |
| 2 | 10-01 | 通常 | 08:55 |
SELECT a.user_id, a.work_date, s.shift_type, a.check_in FROM attendance AS a INNER JOIN shifts AS s ON a.user_id = s.user_id -- ユーザーIDが一致し、かつ日付も一致する行だけを結合する AND a.work_date = s.work_date ORDER BY a.user_id, a.work_date; /* 実行順序: 1. FROM attendance AS a → 出退勤テーブル 2. INNER JOIN shifts AS s → ON句の2つの条件を同時に満たす shifts の行を照合 3. SELECT a.user_id, ... → 結合された仮想テーブル */
LEGEND
① FROM
FROM attendance AS a出退勤テーブル(3行)を読み込みます。この各行に対して、shifts テーブルとの複合キー照合が行われます。| user_id | work_date | check_in |
|---|---|---|
| 1 | '10-01' | '08:50' |
| 1 | '10-02' | '09:05' |
| 2 | '10-01' | '08:55' |
a = attendance s = shifts
user_id と work_date の両方を AND で繋ぐことで、「同じユーザーの、同じ日のデータ」だけを正確に1対1で結びつけることができます。ON a.user_id = s.user_id WHERE a.work_date = s.work_date と書くことも文法上は可能ですが、JOINの意図(どの列で結合するか)が分散して可読性が落ちます。結合のための条件はすべて ON 句にまとめ、結果を絞り込むための条件(例: WHERE s.shift_type = '早番')を WHERE に書くのが美しいSQLの基本です。多対多のJOIN — 中間テーブルを経由して2つのテーブルを紐づける
リレーショナルDBにおいて、「1人のユーザーが複数のタグを持つ」「1つのタグが複数のユーザーにつけられる」という関係を多対多(Many-to-Many)と呼びます。多対多のデータを直接結合することはできないため、間に中間テーブル(交差テーブル)を配置し、JOINを2回連続で行うことでデータを引き出します。
FROM users AS u INNER JOIN user_tags AS ut ON u.user_id = ut.user_id -- 1回目のJOIN (中間テーブルへ) INNER JOIN tags AS t ON ut.tag_id = t.tag_id; -- 2回目のJOIN (目的のテーブルへ)
ユーザー情報(users)、タグ情報(tags)、およびその紐付けを管理する中間テーブル(user_tags)があります。
これら3つのテーブルを結合し、ユーザー名(name)とタグ名(tag_name)の一覧を取得してください。
| user_id | name |
|---|---|
| 1 | 田中 |
| 2 | 佐藤 |
| user_id | tag_id |
|---|---|
| 1 | 10 |
| 1 | 20 |
| 2 | 20 |
| tag_id | tag_name |
|---|---|
| 10 | Python |
| 20 | SQL |
| name | tag_name |
|---|---|
| 田中 | Python |
| 田中 | SQL |
| 佐藤 | SQL |
SELECT u.name, t.tag_name FROM users AS u INNER JOIN user_tags AS ut -- ① まず中間テーブルと結合し、ユーザーが持つ tag_id を引き当てる ON u.user_id = ut.user_id INNER JOIN tags AS t -- ② 中間テーブルの tag_id を使って、tags テーブルからタグ名を引き当てる ON ut.tag_id = t.tag_id ORDER BY u.user_id, t.tag_name; /* 実行順序: 1. FROM users AS u → users (2行) を読み込む 2. INNER JOIN user_tags AS ut→ user_id で照合。田中(1)は2行、佐藤(2)は1行に展開される (計3行の中間結果) 3. INNER JOIN tags AS t → 展開された3行の tag_id をもとに tags を照合し結合 4. SELECT u.name, t.tag_name → 必要な文字列の列だけを射影して出力 */
LEGEND
① FROM
FROM users AS uusers テーブル(2行)を読み込みます。| user_id | name |
|---|---|
| 1 | 田中 |
| 2 | 佐藤 |
u = users ut = user_tags t = tags
GROUP BY や STRING_AGG() 等の集約関数を組み合わせてカンマ区切り文字列にまとめる処理が必要になります。tags = "10,20" のようにカンマ区切りで保存してしまう設計(ジェイウォーク)は最悪のアンチパターンです。特定のタグを持つユーザーを検索する際にインデックスが効かず、全件スキャン(LIKE検索など)になるため大規模データで致命的なパフォーマンス低下を招きます。不等価JOIN — =(イコール)以外の条件でテーブルを結合する
JOINの ON 句には、= (等価) だけでなく BETWEEN や >= などの不等価条件も使用できます。これを「不等価結合(Non-Equi JOIN)」と呼びます。
売上金額をランク(A〜C)に分類したり、日付が特定のキャンペーン期間に含まれるかを判定したりする際に、別テーブルの「範囲マスタ」と結合する強力なテクニックです。
FROM scores AS s INNER JOIN scales AS g -- scoreが、min と max の間に収まる行と結合する ON s.score BETWEEN g.min_score AND g.max_score;
生徒のテスト点数(test_scores)と、成績の評価基準マスタ(grading_scales)があります。これらを結合し、生徒名(student)と点数(score)、および成績ランク(grade)を取得してください。
| student | score |
|---|---|
| 田中 | 85 |
| 佐藤 | 55 |
| 山田 | 72 |
| grade | min_score | max_score |
|---|---|---|
| A | 80 | 100 |
| B | 60 | 79 |
| C | 0 | 59 |
| student | score | grade |
|---|---|---|
| 田中 | 85 | A |
| 佐藤 | 55 | C |
| 山田 | 72 | B |
SELECT ts.student, ts.score, gs.grade FROM test_scores AS ts INNER JOIN grading_scales AS gs -- イコールではなく、スコアが範囲内に収まるマスタ行と結合する ON ts.score BETWEEN gs.min_score AND gs.max_score ORDER BY CASE ts.student WHEN '田中' THEN 1 WHEN '佐藤' THEN 2 ELSE 3 END; /* 実行順序: 1. FROM test_scores AS ts → 点数テーブル(3行)を読み込む 2. INNER JOIN grading_scales → ts.score の値が、gs の min 〜 max の範囲に含まれるか判定 田中の 85 は 80〜100 にマッチ (A) 佐藤の 55 は 0〜59 にマッチ (C) 山田の 72 は 60〜79 にマッチ (B) 3. SELECT ts.student, ... → 結合された仮想テーブルから3列を射影 4. ORDER BY CASE ts.student WHEN '田中' THEN 1 WHEN '佐藤' THEN 2 ELSE 3 END → 入力表の生徒順(田中→佐藤→山田)で出力 */
LEGEND
① FROM
FROM test_scores AS tsテスト点数テーブル(3行)を読み込みます。score の値が JOIN の評価対象になります。| student | score |
|---|---|
| 田中 | 85 |
| 佐藤 | 55 |
| 山田 | 72 |
ts = test_scores gs = grading_scales
=(イコール)である必要はなく、BETWEEN(範囲)や >= など、評価結果が真偽値になる式であれば何でも書くことができます。CASE WHEN score >= 80 THEN 'A' ... END のようにSQL内にベタ書きすることも可能ですが、不等価JOINを使えば「評価基準(マスタデータ)が変更されたら、テーブルの値を更新するだけで済む」という、保守性の高い柔軟なシステム設計が可能になります。grading_scales)の範囲が重複していると(例:80〜100 と 75〜85 のように)、1つの点数が複数のランクにマッチしてしまい、結果行が不要に増殖します。マスタデータの「範囲の隙間」と「重複」には厳密な制約を持たせる必要があります。FULL OUTER JOIN — 両テーブルの全行を保持し、欠損データもすべて出力する
FULL OUTER JOIN(完全外部結合)は、LEFT JOIN と RIGHT JOIN の両方の特性を併せ持つ結合です。
左テーブルと右テーブルのすべての行を結果に保持し、お互いにマッチしないデータについてはすべて NULL で補完して出力します。
FROM table_a AS a FULL OUTER JOIN table_b AS b ON a.id = b.id;
部署マスタ(departments)と、社員テーブル(employees)があります。
これを dept_id で結合し、「社員が誰もいない部署」も「部署が決まっていない社員」もすべて出力してください。
| dept_id | dept_name |
|---|---|
| 1 | 営業 |
| 2 | 開発 |
| 3 | 人事 |
| emp_id | name | dept_id |
|---|---|---|
| 101 | 田中 | 1 |
| 102 | 佐藤 | 2 |
| 103 | 鈴木 | NULL |
| dept_name | name |
|---|---|
| 営業 | 田中 |
| 開発 | 佐藤 |
| 人事 | NULL |
| NULL | 鈴木 |
SELECT d.dept_name, e.name FROM departments AS d FULL OUTER JOIN employees AS e -- LEFT JOIN(人事部を残す) と RIGHT JOIN(鈴木を残す) を同時に行う ON d.dept_id = e.dept_id ORDER BY COALESCE(d.dept_id, e.dept_id); /* 実行順序: 1. FROM departments AS d → 部署テーブル(3行)を読み込む 2. FULL OUTER JOIN employees e → 両者の dept_id を照合 ・マッチあり: 営業(田中)、開発(佐藤) ・d 側のみ存在: 人事(e 側はNULL補完) ・e 側のみ存在: 鈴木(d 側はNULL補完) 3. SELECT d.dept_name, e.name → 全ての行(合計4行)を射影して出力 */
LEGEND
① FROM
FROM departments AS ddepartments テーブル(3行)を左テーブルとして読み込みます。| dept_id | dept_name |
|---|---|
| 1 | 営業 |
| 2 | 開発 |
| 3 | 人事 |
d = departments e = employees
FULL OUTER JOIN を使うことで、両方の「孤立したデータ」を消さずに拾い上げ、欠損部分を NULL で埋めて出力できます。LEFT JOIN の結果と RIGHT JOIN の結果を UNION で結合するテクニックが使われます。JOIN + サブクエリ — 事前に集計した仮想テーブルと結合する
結合するテーブルは物理的なテーブルだけではありません。() 内で事前に集計(GROUP BY)を行った結果を「派生テーブル(インラインビュー)」として定義し、それとメインのテーブルを JOIN することができます。
FROM users AS u LEFT JOIN ( -- 事前にユーザー別の合計などを集計するサブクエリ SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id ) AS o ON u.user_id = o.user_id;
GROUP BY を行うとデータ全体の行数が縮退してしまいますが、サブクエリで先に集計し LEFT JOIN で付与すれば、「ユーザーの基本情報(全行)」を維持したまま、横に「集計値」をくっつけることができます。ユーザーテーブル(users)と、注文履歴(orders)があります。
あらかじめサブクエリを使って「ユーザー別の最新の注文日(last_order)」を計算した仮想テーブルを作成し、それを users に LEFT JOIN して全ユーザーの最新注文日を出力してください。未注文のユーザーは NULL となります。
| user_id | name |
|---|---|
| 1 | 田中 |
| 2 | 佐藤 |
| 3 | 山田 |
| order_id | user_id | order_date |
|---|---|---|
| 1 | 1 | 2023-10-01 |
| 2 | 1 | 2023-10-05 |
| 3 | 2 | 2023-10-02 |
| name | last_order |
|---|---|
| 田中 | 2023-10-05 |
| 佐藤 | 2023-10-02 |
| 山田 | NULL |
SELECT u.name, lo.last_order FROM users AS u LEFT JOIN ( -- 仮想テーブル: orders テーブルをユーザー単位で集計し、最大日付を取得 SELECT user_id, MAX(order_date) AS last_order FROM orders GROUP BY user_id ) AS lo ON u.user_id = lo.user_id -- users と 仮想テーブル(lo) を user_id で結合 ORDER BY u.user_id; /* 実行順序: 1. サブクエリ FROM orders → orders を読み込む 2. サブクエリ集計 → ユーザーごとに MAX 日付を算出(lo) 3. FROM users AS u → users を読み込む 4. LEFT JOIN ... AS lo → lo と結合(未注文はNULL) 5. SELECT u.name, ... → 列を射影 */
LEGEND
① サブクエリ FROM
FROM ordersまずカッコ内のサブクエリが実行されます。注文履歴(orders)テーブル(3行)を読み込みます。| order_id | user_id | order_date |
|---|---|---|
| 1 | 1 | '2023-10-01' |
| 2 | 1 | '2023-10-05' |
| 3 | 2 | '2023-10-02' |
u = users sub = 仮想テーブル
LEFT JOIN orders を行ってから GROUP BY u.name とすることも可能ですが、集計処理(SUM や MAX)が複雑になる場合、事前に () のサブクエリで「ユーザーごとの集計テーブル」を作ってしまった方が、頭の中のロジックとSQLが一致しやすく、可読性も高まります。SELECT u.name, (SELECT MAX(order_date) FROM orders WHERE user_id = u.user_id) FROM users のように SELECT 句内に書く方法(相関サブクエリ)もありますが、行数分だけサブクエリが実行されてパフォーマンスが悪化する可能性があります。JOIN + 派生テーブルのアプローチは1回のバルク処理で済むため、実務で推奨される書き方です。WITH last_orders AS (SELECT ...) SELECT ... FROM users LEFT JOIN last_orders のように書くことで、ネストが深くならず上から下へ処理を記述でき、圧倒的にコードが見やすくなります。サブクエリ(派生テーブル)の概念を理解した後は、ぜひ CTE の書き方もマスターしてください。