SQL カーディナリティ — LEFT JOIN・ROW_NUMBERの基礎

基礎カーディナリティLEFT JOINROW_NUMBERCTEPostgreSQL対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 6

LEFT JOINのゼロ集計の落とし穴 — COUNT(*) と COUNT(列名) の明確な違い

LEFT JOIN1:NNULL処理COUNT関数
前提知識

INNER JOIN では、社員が0人の「人事部」は結果から除外されます。LEFT JOIN(左外部結合)はこの制限を解消し、左テーブルの全行を必ず保持します。右テーブルにマッチする行がない場合はNULLを埋めた行を自動生成します。

-- INNER JOIN: 社員ゼロの部署は除外される
FROM departments INNER JOIN employees ...  -- 人事部: 結果に現れない

-- LEFT JOIN: 社員ゼロの部署も保持、社員側はNULL
FROM departments LEFT JOIN employees ...   -- 人事部: emp_id=NULL で残る
COUNT(*) と COUNT(列名) — 最も重要な違い:
COUNT(*) は「行の存在」を数えます。NULL列を持つ行も「1行として存在する」ため、社員ゼロの部署を誤って1人とカウントします。
COUNT(e.emp_id) は「その列が NULL でない行数」を返し、NULL行をスキップして正しく0を返します。
問題

departmentsemployees を LEFT JOIN し、部門ごとの社員数(emp_count)を取得してください。社員が0人の部門も必ず含めること。

使用テーブル
▸ departments
dept_iddept_name
1営業部
2開発部
3人事部
▸ employees
emp_idnamedept_id
1田中1
2佐藤1
3山田2
4鈴木2
期待出力
dept_nameemp_count
営業部2
開発部2
人事部0
模範解答コード
-- ✗ COUNT(*): NULL 行も1としてカウント → 人事部が 1(誤り)
-- SELECT d.dept_name, COUNT(*) AS emp_count ...

SELECT
  d.dept_name,
  COUNT(e.emp_id) AS emp_count   -- NULL をスキップ → 人事部は 0(正解)
FROM departments AS d
LEFT JOIN employees AS e
  ON d.dept_id = e.dept_id
GROUP BY d.dept_id, d.dept_name
ORDER BY d.dept_id;

/*
  実行順序:
  1. FROM departments AS d          → departments を読み込む
  2. LEFT JOIN employees AS e       → 結合(1:N で展開)
  3. GROUP BY dept_id, dept_name    → グループ化
  4. COUNT(e.emp_id)                → NULLをスキップして件数集計
  5. SELECT d.dept_name, emp_count  → 2列を射影
  6. ORDER BY d.dept_id             → 並び替えて出力
  */
解説(テーブル変化・ポイント)
SELECT d.dept_name, COUNT(e.emp_id) AS emp_count FROM departments AS d LEFT JOIN employees AS e ON d.dept_id = e.dept_id GROUP BY d.dept_id, d.dept_name ORDER BY d.dept_id;
LEGEND
データ取得・読込対象
① 左テーブル
FROM departments AS ddepartments テーブル(3行)を読み込みます。人事部(dept_id=3)には社員が存在しませんが、LEFT JOIN を使えば結果に含めることができます。
1 / 6
dept_iddept_name
1営業部
2開発部
3人事部
全3行 読込(左テーブル — LEFT JOIN で全行保持)
学習ポイント
NULL × COUNT
LEFT JOIN後のNULL行 — COUNT(*) と COUNT(列名) の挙動の違い
社員ゼロの部署を正しく0集計するためのパターン
LEFT JOIN → NULL生成 → COUNT(e.emp_id) でスキップ
COUNT(*) と COUNT(列名) の本質:COUNT(*) は「現在の行が存在するかどうか」だけを評価します。NULL列を持つ行も「1行として存在する」ため、LEFT JOIN で生成された NULL 行を1としてカウントします。一方 COUNT(e.emp_id) は「e.emp_id が NULL でない行数」を返すため、NULL行をスキップして正しく0を返します。この区別は SQL 最頻出の集計ミスの1つです。
LEFT JOIN後の行数変化:departments(3行)を左テーブルとして employees(4行)を LEFT JOIN すると、マッチした社員行(4行)+人事部の NULL 行(1行)で計5行になります。INNER JOIN との違いは、マッチなしの左テーブル行が除外されずに NULL として保持される点です。GROUP BY 前に必ず行数を把握する習慣が重要です。
アンチパターン
COALESCE でNULLを0に変換してから COUNT:COUNT(COALESCE(e.emp_id, 0)) と書くと、COALESCE が NULL を 0 に変換するため COUNT は 0 も「非 NULL」として1と数えます。結果として COUNT(*) と同じく誤った1が返ります。NULL をスキップしたい場合は素直に COUNT(e.emp_id) を使いましょう。
「0件も出力したい」のにINNER JOINを使う:INNER JOIN では右テーブルにマッチしない左テーブルの行が除外されます。社員ゼロの部署を集計に含めるには必ず LEFT JOIN が必要です。「0件も含む集計」要件はまず LEFT JOIN を検討するサインです。
実務コラム
「社員ゼロの部署も0件として表示する」はダッシュボード・レポート開発で非常によく出る要件です。同様のパターン: 注文ゼロの商品も含む売上レポート(products LEFT JOIN orders → COUNT(o.order_id))、回答ゼロの設問も含むアンケート集計(questions LEFT JOIN answers → COUNT(a.answer_id))。COUNT(*) と COUNT(列名) の違いは SUM には存在しません(NULL は SUM 計算でスキップ)が、COUNT には必ず意識してください。
QUESTION 7

N側から1件だけを取り出す — ROW_NUMBER() を活用した最新レコード結合

Window関数CTE1:N→1:1ROW_NUMBER
前提知識

「ユーザーごとに最新のログイン1件だけを取得したい」という要件を 1:N の JOIN で実現しようとすると、そのままでは login_history の行数分だけ users の行が展開されてしまいます。

Window関数(ウィンドウ関数)の ROW_NUMBER() は、グループ内の各行に連番を付与します。グループを PARTITION BY、並び順を ORDER BY で指定するため、「ユーザーごとに日付の新しい順で1番の行(rn=1)」を抽出することができます。

-- ROW_NUMBER() の構文
ROW_NUMBER() OVER (
  PARTITION BY user_id         -- ユーザーごとにリセット
  ORDER BY login_at DESC      -- 新しい順に番号付け
) AS rn                        -- rn=1 が最新ログイン
Window関数 vs GROUP BY:GROUP BY は行を集約して消すのに対し、Window関数は行を消さずに列を追加します。ROW_NUMBER はすべての行を保持しながら順位情報を付与するため、後の JOIN で「rn=1 のみ取得」という絞り込みが可能になります。
問題

users テーブルに対して、各ユーザーの最新ログイン日時(last_login)を結合してください。ログイン履歴がない場合は NULL が入っても構いません。

使用テーブル
▸ users
user_idname
1田中
2佐藤
3山田
▸ login_history
log_iduser_idlogin_at
112024-03-10
212024-03-15
322024-03-08
422024-03-10
532024-03-12
期待出力
namelast_login
田中2024-03-15
佐藤2024-03-10
山田2024-03-12
模範解答コード
WITH ranked_logins AS (
  SELECT
    user_id,
    login_at,
    ROW_NUMBER() OVER (
      PARTITION BY user_id
      ORDER BY login_at DESC   -- 最新が rn=1 になる
    ) AS rn
  FROM login_history
)
SELECT
  u.name,
  r.login_at AS last_login
FROM users AS u
INNER JOIN ranked_logins AS r
  ON  u.user_id = r.user_id
  AND r.rn = 1              -- 最新1件のみと結合 → 1:1 になる
ORDER BY u.user_id;

/*
  実行順序:
  1. CTE ranked_logins                → login_history を読み込む
  2. ROW_NUMBER() OVER (...)          → ユーザーごとに最新順で番号付け
  3. FROM users AS u                  → users を読み込む
  4. INNER JOIN ranked_logins (rn=1)  → 最新行のみ結合
  5. SELECT u.name, r.login_at        → 2列を射影
  6. ORDER BY u.user_id               → 並び替えて出力
  */
解説(テーブル変化・ポイント)
WITH ranked_logins AS ( SELECT user_id, login_at, ROW_NUMBER() OVER ( PARTITION BY user_id ORDER BY login_at DESC ) AS rn FROM login_history ) SELECT u.name, r.login_at AS last_login FROM users AS u INNER JOIN ranked_logins AS r ON u.user_id = r.user_id AND r.rn = 1 ORDER BY u.user_id;
LEGEND
データ取得・読込対象
① CTE — 元データ
FROM login_historylogin_history テーブル(5行)を読み込みます。このままJOINすると users の行が展開されてしまいます。
1 / 6
log_iduser_idlogin_at
112024-03-10
212024-03-15
322024-03-08
422024-03-10
532024-03-12
login_history: 5行
学習ポイント
WINDOW FUNCTION
ROW_NUMBER + rn=1 — N側を1件に絞って 1:1 結合へ変換
N行ある履歴から最新1件だけを安全に取り出すモダンパターン
login_history(N行) → rn=1に絞る → users と1:1 で結合
ROW_NUMBER() vs GROUP BY + MAX:GROUP BY + MAX(login_at) でも最新日時は取れますが、login_at 以外の列(例: ログインIPや端末情報)を同時に取得したい場合は使えません。ROW_NUMBER パターンはその行のすべての列を保持したまま取り出せるため、実務での汎用性が格段に高いです。
AND r.rn = 1 の結合条件の意味:JOIN 条件に AND r.rn = 1 を加えることで、各ユーザーのうちrn=1の行(= 最新ログイン)だけが結合対象になります。rn=2以降の行は結合されないため、JOIN後の行数は users の行数(3行)のまま維持されます。N:1 の展開が起きない理由は「rn=1 がユーザーごとに必ず1行」という保証があるからです。
アンチパターン
ROW_NUMBER と RANK の混同:RANK() は同順位に同じ番号を付与します。もし login_at の値が全く同一の行が2行あった場合、RANK では両方が rn=1 になり JOIN 後に2行が展開されます。必ず1行にしたい場合は ROW_NUMBER を使いましょう。
WHERE r.rn = 1 と書いてしまう(外側のSELECTでフィルタ):CTE を使わずに FROM (SELECT ..., ROW_NUMBER() ... ) AS sub WHERE sub.rn = 1 と書くことも可能ですが、CTE を使うと可読性が上がります。重要なのは ROW_NUMBER を計算した後に rn=1 でフィルタする点で、Window関数は WHERE 句では直接使えないため必ずサブクエリか CTE が必要です。
実務コラム
「テーブルから各グループの最新/最大/最小の1行だけを取り出す」は実務の最頻出パターンの一つです。応用例: 顧客ごとの最新注文(orders テーブルで ROW_NUMBER PARTITION BY customer_id ORDER BY ordered_at DESC)、商品ごとの最新価格(price_history テーブル)、社員ごとの現在の役職(position_history テーブル)。GROUP BY+MAX で代替できない「その行の複数列を取り出したい」場面で特に威力を発揮します。
QUESTION 8

結合前の事前集約(Pre-aggregation) — CTEを用いた複数1:N結合のエレガントな解決

CTE(WITH句)ファントラップ回避事前集約1:N×2
前提知識

ファントラップはインラインサブクエリでも回避できますが、同じ問題を CTE(WITH句)で書くとクエリが格段に読みやすくなります。CTE は名前付きのサブクエリで、メインクエリより先に実行されます。

-- CTE の基本構文: 先に集計テーブルを定義してからJOIN
WITH emp_agg AS (
  SELECT dept_id, SUM(salary) AS total_salary
  FROM employees
  GROUP BY dept_id             -- 1:N を 1:1 に変換
),
sales_agg AS (...)           -- 複数CTEはカンマで連結
SELECT ... FROM departments
JOIN emp_agg ...               -- 1:1 × 1:1 の安全な結合
なぜインラインサブクエリよりCTEが優れるか:
可読性: 集計ロジックを先に定義するため、メインクエリがシンプルになる。
再利用性: 同じCTEを複数回参照できる。
デバッグ容易性: CTEを単独で SELECT して途中結果を確認できる。
問題

部門ごとに社員の給与合計(total_salary)と売上合計(total_sales)を取得してください。CTE(WITH句)を使って各1:Nテーブルを事前に集約してからJOINする正しいアプローチで解いてください。

使用テーブル
▸ departments
dept_iddept_name
1営業部
2開発部
▸ employees
emp_iddept_idnamesalary
11田中400000
21佐藤350000
32山田500000
42鈴木450000
▸ dept_sales
sale_iddept_idamount
11800000
21600000
321200000
42900000
期待出力
dept_nametotal_salarytotal_sales
営業部7500001400000
開発部9500002100000
模範解答コード
WITH emp_agg AS (
  SELECT dept_id, SUM(salary) AS total_salary
  FROM employees
  GROUP BY dept_id            -- 1:N → 1:1 に変換(dept_id が一意になる)
),
sales_agg AS (
  SELECT dept_id, SUM(amount) AS total_sales
  FROM dept_sales
  GROUP BY dept_id            -- 1:N → 1:1 に変換(dept_id が一意になる)
)
SELECT
  d.dept_name,
  e.total_salary,
  s.total_sales
FROM departments AS d
INNER JOIN emp_agg  AS e ON d.dept_id = e.dept_id  -- 1:1
INNER JOIN sales_agg AS s ON d.dept_id = s.dept_id  -- 1:1
ORDER BY d.dept_id;

/*
  実行順序:
  1. CTE emp_agg           → employees を集計(dept_id 一意)
  2. CTE sales_agg         → dept_sales を集計(dept_id 一意)
  3. FROM departments      → departments を読み込む
  4. INNER JOIN emp_agg    → dept_id で 1:1 結合
  5. INNER JOIN sales_agg  → dept_id で 1:1 結合
  6. SELECT, ORDER BY      → 列を射影し並び替え
  */
解説(テーブル変化・ポイント)
WITH emp_agg AS ( SELECT dept_id, SUM(salary) AS total_salary FROM employees GROUP BY dept_id ), sales_agg AS ( SELECT dept_id, SUM(amount) AS total_sales FROM dept_sales GROUP BY dept_id ) SELECT d.dept_name, e.total_salary, s.total_sales FROM departments AS d INNER JOIN emp_agg AS e ON d.dept_id = e.dept_id INNER JOIN sales_agg AS s ON d.dept_id = s.dept_id ORDER BY d.dept_id;
LEGEND
データ取得・読込対象
① CTE①元データ — employees
FROM employees(給与データ)まず、1つ目の 1:N テーブルである employees(4行)を確認します。このままでは部門ごとに複数行あるため、dept_id で集約する必要があります。
1 / 7
emp_iddept_idnamesalary
11田中400000
21佐藤350000
32山田500000
42鈴木450000
employees 4行
学習ポイント
CTE PRE-AGGREGATION
WITH句による事前集約 — インラインサブクエリをCTEで書き直す
概念は同じ「先に集計して1:1に変換」、WITH句で可読性が向上
emp_agg = employees集計   sales_agg = dept_sales集計
の本質的な違い — CTEとインラインサブクエリ:インラインサブクエリ(FROM句内のSELECT)とCTEは同じ結果を返します。違いは書き方の順序と可読性のみです。CTE は「最初に何を準備するか」を宣言的に書けるため、複数の集計ステップがある場合に特に威力を発揮します。大規模なデータ変換クエリではCTEが業界標準の書き方です。
「JOINする前にカーディナリティを1:1に揃える」の徹底:本問の回避策の核心は依然として「JOIN前に集約してdept_idを一意にする」です。CTEを使っても、インラインサブクエリを使っても、この原則は変わりません。2つの 1:N テーブルを同時にJOINする場合は必ずどちらかを先に集約してください。
アンチパターン
CTEを使わずに後からDISTINCTで「修正」しようとする:ファントラップが発生した後に SELECT DISTINCT で重複行を除去しても、SUM や AVG の集計値は膨張した誤った値のままです。DISTINCT は行の重複を除去するだけで、数値の二重カウントは修正しません。回避は「JOIN前」の集約が唯一の正解です。
CTEを深くネストしすぎる:CTEは便利ですが、10個以上のCTEをチェーンすると可読性が逆に低下します。また一部のDBMSではCTEが最適化の境界になりパフォーマンスに影響することがあります。通常3〜5個を上限の目安にし、複雑になる場合はビューや中間テーブルの利用を検討しましょう。
実務コラム
「部門別の売上と人件費を1クエリで出す」「ユーザー別の注文件数とレビュー件数を集計する」はどちらも本問のパターンです。実務のデータマート構築やBIダッシュボードの集計クエリでは、CTEで各ディメンションを事前集約 → ファクトテーブルに1:1でJOINというパターンが標準的なアーキテクチャです。Q5で学んだファントラップを理解した上でCTEの書き方を習得することで、安全かつ読みやすい本番クエリが書けるようになります。
QUESTION 9

粒度(Granularity)不一致の罠 — 月次予算と日次売上の結合による予算膨張の回避

CTE(WITH句)粒度統一DATE_TRUNCGROUP BY
前提知識

テーブルの粒度(Granularity)とは「1行が何を表すか」です。月次テーブルは1行が「1部門・1ヶ月」を表し、日次テーブルは1行が「1部門・1日」を表します。

粒度の異なるテーブルをそのままJOINすると、細かい粒度の行数分だけ粗い粒度の値がコピーされます。月次予算(1行/月)に3日分の売上(3行/月)をJOINすると、予算が3回複製されます。

-- ✗ 直接JOIN → monthly_budget の budget が daily_sales の行数分コピーされる
FROM monthly_budget AS b
JOIN daily_sales    AS d ON b.dept_id = d.dept_id
                              AND b.month = DATE_TRUNC('month', d.sale_date)
-- dept_id=1: budget=500000 が 3日分複製 → SUM(budget) = 1500000(3倍!)
粒度統一の原則:粒度の異なるテーブルをJOINする前に、細かい粒度のテーブルを粗い粒度に集約して粒度を揃えるのが基本原則です。日次→月次への集約には DATE_TRUNC('month', 日付列) + GROUP BY を使います。
問題

monthly_budget(月次予算)と daily_sales(日次売上)を使って、部門・月ごとの予算(budget)、月次売上合計(total_revenue)、予算達成率(achievement_rate: %, 小数点1桁)を取得してください。

使用テーブル
▸ monthly_budget
monthdept_idbudget
2024-03-011500000
2024-03-012800000
▸ daily_sales
sale_datedept_idrevenue
2024-03-051100000
2024-03-121200000
2024-03-20180000
2024-03-082250000
2024-03-152300000
2024-03-222200000
期待出力
monthdept_idbudgettotal_revenueachievement_rate
2024-03-01150000038000076.0
2024-03-01280000075000093.8
模範解答コード
WITH monthly_sales AS (
  SELECT
    DATE_TRUNC('month', sale_date)::DATE AS month,  -- 日次を月次粒度に変換
    dept_id,
    SUM(revenue)                              AS total_revenue
  FROM daily_sales
  GROUP BY DATE_TRUNC('month', sale_date)::DATE, dept_id
)
SELECT
  b.month,
  b.dept_id,
  b.budget,
  ms.total_revenue,
  ROUND(ms.total_revenue::NUMERIC / b.budget * 100, 1) AS achievement_rate
FROM monthly_budget AS b
LEFT JOIN monthly_sales AS ms
  ON  b.month   = ms.month
  AND b.dept_id = ms.dept_id
ORDER BY b.month, b.dept_id;

/*
  実行順序:
  1. CTE monthly_sales              → daily_sales を読み込む
  2. DATE_TRUNC('month', ...)       → 月初に丸める
  3. GROUP BY month, dept_id        → 月×部門でグループ化
  4. SUM(revenue)                   → 月次売上を集計
  5. FROM monthly_budget AS b       → 予算を読み込む
  6. LEFT JOIN monthly_sales AS ms  → month+dept_id で結合
  7. ROUND(...)                     → 達成率(%)を計算
  8. SELECT, ORDER BY               → 列を射影し並び替え
  */
解説(テーブル変化・ポイント)
WITH monthly_sales AS ( SELECT DATE_TRUNC('month', sale_date)::DATE AS month, dept_id, SUM(revenue) AS total_revenue FROM daily_sales GROUP BY DATE_TRUNC('month', sale_date)::DATE, dept_id ) SELECT b.month, b.dept_id, b.budget, ms.total_revenue, ROUND(ms.total_revenue::NUMERIC / b.budget * 100, 1) AS achievement_rate FROM monthly_budget AS b LEFT JOIN monthly_sales AS ms ON b.month = ms.month AND b.dept_id = ms.dept_id ORDER BY b.month, b.dept_id;
LEGEND
データ取得・読込対象
① CTE元データ — daily_sales
FROM daily_sales(日次粒度)部門ごとに複数の日付の売上が記録された日次粒度(1行 = 1部門・1日)のテーブルです。予算テーブルの「月次粒度」に合わせるため、集約して粒度を変換する必要があります。
1 / 7
sale_datedept_idrevenue
2024-03-051100000
2024-03-121200000
2024-03-20180000
2024-03-082250000
2024-03-152300000
2024-03-222200000
daily_sales 6行
学習ポイント
GRANULARITY
粒度(Granularity)統一 — 日次→月次に集約してから結合
「1行が何を表すか」を揃えることがJOIN事故防止の鍵
monthly_budget(月粒度) ← CTE → monthly_sales(月粒度)
粒度(Granularity)とは:テーブルの粒度とは「1行が表す事象の細かさ」です。monthly_budget は「1部門・1ヶ月」が1行(月粒度)、daily_sales は「1部門・1日」が1行(日次粒度)です。異なる粒度のテーブルをJOINすると、粗い粒度の値が細かい粒度の行数分コピーされます。JOIN前に粒度を把握し、必要なら集約して揃えるのが基本動作です。
DATE_TRUNC の役割 — 日付を月単位に丸める:DATE_TRUNC('month', sale_date) は日付を月初(1日)に丸めます。2024-03-05、2024-03-12、2024-03-20 はすべて 2024-03-01 になります。これにより「異なる日付でも同じ月ならGROUP BY で同一グループ」として扱えます。GROUP BY のキーとして使うことで日次→月次への粒度変換が実現します。
アンチパターン
粒度確認を省略してJOINする:「このテーブルの1行は何を表すか」を確認せずにJOINすると、本問のような予算膨張が発生します。JOINを書く前に必ず各テーブルの粒度(主キーや一意キー)を確認してください。主キーがどの列かを知ることで粒度が分かります(monthly_budget の主キーは(month, dept_id)の複合キー)。
日次テーブルを「月次に分解」しようとする逆方向の誤り:月次予算を日次に割り当て(月次÷日数)してからJOINしようとするのは逆方向です。正しい方向は日次テーブルを月次に集約すること(細→粗)です。「分析の最小粒度に合わせる」のではなく、「比較したい粒度に細かい方を合わせる」が原則です。
実務コラム
粒度の問題はBIツール(Looker/Tableau)連携やデータウェアハウス設計で最も頻出します。典型例: 月次KPIレポート(月次目標テーブルと日次ログをJOIN)、週次サマリー(日次テーブルをDATE_TRUNC('week')で集約)。DateSpine(日付シーケンステーブル)との結合でも粒度管理が必須です。実務では「集計クエリが遅い・値がおかしい」と感じたらまず粒度の確認を疑う習慣が重要です。
QUESTION 10

N対Nの AND検索の落とし穴 — 中間テーブルでの「AかつB」関係除算

IN + GROUP BYN:NHAVING関係除算
前提知識

N:N の中間テーブルで「商品AとBを両方購入したユーザー」を探す際、直感的に WHERE product = 'A' AND product = 'B' と書きたくなりますが、これは常に0件を返します。

-- ✗ AND条件は「同一行」に適用される
WHERE p.product_name = 'コーヒー'
  AND p.product_name = 'ケーキ'
-- 1行のproduct_nameは同時に2つの値を持てない → 必ず0件

-- ✓ IN で候補行を絞り、HAVING で「両方持つ」を判定する
WHERE p.product_name IN ('コーヒー', 'ケーキ')   -- OR条件で候補行を取得
HAVING COUNT(DISTINCT p.product_name) = 2   -- 2種類持つグループを残す
SQL の集合演算思考:1行は同時に1つの値しか持てません。「AかつB」を複数行にまたがって評価するには、行を縦に並べ(IN)てからグループで横断的に集計(HAVING)する、RDB特有の発想の転換が必要です。
問題

users テーブルと purchases テーブルを使って、「コーヒー」と「ケーキ」の両方を購入したユーザーを取得してください。

使用テーブル
▸ users
user_idname
1田中
2佐藤
3山田
4鈴木
▸ purchases
purchase_iduser_idproduct_name
11コーヒー
21ケーキ
32コーヒー
42サンドイッチ
53ケーキ
63サンドイッチ
74コーヒー
84ケーキ
期待出力
user_idname
1田中
4鈴木
模範解答コード
-- ✗ AND: 同一行に「コーヒー」かつ「ケーキ」は不可能 → 0件
-- WHERE p.product_name = 'コーヒー' AND p.product_name = 'ケーキ'

SELECT
  u.user_id,
  u.name
FROM users AS u
INNER JOIN purchases AS p
  ON u.user_id = p.user_id
WHERE p.product_name IN ('コーヒー', 'ケーキ')  -- まずORで候補行を絞る
GROUP BY u.user_id, u.name
HAVING COUNT(DISTINCT p.product_name) = 2  -- 2種類とも持つグループのみ残す
ORDER BY u.user_id;

/*
  実行順序:
  1. FROM users AS u                      → users を読み込む
  2. INNER JOIN purchases AS p            → 結合(1:N で展開)
  3. WHERE product_name IN (...)          → 対象商品に絞る
  4. GROUP BY u.user_id, u.name           → グループ化
  5. HAVING COUNT(DISTINCT product_name)  → 2種類購入者のみ残す
  6. SELECT u.user_id, u.name             → 2列を射影
  7. ORDER BY u.user_id                   → 並び替えて出力
  */
解説(テーブル変化・ポイント)
SELECT u.user_id, u.name FROM users AS u INNER JOIN purchases AS p ON u.user_id = p.user_id WHERE p.product_name IN ('コーヒー', 'ケーキ') GROUP BY u.user_id, u.name HAVING COUNT(DISTINCT p.product_name) = 2 ORDER BY u.user_id;
LEGEND
データ取得・読込対象
① 対象データ(JOIN直後)
INNER JOIN users AS u ON u.user_id = p.user_idまず user_id で users と purchases を結合した状態です。1:Nの関係により、ユーザーが購入履歴の数だけ展開されています。全8行のこのテーブルに対して、これからWHERE句を適用します。
1 / 6
user_idnameproduct_name
1田中コーヒー
1田中ケーキ
2佐藤コーヒー
2佐藤サンドイッチ
3山田ケーキ
3山田サンドイッチ
4鈴木コーヒー
4鈴木ケーキ
JOIN後: 8行(1:N展開)
学習ポイント
RELATIONAL DIVISION
関係除算 — 「縦のデータ」を「横の条件」で評価するSQLの集合演算
AND検索の正しい方法: IN で行を縦に展開 → HAVING で横断評価
WHERE IN → GROUP BY → HAVING COUNT(DISTINCT) = N
AND条件の評価単位 — なぜ0件になるのか:WHERE句のAND条件は「1行のデータに対して同時に評価」されます。1行の product_name は1つの値しか持てないため、「コーヒー」であり「ケーキ」でもある行は存在しません。これはバグではなくSQLの正常な動作です。「複数の行にまたがるAND」を表現するには発想を変える必要があります。
IN + GROUP BY + HAVING — 関係除算の仕組み:まず IN ('コーヒー', 'ケーキ')(OR条件)で対象商品の行を縦に集め、次に GROUP BY user_id でユーザーごとにまとめ、最後に HAVING COUNT(DISTINCT product_name) = 2 で「グループ内に2種類の商品がある(= 両方購入した)」ユーザーだけを残します。「縦(行)に展開してから横(グループ)で評価する」がSQLらしい集合演算の本質です。
アンチパターン
COUNT(DISTINCT) の N を間違える:HAVING COUNT(DISTINCT product_name) = 2 の「2」は検索対象の商品数です。3種類(コーヒー・ケーキ・サンドイッチすべて)を購入したユーザーを探すなら = 3 にします。N の値は必ず「IN の中の要素数」と一致させましょう。要素数を動的に変える場合はサブクエリで = (SELECT COUNT(DISTINCT ...) FROM ...) と書けます。
自己結合で解こうとする旧来の方法:purchases AS a JOIN purchases AS b ON a.user_id = b.user_id WHERE a.product='コーヒー' AND b.product='ケーキ' という自己結合でも同じ結果は得られますが、検索対象が3種類・4種類に増えると結合回数が増えてクエリが爆発的に複雑になります。IN + HAVING COUNT(DISTINCT) パターンは拡張性に優れたモダンな書き方です。
実務コラム
このパターンは「関係除算(Relational Division)」と呼ばれ、集合論が基礎のRDBならではの演算です。実務での応用例: 指定した全タグを持つ商品を検索(product_tags テーブルで IN + HAVING)、必要なスキルをすべて持つ候補者を探す(candidate_skills テーブル)、特定の権限をすべて持つユーザーを抽出(user_roles テーブル)。N の値を動的に変えることでより汎用的な「すべての条件を満たす行」の検索に応用できます。