SQL JOIN — 結合パターンとテーブル変化の基礎

基礎JOIN結合パターンWeb開発PostgreSQL対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 1

複合キーJOIN — 複数の条件を組み合わせて正確なレコードを結合する

INNER JOINAND条件複合キー多重照合防止
前提知識

結合キーが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;
WHERE句での絞り込みとの違い: 結合条件をWHEREに書くことも可能ですが、JOINの目的である「正しいレコード同士を紐づける」意図を明確にするため、複合キーの条件はすべて ON句に書くのがベストプラクティスです。
問題

出退勤履歴(attendance)とシフト予定(shifts)の2つのテーブルがあります。これらを user_idwork_date の両方が一致する条件で結合し、誰が・いつ・どんなシフトで・何時に出勤したかを取得してください。

使用テーブル
▸ attendance
user_idwork_datecheck_in
110-0108:50
110-0209:05
210-0108:55
▸ shifts
user_idwork_dateshift_type
110-01早番
110-02通常
210-01通常
210-02早番
期待出力
user_idwork_dateshift_typecheck_in
110-01早番08:50
110-02通常09:05
210-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, ...   → 結合された仮想テーブル
  */
解説(テーブル変化・ポイント)
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 AND a.work_date = s.work_date;
LEGEND
データ取得・読込対象
① FROM
FROM attendance AS a出退勤テーブル(3行)を読み込みます。この各行に対して、shifts テーブルとの複合キー照合が行われます。
1 / 3
user_idwork_datecheck_in
1'10-01''08:50'
1'10-02''09:05'
2'10-01''08:55'
全 3行 読込
学習ポイント
JOIN PATTERN
複合キーでの INNER JOIN
AND条件で複数の列がすべて一致する行を結合
a = attendance   s = shifts
複合キーによる精緻な照合:「ユーザーID」だけでJOINすると、1人のユーザーが複数日のシフトを持っている場合、全ての日付がクロスして結合されてしまいます(CROSS JOINのようになります)。user_idwork_date の両方を AND で繋ぐことで、「同じユーザーの、同じ日のデータ」だけを正確に1対1で結びつけることができます。
実務での頻出シーン:複合キーは、本問のような「履歴データ(同じIDが複数回登場する)」の他にも、「テナントID(企業ID)+ ユーザーID」のようなマルチテナント型SaaSのデータベース設計で常に要求される記述です。
アンチパターン
一部の条件を WHERE に分離してしまう:ON a.user_id = s.user_id WHERE a.work_date = s.work_date と書くことも文法上は可能ですが、JOINの意図(どの列で結合するか)が分散して可読性が落ちます。結合のための条件はすべて ON 句にまとめ、結果を絞り込むための条件(例: WHERE s.shift_type = '早番')を WHERE に書くのが美しいSQLの基本です。
QUESTION 2

多対多のJOIN — 中間テーブルを経由して2つのテーブルを紐づける

INNER JOIN中間テーブル交差テーブル多対多
前提知識

リレーショナル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)の一覧を取得してください。

使用テーブル
▸ users
user_idname
1田中
2佐藤
▸ user_tags (中間テーブル)
user_idtag_id
110
120
220
▸ tags
tag_idtag_name
10Python
20SQL
期待出力
nametag_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 → 必要な文字列の列だけを射影して出力
*/
解説(テーブル変化・ポイント)
SELECT u.name, t.tag_name FROM users AS u INNER JOIN user_tags AS ut ON u.user_id = ut.user_id INNER JOIN tags AS t ON ut.tag_id = t.tag_id;
LEGEND
データ取得・読込対象
① FROM
FROM users AS uusers テーブル(2行)を読み込みます。
1 / 4
user_idname
1田中
2佐藤
全 2行 読込
学習ポイント
JOIN PATTERN
中間テーブルを介した JOIN
ブリッジテーブルを経由して多対多を解決する
u = users   ut = user_tags   t = tags
多対多(Many-to-Many)の解決:「ブログ記事とカテゴリ」「ユーザーと権限ロール」など、実務のDB設計において多対多の関連は頻繁に登場します。これらをJOINで取得する場合は、必ず間に「ID同士のペアだけを持つテーブル(中間テーブル)」を挟んで、2回 INNER JOIN するのが定石です。
行の増殖(展開)に注意:ユーザーテーブルは元々2行ですが、中間テーブルと結合した時点で、持っているタグの数だけ行が複製(展開)されます。もし最終的に「ユーザーごとの行」に戻したい場合は、GROUP BYSTRING_AGG() 等の集約関数を組み合わせてカンマ区切り文字列にまとめる処理が必要になります。
アンチパターン
カンマ区切りでデータを保存する(アンチパターン):中間テーブルを作らずに、usersテーブルに tags = "10,20" のようにカンマ区切りで保存してしまう設計(ジェイウォーク)は最悪のアンチパターンです。特定のタグを持つユーザーを検索する際にインデックスが効かず、全件スキャン(LIKE検索など)になるため大規模データで致命的なパフォーマンス低下を招きます。
QUESTION 3

不等価JOIN — =(イコール)以外の条件でテーブルを結合する

INNER JOINBETWEEN範囲結合不等価
前提知識

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)を取得してください。

使用テーブル
▸ test_scores
studentscore
田中85
佐藤55
山田72
▸ grading_scales
grademin_scoremax_score
A80100
B6079
C059
期待出力
studentscoregrade
田中85A
佐藤55C
山田72B
模範解答コード
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 → 入力表の生徒順(田中→佐藤→山田)で出力
*/
解説(テーブル変化・ポイント)
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;
LEGEND
データ取得・読込対象
① FROM
FROM test_scores AS tsテスト点数テーブル(3行)を読み込みます。score の値が JOIN の評価対象になります。
1 / 3
studentscore
田中85
佐藤55
山田72
全 3行 読込
学習ポイント
JOIN PATTERN
不等価結合(Non-Equi JOIN)
等号(=)以外を用いて条件に合致する行を結合
ts = test_scores   gs = grading_scales
ON句は単なる「真偽判定」:ON句の役割は、結合した行の組み合わせが「条件を満たす(TRUE)か、満たさない(FALSE)か」を評価することです。=(イコール)である必要はなく、BETWEEN(範囲)や >= など、評価結果が真偽値になる式であれば何でも書くことができます。
CASE式によるハードコードの回避:成績ランクを CASE WHEN score >= 80 THEN 'A' ... END のようにSQL内にベタ書きすることも可能ですが、不等価JOINを使えば「評価基準(マスタデータ)が変更されたら、テーブルの値を更新するだけで済む」という、保守性の高い柔軟なシステム設計が可能になります。
アンチパターン
範囲マスタの重複:不等価JOINを行うマスタテーブル(今回なら grading_scales)の範囲が重複していると(例:80〜100 と 75〜85 のように)、1つの点数が複数のランクにマッチしてしまい、結果行が不要に増殖します。マスタデータの「範囲の隙間」と「重複」には厳密な制約を持たせる必要があります。
QUESTION 4

FULL OUTER JOIN — 両テーブルの全行を保持し、欠損データもすべて出力する

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;
実務での用途: 日常的なWeb開発で頻出するわけではありませんが、「新旧システムのデータ突合(どちらかに欠損がないか)」や「予実管理(予算だけある部門、実績だけある部門の両方を並べる)」などのデータ分析・監査タスクで非常に重宝します。
問題

部署マスタ(departments)と、社員テーブル(employees)があります。

これを dept_id で結合し、「社員が誰もいない部署」も「部署が決まっていない社員」もすべて出力してください。

使用テーブル
▸ departments
dept_iddept_name
1営業
2開発
3人事
▸ employees
emp_idnamedept_id
101田中1
102佐藤2
103鈴木NULL
期待出力
dept_namename
営業田中
開発佐藤
人事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行)を射影して出力
*/
解説(テーブル変化・ポイント)
SELECT d.dept_name, e.name FROM departments AS d FULL OUTER JOIN employees AS e ON d.dept_id = e.dept_id;
LEGEND
データ取得・読込対象
① FROM
FROM departments AS ddepartments テーブル(3行)を左テーブルとして読み込みます。
1 / 3
dept_iddept_name
1営業
2開発
3人事
全 3行 読込
学習ポイント
JOIN PATTERN
FULL OUTER JOIN — 完全外部結合
両テーブルの全データを失わずに1つにまとめる
d = departments   e = employees
LEFT JOIN と RIGHT JOIN の融合:LEFT JOIN では「鈴木(部署なし)」が消え、RIGHT JOIN では「人事部(社員なし)」が消えてしまいます。FULL OUTER JOIN を使うことで、両方の「孤立したデータ」を消さずに拾い上げ、欠損部分を NULL で埋めて出力できます。
RDBMSごとの対応状況:PostgreSQL、SQL Server、Oracle では標準サポートされています。MySQL は長らくサポートしていなかったため、MySQLで同じ結果を得るには LEFT JOIN の結果と RIGHT JOIN の結果を UNION で結合するテクニックが使われます。
アンチパターン
安易な FULL OUTER の利用:何でも FULL OUTER JOIN にすればデータが消えないので安全だ、と考えて多用するのはアンチパターンです。行数が爆発的に増えたり、意図しない NULL データが混入して後続の集計(COUNT等)を狂わせる原因になります。「マスタ結合」などの明確な目的には INNER や LEFT を使い、FULL は「差分突合」など特定の目的にのみ使いましょう。
QUESTION 5

JOIN + サブクエリ — 事前に集計した仮想テーブルと結合する

LEFT 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;
サブクエリ JOIN の強み: メインクエリの中で GROUP BY を行うとデータ全体の行数が縮退してしまいますが、サブクエリで先に集計し LEFT JOIN で付与すれば、「ユーザーの基本情報(全行)」を維持したまま、横に「集計値」をくっつけることができます。
問題

ユーザーテーブル(users)と、注文履歴(orders)があります。

あらかじめサブクエリを使って「ユーザー別の最新の注文日(last_order)」を計算した仮想テーブルを作成し、それを usersLEFT JOIN して全ユーザーの最新注文日を出力してください。未注文のユーザーは NULL となります。

使用テーブル
▸ users
user_idname
1田中
2佐藤
3山田
▸ orders
order_iduser_idorder_date
112023-10-01
212023-10-05
322023-10-02
期待出力
namelast_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, ...   → 列を射影
  */
解説(テーブル変化・ポイント)
SELECT u.name, lo.last_order FROM users AS u LEFT JOIN ( SELECT user_id, MAX(order_date) AS last_order FROM orders GROUP BY user_id ) AS lo ON u.user_id = lo.user_id;
LEGEND
データ取得・読込対象
① サブクエリ FROM
FROM ordersまずカッコ内のサブクエリが実行されます。注文履歴(orders)テーブル(3行)を読み込みます。
1 / 5
order_iduser_idorder_date
11'2023-10-01'
21'2023-10-05'
32'2023-10-02'
全 3行 読込
学習ポイント
JOIN PATTERN
サブクエリ(派生テーブル)との LEFT JOIN
事前に計算した結果を横付けしてデータ粒度を揃える
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回のバルク処理で済むため、実務で推奨される書き方です。
実務コラム
サブクエリを利用した仮想テーブルの生成は、さらに進化した「CTE(WITH句)」で書くことが現代のモダンSQLの主流です。WITH last_orders AS (SELECT ...) SELECT ... FROM users LEFT JOIN last_orders のように書くことで、ネストが深くならず上から下へ処理を記述でき、圧倒的にコードが見やすくなります。サブクエリ(派生テーブル)の概念を理解した後は、ぜひ CTE の書き方もマスターしてください。