基本CTE(WITH句) — サブクエリをCTEに置き換えて可読性を上げる
CTE(Common Table Expression)は WITH 名前 AS (...) の形式で定義する「一時的な名前付きクエリ」です。サブクエリをネストする代わりにCTEに切り出すことで、SQLが上から下へ読めるようになります。
WITH cte_name AS ( -- ここに SELECT 文を書く(一時テーブルのイメージ) SELECT col1, SUM(col2) AS total FROM some_table GROUP BY col1 ) -- CTE定義の後に本体のSELECTを書く SELECT * FROM cte_name WHERE total > 10000;
CTEは SELECT / INSERT / UPDATE / DELETE すべてで使えます。本クイズではSELECTのみ扱います。
以下の orders テーブルから、月ごとの売上合計・注文件数・平均注文額を集計するAPIを想定し、CTEを使ってクエリを作成してください。
さらに、平均注文額が 40,000円超の月のみ を最終結果として返してください。
| order_id | order_month | amount |
|---|---|---|
| 1 | 2024-01 | 30000 |
| 2 | 2024-01 | 45000 |
| 3 | 2024-01 | 20000 |
| 4 | 2024-02 | 55000 |
| 5 | 2024-02 | 60000 |
| 6 | 2024-03 | 80000 |
| 7 | 2024-03 | 15000 |
| 8 | 2024-03 | 25000 |
| order_month | total_sales | order_count | avg_order |
|---|---|---|---|
| 2024-02 | 115000 | 2 | 57500 |
-- CTEを定義する: WITH句を使うことで、サブクエリを一時テーブルのように扱える WITH monthly_summary AS ( -- ① orders テーブルを月ごとに集計した「中間テーブル」を作る SELECT -- SELECT: 取得する列を指定 order_month, -- 月ごとに集計するキー列 SUM(amount) AS total_sales, -- SUM: 月の売上合計を計算。ASで別名をつける COUNT(*) AS order_count, -- COUNT(*): 全行数 = 注文件数を計算 AVG(amount) AS avg_order -- AVG: 月の平均注文額を計算 FROM orders -- FROM: データを取得する元テーブルを指定 GROUP BY order_month -- GROUP BY: 同じ月の行をまとめて集計 ) -- ② CTE の結果を「テーブルのように」FROM に指定して使う SELECT -- SELECT: 最終的に出力する列を指定 order_month, total_sales, order_count, ROUND(avg_order, 0) AS avg_order -- ROUND(値, 桁数): 小数を四捨五入 (0なら整数に) FROM monthly_summary -- FROM: 上で定義したCTEを指定 WHERE avg_order > 40000 -- WHERE: 条件に合う行のみに絞り込む(40000より大きい) ORDER BY order_month; -- ORDER BY: 月の昇順(小さい順)で並び替え /* 実行順序: 1. CTE内 FROM orders → テーブル全件読み込み 2. CTE内 GROUP BY order_month → 月ごとにグループ化・SUM/COUNT/AVG集計 3. 外側 FROM monthly_summary → CTE結果を仮想テーブルとして参照 4. 外側 WHERE avg_order > 40000 → フィルタ適用 5. 外側 ORDER BY order_month → 昇順並び替え */
LEGEND
① FROM
FROM ordersorders テーブル全 8行を読み込みます。これが CTE monthly_summary の入力データです。| order_id | order_month | amount |
|---|---|---|
| 1 | 2024-01 | 30,000 |
| 2 | 2024-01 | 45,000 |
| 3 | 2024-01 | 20,000 |
| 4 | 2024-02 | 55,000 |
| 5 | 2024-02 | 60,000 |
| 6 | 2024-03 | 80,000 |
| 7 | 2024-03 | 15,000 |
| 8 | 2024-03 | 25,000 |
HAVING avg(amount) > 40000 でも同じ結果ですが、CTEにすると「集計ロジック」と「フィルタロジック」が分離でき、条件変更が容易です。WITH cte AS (...); SELECT ... は文法エラー。WITHからSELECTの末尾まで1つのSQL文です。複数CTE連結 — ステップを分けてユーザー購買ランクを計算する
CTEは WITH の後に カンマ区切りで複数定義でき、後に定義したCTEは前のCTEを参照できます。「ステップ①の結果をステップ②で使う」というパイプライン処理をSQLで表現できます。
WITH step1 AS ( SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id ), -- step2 は step1 を参照できる(カンマで区切って連続定義) step2 AS ( SELECT *, NTILE(4) OVER (ORDER BY total DESC) AS quartile FROM step1 -- 前のCTEをテーブルのように参照 ) SELECT * FROM step2;
以下の users と orders テーブルを使って、ユーザーごとの合計購入額を集計し、上位から4分位(NTILE)でランクを付けるAPIを作成してください。
ランクは quartile=1 が最上位。最終出力には users テーブルから user_name も結合してください。
| user_id | user_name |
|---|---|
| U01 | Alice |
| U02 | Bob |
| U03 | Carol |
| U04 | Dave |
| U05 | Eve |
| U06 | Frank |
| U07 | Grace |
| U08 | Hank |
| order_id | user_id | amount |
|---|---|---|
| 1 | U01 | 120000 |
| 2 | U01 | 50000 |
| 3 | U02 | 30000 |
| 4 | U03 | 200000 |
| 5 | U04 | 75000 |
| 6 | U05 | 10000 |
| 7 | U06 | 95000 |
| 8 | U07 | 40000 |
| 9 | U08 | 160000 |
| 10 | U06 | 20000 |
| user_id | user_name | total_amount | quartile |
|---|---|---|---|
| U03 | Carol | 200000 | 1 |
| U01 | Alice | 170000 | 1 |
| U08 | Hank | 160000 | 2 |
| U06 | Frank | 115000 | 2 |
| U04 | Dave | 75000 | 3 |
| U07 | Grace | 40000 | 3 |
| U02 | Bob | 30000 | 4 |
| U05 | Eve | 10000 | 4 |
WITH user_totals AS ( -- ① ユーザーごとに注文を合計する(中間集計) SELECT -- SELECT: 取得する列を指定 user_id, SUM(amount) AS total_amount -- SUM: 複数注文を合算。ASで別名をつける FROM orders -- FROM: 取得元テーブル GROUP BY user_id -- GROUP BY: ユーザー単位でグループ化 ), user_ranked AS ( -- ② user_totals を使って NTILE で4分位を付ける(前のCTEを参照) SELECT user_id, total_amount, NTILE(4) OVER ( -- NTILE(4): 全行を4等分してバケット番号を付与 ORDER BY total_amount DESC -- ORDER BY ... DESC: 購入額の降順(大きい順)で並べる ) AS quartile FROM user_totals -- FROM: 前のCTE(user_totals)をテーブルとして参照 ) -- ③ 本体: user_ranked に users テーブルを JOIN して名前を付加 SELECT r.user_id, u.user_name, -- users テーブルから名前を取得(結合して追加) r.total_amount, r.quartile FROM user_ranked r -- FROM: r は user_ranked のエイリアス(短縮名) INNER JOIN users u -- INNER JOIN: 両方のテーブルに存在する行のみ結合 ON r.user_id = u.user_id -- ON: 結合条件(user_idが一致する行を繋げる) ORDER BY r.quartile, r.total_amount DESC; -- ORDER BY: 四分位の昇順 → 購入額降順の順で並べる /* 実行順序: 1. CTE user_totals: FROM orders → GROUP BY user_id 2. CTE user_ranked: FROM user_totals 3. 本体: FROM user_ranked INNER JOIN users ON user_id → 名前付加 4. 本体: ORDER BY quartile, total_amount DESC */
LEGEND
① FROM
FROM ordersorders テーブル全 10行を読み込みます。これが最初の CTE user_totals の入力データになります。| order_id | user_id | amount |
|---|---|---|
| 1 | U01 | 120,000 |
| 2 | U01 | 50,000 |
| 3 | U02 | 30,000 |
| 4 | U03 | 200,000 |
| 5 | U04 | 75,000 |
| 6 | U05 | 10,000 |
| 7 | U06 | 95,000 |
| 8 | U07 | 40,000 |
| 9 | U08 | 160,000 |
| 10 | U06 | 20,000 |
user_ranked のCTEだけ修正すればOKです。RANK() は値の大小で順位を付けますが、NTILE(n) は行数をn等分してバケット番号を付けます。同率があっても強制的に振り分けられる点が異なります。step2 の中で step3 を使うことはできません(再帰CTEを除く)。CTE + ROW_NUMBER — カテゴリ別トップN商品を抽出する
「カテゴリ別の上位N件だけ取得する」というAPIは非常に頻出です。ROW_NUMBER() + CTE の組み合わせが最もシンプルで汎用性が高いパターンです。
WITH ranked AS ( SELECT *, ROW_NUMBER() OVER ( PARTITION BY category -- カテゴリごとに独立して番号を振る ORDER BY sales DESC -- 売上降順で1から番号付け ) AS rn FROM products ) SELECT * FROM ranked WHERE rn <= 2; -- 各カテゴリの上位2件だけ取る
以下の product_sales テーブルから、カテゴリ(category)ごとに売上(sales)の上位2商品を抽出してください。
同率がある場合も ROW_NUMBER で強制的に2件に絞ること。
| product_id | category | product_name | sales |
|---|---|---|---|
| P01 | Food | りんご | 85000 |
| P02 | Food | バナナ | 62000 |
| P03 | Food | みかん | 62000 |
| P04 | Food | ぶどう | 41000 |
| P05 | Drink | 緑茶 | 95000 |
| P06 | Drink | コーヒー | 78000 |
| P07 | Drink | ジュース | 78000 |
| P08 | Drink | 水 | 55000 |
| category | product_name | sales | rn |
|---|---|---|---|
| Drink | 緑茶 | 95000 | 1 |
| Drink | コーヒー | 78000 | 2 |
| Food | りんご | 85000 | 1 |
| Food | バナナ | 62000 | 2 |
WITH ranked_products AS ( -- ① 全商品に「カテゴリ内の売上順位」を付ける SELECT -- SELECT: 抽出する列を指定 category, product_name, sales, ROW_NUMBER() OVER ( -- ROW_NUMBER(): 1から始まる連番を付けるウィンドウ関数 PARTITION BY category -- PARTITION BY: カテゴリごとに区切って番号をリセット ORDER BY sales DESC, product_id -- 同額はproduct_id昇順で決定 ) AS rn -- AS rn: row_number の略として別名を定義 FROM product_sales -- FROM: 対象テーブル ) -- ここでは rn を WHERE に使えない(同一SELECT内でウィンドウ関数結果は参照不可) -- ② CTE の結果から rn が 2 以下(上位2件)だけを取り出す SELECT category, product_name, sales, rn FROM ranked_products -- FROM: 上で作ったCTEを参照 WHERE rn <= 2 -- WHERE: 各カテゴリで順位が2以下(1位・2位)のみ残す ORDER BY category, rn; -- ORDER BY: カテゴリの昇順 → 順位の昇順で並べる /* 実行順序: 1. CTE内 FROM product_sales → 全商品行を読み込み 2. CTE内 ROW_NUMBER() OVER(...) → カテゴリごとに売上降順で番号付与 3. 外側 FROM ranked_products → CTE結果を参照 4. 外側 WHERE rn <= 2 → 各カテゴリの上位2件を残す 5. 外側 ORDER BY category, rn → 整列して出力 */
LEGEND
① FROM
FROM product_salesproduct_sales テーブル全 8行を読み込みます。CTE ranked_products の入力データです。| product_id | category | product_name | sales |
|---|---|---|---|
| P01 | Food | りんご | 85,000 |
| P02 | Food | バナナ | 62,000 |
| P03 | Food | みかん | 62,000 |
| P04 | Food | ぶどう | 41,000 |
| P05 | Drink | 緑茶 | 95,000 |
| P06 | Drink | コーヒー | 78,000 |
| P07 | Drink | ジュース | 78,000 |
| P08 | Drink | 水 | 55,000 |
SELECT *, ROW_NUMBER() OVER(...) AS rn FROM t WHERE rn <= 2 はエラー。必ずCTEかサブクエリを経由してください。CTE + LAG — 前月比(MoM成長率)を計算する
LAG(col, n) はウィンドウ関数で、現在行から n 行前の値を返します。前月比・前年同月比・前日比など「時系列の比較」に必須です。
LAG(amount, 1) OVER (ORDER BY order_month) -- order_month 順に並べたとき、1行前の amount を返す -- 最初の行(前の行がない場合)は NULL になる
前月比の計算式:(今月 − 前月)÷ 前月 × 100。前月がNULLまたは0の場合はゼロ除算が発生するため NULLIF で防ぎます。
LAG(col, 1, 0) のように書くと、NULLの代わりに 0 を返せます。APIのレスポンスで NULL を避けたい場合に使います。逆に次の行を参照する
LEAD もあります。以下の monthly_sales テーブルから、各月の売上・前月売上・前月比(MoM成長率 %)を計算するAPIを作成してください。
前月がない最初の月は prev_sales を NULL、mom_rate も NULL として出力してください。
| order_month | sales |
|---|---|
| 2024-01 | 100000 |
| 2024-02 | 120000 |
| 2024-03 | 108000 |
| 2024-04 | 135000 |
| 2024-05 | 135000 |
| 2024-06 | 150000 |
| order_month | sales | prev_sales | mom_rate |
|---|---|---|---|
| 2024-01 | 100000 | NULL | NULL |
| 2024-02 | 120000 | 100000 | +20.0 |
| 2024-03 | 108000 | 120000 | -10.0 |
| 2024-04 | 135000 | 108000 | +25.0 |
| 2024-05 | 135000 | 135000 | 0.0 |
| 2024-06 | 150000 | 135000 | +11.1 |
WITH sales_with_lag AS ( -- ① LAG で「1行前の売上」を各行に追加する SELECT order_month, sales, LAG(sales, 1) OVER ( -- LAG(列, n): n行前の値を取得(なければNULL) ORDER BY order_month -- ORDER BY: 月の昇順で並べ、何が「前」かを定義する ) AS prev_sales -- AS: 列に名前をつける FROM monthly_sales -- FROM: データ取得元テーブル ) -- ② CTE を使って mom_rate(前月比)を計算する SELECT order_month, sales, prev_sales, ROUND( -- ROUND(値, 桁数): 指定した桁数で四捨五入 (sales - prev_sales) -- 今月と前月の差分(増加分) * 100.0 -- 100.0を掛けて%化(整数同士の除算を浮動小数点に変換) / NULLIF(prev_sales, 0) -- NULLIF(a,b): aがbなら NULLを返す(0で割るエラーを防ぐ) , 1) -- 第2引数: 小数点以下1桁に丸める AS mom_rate -- 計算結果に mom_rate という名前をつける FROM sales_with_lag -- FROM: 上で作ったCTEを参照 ORDER BY order_month; -- ORDER BY: 月の昇順で並べる /* 実行順序: 1. CTE内 FROM monthly_sales → 全行読み込み 2. CTE内 LAG(sales,1) OVER(...) → 前月の sales を付与 3. 外側 FROM sales_with_lag → CTE結果を参照 4. 外側 ROUND(...) → 前月比を計算 5. 外側 ORDER BY order_month → 月順で並べる */
LEGEND
① FROM
FROM monthly_salesmonthly_sales テーブル全 6行を読み込みます。CTE sales_with_lag の入力データです。| order_month | sales |
|---|---|
| 2024-01 | 100,000 |
| 2024-02 | 120,000 |
| 2024-03 | 108,000 |
| 2024-04 | 135,000 |
| 2024-05 | 135,000 |
| 2024-06 | 150,000 |
prev_sales を使って mom_rate を計算するには、同じSELECT内でLAG結果の別名を参照できないため、CTEで先に prev_sales を確定してから次のSELECTで使います。(sales - prev_sales) / prev_sales は整数÷整数 = 整数(切り捨て)になりゼロが返ります。* 100.0 で先に浮動小数点に変換するのが必須です。再帰CTE — 組織階層(上司→部下)を全レベル展開する
再帰CTE(Recursive CTE)は WITH RECURSIVE を使い、自分自身を参照することで階層構造を反復処理できます。組織図・カテゴリ階層・BOM(部品展開)などに使います。
WITH RECURSIVE org_tree AS ( -- ① アンカーメンバー: 再帰の起点(トップの行を選ぶ) SELECT id, name, manager_id, 0 AS depth FROM employees WHERE manager_id IS NULL -- 上司がいない = トップ UNION ALL -- アンカーと再帰部を縦結合(重複含む) -- ② 再帰メンバー: 前ステップの結果に部下を追加し続ける SELECT e.id, e.name, e.manager_id, t.depth + 1 FROM employees e JOIN org_tree t ON e.manager_id = t.id ) SELECT * FROM org_tree;
WHERE depth < 10 のような深さ制限を実務では入れることを推奨します。以下の employees テーブル(自己参照テーブル)から、社長(manager_idがNULL)を起点に全従業員の階層を展開してください。
各従業員について depth(階層深さを:社長=0)と path(例: "社長→部長→係長")も出力してください。
| emp_id | name | manager_id |
|---|---|---|
| 1 | 田中(社長) | NULL |
| 2 | 鈴木(営業部長) | 1 |
| 3 | 佐藤(技術部長) | 1 |
| 4 | 山田(営業係長) | 2 |
| 5 | 伊藤(営業担当) | 4 |
| 6 | 渡辺(技術リード) | 3 |
| emp_id | name | manager_id | depth | path |
|---|---|---|---|---|
| 1 | 田中(社長) | NULL | 0 | 田中(社長) |
| 2 | 鈴木(営業部長) | 1 | 1 | 田中(社長)→鈴木(営業部長) |
| 3 | 佐藤(技術部長) | 1 | 1 | 田中(社長)→佐藤(技術部長) |
| 4 | 山田(営業係長) | 2 | 2 | 田中(社長)→鈴木(営業部長)→山田(営業係長) |
| 6 | 渡辺(技術リード) | 3 | 2 | 田中(社長)→佐藤(技術部長)→渡辺(技術リード) |
| 5 | 伊藤(営業担当) | 4 | 3 | 田中(社長)→鈴木(営業部長)→山田(営業係長)→伊藤(営業担当) |
-- 再帰CTEには WITH RECURSIVE を使う(PostgreSQL/BigQuery共通) WITH RECURSIVE org_tree AS ( -- ① アンカーメンバー: 再帰の「起点」となる行(社長)を選ぶ SELECT -- SELECT: 取得する列 emp_id, name, manager_id, 0 AS depth, -- 起点の階層深さを0とする(ASで列名付与) name AS path -- pathの初期値は自分の名前のみ FROM employees -- FROM: データ元 WHERE manager_id IS NULL -- WHERE ... IS NULL: 値が存在しない(上司がいない = トップ)行に絞る UNION ALL -- UNION ALL: 複数のSELECT結果を縦にそのまま結合(重複含む) -- ② 再帰メンバー: org_tree の結果に「部下」を追加し続ける SELECT e.emp_id, e.name, e.manager_id, t.depth + 1, -- 親の階層(depth)に +1 して1段深くする t.path || '→' || e.name -- ||: 文字列同士を連結する演算子(PostgreSQL等) FROM employees e -- FROM: e は部下側(次の階層) INNER JOIN org_tree t -- INNER JOIN: t は前ステップで確定した親の行 ON e.manager_id = t.emp_id -- ON: 「部下の上司ID = 親のemp_id」という条件で結合 WHERE t.depth < 10 -- WHERE: 無限ループを防止するため、最大10階層までに制限 ) SELECT emp_id, name, manager_id, depth, path FROM org_tree -- FROM: 完成した再帰CTEを参照 ORDER BY depth, emp_id; -- ORDER BY: 階層(深さ)の昇順 → emp_idの昇順で並べる /* 実行順序(再帰の反復): 1. アンカー → 起点行を生成(社長) 2. 再帰1回目 → 子を展開 3. 再帰2回目 → 子を展開 4. 再帰3回目 → 子を展開 5. 再帰4回目 → 追加行なしで終了 6. UNION ALL → ORDER BY → 縦積みして整列 */
LEGEND
⓪ FROM
FROM employeesemployees テーブル全 6行です。これが自己参照して組織ツリーを作っていく元データになります。| emp_id | name | manager_id |
|---|---|---|
| 1 | 田中(社長) | NULL |
| 2 | 鈴木(営業部長) | 1 |
| 3 | 佐藤(技術部長) | 1 |
| 4 | 山田(営業係長) | 2 |
| 5 | 伊藤(営業担当) | 4 |
| 6 | 渡辺(技術リード) | 3 |
WHERE depth < N で必ず上限を設けてください。WHERE depth < 10 のような深さのフェールセーフ(安全装置)を必ず入れるのがプロの鉄則です。