階層別給与ランキング — WITH RECURSIVE + RANK() ウィンドウ関数で深度内順位を算出する
再帰CTEで各ノードに depth(階層深度)を付与しておけば、その結果を外側クエリでウィンドウ関数に渡すことができます。これにより「同じ階層レベル内でのランキング」や「階層ごとの集計」が一つのクエリで完結します。
WITH RECURSIVE emp_tree AS ( -- アンカー: ルート社員(depth=0) SELECT emp_id, manager_id, salary, 0 AS depth FROM employees WHERE manager_id IS NULL UNION ALL -- 再帰: 部下を depth+1 で追加 SELECT e.emp_id, e.manager_id, e.salary, et.depth + 1 FROM employees e JOIN emp_tree et ON e.manager_id = et.emp_id ) -- 外側クエリでウィンドウ関数を適用(再帰部内では使用不可) SELECT emp_id, depth, salary, RANK() OVER (PARTITION BY depth ORDER BY salary DESC) AS rank_in_level FROM emp_tree;
RANK() を書くと構文エラーになります。必ず 外側クエリで適用します。再帰CTEは「depth列の生成」に徹し、ウィンドウ計算は外に出す設計が基本パターンです。employees テーブルには社員・上司・給与が格納されています。各社員の組織上の深度(depth)と、同じ深度内での給与ランキング(rank_in_level)を求めてください。ランキングは RANK() を使い、深度内で給与が高い順(DESC)とします。取得列は emp_id, emp_name, depth, salary, rank_in_level、depth 昇順 → rank_in_level 昇順でソートしてください。
| emp_id | emp_name | manager_id | salary |
|---|---|---|---|
| 1 | Alice(CEO) | NULL | 1200000 |
| 2 | Bob | 1 | 850000 |
| 3 | Carol | 1 | 950000 |
| 4 | Dave | 2 | 520000 |
| 5 | Eve | 2 | 680000 |
| 6 | Frank | 3 | 590000 |
| 7 | Grace | 3 | 730000 |
| emp_id | emp_name | depth | salary | rank_in_level |
|---|---|---|---|---|
| 1 | Alice(CEO) | 0 | 1200000 | 1 |
| 3 | Carol | 1 | 950000 | 1 |
| 2 | Bob | 1 | 850000 | 2 |
| 7 | Grace | 2 | 730000 | 1 |
| 5 | Eve | 2 | 680000 | 2 |
| 6 | Frank | 2 | 590000 | 3 |
| 4 | Dave | 2 | 520000 | 4 |
グラフ経路探索(配列で循環防止) — 訪問済み配列 visited で全経路を安全に列挙する
再帰CTEはツリー(木構造)だけでなく、循環(サイクル)を持つグラフ(有向グラフ)にも適用できます。ただし循環ルートがあると無限ループになるため、訪問済みノードを配列に記録し、同じノードを再訪しないよう制御する必要があります。
WITH RECURSIVE paths AS ( -- アンカー: 出発地点, 訪問済み配列の初期化 SELECT 'A' AS cur, 0 AS cost, ARRAY['A'] AS visited, 'A' AS route UNION ALL -- 再帰: 未訪問のノードへ移動 SELECT r.to_code, p.cost + r.cost, p.visited || r.to_code, -- || で配列に追加 p.route || '→' || r.to_code FROM paths p JOIN routes r ON r.from_code = p.cur WHERE r.to_code != ALL(p.visited) -- 循環防止 AND array_length(p.visited, 1) < 4 -- 最大3フライト )
visited 列に通過した都市コードを配列で蓄積し、次の候補都市が != ALL(visited)(配列の全要素と異なる)ことを確認してから進みます。PostgreSQL では ARRAY['TYO'] || 'OSA' で配列に要素を追加できます。MySQL 8は配列型がないため、文字列 'TYO,OSA,' + FIND_IN_SET で代替します。航空路線ネットワーク(有向グラフ)があります。TYO から FUK への全経路を3フライト以内で列挙し、コストが安い順に出力してください。配列 visited を使って同一都市への再訪(循環)を防ぐこと。取得列は route_str, total_cost。
| from_code | to_code | cost |
|---|---|---|
| TYO | OSA | 13000 |
| TYO | NGO | 8000 |
| NGO | OSA | 6000 |
| NGO | FUK | 18000 |
| OSA | FUK | 15000 |
| OSA | HIR | 7000 |
| HIR | FUK | 8000 |
| OSA | TYO | 13000 |
| route_str | total_cost |
|---|---|
| TYO→NGO→FUK | 26000 |
| TYO→OSA→FUK | 28000 |
| TYO→OSA→HIR→FUK | 28000 |
| TYO→NGO→OSA→FUK | 29000 |
ロール継承によるパーミッション集計 — ボトムアップ再帰で RBAC の権限チェーンを実装する
ロールベースアクセス制御(RBAC)では、ロールが階層構造(親ロールの権限を継承)を持つことがあります。例:viewer → editor → admin → super_admin。ユーザーのロールからボトムアップ再帰で全祖先ロールを辿り、各ロールに付与された権限を集約するパターンは、認証・認可システムの実装で頻出です。
WITH RECURSIVE role_chain AS ( -- アンカー: 対象ユーザーの直接ロール(ボトムアップの起点) SELECT r.role_id, r.role_name, r.parent_role_id, 0 AS chain_level FROM users u JOIN roles r ON r.role_id = u.role_id WHERE u.user_id = :uid UNION ALL -- 再帰: parent_role_id を辿って上位ロールへ(ボトムアップ) SELECT r.role_id, r.role_name, r.parent_role_id, rc.chain_level + 1 FROM roles r JOIN role_chain rc ON r.role_id = rc.parent_role_id -- 親ロールへの逆JOIN )
users の user_id=1(alice) が持つ全パーミッションを、ロール継承チェーンを辿って取得してください。直接ロール・継承ロールの両方のパーミッションを列挙し、取得列は permission_name, granted_by_role, chain_level。chain_level 昇順 → permission_name 昇順でソートしてください。
| role_id | role_name | parent_role_id |
|---|---|---|
| 1 | super_admin | NULL |
| 2 | admin | 1 |
| 3 | editor | 2 |
| 4 | viewer | 3 |
| role_id | permission_name |
|---|---|
| 1 | system.config |
| 1 | user.delete |
| 2 | user.create |
| 2 | user.edit |
| 3 | content.edit |
| 3 | content.publish |
| 4 | content.read |
| user_id | username | role_id |
|---|---|---|
| 1 | alice | 3 |
| 2 | bob | 4 |
| 3 | carol | 2 |
| permission_name | granted_by_role | chain_level |
|---|---|---|
| content.edit | editor | 0 |
| content.publish | editor | 0 |
| user.create | admin | 1 |
| user.edit | admin | 1 |
| system.config | super_admin | 2 |
| user.delete | super_admin | 2 |
BOM展開 × 原価積み上げ — 再帰展開と数量乗算で製品の総原価を算出する
製造業の「部品表(BOM: Bill of Materials)」は、製品→サブアセンブリ→部品という木構造です。各レベルで数量が乗算されて積み上がるため、単純な SUM ではなく再帰展開 × 数量乗算を組み合わせる必要があります。
WITH RECURSIVE bom_tree AS ( -- アンカー: 起点となる製品や部品 SELECT part_id, part_id AS root_id, 1 AS accumulated_qty FROM parts UNION ALL -- 再帰: 子部品へ展開し数量を乗算 SELECT b.child_part_id, bt.root_id, bt.accumulated_qty * b.quantity FROM bom_tree bt JOIN bom b ON b.parent_part_id = bt.part_id )
設計のポイント:全部品を起点(アンカー)として展開し、BOM 経由で子部品へ再帰します。数量は accumulated_qty * child_quantity で乗算しながら引き継ぎ、葉部品(unit_cost IS NOT NULL)の行だけをコスト計上して最後に集計します。
親の累積数量 * 子の所要量 を計算して引き継ぎます。加算(+)ではなく乗算(*)である点に注意してください。以下の製品構成を前提に、全部品・サブアセンブリ・製品それぞれの総原価(total_component_cost)を再帰CTEで算出してください。
・集計対象は葉部品(unit_cost IS NOT NULL)のコストのみ。中間アセンブリは自身では原価を持ちません
・数量は親から子への経路全体を乗算して積み上げます(例:完成品Xのネジ使用数 = サブαのネジ数 × サブαの個数)
・出力は part_id, part_name, total_component_cost、part_id 昇順
| part_id | part_name | unit_cost |
|---|---|---|
| 1 | 完成品X | NULL |
| 2 | サブASSY-α | NULL |
| 3 | サブASSY-β | NULL |
| 4 | ネジM3 | 5 |
| 5 | ボルトM8 | 12 |
| 6 | フレーム | 200 |
| 7 | 基板 | 150 |
| parent_part_id | child_part_id | quantity | 備考 |
|---|---|---|---|
| 1 | 2 | 2 | 完成品X → サブα ×2 |
| 1 | 3 | 1 | 完成品X → サブβ ×1 |
| 1 | 6 | 1 | 完成品X → フレーム ×1 |
| 2 | 4 | 6 | サブα → ネジ ×6 |
| 2 | 5 | 2 | サブα → ボルト ×2 |
| 3 | 4 | 4 | サブβ → ネジ ×4 |
| 3 | 7 | 1 | サブβ → 基板 ×1 |
| part_id | part_name | total_component_cost |
|---|---|---|
| 1 | 完成品X | 478 |
| 2 | サブASSY-α | 54 |
| 3 | サブASSY-β | 170 |
| 4 | ネジM3 | 5 |
| 5 | ボルトM8 | 12 |
| 6 | フレーム | 200 |
| 7 | 基板 | 150 |
部門予算の二重再帰集計 — dept_tree + closure の2つのCTEで配下予算を完全集計する
複数の再帰CTEを1クエリ内で連鎖させる「二重再帰」パターンを学びます。再帰CTE 1本目でパス文字列・深度を付与した部門ツリー(dept_tree)を展開し、2本目で閉包テーブル(Closure Table)を生成します。
WITH RECURSIVE -- ① 1つ目の再帰CTE: 階層ツリーの展開 tree_cte AS ( SELECT id, parent_id, name FROM nodes WHERE parent_id IS NULL UNION ALL SELECT n.id, n.parent_id, n.name FROM nodes n JOIN tree_cte t ON n.parent_id = t.id ), -- ② 2つ目の再帰CTE: 閉包テーブルの生成 closure_cte AS ( SELECT id AS ancestor, id AS descendant FROM nodes UNION ALL SELECT c.ancestor, n.id AS descendant FROM closure_cte c JOIN nodes n ON n.parent_id = c.descendant ) -- 2つのCTEを組み合わせて集計等を行う SELECT ...
閉包テーブルとは (ancestor_id, descendant_id) の全ペアを保持するテーブルで、「任意の部門の全配下」を O(1) の JOIN で取得できる設計パターンです。これを経由して各部門の own_budget を SUM することで、direct/indirect を含むtotal_budgetを一括算出します。
WITH RECURSIVE を一度宣言するだけで、カンマ区切りで複数の再帰CTEを定義できます。後続のCTEは先行するCTEを参照可能ですが、先行するCTEが後続を参照することはできません(前方参照不可)。以下の部門テーブルを使い、2つの再帰CTEを連鎖させて以下を出力してください。
・dept_tree CTE(再帰①): 深度(depth)とパス文字列(例:本社 > 技術部 > バックエンド)を付与して全部門を展開する
・closure CTE(再帰②): (ancestor_id, descendant_id) の全ペアを生成する閉包テーブルを構築する
・外側クエリで closure を介して各部門の total_budget(own_budget の配下合計)を SUM する
・出力: dept_id, dept_name, depth, path, own_budget, total_budget、depth 昇順 → dept_id 昇順
| dept_id | dept_name | parent_id | own_budget |
|---|---|---|---|
| 1 | 本社 | NULL | 5000 |
| 2 | 技術部 | 1 | 3000 |
| 3 | 営業部 | 1 | 2000 |
| 4 | バックエンド | 2 | 1500 |
| 5 | フロントエンド | 2 | 1200 |
| 6 | 国内営業 | 3 | 1800 |
| 7 | 海外営業 | 3 | 900 |
| dept_id | dept_name | depth | path | own_budget | total_budget |
|---|---|---|---|---|---|
| 1 | 本社 | 0 | 本社 | 5000 | 15400 |
| 2 | 技術部 | 1 | 本社 > 技術部 | 3000 | 5700 |
| 3 | 営業部 | 1 | 本社 > 営業部 | 2000 | 4700 |
| 4 | バックエンド | 2 | 本社 > 技術部 > バックエンド | 1500 | 1500 |
| 5 | フロントエンド | 2 | 本社 > 技術部 > フロントエンド | 1200 | 1200 |
| 6 | 国内営業 | 2 | 本社 > 営業部 > 国内営業 | 1800 | 1800 |
| 7 | 海外営業 | 2 | 本社 > 営業部 > 海外営業 | 900 | 900 |