FULL OUTER JOIN — 双方向の欠損を許容して2つのデータセットを完全突合する
LEFT JOIN は「左テーブルを全て残す」、RIGHT JOIN は「右テーブルを全て残す」のに対し、FULL OUTER JOIN は「どちらのテーブルの行も取りこぼさずに全て結果に残す」結合です。片方にしか存在しない行は、反対側が NULL で補完されます。
-- 結合タイプの比較(左: A={1,2,3} 右: B={1,3,4} の場合) LEFT JOIN → 1, 2, 3 (Bに無い2はB側がNULL) RIGHT JOIN → 1, 3, 4 (Aに無い4はA側がNULL) FULL JOIN → 1, 2, 3, 4 (2はB側NULL、4はA側NULL)
COALESCE(lm.col, tm.col) と両側を書いてNULLを補完するのが定石です。LEFT JOIN … UNION ALL … RIGHT JOIN WHERE A.id IS NULL で代替します。PostgreSQL・BigQuery・SQL Server等では利用可能です。ECサイトの先月(last_month)と今月(this_month)の商品別売上テーブルがあります。商品によって、先月のみ売れたもの・今月のみ売れたもの・両月売れたものが混在しています。
全商品を網羅して、先月の売上(last_amount)と今月の売上(this_amount)を横に並べてください。片方の月に存在しない商品は NULL となります。
| product_id | product_name | amount |
|---|---|---|
| 1 | PC | 500 |
| 2 | マウス | 120 |
| 3 | キーボード | 280 |
| product_id | product_name | amount |
|---|---|---|
| 1 | PC | 630 |
| 3 | キーボード | 210 |
| 4 | モニター | 450 |
| product_id | product_name | last_amount | this_amount |
|---|---|---|---|
| 1 | PC | 500 | 630 |
| 2 | マウス | 120 | NULL |
| 3 | キーボード | 280 | 210 |
| 4 | モニター | NULL | 450 |
ANTI JOIN — LEFT JOIN + WHERE IS NULL で「存在しないこと」を条件にする
「テーブルAにあるが、テーブルBにはない行」を取得する操作を ANTI JOIN(差集合)と呼びます。離脱ユーザー検出・未処理チケットの洗い出し・マスタに存在しないデータの特定など、実務で非常に頻出するパターンです。
実現方法は主に2つあります。
-- ① LEFT JOIN + WHERE IS NULL(本問で学ぶ手法) SELECT a.* FROM A LEFT JOIN B ON a.id = b.id WHERE b.id IS NULL; -- ② NOT EXISTS(意図がより明確) SELECT a.* FROM A WHERE NOT EXISTS (SELECT 1 FROM B WHERE b.id = a.id);
WHERE a.id NOT IN (SELECT id FROM B) は、サブクエリ結果に NULL が1つでも含まれると全行が UNKNOWN になり、結果が空になります。ANTI JOINには LEFT JOIN か NOT EXISTS を使ってください。先月購入した顧客テーブル(last_buyers)と今月購入した顧客テーブル(this_buyers)があります。
先月は購入したが、今月は購入しなかった(離脱した)顧客の user_id と user_name を抽出してください。
| user_id | user_name |
|---|---|
| 1 | 田中 |
| 2 | 佐藤 |
| 3 | 鈴木 |
| 4 | 高橋 |
| user_id |
|---|
| 1 |
| 3 |
| user_id | user_name |
|---|---|
| 2 | 佐藤 |
| 4 | 高橋 |
多対多 (M:N) JOIN — 中間テーブルを2段階でたどってタグ検索を実装する
「1つの記事には複数のタグが付く」「1つのタグは複数の記事に付く」というような多対多(M:N)の関係は、外部キーを2つ持つ中間テーブル(junction table)でモデリングされます。これはWebアプリのDB設計で最も頻出する構造の1つです。
-- 多対多の代表的なDB構造 articles (article_id, title) ← エンティティA tags (tag_id, tag_name) ← エンティティB article_tags (article_id, tag_id) ← 中間テーブル(橋渡し) -- 取得パターン: articles → article_tags → tags と2段階でたどる FROM articles AS a INNER JOIN article_tags AS at ON a.article_id = at.article_id INNER JOIN tags AS t ON at.tag_id = t.tag_id
SELECT DISTINCT で重複を除去するのが定石です。記事テーブル(articles)、タグテーブル(tags)、および両者を紐付ける中間テーブル(article_tags)があります。
タグ名が 'React' のタグが付いた記事の article_id と title を取得してください。
| article_id | title |
|---|---|
| 1 | ReactでSPAを作る |
| 2 | SQL JOINを完全解説 |
| 3 | VueとReactを比較する |
| tag_id | tag_name |
|---|---|
| 1 | React |
| 2 | SQL |
| 3 | Vue |
| article_id | tag_id |
|---|---|
| 1 | 1 |
| 2 | 2 |
| 3 | 1 |
| 3 | 3 |
| article_id | title |
|---|---|
| 1 | ReactでSPAを作る |
| 3 | VueとReactを比較する |
CTE + JOIN — WITH句で仮想テーブルを段階的に構築してコホート分析を実装する
複雑な集計SQLを一つのSELECT文に詰め込むと、ネストしたサブクエリが積み重なって可読性・保守性が著しく低下します。WITH 句(CTE: Common Table Expression)を使うと、集計を段階的な名前付き仮想テーブルに分解し、それをメインクエリでJOINして使うことができます。
-- CTEの基本構文 WITH cte_name AS ( SELECT ... -- ここに集計クエリを書く FROM source_table GROUP BY ... ) SELECT * FROM main_table LEFT JOIN cte_name ON ...; -- CTEをテーブルとしてJOINできる
fo.first_order_date <= u.register_date + INTERVAL '30 days' のような計算式も書けます。WHEREでなくON句に書くことで、LEFT JOINの左側の行を消さずに条件を適用できます。ユーザーの登録情報(users)と注文履歴(orders)テーブルがあります。ユーザーは複数回注文することがあります。
登録月(コホート月)ごとに、登録から30日以内に初回購入したユーザー数(converted_users)と全体の登録者数(total_users)を集計してください。
| user_id | register_date |
|---|---|
| 1 | 2024-04-01 |
| 2 | 2024-04-20 |
| 3 | 2024-05-05 |
| 4 | 2024-05-10 |
| order_id | user_id | order_date |
|---|---|---|
| 101 | 1 | 2024-04-10 |
| 102 | 1 | 2024-06-01 |
| 103 | 2 | 2024-07-01 |
| 104 | 3 | 2024-05-15 |
| cohort_month | total_users | converted_users |
|---|---|---|
| 2024-04 | 2 | 1 |
| 2024-05 | 2 | 1 |
関係除算的 JOIN — 同一テーブルを複数回 JOIN して「AかつB」を満たす行を抽出する
「商品Aと商品Bの両方を購入したことがあるユーザーを探せ」という問題は、単純な WHERE … IN では解けません。なぜなら1行は1購入しか表さないため、「1行が同時に商品AであるかつBでもある」という矛盾した条件になるからです。
解決策は2つあります。
-- ① 同一テーブルを2回 SELF JOIN して、p1=商品A と p2=商品B を「横に並べる」 FROM purchases AS p1 INNER JOIN purchases AS p2 ON p1.user_id = p2.user_id WHERE p1.product_code = 'A' AND p2.product_code = 'B' -- ② HAVING で集計してから絞り込む(別解・商品数が多い場合に有効) SELECT user_id FROM purchases WHERE product_code IN ('A', 'B') GROUP BY user_id HAVING COUNT(DISTINCT product_code) = 2
ユーザーの購入履歴テーブル(purchases)があります。各行は「あるユーザーが特定の商品を1回購入した」という1レコードです。
商品コード 'A' と商品コード 'B' の両方を購入したことがあるユーザーの user_id を抽出してください。
| user_id | product_code |
|---|---|
| 1 | A |
| 1 | B |
| 1 | C |
| 2 | A |
| 3 | B |
| 4 | A |
| 4 | B |
| user_id |
|---|
| 1 |
| 4 |