階層別給与ランキング — 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 |
WITH RECURSIVE emp_tree AS ( -- ① アンカー部: ルート社員(manager_id IS NULL)を取得、depth=0 で初期化 SELECT emp_id, emp_name, manager_id, salary, 0 AS depth FROM employees WHERE manager_id IS NULL UNION ALL -- ② 再帰部: 部下を depth+1 で追加(ウィンドウ関数はここでは使えない) SELECT e.emp_id, e.emp_name, e.manager_id, e.salary, et.depth + 1 -- 親の depth に +1 して階層を追跡 FROM employees e JOIN emp_tree et ON e.manager_id = et.emp_id ) -- ③ 外側クエリ: 再帰結果に対してウィンドウ関数を適用 SELECT emp_id, emp_name, depth, salary, RANK() OVER ( PARTITION BY depth -- 同じ depth 層ごとにランキングをリセット ORDER BY salary DESC -- 給与が高い順に順位付け ) AS rank_in_level FROM emp_tree ORDER BY depth, rank_in_level; /* 実行順序(SQLの論理的な評価順): 1. アンカー部 2. 再帰1回目 3. 再帰2回目 4. 再帰3回目 5. 外側クエリ */
LEGEND
① アンカー部(depth=0)
WHERE manager_id IS NULL → Alice のみ取得manager_id IS NULLのルート社員(Alice)だけを取得します。depth=0を初期値として設定。この1行が最初の「ワーキングテーブル(現在の探索起点)」となり、次の再帰展開の入力になります。| emp_id | emp_name | manager_id | salary | depth |
|---|---|---|---|---|
| 1 | Alice(CEO) | NULL | 1200000 | 0 |
| emp_id | depth | salary |
|---|---|---|
| Alice(1) | 0 | 1,200,000 |
| Bob(2) | 1 | 850,000 |
| Carol(3) | 1 | 950,000 |
| Dave(4) | 2 | 520,000 |
| Eve(5) | 2 | 680,000 |
| Frank(6) | 2 | 590,000 |
| Grace(7) | 2 | 730,000 |
| PARTITION BY depth | RANK(salary DESC) |
|---|---|
| depth=0 (Alice のみ) | → rank=1 |
| depth=1 (Carol, Bob) | → rank=1, 2 |
| depth=2 (Grace,Eve,Frank,Dave) | → rank=1,2,3,4 |
RANK() ROW_NUMBER() 等を書くとエラーになります。再帰CTEは「行を生成する役割」に徹し、ウィンドウ計算は外側クエリに委ねるのが正しい設計です。RANK() は同順位があると次の順位が飛びます(1,1,3…)。DENSE_RANK() は飛ばしません(1,1,2…)。「人数に対して何位か」が重要ならDENSE_RANK、「何人が自分より上か」が重要ならRANKを使います。給与レポートでは一般にDENSE_RANKが好まれます。RANK() OVER (...) を再帰部内の SELECT に書くと 「ウィンドウ関数は再帰CTEでは使用できません」というエラーになります。まず再帰で構造(depth)を作り、次に外側でウィンドウ適用という2段階の思考が必要です。NTILE(4) OVER (PARTITION BY depth ORDER BY salary) と組み合わせれば、階層ごとの給与四分位数(Q1〜Q4)を算出でき、ハイパフォーマー抽出の基礎データになります。再帰CTEで「木構造の属性」を取得し、ウィンドウ関数で「統計分析」するという組み合わせは、BI・分析エンジニアリングの必須テクニックです。グラフ経路探索(配列で循環防止) — 訪問済み配列 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 |
WITH RECURSIVE paths AS ( -- ① アンカー部: 出発地 TYO を起点として登録、訪問済み配列を初期化 SELECT 'TYO' AS current_city, 0 AS total_cost, ARRAY['TYO'] AS visited, -- 訪問済み都市コードの配列 'TYO' AS route_str UNION ALL -- ② 再帰部: 未訪問かつホップ数制限内の次の都市へ移動 SELECT r.to_code, p.total_cost + r.cost, p.visited || r.to_code, -- 配列に次の都市を追加(||はPG配列連結演算子) p.route_str || '→' || r.to_code FROM paths p JOIN routes r ON r.from_code = p.current_city WHERE r.to_code != ALL(p.visited) -- 循環防止: visitedの全要素と異なる都市のみ AND array_length(p.visited, 1) < 4 -- 最大3フライト: 配列長4(4都市)未満 ) SELECT route_str, total_cost FROM paths WHERE current_city = 'FUK' -- 目的地 FUK に到達した経路のみ抽出 ORDER BY total_cost; /* 実行順序(SQLの論理的な評価順): 1. アンカー部 2. 再帰1回目(from TYO) 3. 再帰2回目(from OSA,NGO) 4. 再帰3回目(from HIR,OSA[NGO経由]) 5. 再帰4回目 6. 外側クエリ */
LEGEND
① アンカー部(出発点の初期化)
TYO を起点に visited=['TYO'] で登録出発地TYOを登録します。visited配列に['TYO']を初期化することで、以降の再帰でTYOへの再訪を検出できます。この1行が最初の「ワーキングテーブル(次回の探索起点)」となります。| current_city | total_cost | visited(訪問済み配列) | route_str |
|---|---|---|---|
| TYO | 0 | ['TYO'] | TYO |
| ステップ | visited | 次の候補 | 評価 |
|---|---|---|---|
| 出発 | ['TYO'] | OSA | ✓ 未訪問 |
| TYO→OSA | ['TYO','OSA'] | TYO(折返し) | ✗ TYO∈visited → 除外 |
| TYO→OSA | ['TYO','OSA'] | FUK, HIR | ✓ 未訪問 → 続行 |
| visited 配列長 | array_length<4 | 再帰 |
|---|---|---|
| 1 ('TYO') | TRUE | 継続 |
| 2 ('TYO','OSA') | TRUE | 継続 |
| 3 ('TYO','OSA','HIR') | TRUE | 継続 |
| 4 ('TYO','OSA','HIR','FUK') | FALSE | 停止 |
CYCLE node_col SET is_cycle USING path という標準構文で循環を自動検出できます。配列を手動管理するより簡潔です。ただし CYCLE 句は「循環を検出して停止」するだけで、全経路列挙には引き続き深さ制限が必要です。'TYO,OSA,' のようなCSV文字列として管理し、FIND_IN_SET(r.to_code, p.visited) = 0 で未訪問チェックを行います。パフォーマンスは落ちますがロジックは同一です。array_length < N)は「何次のつながりまで追跡するか」の制御です。SNSの友人推薦は通常2〜3次までに制限します。大規模グラフ(数百万ノード)では再帰CTEよりもApache Spark GraphX や Neo4j などのグラフデータベースが現実的な選択肢となります。ロール継承によるパーミッション集計 — ボトムアップ再帰で 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 |
WITH RECURSIVE role_chain AS ( -- ① アンカー部: alice(user_id=1)の直接ロール(editor)を起点に登録 SELECT r.role_id, r.role_name, r.parent_role_id, 0 AS chain_level -- 直接ロール = chain_level 0 FROM users u JOIN roles r ON r.role_id = u.role_id WHERE u.user_id = 1 -- 対象ユーザー UNION ALL -- ② 再帰部: 現在のロールの parent_role_id を辿って上位ロールへ遡る SELECT r.role_id, r.role_name, r.parent_role_id, rc.chain_level + 1 -- 上位ロールになるほど chain_level が増加 FROM roles r JOIN role_chain rc ON r.role_id = rc.parent_role_id -- ← ボトムアップ方向のJOIN ) SELECT p.permission_name, rc.role_name AS granted_by_role, -- どのロールで付与されたか(監査ログ用途) rc.chain_level FROM role_chain rc JOIN permissions p ON p.role_id = rc.role_id ORDER BY rc.chain_level, p.permission_name; /* 実行順序(SQLの論理的な評価順): 1. アンカー部 2. 再帰1回目 3. 再帰2回目 4. 再帰3回目 5. 外側クエリ */
LEGEND
① アンカー部(JOIN評価)
users と roles を結合して直接ロールを取得対象ユーザー(user_id=1: alice)の情報を users テーブルから取得し、roles テーブルと結合して対象の起点ロール(editor)を特定します。| user_id | username | role_id |
|---|---|---|
| 1 | alice | 3 |
| role_id | role_name | parent_role_id |
|---|---|---|
| 1 | super_admin | NULL |
| 2 | admin | 1 |
| 3 | editor | 2 |
| 4 | viewer | 3 |
| chain_level | role_name | parent |
|---|---|---|
| 0 | editor(3) | admin(2) |
| 1 | admin(2) | super_admin(1) |
| 2 | super_admin(1) | NULL → 終了 |
| permission | from |
|---|---|
| content.edit | editor(直接) |
| content.publish | editor(直接) |
| user.create | admin(継承) |
| user.edit | admin(継承) |
| system.config | super_admin(継承) |
| user.delete | super_admin(継承) |
chain_level=0 は直接付与、大きいほど継承元が遠い祖先ロールです。「どのロールから継承された権限か」を記録することで、権限変更の影響範囲トレースや、不審な権限昇格の検出に活用できます。user_roles(多対多テーブル)を使い、アンカー部を JOIN user_roles ON user_roles.user_id = u.user_id JOIN roles ON roles.role_id = user_roles.role_id のように変更し、外側で SELECT DISTINCT permission_name して重複を除去します。WHERE p.permission_name = 'user.create' LIMIT 1 を追加してショートサーキットさせた方が高速です。SELECT DISTINCT permission_name, ... にするか、MIN(chain_level) で最も直接的な付与元だけを残すようにします。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 |
WITH RECURSIVE bom_expand AS ( -- アンカー: 全部品を「自分自身の直接の起点」として初期化 SELECT p.part_id AS root_id, -- コスト集計の起点となる部品 p.part_id AS current_id, -- 現在処理中の部品(展開の先端) 1 AS accumulated_qty, -- 累積数量(最初は1) p.unit_cost -- 葉部品の単価(アセンブリはNULL) FROM parts p UNION ALL -- 再帰部: BOMを辿って子部品へ展開、数量を乗算しながら引き継ぐ SELECT be.root_id, -- 起点は変えない b.child_part_id AS current_id, -- 展開先が新たな先端 be.accumulated_qty * b.quantity -- ★ 数量を乗算で積み上げる AS accumulated_qty, p.unit_cost FROM bom_expand be JOIN bom b ON b.parent_part_id = be.current_id -- 子部品を結合 JOIN parts p ON p.part_id = b.child_part_id -- 子部品の情報を取得 ) SELECT p.part_id, p.part_name, SUM(be.accumulated_qty * be.unit_cost) AS total_component_cost -- 葉部品のコスト(累積数量 × 単価)を集計 FROM bom_expand be JOIN parts p ON p.part_id = be.root_id WHERE be.unit_cost IS NOT NULL -- 葉部品行のみ集計対象(アセンブリ行を除外) GROUP BY p.part_id, p.part_name ORDER BY p.part_id; /* 実行順序: 1. WITH RECURSIVE bom_expand → 全部品を自分自身でアンカー化 2. UNION ALL 再帰部(iter1) → 直接子を展開 3. UNION ALL 再帰部(iter2) → 孫を展開し終了 4. 外側WHERE unit_cost IS NOT NULL → 中間アセンブリ行を除外 5. GROUP BY + SUM → root_id 別にコストを合計 6. JOIN parts + ORDER BY part_id → 部品名を付加し昇順出力 */
LEGEND
INPUT
parts テーブル & bom テーブル入力データを確認します。parts の unit_cost が NULL の行はサブアセンブリ/製品(原価を自分では持たない)。bom は親→子の数量関係を表します。| 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 | child | qty |
|---|---|---|
| 1(完成品) | 2(サブα) | 2 |
| 1(完成品) | 3(サブβ) | 1 |
| 1(完成品) | 6(フレーム) | 1 |
| 2(サブα) | 4(ネジ) | 6 |
| 2(サブα) | 5(ボルト) | 2 |
| 3(サブβ) | 4(ネジ) | 4 |
| 3(サブβ) | 7(基板) | 1 |
| root | current | qty |
|---|---|---|
| 完成品X | サブα | 1×2=2 |
| 完成品X | サブβ | 1×1=1 |
| 完成品X | フレーム | 1×1=1 |
| サブα | ネジ | 1×6=6 |
| サブβ | ネジ | 1×4=4 |
| root | current | qty×cost |
|---|---|---|
| 完成品X | ネジ(via α) | 2×6×5=60 |
| 完成品X | ボルト(via α) | 2×2×12=48 |
| 完成品X | ネジ(via β) | 1×4×5=20 |
| 完成品X | 基板(via β) | 1×1×150=150 |
| 合計: 200+60+48+20+150 = 478 | ||
be.accumulated_qty * b.quantity により、数量は経路全体で乗算されます。「完成品X→サブα(×2)→ネジ(×6)」ならネジの accumulated_qty は 1×2×6=12。これが BOM 展開の核心です。be.accumulated_qty + b.quantity と加算すると誤りです。「サブαを2個使い、その中にネジが6本」なら必要なネジは 2×6=12 本であり、乗算でなければ正しい積み上げになりません。CYCLE part_id SET is_cycle USING path 句で循環を自動検出できます。本番では必ずガード条件を追加してください。WITH RECURSIVE 一本で同等の展開が可能です。実務では①原価バージョン管理(有効期限付きの unit_cost)、②代替部品(alternate BOM)、③生産スクラップ率(yield)の考慮が追加されます。これらは bom テーブルに effective_date・alternate_flag・scrap_rate カラムを追加し、WHERE 条件と qty 計算式を拡張することで対応できます。部門予算の二重再帰集計 — 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 |
WITH RECURSIVE dept_tree AS ( -- ① 部門ツリー: 深度とパス文字列を付与しながらトップダウン展開 SELECT dept_id, dept_name, parent_id, own_budget, 0 AS depth, dept_name AS path -- パスの起点はルート部門名 FROM departments WHERE parent_id IS NULL -- ルート部門(本社)がアンカー UNION ALL SELECT d.dept_id, d.dept_name, d.parent_id, d.own_budget, dt.depth + 1 AS depth, dt.path || ' > ' || d.dept_name AS path -- ★ パスを連結 FROM departments d JOIN dept_tree dt ON d.parent_id = dt.dept_id ), closure AS ( -- ② 閉包テーブル: (ancestor, descendant) の全ペアを生成 SELECT dept_id AS ancestor_id, dept_id AS descendant_id -- 自分自身へのペア(distance=0) FROM departments UNION ALL SELECT c.ancestor_id, d.dept_id AS descendant_id -- ★ 祖先から全子孫へのペアを展開 FROM closure c JOIN departments d ON d.parent_id = c.descendant_id ) SELECT dt.dept_id, dt.dept_name, dt.depth, dt.path, dt.own_budget, SUM(d2.own_budget) AS total_budget -- 配下全部門のown_budgetを合計 FROM dept_tree dt JOIN closure cl ON cl.ancestor_id = dt.dept_id -- 対象部門を祖先として検索 JOIN departments d2 ON d2.dept_id = cl.descendant_id -- 配下部門の予算を取得 GROUP BY dt.dept_id, dt.dept_name, dt.depth, dt.path, dt.own_budget ORDER BY dt.depth, dt.dept_id; /* 実行順序: 1. dept_tree(アンカー): parent_id IS NULL の本社のみ取得(1行) 2. dept_tree(再帰 iter1): 技術部・営業部を展開、pathに' > '連結(+2行) 3. dept_tree(再帰 iter2): バックエンド・フロント・国内・海外を展開(+4行) → 計7行 4. closure(アンカー): 全7部門の自己ペア(ancestor=descendant)を生成(7行) 5. closure(再帰 iter1): 各部門の直接子へのペアを追加(+6ペア) 6. closure(再帰 iter2): 本社→孫部門(バックエンド等)のペアを追加(+4ペア) → 計17ペア 7. 外側: dept_tree JOIN closure(ancestor=dept) JOIN departments(d2=descendant) 8. GROUP BY + SUM: 各部門を祖先とする全ペアのown_budgetを合算 9. ORDER BY depth, dept_id: 深度・部門ID順に出力 */
LEGEND
INPUT
departments テーブル7部門の階層構造。parent_id=NULL が頂点(本社)。| 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 |
| depth | dept_name | path |
|---|---|---|
| 0 | 本社 | 本社 |
| 1 | 技術部 | 本社 > 技術部 |
| 1 | 営業部 | 本社 > 営業部 |
| 2 | バックエンド | 本社 > 技術部 > BE |
| 2 | 国内営業 | 本社 > 営業部 > 国内 |
| ancestor | descendant |
|---|---|
| 本社(1) | 本社(1) |
| 本社(1) | 技術部(2) |
| 本社(1) | 営業部(3) |
| 本社(1) | BE(4) |
| 本社(1) | FE(5) |
| …7ペア合算 | = 15400 |
WHERE ancestor_id = 1 とするだけで本社の全配下が得られ、集計・権限チェック・表示の3用途で特に高速です。WITH RECURSIVE は宣言一度で複数の再帰 CTE を同時に定義できます。ただしCTEは順番に定義され、後ろの CTE は前の CTE を参照できますが、前の CTE が後ろを参照することはできません(前方参照不可)。本問では dept_tree を先に、closure を後に置いています。dt.path || ' > ' || d.dept_name で生成されたパスはパンくずリスト表示、ソート、前方一致検索(path LIKE '本社 > 技術部%')に直接使えます。ただし dept_name に ' > ' が含まれる場合は区切り文字の衝突が起きるため、実務では区切り文字を工夫するか ltree 型(PostgreSQL 拡張)を使うと安全です。own_budget が total_budget に含まれません。葉部門(配下なし)の total_budget は own_budget だけになるはずが 0 になります。アンカーに必ず自己ペアを含めるのが閉包テーブルの基本ルールです。ltree 拡張も選択肢に入ります。