モダンSQLの3本柱とは何か

第4章までで扱った基本構文(SELECT/FROM/WHERE/JOIN/GROUP BY)はSQL-92で標準化された古典SQLの領域です。 本章で扱うサブクエリ・CTE・ウィンドウ関数は、SQL:1999以降に整備された「モダンSQL」の中核であり、 これらを使いこなせるかどうかがSQLの実力を大きく左右します。

機能 標準化 主な用途
サブクエリ SQL-92 (相関サブクエリ) 「あるクエリの結果を使って別のクエリを絞る」
CTE (WITH句) SQL:1999 (再帰CTEも同時) 複雑なクエリの可読性向上、再帰処理
ウィンドウ関数 SQL:2003 (強化はSQL:2011, 2016) ランキング、移動平均、累積和、行間比較

サブクエリ — 置ける場所で3分類

サブクエリ(subquery)は他のクエリの中に埋め込まれたSELECT文で、 返す形によって置ける場所が決まります。

種類 返り値の形 置ける場所
スカラサブクエリ 1行×1列 SELECT, WHERE, ORDER BY のほぼどこでも (SELECT AVG(price) FROM products)
行サブクエリ 1行×N列 WHERE の行比較 WHERE (a, b) = (SELECT a, b FROM ...)
表サブクエリ N行×N列 FROM, IN, EXISTS FROM (SELECT ...) AS t

相関サブクエリ vs 非相関サブクエリ

もう1つの重要な分類が「外側のクエリを参照するかどうか」です。 これは実行コストに直結します。

-- 非相関サブクエリ: 外側を参照しない → 1回だけ実行して使いまわせる
SELECT name, price
FROM products
WHERE price > (SELECT AVG(price) FROM products);
--             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 1回評価してキャッシュ可能

-- 相関サブクエリ: 外側の行を参照する → 外側の各行ごとに再評価 (遅い)
SELECT p1.name, p1.price
FROM products p1
WHERE p1.price > (
  SELECT AVG(p2.price)
  FROM products p2
  WHERE p2.category_id = p1.category_id  -- ← 外側を参照 = 相関
);
-- 各行で評価されるので O(n^2) に近いコストになる場合が多い
-- ウィンドウ関数で書き換えるほうが速いケースが多い

IN / EXISTS / NOT IN / NOT EXISTS

「特定集合に属する/属さない」判定で頻出の4つの構文は、見た目似ていてもNULLの扱いで罠があります。

-- ✅ IN と EXISTS は基本的に等価
SELECT * FROM orders o WHERE o.user_id IN (SELECT id FROM vip_users);
SELECT * FROM orders o WHERE EXISTS (SELECT 1 FROM vip_users v WHERE v.id = o.user_id);

-- ⚠️ NOT IN の罠: サブクエリが1つでもNULLを返すと結果が空になる
SELECT * FROM orders o WHERE o.user_id NOT IN (SELECT id FROM banned_users);
-- ↑ banned_users.id に NULL が1つでもあれば「全部NULL = UNKNOWN」扱いで
--   どの行も残らない

-- ✅ NOT EXISTS なら NULL安全
SELECT * FROM orders o
WHERE NOT EXISTS (SELECT 1 FROM banned_users b WHERE b.id = o.user_id);

CTE (WITH句) — 可読性の革命

CTE(Common Table Expression, 共通テーブル式)はSQL:1999で導入された機能で、 クエリの前に名前付きの中間結果を定義できます。 サブクエリのネスト地獄から脱出する決定打であり、多くのモダンSQLチュートリアルで最初に教えられます。

-- ❌ ネストしたサブクエリは読みづらい
SELECT category, avg_price
FROM (
  SELECT category, AVG(price) AS avg_price
  FROM (
    SELECT category, price
    FROM products
    WHERE in_stock = TRUE
  ) t
  GROUP BY category
) t2
WHERE avg_price > 1000;

-- ✅ CTEなら上から下に読める
WITH in_stock_products AS (
  SELECT category, price
  FROM products
  WHERE in_stock = TRUE
),
category_avg AS (
  SELECT category, AVG(price) AS avg_price
  FROM in_stock_products
  GROUP BY category
)
SELECT category, avg_price
FROM category_avg
WHERE avg_price > 1000;

再帰CTE — 階層・グラフをSQLで扱う

CTEの中でも強力なのが再帰CTEです。組織図・カテゴリツリー・友達の友達検索など、 階層/グラフ構造を純粋なSQLで表現できます。

-- 組織の階層を全展開(id, name, manager_id を持つ employees テーブル)
WITH RECURSIVE org AS (
  -- アンカー: CEO (manager_idがNULL)
  SELECT id, name, manager_id, 0 AS level
  FROM employees
  WHERE manager_id IS NULL

  UNION ALL

  -- 再帰部: 前ステップの結果から、それを上司とする部下を取ってくる
  SELECT e.id, e.name, e.manager_id, o.level + 1
  FROM employees e
  JOIN org o ON e.manager_id = o.id
)
SELECT REPEAT('  ', level) || name AS hierarchy, level
FROM org
ORDER BY level, name;
flowchart TD
    A[アンカー部分<br/>SELECT ... FROM ... WHERE manager_id IS NULL] --> U[UNION ALL]
    U --> R[再帰部分<br/>JOIN で前ステップの結果と結合]
    R -->|新しい行があれば繰り返し| U
    R -->|新しい行が無くなれば| DONE[終了]
    style A fill:#3b82f6,color:#fff
    style DONE fill:#10b981,color:#fff
再帰CTE: アンカー → 再帰部 → 新しい行がなくなるまで繰り返し

ウィンドウ関数 — 「集約しない集約」

ウィンドウ関数(window functions, 分析関数とも呼ぶ)は、 GROUP BYのように集約しつつ、行を潰さないという一見矛盾する操作を可能にします。 SQL:2003で標準化され、現代SQLで最もインパクトの大きい機能です。

-- 各社員と、その部署の平均給与を並べて表示
SELECT
  name,
  dept,
  salary,
  AVG(salary) OVER (PARTITION BY dept) AS dept_avg
FROM employees;

-- 結果:
-- name  | dept | salary | dept_avg
-- Alice | Eng  | 800    | 700      <- Engの平均
-- Bob   | Eng  | 600    | 700
-- Carol | Sales| 500    | 550      <- Salesの平均
-- Dave  | Sales| 600    | 550

ウィンドウ関数は 関数(...) OVER (PARTITION BY ... ORDER BY ... frame) という構文で、 3つの要素を制御できます。

要素 役割
PARTITION BY 分割キー(GROUP BY相当) PARTITION BY deptで部署ごとに分ける
ORDER BY ウィンドウ内の順序 ORDER BY hire_dateで入社順
frame 現在行からどの範囲を集約対象にするか ROWS BETWEEN 2 PRECEDING AND CURRENT ROWで直近3行

代表的なウィンドウ関数

分類 関数 用途
ランキング ROW_NUMBER() 重複を許さない連番 (1,2,3,4)
ランキング RANK() 同点は同順位、次が飛ぶ (1,2,2,4)
ランキング DENSE_RANK() 同点は同順位、次は飛ばない (1,2,2,3)
行間参照 LAG(col, n) n行前の値を取得
行間参照 LEAD(col, n) n行後の値を取得
集約 SUM/AVG/COUNT/MIN/MAX 通常の集約関数をウィンドウとして使える
集約 FIRST_VALUE / LAST_VALUE / NTH_VALUE ウィンドウ内の特定位置の値
分位 NTILE(n) n分位に分割
分位 PERCENT_RANK() / CUME_DIST() 相対順位
-- 部署ごとの給与ランキング Top 3 を抽出(モダンSQL定番パターン)
SELECT *
FROM (
  SELECT
    name, dept, salary,
    RANK() OVER (PARTITION BY dept ORDER BY salary DESC) AS rnk
  FROM employees
) t
WHERE rnk <= 3;

-- 前月比売上の増減率(LAGで1行前を参照)
SELECT
  month,
  sales,
  LAG(sales) OVER (ORDER BY month) AS prev_sales,
  (sales - LAG(sales) OVER (ORDER BY month)) * 100.0
    / LAG(sales) OVER (ORDER BY month) AS growth_pct
FROM monthly_sales;

-- 7日間移動平均
SELECT
  date, daily_users,
  AVG(daily_users) OVER (
    ORDER BY date
    ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
  ) AS moving_avg_7d
FROM user_stats;

アンチパターン — GROUP BY→ウィンドウ、相関→JOIN

モダンSQLの3本柱を身につけると、書き換えでパフォーマンスが一桁改善するケースが多々あります。

-- アンチパターン: 相関サブクエリで各行の「部署内順位」を計算
SELECT
  e1.name, e1.salary,
  (SELECT COUNT(*) + 1
   FROM employees e2
   WHERE e2.dept = e1.dept AND e2.salary > e1.salary) AS rank_in_dept
FROM employees e1;
-- O(n^2)に近いコスト

-- リファクタ後: ウィンドウ関数で1回走査
SELECT
  name, salary,
  RANK() OVER (PARTITION BY dept ORDER BY salary DESC) AS rank_in_dept
FROM employees;
-- O(n log n) になる

第5章のまとめ

  • サブクエリは返り値の形(スカラ/行/表)相関/非相関の2軸で分類する。相関は遅いので可能ならJOINやウィンドウ関数に書き換える
  • NOT INはNULLが混じると全行消える罠がある。NOT EXISTSが安全
  • CTE(WITH句)はネストしたサブクエリを上から下に読める形に整理する。再帰CTEで階層・グラフも扱える
  • ウィンドウ関数はPARTITION BY(分割)、ORDER BY(順序)、frame(範囲)の3要素で集約範囲を制御する。frame省略のデフォルトに注意
  • GROUP BYでは集約できるが行が潰れる。行を残したまま集約したい場合はウィンドウ関数を使う

次章では、ここまでに書いてきたSQLを実際に実行するクエリプランナ/オプティマイザの世界に踏み込みます。 「WHAT」を書けば「HOW」を自動で決める頭脳が、どうやって最適な実行計画を選んでいるのかを解剖します。

理解度チェック

問題 0 / 50%
Q1

相関サブクエリ(correlated subquery)の定義として正しいものはどれですか?

キーボード: 1〜4 で選択、Enter で回答