SQL サブクエリ — NOT EXISTS・相関サブクエリの基礎

基礎サブクエリスカラーサブクエリIN / NOT IN / EXISTSFROM句 / 相関CTE比較PostgreSQL対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 6

NOT EXISTS サブクエリ — NULLに安全な「存在しない行」の取得

NOT EXISTS相関SQ差集合未完了検出
前提知識

WHERE NOT EXISTS (サブクエリ) は、サブクエリが「1行も結果を返さない場合」にその行を残します。NOT IN の NULL の罠がなく、実務で差集合を取る場合の推奨パターンです。

SELECT * FROM users u
WHERE NOT EXISTS (
  SELECT 1
  FROM   orders o
  WHERE  o.user_id = u.user_id   -- 一致する行が0件なら通過
);
NOT EXISTS が NOT IN より安全な理由:NOT IN はリストに NULL が混入すると全行除外になりますが、NOT EXISTS は「行が存在するか」という2値評価のみを行うため NULL の影響を受けません。本番データでは NULL が想定外に混入していることがあるため、NOT EXISTS が推奨されます。
問題

users テーブルから、status が '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
1032pending
1043completed
1053completed
1064cancelled
期待出力
user_idnameplan
2佐藤 花子free
4山田 次郎standard
5伊藤 三郎free
模範解答コード
SELECT
  user_id, name, plan
FROM   users u
WHERE  NOT EXISTS (            -- SQが0行を返す(行が存在しない)場合に通過
  SELECT 1
  FROM   orders o
  WHERE  o.user_id = u.user_id -- 外側列を参照する相関SQ
    AND  o.status  = 'completed'
)
ORDER BY user_id;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM users u                → 1行ずつ処理
  2. NOT EXISTS(...)             → ユーザーごとにサブクエリを実行
  3. SELECT user_id, name, plan  → 通過行を選択
  4. ORDER BY user_id            → user_id 昇順
  */
解説(テーブル変化・ポイント)
SELECT user_id, name, plan FROM users u WHERE NOT EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.user_id AND o.status = 'completed' ) ORDER BY user_id;
LEGEND
データ取得・読込対象
① FROM users
FROM users uusersテーブル全5行を1行ずつ処理します。各行に対して NOT EXISTS サブクエリを評価します。
1 / 3
user_idnameplan
1田中 太郎premium
2佐藤 花子free
3鈴木 一郎premium
4山田 次郎standard
5伊藤 三郎free
全 5行 読込
学習ポイント
NOT EXISTS = 「行が存在しなければ通過」:EXISTS の逆です。サブクエリが0件を返す場合にのみ TRUE となります。EXISTS と対比することで理解が深まります。
NOT IN より NOT EXISTS が安全な理由:NOT IN のリストに NULL が混入すると評価が UNKNOWN になり全行が除外されます。NOT EXISTS は「行が存在するか」の2値評価のみなので NULL の影響を受けません。本番データではNOT EXISTS を第一選択にしましょう。
LEFT JOIN + IS NULL との等価変換:LEFT JOIN orders o ON u.user_id=o.user_id AND o.status='completed' WHERE o.user_id IS NULL でも同じ結果になります。ORMが生成するクエリでよく見かけるパターンです。
アンチパターン
NOT IN でNULL混入が起きた実例:例えば外部APIから取り込んだユーザーデータに user_id=NULL の行が混じっていた場合、WHERE user_id NOT IN (SELECT user_id FROM orders) は全件0件を返します。NOT EXISTS に変えるだけで正しく動きます。
相関条件を忘れる:EXISTS / NOT EXISTS 内に WHERE o.user_id = u.user_id という外側クエリとの結合条件を書かないと、orders に1件でも行があれば全ユーザーが通過/除外されます。相関条件は必須です。
実務コラム:休眠ユーザーの定期バッチ検出
「直近30日間に completed の注文がないユーザーにリマインドメールを送る」といったバッチ処理は、NOT EXISTS (SELECT 1 FROM orders WHERE user_id = u.user_id AND status='completed' AND ordered_at >= NOW() - INTERVAL '30 days') のように期間条件をサブクエリ内に入れるだけで実現できます。NOT EXISTS は条件の柔軟性が高く、実務バッチクエリの定番パターンです。
QUESTION 7

相関サブクエリ — 外側クエリの値を内側で参照してユーザー別平均と比較する

相関SQスカラーSQ行ごと評価パーソナライズ分析
前提知識

相関サブクエリは、外側クエリの現在処理中の行の値を内側のサブクエリが参照する形式です。外側クエリが1行処理されるたびに内側が実行されます。「各ユーザーの平均と個々の注文を比較する」のように、グループごとの集計値と各行を対比するパターンで使います。

SELECT o.order_id, o.amount
FROM   orders o
WHERE  o.amount > (
  SELECT AVG(o2.amount)
  FROM   orders o2
  WHERE  o2.user_id = o.user_id  -- 外側 o.user_id を参照(相関条件)
);
通常のサブクエリとの違い:通常のスカラーSQ は1回評価されて全行に同じ値を返します。相関SQ は外側クエリの各行に対して毎回評価されるため、行ごとに異なる値(ここでは「そのユーザーの平均」)が返されます。
問題

orders テーブルから、各ユーザーの平均注文額を上回る注文のみを取得してください。取得列は order_id, user_id, amount, user_avg(そのユーザーの平均を付与)とし、user_id 昇順、同一ユーザー内は amount 降順で並べてください。

使用テーブル
▸ orders
order_iduser_idamountstatus
10118000completed
102112000completed
10323500pending
10439500completed
10536000completed
10642000cancelled
集計対象と比較単位を整理してから、サブクエリの返す値を決めてください。
期待出力
order_iduser_idamountuser_avg
10211200010000
104395007750
模範解答コード
SELECT
  o.order_id,
  o.user_id,
  o.amount,
  (SELECT ROUND(AVG(o2.amount))    -- 相関SQ: そのユーザーの平均を返す
   FROM   orders o2
   WHERE  o2.user_id = o.user_id    -- 外側 o.user_id を参照(相関条件)
  ) AS user_avg
FROM   orders o
WHERE  o.amount > (               -- 各ユーザーの平均と比較して絞り込む
  SELECT AVG(o2.amount)
  FROM   orders o2
  WHERE  o2.user_id = o.user_id   -- WHERE でも同じ相関SQを使用
)
ORDER BY o.user_id, o.amount DESC;

/*
  実行順序(SQLの論理的な評価順):
  1. FROM orders o                  → 全行を1行ずつ処理
  2. WHERE o.amount > (相関SQ)        → 各行に対しサブクエリを実行
  3. SELECT ... user_avg            → 相関SQで各ユーザー平均を付与
  4. ORDER BY user_id, amount DESC  → 並び替え
  */
解説(テーブル変化・ポイント)
SELECT o.order_id, o.user_id, o.amount, ( SELECT ROUND(AVG(o2.amount)) FROM orders o2 WHERE o2.user_id = o.user_id ) AS user_avg FROM orders o WHERE o.amount > ( SELECT AVG(o2.amount) FROM orders o2 WHERE o2.user_id = o.user_id ) ORDER BY o.user_id, o.amount DESC;
LEGEND
データ取得・読込対象
① FROM orders
FROM orders oordersテーブル全6行を1行ずつ処理します。各行に対して相関サブクエリが実行されます。
1 / 3
order_iduser_idamountstatus
10118000completed
102112000completed
10323500pending
10439500completed
10536000completed
10642000cancelled
全 6行 読込
学習ポイント
相関サブクエリの本質:外側クエリが1行処理されるたびに内側が実行されます。そのため「各ユーザーの平均」「各カテゴリの最大値」のように、グループごとの集計値と各行を対比する処理が1クエリで実現できます。
実務パターン:「自分の平均を超えた注文だけ表示」「カテゴリ内で最高価格の商品を抽出」「月次予算を超えた部署を検出」など、グループ内の集計値との比較に使います。
エイリアスで外側・内側を区別:FROM orders o(外側)と FROM orders o2(内側)のように同じテーブルを別名で使うことで、相関条件 o2.user_id = o.user_id が意味を持ちます。エイリアスを忘れると意図しない自己参照になります。
ウィンドウ関数との比較:PostgreSQL / MySQL 8.0+ では AVG(amount) OVER (PARTITION BY user_id) で同じ結果を得られ、パフォーマンス上も有利です。相関SQ は行ごとに内側を実行するためデータが多いと遅くなる場合があります。
アンチパターン
相関条件の書き忘れ:WHERE o2.user_id = o.user_id を省略すると、全注文の平均(6833)との比較になり、相関サブクエリではなく通常のスカラーSQと同じ動作になります。相関条件は必ず確認しましょう。
大量データでのパフォーマンス悪化:相関SQ は外側の全行に対して内側を実行します。orders が10万行あれば最大10万回の内側クエリが実行されます。インデックスを活用するか、ウィンドウ関数やCTEを使いましょう。
実務コラム:相関SQ vs ウィンドウ関数の選択基準
相関SQ は「古い DB でも動く」「直感的に読める」という強みがありますが、大量データでは遅くなる弱点があります。PostgreSQL・MySQL 8.0+・SQL Server 等の現代的 RDB では AVG(amount) OVER (PARTITION BY user_id) というウィンドウ関数を使うと1回のテーブルスキャンで済み、パフォーマンスが大幅に改善します。ただし、ウィンドウ関数を WHERE で直接使えないため SELECT 後に派生テーブルや CTE で包む必要があります。
QUESTION 8

FROM句サブクエリ + JOIN — カテゴリ別最高価格商品を1クエリで取得する

FROM句SQINNER JOINGROUP BY MAXカタログ管理
前提知識

「各カテゴリの最高価格商品を取得する」には、まずカテゴリごとの MAX 価格を集計し、その結果テーブルと元テーブルを JOIN するパターンが有効です。相関サブクエリで書くこともできますが、派生テーブル + JOIN の方が大量データで効率的です。

SELECT p.*
FROM   products p
INNER JOIN (
  SELECT category, MAX(price) AS max_price  -- 派生テーブルでMAXを集計
  FROM   products
  GROUP BY category
) AS cat_max
  ON  p.category = cat_max.category
  AND p.price    = cat_max.max_price;   -- MAX値と一致する行のみ結合
このパターンが有効な理由:カテゴリの MAX を先に集計してから JOIN することで、相関SQ(行ごとに MAX を計算)より効率的に動作します。同じ MAX 価格の商品が複数ある場合、全件が返ってくる点も覚えておきましょう。
問題

products テーブルから、カテゴリごとの最高価格の商品を取得してください。取得列は product_id, name, category, price とし、price 降順で並べてください。

使用テーブル
▸ products
product_idnamecategoryprice
1プランAservice9800
2プランBservice4900
3テンプレートXcontent5500
4テンプレートYcontent3800
5APIアドオンoption3500
6サポート拡張option2200
期待出力
product_idnamecategoryprice
1プランAservice9800
3テンプレートXcontent5500
5APIアドオンoption3500
模範解答コード
SELECT
  p.product_id,
  p.name,
  p.category,
  p.price
FROM   products p
INNER JOIN (                       -- JOIN の右辺に派生テーブルを配置
  SELECT
    category,
    MAX(price) AS max_price       -- カテゴリごとの最高価格を集計
  FROM   products
  GROUP BY category
) AS cat_max                      -- 派生テーブルには必ずエイリアスを付ける
  ON  p.category = cat_max.category  -- カテゴリが一致
  AND p.price    = cat_max.max_price  -- かつ価格がMAX値と一致
ORDER BY p.price DESC;

/*
  実行順序(SQLの論理的な評価順):
  1. 派生テーブルを評価
  2. FROM products p         → productsを全件読み込む
  3. INNER JOIN ... ON ...   → category一致かつprice=MAX値の行のみ結合
  4. SELECT p.product_id...  → 必要列を選択
  5. ORDER BY p.price DESC   → price降順に並び替え
  */
解説(テーブル変化・ポイント)
SELECT p.product_id, p.name, p.category, p.price FROM products p INNER JOIN ( SELECT category, MAX(price) AS max_price FROM products GROUP BY category ) AS cat_max ON p.category = cat_max.category AND p.price = cat_max.max_price ORDER BY p.price DESC;
LEGEND
グループ化キー・集計対象
グループ分類
① 派生テーブル(GROUP BY)
SELECT category, MAX(price) FROM products GROUP BY categoryまず派生テーブルの内側クエリが実行。3カテゴリそれぞれの最高価格を集計します。
1 / 3
product_idnamecategorypriceグループ
1プランAservice9800serviceグループ
2プランBservice4900serviceグループ
3テンプレートXcontent5500contentグループ
4テンプレートYcontent3800contentグループ
5APIアドオンoption3500optionグループ
6サポート拡張option2200optionグループ
→ 派生テーブル: 3行(service:9800 / content:5500 / option:3500)
学習ポイント
「集計してからJOIN」パターン:まず派生テーブルでグループ集計(MAX)を行い、その結果と元テーブルを JOIN する手法です。相関SQ(行ごとに MAX を計算)より効率的で、「カテゴリ別Top1」「グループ内最新行」などに応用できます。
ON 句に複数条件:JOIN の ON 句に AND で条件を追加できます。カテゴリ一致 + 価格一致の2条件を満たす行だけが結合結果に残ります。WHERE で書いても同じ結果ですが、JOIN 結合条件として ON に書く方が意図が明確です。
同一MAX価格が複数ある場合:あるカテゴリに同じ MAX 価格の商品が複数存在する場合、このクエリは全件返します(意図通りかどうかを設計段階で確認)。1件だけ返したい場合は ROW_NUMBER() などのウィンドウ関数が必要です。
アンチパターン
WHERE で MAX を使おうとする(エラー):WHERE price = MAX(price) と書くと構文エラーになります。集計関数は WHERE 句で直接使えません。派生テーブルを介するかウィンドウ関数を使いましょう。
相関SQで代替する場合のパフォーマンス:WHERE price = (SELECT MAX(price) FROM products p2 WHERE p2.category = p.category) でも同じ結果になりますが、行ごとに内側を実行するため大量データで遅くなります。派生テーブル + JOIN の方が効率的です。
実務コラム:グループ内Top-N取得の設計パターン
「カテゴリ別Top1」は今回のパターンで対応できますが、「カテゴリ別Top3」になると複雑化します。現代的な RDB では ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC) というウィンドウ関数を使い、WHERE rank <= 3 で絞り込む方法が標準です。サブクエリ + JOIN パターンはウィンドウ関数が使えない環境でのフォールバックとして覚えておきましょう。
QUESTION 9

サブクエリ vs CTE(WITH句)— 同じ処理の2つの書き方を理解する

CTE/WITHFROM句SQ可読性等価変換
前提知識

CTE(Common Table Expression)は WITH 名前 AS (サブクエリ) で定義し、後続のクエリから名前で参照できる「一時的な名前付きテーブル」です。FROM句のサブクエリ(派生テーブル)と多くの場合で等価ですが、可読性と再利用性で優ります。

-- ■ サブクエリ版(ネストが深い)
SELECT * FROM (
  SELECT status, COUNT(*) AS cnt FROM orders GROUP BY status
) AS sub WHERE cnt >= 2;

-- ■ CTE版(フラットで読みやすい)
WITH sub AS (
  SELECT status, COUNT(*) AS cnt FROM orders GROUP BY status
)
SELECT * FROM sub WHERE cnt >= 2;
CTEが優れる場面:①同じサブクエリを複数回参照するとき(1回の定義で使い回せる)②ネストが3段以上になるとき③ロジックをステップごとに名前を付けて整理したいとき。
問題

orders テーブルを使い、status ごとに注文件数(order_cnt)と合計金額(total_amount)を集計し、order_cnt が 2 以上のステータスのみを total_amount 降順で返してください。サブクエリ版とCTE版の両方を解答してください。

使用テーブル
▸ orders
order_iduser_idamountstatus
10118000completed
102112000completed
10323500pending
10439500completed
10536000completed
10642000cancelled
期待出力

期待する出力①:

statusorder_cnttotal_amount
completed435500

期待する出力②:

statusorder_cnttotal_amount
completed435500
模範解答コード
-- ■ サブクエリ版(派生テーブル)
SELECT
  status,
  order_cnt,
  total_amount
FROM (
  SELECT
    status,
    COUNT(*)    AS order_cnt,   -- 件数を集計
    SUM(amount) AS total_amount -- 合計金額を集計
  FROM   orders
  GROUP BY status
) AS summary                    -- 派生テーブルに必ずエイリアスを付ける
WHERE  order_cnt >= 2           -- 集計後の件数で絞り込む
ORDER BY total_amount DESC;

-- ■ CTE版(WITH句)— 上と全く同じ結果を返す
WITH summary AS (               -- CTEの定義(名前: summary)
  SELECT
    status,
    COUNT(*)    AS order_cnt,
    SUM(amount) AS total_amount
  FROM   orders
  GROUP BY status
)
SELECT                           -- CTE名で参照(普通のテーブルと同様)
  status,
  order_cnt,
  total_amount
FROM   summary
WHERE  order_cnt >= 2
ORDER BY total_amount DESC;

/*
  実行順序(いずれも同じ論理評価順):
  1. 集計クエリ(SQまたはCTE)を評価          → status ごとに集計
  2. FROM summary                → 集計結果を参照
  3. WHERE order_cnt のしきい値       → 条件で絞る
  4. SELECT ...                  → 3列を選択
  5. ORDER BY total_amount DESC  → 合計降順
  */
解説(テーブル変化・ポイント)
SELECT status, order_cnt, total_amount FROM ( SELECT status, COUNT(*) AS order_cnt, SUM(amount) AS total_amount FROM orders GROUP BY status ) AS summary WHERE order_cnt >= 2 ORDER BY total_amount DESC;
LEGEND
グループ化キー・集計対象
グループ分類
① 内側クエリ(GROUP BY)
SELECT status, COUNT(*) AS order_cnt, SUM(amount) AS total_amount FROM orders GROUP BY statusまず派生テーブルの内側クエリが実行。ordersをstatus別にグループ化して件数・合計を集計します。
1 / 4
order_idstatusamountグループ
101completed8000completedグループ
102completed12000completedグループ
103pending3500pendingグループ
104completed9500completedグループ
105completed6000completedグループ
106cancelled2000cancelledグループ
→ 派生テーブル: 3行
WITH summary AS ( SELECT status, COUNT(*) AS order_cnt, SUM(amount) AS total_amount FROM orders GROUP BY status ) SELECT status, order_cnt, total_amount FROM summary WHERE order_cnt >= 2 ORDER BY total_amount DESC;
LEGEND
グループ化キー・集計対象
グループ分類
① CTE定義(WITH句)
WITH summary AS (SELECT status, COUNT(*) AS order_cnt, SUM(amount) AS total_amount FROM orders GROUP BY status)CTEとして「summary」という名前でクエリを定義します。実行内容はサブクエリ版と全く同じです。ネストがなくフラットに書ける点が特徴です。
1 / 4
order_idstatusamountグループ
101completed8000completedグループ
102completed12000completedグループ
103pending3500pendingグループ
104completed9500completedグループ
105completed6000completedグループ
106cancelled2000cancelledグループ
→ CTE「summary」: 3行
学習ポイント
CTE と FROM句SQ は多くの場合等価:PostgreSQL・MySQL 8.0+・SQL Server などでは CTE と FROM句サブクエリは同じ実行計画に変換されることが多く、パフォーマンスに差はありません。書き方の好みと可読性で選択します。
CTE が優れる場面:①同じ集計を複数箇所で参照するとき(WITH a AS (...), b AS (...) SELECT ... と複数CTE定義可能)②ネストが3段以上になるとき③デバッグ時に中間結果を段階的に確認したいとき。
FROM句SQ が優れる場面:古い MySQL(5.x)はCTEをサポートしていないため FROM句SQ しか使えません。また、使い捨てのシンプルな集計で「名前を付けるほどでもない」場合は FROM句SQ の方がコンパクトに書けます。
アンチパターン
深いネストのサブクエリ:FROM句SQ が3段以上ネストすると可読性が著しく低下します。FROM (FROM (FROM ...)) のようなクエリは CTE で分解するのが実務標準です。
CTE の過信(再帰CTEの混同):通常のCTEは「使い捨ての名前付きクエリ」にすぎません。「再帰CTE(WITH RECURSIVE)」は別機能で、ツリー構造の探索などに使います。両者を混同しないようにしましょう。
実務コラム:チームでの SQL 可読性標準
チーム開発では「他人が読めるSQL」を書くことが重要です。複雑なロジックは必ずCTEで段階的に書き、各CTEに意味のある名前を付けましょう。例えば WITH active_users AS (...), recent_orders AS (...), summary AS (...) SELECT ... のようにすれば、SQL がドキュメントとしても機能します。「サブクエリが3段を超えたらCTEへ」を目安にしてください。
QUESTION 10

複合サブクエリ — IN/HAVING・スカラーSQ・相関SQを組み合わせて実務クエリを構築する

複合SQHAVING実務レベルVIP分析
前提知識

実務のSQLでは複数種類のサブクエリを組み合わせて使うケースが多くあります。この問題では ①WHERE IN + HAVING(高額ユーザーを特定)、②スカラーSQ(相関)(最新注文日を取得)、③スカラーSQ(相関)(注文件数を付与)を1つのクエリで組み合わせます。

SELECT m.id_col,
       (SELECT MAX(s.date_col)                 -- 相関スカラーサブクエリ(1値を返す)
        FROM   sub_table s
        WHERE  s.id_col = m.id_col) AS latest_date,
       (SELECT COUNT(*)
        FROM   sub_table s
        WHERE  s.id_col = m.id_col) AS cnt
FROM   main_table m
WHERE  m.id_col IN (                            -- IN + HAVING で対象を絞る
  SELECT s.id_col
  FROM   sub_table s
  GROUP BY s.id_col
  HAVING SUM(s.num_col) >= 1000
);
複雑なクエリの読み方:複合クエリは「内側から外側へ」順に読み解くのがコツです。まず一番内側のサブクエリが何を返すかを確認し、それが外側でどう使われるかを追います。
問題

以下の条件を満たすクエリを作成してください:
orders テーブルで completed の合計金額が 10,000 以上のユーザーの最新注文1件のみを取得
② 取得列は name(users), order_id, amount, ordered_at, total_orders(そのユーザーの全注文件数)
③ total_orders 降順、同一件数は amount 降順で並べること

使用テーブル
▸ users
user_idnameplan
1田中 太郎premium
2佐藤 花子free
3鈴木 一郎premium
4山田 次郎standard
5伊藤 三郎free
▸ orders
order_iduser_idamountstatusordered_at
10118000completed2024-05-01
102112000completed2024-05-20
10323500pending2024-05-10
10439500completed2024-05-15
10536000completed2024-05-25
10642000cancelled2024-05-08
期待出力
nameorder_idamountordered_attotal_orders
田中 太郎102120002024-05-202
鈴木 一郎10560002024-05-252
模範解答コード
SELECT
  u.name,
  o.order_id,
  o.amount,
  o.ordered_at,
  (SELECT COUNT(*)                    -- 相関スカラーSQ: 全注文件数を付与
   FROM   orders o3
   WHERE  o3.user_id = u.user_id
  ) AS total_orders
FROM   users u
INNER JOIN orders o
  ON  u.user_id = o.user_id
WHERE  u.user_id IN (              -- ① WHERE IN + HAVING: 高額ユーザーを特定
  SELECT user_id
  FROM   orders
  WHERE  status = 'completed'
  GROUP BY user_id
  HAVING SUM(amount) >= 10000     -- completedの合計が10000以上のuser_idリスト
)
  AND o.ordered_at = (            -- ② 相関スカラーSQ: 最新注文日と一致する行のみ
    SELECT MAX(o2.ordered_at)
    FROM   orders o2
    WHERE  o2.user_id = u.user_id
  )
ORDER BY total_orders DESC, o.amount DESC;

/*
  実行順序(SQLの論理的な評価順):
  1. IN サブクエリを評価                                → HAVING付き集計でリストを生成
  2. FROM users INNER JOIN orders               → user_id で結合
  3. WHERE user_id IN (...)                     → 該当ユーザーに絞る
  4. AND o.ordered_at = (相関スカラーSQ)              → 各ユーザーの最新注文に絞る
  5. SELECT (相関スカラーSQ)                          → 全注文件数を付与
  6. ORDER BY total_orders DESC, o.amount DESC  → 件数→金額降順
  */
解説(テーブル変化・ポイント)
SELECT u.name, o.order_id, o.amount, o.ordered_at, ( SELECT COUNT(*) FROM orders o3 WHERE o3.user_id = u.user_id ) AS total_orders FROM users u INNER JOIN orders o ON u.user_id = o.user_id WHERE u.user_id IN ( SELECT user_id FROM orders WHERE status = 'completed' GROUP BY user_id HAVING SUM(amount) >= 10000 ) AND o.ordered_at = ( SELECT MAX(o2.ordered_at) FROM orders o2 WHERE o2.user_id = u.user_id ) ORDER BY total_orders DESC, o.amount DESC;
LEGEND
評価対象の列・キー
除外・非表示データ
✓ 通過
✗ 除外
① IN + HAVING サブクエリ
SELECT user_id FROM orders WHERE status='completed' GROUP BY user_id HAVING SUM(amount)>=10000まずcompleted注文をuser_idでグループ化し、合計が10000以上のuser_idだけをリスト化します。これが外側クエリのユーザー絞り込みに使われます。
1 / 4
user_idSUM(completed)HAVING >= 10000?
18000+12000=20000✓ [1,3]に追加
20(completedなし)✗ 除外
39500+6000=15500✓ [1,3]に追加
40(completedなし)✗ 除外
5(注文なし)✗ 除外
→ IN リスト: [1, 3]
学習ポイント
サブクエリの組み合わせ方:複合クエリは「どの位置にどの種類のSQを使うか」を意識して設計します。①絞り込みリスト生成→IN SQ ②集計後の絞り込み→HAVING付きIN SQ ③行ごとに変わる単一値→相関スカラーSQ ④存在確認→EXISTS というマッピングが実務の目安です。
「最新N件」を取得するパターン:ordered_at = (SELECT MAX(ordered_at) FROM orders WHERE user_id = u.user_id) は「各ユーザーの最新注文」を取得する定番パターンです。MAX による相関SQ を ON や AND 条件に置くことで、JOINしながら最新行だけを残せます。
HAVING でグループ後に絞り込む:IN のサブクエリ内で GROUP BY + HAVING を使うことで、「合計金額が一定以上のユーザーID」のリストを1クエリで動的生成できます。HAVING はGROUP BY後の集計値に対してのみ使える絞り込み条件です。
複雑なクエリはCTEで分解すると読みやすい:この問題のようなクエリは WITH high_value_users AS (...), latest_orders AS (...), summary AS (...) SELECT ... のようにCTEで各ステップを名前付きで分解すると、デバッグや仕様変更時に格段に扱いやすくなります。
アンチパターン
最新注文を取得する際の日時一致の注意:ordered_at が timestamp 型の場合、秒・ミリ秒まで含むため同一ユーザーの注文が全て異なる timestamp になります。日付だけ(DATE型)なら同日2注文が両方返ってくる可能性があります。意図に応じてROW_NUMBER()ウィンドウ関数の使用も検討しましょう。
複雑な相関SQの性能問題:SELECT句に相関スカラーSQ(total_orders)があり、さらにWHERE句にも相関SQ(最新ordered_at)があります。行数が増えると O(N²) 相当の評価が発生することがあります。本番では EXPLAIN で実行計画を確認し、必要に応じてウィンドウ関数やCTEに置き換えましょう。
実務コラム:複雑なクエリの段階的な設計アプローチ
実務で複雑なクエリを書く際は「まず最もシンプルな部分から確認していく」段階的設計が有効です。①まず IN + HAVING だけ実行してユーザーリストが正しいか確認 → ②JOIN して結合結果を確認 → ③相関SQの最新注文条件を追加して絞り込みを確認 → ④スカラーSQで total_orders を付与 という順で積み上げていくと、どのステップで意図と違う結果が出ているかを特定しやすくなります。「一気に書かず、分割して検証する」のが実務クエリ開発の鉄則です。