SQL サブクエリ — 相関・派生テーブル×JOINの応用

応用サブクエリ (応用)相関サブクエリHAVING + サブクエリ派生テーブル × JOIN多段ネストPostgreSQL対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 1

相関サブクエリ — 同一テーブルから各ユーザーの「最新注文」のみを抽出する

相関SQWHERE句最新行抽出同一テーブル比較
前提知識

相関サブクエリ(Correlated Subquery)とは、内側のサブクエリが外側クエリの列を参照するサブクエリです。外側クエリが1行処理されるたびに内側が実行されるため、「その行のユーザーに対する最大値」のように行ごとに異なる値を動的に計算できます。

SELECT * FROM orders o1
WHERE ordered_at = (
  SELECT MAX(ordered_at)  -- o1 の user_id に対する最大値を計算
  FROM   orders o2       -- 同一テーブルを別エイリアスで参照
  WHERE  o2.user_id = o1.user_id  -- 外側の列を参照(これが「相関」)
);
非相関SQとの違い:基礎編の非相関スカラーSQは外側クエリから独立して1回だけ評価されます。相関SQは外側クエリの各行に対して評価されるため、「ユーザーごとに異なる最大値」のような行依存の計算が可能です。
問題

orders テーブルから、各ユーザーの最新注文(ordered_at が最大の行)のみを取得してください。order_id, user_id, amount, status, ordered_at を user_id 昇順で返してください。

使用テーブル
▸ orders
order_iduser_idamountstatusordered_at
10118,000completed2024-05-01
102112,000completed2024-05-20
10323,500completed2024-05-10
10439,500completed2024-05-15
105311,000completed2024-05-25
10642,000completed2024-05-08
107115,000completed2024-06-10
期待出力
order_iduser_idamountstatusordered_at
107115,000completed2024-06-10
10323,500completed2024-05-10
105311,000completed2024-05-25
10642,000completed2024-05-08
模範解答コード
SELECT
  order_id, user_id, amount, status, ordered_at
FROM   orders o1                        -- 外側クエリに o1 というエイリアス
WHERE  ordered_at = (                   -- 各行の ordered_at を SQ 結果と比較
  SELECT MAX(ordered_at)               -- そのユーザーの最大日付を求める
  FROM   orders o2                     -- 同一テーブルを別名 o2 で参照(自己参照)
  WHERE  o2.user_id = o1.user_id      -- 外側の列を参照するのが「相関」の本質
)
ORDER BY user_id;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM orders o1    → 全7行を1行ずつ処理
  2. 相関SQ              → 各行で MAX(ordered_at) と比較
  3. WHERE フィルタ        → 各ユーザーの最新注文に絞る
  4. SELECT ...        → 必要列を選択
  5. ORDER BY user_id  → user_id 昇順
  */
解説(テーブル変化・ポイント)
SELECT order_id, user_id, amount, status, ordered_at FROM orders o1 WHERE ordered_at = ( SELECT MAX(ordered_at) FROM orders o2 WHERE o2.user_id = o1.user_id ) ORDER BY user_id;
LEGEND
データ取得・読込対象
① FROM orders o1(外側クエリ)
FROM orders o1外側クエリのordersテーブル(o1)全7行を1行ずつ処理します。ここから1行ごとに相関サブクエリが評価されます。
1 / 3
o1.order_iduser_idamountordered_at
10118,0002024-05-01
102112,0002024-05-20
10323,5002024-05-10
10439,5002024-05-15
105311,0002024-05-25
10642,0002024-05-08
107115,0002024-06-10
全7行 読込
学習ポイント
相関SQの本質 — 外側の列を内側で使う:内側に o2.user_id = o1.user_id と書くことで、外側クエリが処理中の行の user_id に応じた MAX を計算します。外側行が変わるたびに内側が再実行されるため、行ごとに異なる集計値が得られます。非相関SQは1回だけ評価される点が根本的に異なります。
同一テーブルを2つの名前で参照する自己参照:orders o1(外側)と orders o2(内側)は同じテーブルですが別のエイリアスを付けることで区別します。エイリアスがないと「どちらの列か」が曖昧になりエラーになります。
パフォーマンスの考え方:相関SQは外側N行 × 内側スキャンの繰り返しになります。orders.user_id にインデックスがない場合は全件スキャンが外側行数分発生します。大量データでは ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY ordered_at DESC) のウィンドウ関数+CTEが推奨されます。
同日付が複数ある場合の注意:MAX が同じ日付の行が複数存在すると複数行が返ります。厳密に1行に絞りたい場合は ORDER BY ordered_at DESC, order_id DESC LIMIT 1 を使う派生テーブルか、ウィンドウ関数+CTEで対処します。
アンチパターン
外側と内側で同じエイリアスを使う:FROM orders o WHERE ... = (SELECT MAX(...) FROM orders o WHERE ...) と書くと内側の o が外側を隠してしまいます。内側・外側に必ず別エイリアス(o1/o2 など)を付けることが必須です。
大量データへの相関SQ適用:行数が多いテーブルにインデックスなしで相関SQを使うと N×M のスキャンが発生し深刻に遅くなります。実行計画(EXPLAIN)を確認し、必要に応じてウィンドウ関数への書き換えを検討しましょう。
実務コラム:「最新行取得」のベストプラクティス比較
相関SQはシンプルで読みやすいですが、PostgreSQL・MySQL 8+ では ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY ordered_at DESC) を使ったCTEが実務の第一選択です。これはインデックスを効率的に使え、同日付のタイブレークも order_id で制御できます。相関SQは「どのRDBMSでも動くポータブルな書き方」として理解しておくと古い環境や試験問題でも対応できます。
QUESTION 2

HAVING × スカラーサブクエリ — グループ集計値を全体平均と比較して絞り込む

HAVINGスカラーSQグループ比較セグメント分析
前提知識

HAVING 句は GROUP BY で集計した結果に対してフィルタをかけます。WHERE 句は集計前(行単位)、HAVING 句は集計後(グループ単位)のフィルタです。この HAVING 句の比較値にスカラーSQ を使うことで、全体集計値とグループ集計値を動的に比較できます。

SELECT col, AVG(amount)
FROM   orders
GROUP BY col
HAVING AVG(amount) > (             -- 集計後のグループ条件
  SELECT AVG(amount) FROM orders  -- スカラーSQ: 全体平均を返す
);
WHERE vs HAVING の選択:WHERE AVG(amount) > ...構文エラーになります。WHERE は GROUP BY より先に評価されるため集計関数を条件に使えません。集計後に絞り込むには必ず HAVING を使います。
問題

orders テーブルから、status が 'completed' の注文に限り、ユーザーごとの平均注文額が全体平均より高いユーザーを取得してください。user_id, avg_amount(ROUND後の整数値) を avg_amount 降順で返してください。

使用テーブル
▸ orders
order_iduser_idamountstatus
10118,000completed
102112,000completed
10323,500completed
10439,500completed
105311,000completed
10642,000completed
107115,000completed
期待出力
user_idavg_amount
111,667
310,250
模範解答コード
SELECT
  user_id,
  ROUND(AVG(amount)) AS avg_amount  -- 表示用にROUNDで整数化
FROM   orders
WHERE  status = 'completed'         -- 集計前フィルタ(行単位)
GROUP BY user_id                    -- ユーザーごとにグループ化
HAVING AVG(amount) > (              -- 集計後フィルタ(グループ単位)
  SELECT AVG(amount)                -- スカラーSQ: completed全体の平均を先行評価
  FROM   orders
  WHERE  status = 'completed'       -- 外側と同じ条件で揃える
)
ORDER BY avg_amount DESC;

/*
  実行順序(SQLの論理的な評価順):
  1. スカラーSQ先行評価                 → completed の平均を閾値として確定
  2. FROM orders                → 全行読み込み
  3. WHERE status='completed'   → completed に絞る
  4. GROUP BY user_id           → ユーザーで集計
  5. HAVING AVG(amount) > 閾値    → 平均超のグループを残す
  6. SELECT ROUND(AVG(amount))  → 平均を出力
  7. ORDER BY avg_amount DESC   → 降順
  */
解説(テーブル変化・ポイント)
SELECT user_id, ROUND(AVG(amount)) AS avg_amount FROM orders WHERE status = 'completed' GROUP BY user_id HAVING AVG(amount) > ( SELECT AVG(amount) FROM orders WHERE status = 'completed' ) ORDER BY avg_amount DESC;
LEGEND
評価対象の列・キー
✓ 通過
① スカラーSQ(全体平均の確定)
SELECT AVG(amount) FROM orders WHERE status='completed'HAVING句の比較閾値となる全体平均をサブクエリが先行して1回だけ計算します。7件全体の平均値(約8,714)が定数として確定します。
1 / 5
order_iduser_idamountAVG計算対象
10118,000✓ 対象
102112,000✓ 対象
10323,500✓ 対象
10439,500✓ 対象
105311,000✓ 対象
10642,000✓ 対象
107115,000✓ 対象
→ AVG(amount) ≈ 8,714 を返す
学習ポイント
SQL評価順 — FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY:この順序が根本原則です。WHERE AVG(amount) > ... は GROUP BY よりに評価されるため集計関数を使えず構文エラーになります。集計値を条件にするには必ず HAVING を使います。
スカラーSQは HAVING より先に評価:スカラーSQは外側クエリ全体より先に1回だけ評価されて定数値(≈8,714)を確定させます。GROUP BY → HAVING の評価時にはすでにこの値が確定しているため、「全体平均という固定の閾値でグループを絞る」処理が1クエリで実現できます。
外側と内側の WHERE 条件を揃える意識:今回は外側も内側も WHERE status='completed' で揃えています。内側を WHERE なしにすると「全ステータス込みの全体平均」になり意味が変わります。「何の平均と比べるのか」を明確に設計することが重要です。
実務パターン — 高価値ユーザーのセグメント抽出:「全体平均より購買単価の高い顧客」「基準値を超えた部門」などのセグメンテーションクエリは分析ダッシュボードや CRM で頻出です。スカラーSQ の値を変えることで閾値を動的に変更できます。
アンチパターン
WHERE に集計関数を書く:WHERE AVG(amount) > 8714 は構文エラーです。集計関数は WHERE 句では使用できません。集計後の条件には必ず HAVING を使いましょう。初学者が最もハマるポイントの一つです。
外側と内側のWHERE条件を無意識に揃えない:内側スカラーSQが WHERE なし(全ステータス平均)だと「全体平均」の意味が変わります。内外の絞り込み条件を意図的に揃えるか変えるかを明確に設計しましょう。
実務コラム:HAVING と派生テーブルの使い分け
単純な「グループ集計値 vs 定数」の比較なら HAVING が最も短く書けます。一方、集計結果にさらに別テーブルをJOINしたい場合や、複数の集計列を組み合わせて複合条件を作る場合は派生テーブル(Q4)または CTE に移行します。「まず HAVING を試し、複雑化したら CTE に切り出す」という流れが実務では自然です。
QUESTION 3

EXISTS + NOT EXISTS — 「注文済み・未レビュー」ユーザーを複合条件で抽出する

NOT EXISTS複合EXISTS行動ファネル差集合検出
前提知識

WHERE EXISTS (...) AND NOT EXISTS (...) のように複数の EXISTS / NOT EXISTS を AND で組み合わせることで、「条件Aを満たし、かつ条件Bを満たさない行」を1クエリで抽出できます。基礎編の EXISTS を複合化した実務パターンです。

SELECT * FROM users u
WHERE EXISTS (
  SELECT 1 FROM orders o
  WHERE  o.user_id = u.user_id  -- 注文が存在する
)
  AND NOT EXISTS (
  SELECT 1 FROM reviews r
  WHERE  r.user_id = u.user_id  -- かつレビューが存在しない
);
NOT EXISTS は NULL に安全:NOT IN(基礎編で既出)はリストに NULL が混入すると全行除外される罠がありますが、NOT EXISTS は NULL の影響を受けません。本番データでは NOT IN より NOT EXISTS を優先する習慣が安全です。
問題

users テーブルから、completed の注文が1件以上あるにもかかわらず、まだ一度もレビューを投稿していないユーザーを取得してください。user_id, name, plan を user_id 昇順で返してください。

使用テーブル
▸ users
user_idnameplan
1田中 太郎premium
2佐藤 花子free
3鈴木 一郎premium
4山田 次郎standard
5伊藤 三郎free
▸ orders(関連列のみ)
order_iduser_idstatus
1011completed
1021completed
1032completed
1043completed
1053completed
1064completed
1071completed
▸ reviews
review_iduser_idorder_idrating
111015
231044
期待出力
user_idnameplan
2佐藤 花子free
4山田 次郎standard
模範解答コード
SELECT
  u.user_id, u.name, u.plan
FROM   users u
WHERE  EXISTS (                   -- 条件①: completedの注文が1件以上ある
  SELECT 1
  FROM   orders o
  WHERE  o.user_id = u.user_id
    AND  o.status  = 'completed'  -- completedのみ対象
)
  AND NOT EXISTS (                -- 条件②: レビューが1件もない
  SELECT 1
  FROM   reviews r
  WHERE  r.user_id = u.user_id    -- reviewsに行が存在しないことを確認
)
ORDER BY u.user_id;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM users u                      → 1行ずつ処理
  2. EXISTS(completed注文)               → completed ありを判定
  3. AND NOT EXISTS(レビュー不在)            → レビューなしを判定
  4. SELECT u.user_id, u.name, u.plan  → 通過行を選択
  5. ORDER BY u.user_id                → user_id 昇順
  */
解説(テーブル変化・ポイント)
SELECT u.user_id, u.name, u.plan FROM users u WHERE EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.user_id AND o.status = 'completed' ) AND NOT EXISTS ( SELECT 1 FROM reviews r WHERE r.user_id = u.user_id ) ORDER BY u.user_id;
LEGEND
データ取得・読込対象
① FROM users u(全行読み込み)
FROM users uusersテーブル全5行を1行ずつ処理します。各行に対してEXISTSとNOT EXISTSの2つのサブクエリが順番に評価されます。
1 / 4
user_idnameplan
1田中 太郎premium
2佐藤 花子free
3鈴木 一郎premium
4山田 次郎standard
5伊藤 三郎free
全5行 読込
学習ポイント
EXISTS + NOT EXISTS の AND 結合:「条件Aを満たす AND 条件Bを満たさない」というパターンは AND で組み合わせるだけです。SQLオプティマイザは AND の条件を効率的な順序で評価するため、記述順とパフォーマンスは必ずしも一致しません。どちらを先に評価するかは実行計画(EXPLAIN)で確認できます。
NOT EXISTS は NULL に安全:reviews テーブルの user_id に NULL が混入しても NOT EXISTS は期待通り動作します。一方 NOT IN (SELECT user_id FROM reviews) は NULL が1件でも存在すると全行 UNKNOWN になり0件が返る危険があります(基礎編 Q3参照)。実務では NOT EXISTS を第一選択にしましょう。
実務パターン — ユーザー行動ファネルの欠損検出:「注文したがレビューしていない」「登録したがプロフィール未設定」「ログインしたがチュートリアル未完了」など、ステップ間の離脱ユーザーを発見するクエリはリテンション施策の基本です。このパターンを覚えておくとマーケティングバッチで即座に使えます。
短絡評価のメリット:EXISTS は1行見つかれば即 TRUE、NOT EXISTS は1行見つかった時点で即 FALSE を返します。reviews に1件でも行があればスキャンを打ち切るため、インデックスが効いている場合は非常に高速です。
アンチパターン
NOT IN でレビューなし判定を行う:WHERE user_id NOT IN (SELECT user_id FROM reviews) は reviews.user_id に NULL が入った瞬間に全行除外されます。reviews は運用中に NULL が混入しうるため必ず NOT EXISTS を使うか、内側に WHERE user_id IS NOT NULL を追加してください。
LEFT JOIN + IS NULL との使い分けを誤る:同じ結果を LEFT JOIN reviews ON ... WHERE reviews.user_id IS NULL でも得られますが、EXISTS/NOT EXISTS は「存在確認のみ」で選択列を増やさない点が明確です。結合先の列も SELECT に出したい場合は JOIN を使い、存在確認だけなら EXISTS が意図を明確に表します。
実務コラム:3パターン比較 — NOT EXISTS / NOT IN / LEFT JOIN
「テーブルBに存在しない行をテーブルAから取る」差集合には① NOT EXISTS ② NOT IN ③ LEFT JOIN + IS NULL の3通りがあります。① NULL に安全・意図が明確で最も推奨。② リストが小さく NULL がないと確実な場合のみ OK。③ JOIN 先の列も SELECT に出す場合や OUTER JOIN 込みの複雑クエリへの組み込み時。本番環境では ① NOT EXISTS をデフォルトにしておくのが最も安全です。
QUESTION 4

派生テーブル × INNER JOIN — 集計サマリにユーザー情報を結合して一覧を返す

派生テーブルINNER JOIN集計+マスタ結合APIレスポンス設計
前提知識

基礎編では FROM 句サブクエリ(派生テーブル)でグループ集計し外側 WHERE でフィルタしました。このパターンをさらに発展させ、集計結果(派生テーブル)を別のマスタテーブルと INNER JOINすることで、集計値と関連情報を1クエリで取得するのが実務で最頻出のパターンです。

SELECT m.name, s.total
FROM   master m
INNER JOIN (
  SELECT id, SUM(amount) AS total
  FROM   transactions
  GROUP BY id
) AS s ON m.id = s.id;  -- 集計結果とマスタを結合
INNER JOIN の除外効果:INNER JOIN はマッチする行が両テーブルに存在するときのみ結果に含めます。派生テーブル側に存在しないユーザー(注文ゼロのユーザー)は自動的に除外されます。全ユーザーを含めたい場合は LEFT JOIN を使います。
問題

orders テーブルを集計してユーザーごとの注文件数・合計金額・平均金額を求め、users テーブルの name / plan と組み合わせて返してください。completed の注文のみ対象とし、user_id, name, plan, order_count, total_amount, avg_amount を total_amount 降順で返してください。

使用テーブル
▸ users
user_idnameplan
1田中 太郎premium
2佐藤 花子free
3鈴木 一郎premium
4山田 次郎standard
5伊藤 三郎free
▸ orders
order_iduser_idamountstatus
10118,000completed
102112,000completed
10323,500completed
10439,500completed
105311,000completed
10642,000completed
107115,000completed
期待出力
user_idnameplanorder_counttotal_amountavg_amount
1田中 太郎premium335,00011,667
3鈴木 一郎premium220,50010,250
2佐藤 花子free13,5003,500
4山田 次郎standard12,0002,000
模範解答コード
SELECT
  u.user_id,
  u.name,
  u.plan,
  s.order_count,
  s.total_amount,
  s.avg_amount
FROM   users u
INNER JOIN (                          -- 集計結果を派生テーブルとして INNER JOIN
  SELECT
    user_id,
    COUNT(*)           AS order_count, -- 注文件数
    SUM(amount)         AS total_amount,-- 合計金額
    ROUND(AVG(amount))  AS avg_amount   -- 平均金額(整数化)
  FROM   orders
  WHERE  status = 'completed'          -- 集計前に completed のみに絞る
  GROUP BY user_id
) AS s ON u.user_id = s.user_id      -- AS エイリアス必須。ON で結合キー指定
ORDER BY s.total_amount DESC;

/*
  実行順序(SQLの論理的な評価順):
  1. 派生テーブル(s)を評価
  2. FROM users u                  → users 全5行を読み込む
  3. INNER JOIN s ON user_id       → user5はsに行がないため結合から自動除外
  4. SELECT u.*, s.*               → 必要列を選択
  5. ORDER BY s.total_amount DESC  → 合計金額降順
  */
解説(テーブル変化・ポイント)
SELECT u.user_id, u.name, u.plan, s.order_count, s.total_amount, s.avg_amount FROM users u INNER JOIN ( SELECT user_id, COUNT(*) AS order_count, SUM(amount) AS total_amount, ROUND(AVG(amount)) AS avg_amount FROM orders WHERE status = 'completed' GROUP BY user_id ) AS s ON u.user_id = s.user_id ORDER BY s.total_amount DESC;
LEGEND
グループ化キー・集計対象
グループ分類
① 派生テーブル(s)の生成
SELECT user_id, COUNT(*), SUM(amount), ROUND(AVG(amount)) FROM orders WHERE status='completed' GROUP BY user_id内側のサブクエリが先に評価され、ユーザーごとの集計結果を持つ「仮想テーブル(s)」がメモリ上に生成されます。この時点で注文のないuser5は含まれません。
1 / 3
user_idorder_counttotal_amountavg_amount
1335,00011,667
213,5003,500
3220,50010,250
412,0002,000
派生テーブル(s) 4行生成
学習ポイント
派生テーブル × JOIN の構造:FROM句のサブクエリ(派生テーブル)は通常のテーブルと同様に INNER JOIN / LEFT JOIN の対象にできます。集計結果とマスタ情報を1クエリで組み合わせるこのパターンはWebアプリのAPIレスポンス構築で非常に頻出です。
INNER JOIN の暗黙的除外:INNER JOIN は両テーブルで結合キーが一致する行だけを返します。今回、user5 は派生テーブル側に行が存在しないためWHERE 句なしで自動的に除外されます。「注文のないユーザーを除く」という要件はINNER JOINで自然に実現できます。
派生テーブルへの AS エイリアスは必須:FROM句のサブクエリには必ず AS s のようなエイリアスを付けてください。エイリアスなしは PostgreSQL・MySQL どちらもエラーになります。外側クエリでは s.order_count のようにエイリアス経由で列を参照します。
集計は派生テーブルで、マスタ情報はJOINで:このパターンは「集計ロジック」と「表示用データの付与」を明確に分離できます。集計条件を内側 WHERE で制御し、表示用の name や plan は外側 JOIN で取得するという責務分離が読みやすいクエリを生みます。
アンチパターン
AS エイリアスを忘れる:FROM句のサブクエリに AS s を付けないと構文エラーになります(PostgreSQL・MySQL ともに必須)。外側から参照する列名もエイリアス経由でないと曖昧さエラーになる場合があります。
INNER JOIN と LEFT JOIN の混同:「全ユーザーを出力し、注文がなければNULLで表示したい」場合は INNER JOIN ではなく LEFT JOIN を使います。要件によって使い分けましょう。INNER JOIN は「両方に存在する行だけ」、LEFT JOIN は「左テーブルの全行+右テーブルのマッチ行(なければNULL)」です。
実務コラム:このパターンをCTEで書き換えると可読性がさらに向上する
派生テーブル×JOINは、CTE(Common Table Expression)を使うとさらに読みやすくなります。WITH order_summary AS (SELECT user_id, COUNT(*) AS order_count, ... FROM orders WHERE status='completed' GROUP BY user_id) SELECT u.*, s.* FROM users u INNER JOIN order_summary s ON u.user_id = s.user_id ORDER BY s.total_amount DESC; とすることで集計ロジックを本体から切り出せます。複数の派生テーブルが必要な複雑なクエリはCTEが特に有効で、デバッグやレビューが格段に楽になります。
QUESTION 5

多段ネストSQ — 「最も売れたカテゴリ」の商品一覧をサブクエリの入れ子で取得する

多段SQWHERE INカテゴリ集計ランキング抽出
前提知識

サブクエリはさらに別のサブクエリを内包することができます(多段ネスト)。外側から順に「カテゴリ最高売上を取り出す → そのカテゴリ名を特定する → そのカテゴリの商品を取り出す」のように、段階的に絞り込みを行うことで複雑な条件を表現できます。

SELECT * FROM products
WHERE category = (
  SELECT category          -- ② カテゴリ名を求める
  FROM   sales_summary
  WHERE  total = (
    SELECT MAX(total)      -- ① 最大値を先に確定
    FROM   sales_summary
  )
);
多段ネストの評価順:最も内側のサブクエリから評価されます。「最大値を求める → その最大値を持つカテゴリを取り出す → そのカテゴリの商品を取り出す」という流れになります。内側→外側の順で逆算しながら設計すると理解しやすいです。
問題

以下の3テーブルを使い、完了注文(completed)における販売数量(qty)の合計が最も多いカテゴリの商品一覧を取得してください。product_id, name, category, price を price 降順で返してください。

使用テーブル
▸ products
product_idnamecategoryprice
1ワイヤレスイヤホンelectronics8,000
2スマートウォッチelectronics25,000
3コットンTシャツapparel3,500
4デニムジャケットapparel12,000
5プロテインパウダーhealth5,000
▸ orders(関連列のみ)
order_idstatus
101completed
102completed
103completed
104pending
▸ order_items
item_idorder_idproduct_idqty
110112
210131
310221
410213
510342
610351
710422
期待出力
product_idnamecategoryprice
2スマートウォッチelectronics25,000
1ワイヤレスイヤホンelectronics8,000
模範解答コード
SELECT
  product_id, name, category, price
FROM   products
WHERE  category = (              -- 最多売上カテゴリ名と一致する商品を取り出す
  SELECT p2.category            -- ② カテゴリ名を取得(スカラーSQ)
  FROM   products p2
  INNER JOIN order_items oi ON p2.product_id = oi.product_id
  INNER JOIN orders o       ON oi.order_id   = o.order_id
  WHERE  o.status = 'completed'  -- completed 注文のみ対象
  GROUP BY p2.category
  HAVING SUM(oi.qty) = (       -- カテゴリ別合計qty が最大値と一致
    SELECT MAX(cat_qty)          -- ① 最大の合計qty を先に確定(最内側SQ)
    FROM (
      SELECT SUM(oi2.qty) AS cat_qty -- カテゴリ別の合計qty
      FROM   products p3
      INNER JOIN order_items oi2 ON p3.product_id = oi2.product_id
      INNER JOIN orders o2      ON oi2.order_id  = o2.order_id
      WHERE  o2.status = 'completed'
      GROUP BY p3.category
    ) AS cat_totals              -- 派生テーブルに必ずエイリアスを付ける
  )
)
ORDER BY price DESC;

/*
  実行順序(SQLの論理的な評価順):
  1. 最内側SQ(派生テーブル cat_totals)を評価
  2. 2段目SQ: MAX(cat_qty) = 6(最大値を確定)
  3. 中間SQ: HAVING SUM(oi.qty) = 6 のカテゴリを取得
  4. 外側WHERE: products.category = 'electronics' で絞り込む
  5. SELECT ...           → 必要列を選択
  6. ORDER BY price DESC  → 価格降順
  */
解説(テーブル変化・ポイント)
SELECT product_id, name, category, price FROM products WHERE category = ( SELECT p2.category FROM products p2 INNER JOIN order_items oi ON p2.product_id = oi.product_id INNER JOIN orders o ON oi.order_id = o.order_id WHERE o.status = 'completed' GROUP BY p2.category HAVING SUM(oi.qty) = ( SELECT MAX(cat_qty) FROM ( SELECT SUM(oi2.qty) AS cat_qty FROM products p3 INNER JOIN order_items oi2 ON p3.product_id = oi2.product_id INNER JOIN orders o2 ON oi2.order_id = o2.order_id WHERE o2.status = 'completed' GROUP BY p3.category ) AS cat_totals ) ) ORDER BY price DESC;
LEGEND
データ取得・読込対象
除外・非表示データ
① 最内側SQ(データ取得・結合)
FROM products p3 INNER JOIN order_items oi2 ... WHERE o2.status='completed'最も深いサブクエリから評価が始まります。まず、完了(completed)した注文の明細(order_items)と商品情報(products)を結合し、集計のベースとなるデータを用意します。
1 / 6
o2.order_idcategoryqtystatus
101electronics2completed
101apparel1completed
102electronics1completed
102electronics3completed
103apparel2completed
103health1completed
104electronics2pending(除外)
対象となる明細: 6行
学習ポイント
多段ネストは「内側から外側へ」評価される:最も内側の SELECT MAX(cat_qty) FROM (...) が最初に評価され定数値(6)を返します。次に中間SQが HAVING SUM(oi.qty) = 6 でカテゴリ名を取得し、最後に外側クエリがそのカテゴリの商品を取り出します。設計時は「何を内側で確定させれば外側がシンプルになるか」を内側から逆算するのがコツです。
スカラーSQの連鎖活用:この問題では「最大qty(定数)→ 最大カテゴリ名(文字列)→ 商品一覧」という3段階の絞り込みを行います。各段階が単一値を返すスカラーSQとして機能するため、どの段階も「1行1列だけ返すこと」を意識して設計することが重要です。
JOIN と SQ の組み合わせ:今回のように複数テーブルの結合が必要な集計は、サブクエリの内側に INNER JOIN を入れるパターンが標準的です。products・order_items・orders の3テーブルを内側で JOIN して集計し、外側は products に対するシンプルな WHERE でまとめることで全体の可読性を保ちます。
実務パターン — ランキング系クエリ:「最もよく売れたカテゴリ」「今月購入数が最多の商品」「アクセス数トップのページ」などのランキング系クエリは分析ダッシュボードやレコメンドエンジンで頻出です。多段ネストSQは可読性が落ちるため、実務ではCTEを使って各ステップを名前付きで定義するリファクタリングが推奨されます。
アンチパターン
同一集計を複数箇所で繰り返す:今回の例では「カテゴリ別qty合計」を2回(最大値用と比較用)計算しています。本番では CTE を使って1回の集計結果を再利用すると評価回数を減らせます。WITH cat_totals AS (...) SELECT MAX(cat_qty) FROM cat_totals のように整理しましょう。
同一qtqが複数カテゴリに存在するとスカラーSQエラー:万一2つのカテゴリが同じ最大qtyを持つと、中間SQが2行返してスカラーSQエラーになります。本番では LIMIT 1ORDER BY ... LIMIT 1 でタイブレークを明示するか、INに変更して複数対応させる設計が必要です。
実務コラム:多段ネストSQをCTEで書き換えると格段に読みやすくなる
多段ネストは理解しやすさが課題です。実務ではCTE(WITH句)で各ステップを名前付きで切り出すことで、同じロジックをはるかに読みやすく書けます。① WITH cat_totals AS (...カテゴリ別qty集計...)top_category AS (SELECT category FROM cat_totals WHERE cat_qty = (SELECT MAX(cat_qty) FROM cat_totals))SELECT * FROM products WHERE category IN (SELECT category FROM top_category) というように段階的に分解することで、各ステップのデバッグも容易になります。