SQL 再帰CTE — グラフ探索・RBAC・BOM展開の応用

応用WITH RECURSIVEWINDOW FUNCTIONグラフ探索RBAC / BOM展開PostgreSQL/MySQL8対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 1

階層別給与ランキング — WITH RECURSIVE + RANK() ウィンドウ関数で深度内順位を算出する

WITH RECURSIVEWINDOW FUNCTION組織図RANK / PARTITION BY
前提知識

再帰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;
重要制約:ウィンドウ関数は再帰部内で使用できません。再帰CTEの SELECT 句に RANK() を書くと構文エラーになります。必ず 外側クエリで適用します。再帰CTEは「depth列の生成」に徹し、ウィンドウ計算は外に出す設計が基本パターンです。
問題

employees テーブルには社員・上司・給与が格納されています。各社員の組織上の深度(depth)と、同じ深度内での給与ランキング(rank_in_levelを求めてください。ランキングは RANK() を使い、深度内で給与が高い順(DESC)とします。取得列は emp_id, emp_name, depth, salary, rank_in_leveldepth 昇順 → rank_in_level 昇順でソートしてください。

使用テーブル
▸ employees
emp_idemp_namemanager_idsalary
1Alice(CEO)NULL1200000
2Bob1850000
3Carol1950000
4Dave2520000
5Eve2680000
6Frank3590000
7Grace3730000
入力データの関係と、再帰処理の停止条件を確認してください。
期待出力
emp_idemp_namedepthsalaryrank_in_level
1Alice(CEO)012000001
3Carol19500001
2Bob18500002
7Grace27300001
5Eve26800002
6Frank25900003
4Dave25200004
模範解答コード
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. 外側クエリ
  */
解説(テーブル変化・ポイント)
WITH RECURSIVE emp_tree AS ( SELECT emp_id, emp_name, manager_id, salary, 0 AS depth FROM employees WHERE manager_id IS NULL UNION ALL SELECT e.emp_id, e.emp_name, 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, emp_name, depth, salary, RANK() OVER ( PARTITION BY depth ORDER BY salary DESC ) AS rank_in_level FROM emp_tree ORDER BY depth, rank_in_level;
LEGEND
データ取得・読込対象
除外・非表示データ
① アンカー部(depth=0)
WHERE manager_id IS NULL → Alice のみ取得manager_id IS NULLのルート社員(Alice)だけを取得します。depth=0を初期値として設定。この1行が最初の「ワーキングテーブル(現在の探索起点)」となり、次の再帰展開の入力になります。
1 / 6
emp_idemp_namemanager_idsalarydepth
1Alice(CEO)NULL12000000
アンカー: 1行
再帰 → ウィンドウ関数の2段処理フロー
PHASE 1 WITH RECURSIVE で全社員に depth を付与
再帰後の emp_tree(全7行)
emp_iddepthsalary
Alice(1)01,200,000
Bob(2)1850,000
Carol(3)1950,000
Dave(4)2520,000
Eve(5)2680,000
Frank(6)2590,000
Grace(7)2730,000
PHASE 2: RANK() の適用
PARTITION BY depthRANK(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
学習ポイント
ウィンドウ関数は再帰部内で使用不可:これは SQL 標準の制約です。再帰 CTE の SELECT 句に RANK() ROW_NUMBER() 等を書くとエラーになります。再帰CTEは「行を生成する役割」に徹し、ウィンドウ計算は外側クエリに委ねるのが正しい設計です。
RANK vs DENSE_RANK の使い分け:RANK() は同順位があると次の順位が飛びます(1,1,3…)。DENSE_RANK() は飛ばしません(1,1,2…)。「人数に対して何位か」が重要ならDENSE_RANK、「何人が自分より上か」が重要ならRANKを使います。給与レポートでは一般にDENSE_RANKが好まれます。
PARTITION BY が「ランキングのリセット」を制御:PARTITION BY depth を指定することで、depth=0, 1, 2 それぞれが独立したウィンドウになります。PARTITION BY を省略すると全7行が1つのウィンドウになり、グローバルランキング(1〜7位)になってしまいます。
アンチパターン
再帰部にウィンドウ関数を書いてしまう:RANK() OVER (...) を再帰部内の SELECT に書くと 「ウィンドウ関数は再帰CTEでは使用できません」というエラーになります。まず再帰で構造(depth)を作り、次に外側でウィンドウ適用という2段階の思考が必要です。
PARTITION BY を忘れてグローバルランキングになる:PARTITION BY depth を省略すると Alice(rank=1), Carol(rank=2)…という全社員グローバルランキングになります。「どの単位でランキングをリセットしたいか」を常に PARTITION BY で明示するのが鉄則です。
実務コラム:HRダッシュボードでの活用例
本問のパターン(再帰CTEで構造 → ウィンドウ関数で分析)はHR分析の典型です。実務では depth ごとの平均給与差(昇格インセンティブ)や、同一 depth 内での給与分布の偏り(給与公平性チェック)に使われます。さらに NTILE(4) OVER (PARTITION BY depth ORDER BY salary) と組み合わせれば、階層ごとの給与四分位数(Q1〜Q4)を算出でき、ハイパフォーマー抽出の基礎データになります。再帰CTEで「木構造の属性」を取得し、ウィンドウ関数で「統計分析」するという組み合わせは、BI・分析エンジニアリングの必須テクニックです。
QUESTION 2

グラフ経路探索(配列で循環防止) — 訪問済み配列 visited で全経路を安全に列挙する

WITH RECURSIVEグラフ探索配列・ARRAY循環防止
前提知識

再帰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

使用テーブル
▸ routes(有向グラフ・一部は往復あり)
from_codeto_codecost
TYOOSA13000
TYONGO8000
NGOOSA6000
NGOFUK18000
OSAFUK15000
OSAHIR7000
HIRFUK8000
OSATYO13000
入力データの関係と、再帰処理の停止条件を確認してください。
期待出力
route_strtotal_cost
TYO→NGO→FUK26000
TYO→OSA→FUK28000
TYO→OSA→HIR→FUK28000
TYO→NGO→OSA→FUK29000
模範解答コード
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. 外側クエリ
  */
解説(テーブル変化・ポイント)
WITH RECURSIVE paths AS ( 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, 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) AND array_length(p.visited, 1) < 4 ) SELECT route_str, total_cost FROM paths WHERE current_city = 'FUK' ORDER BY total_cost;
LEGEND
データ取得・読込対象
① アンカー部(出発点の初期化)
TYO を起点に visited=['TYO'] で登録出発地TYOを登録します。visited配列に['TYO']を初期化することで、以降の再帰でTYOへの再訪を検出できます。この1行が最初の「ワーキングテーブル(次回の探索起点)」となります。
1 / 7
current_citytotal_costvisited(訪問済み配列)route_str
TYO0['TYO']TYO
アンカー: 1行
配列による循環防止の仕組み
KEY MECHANISM != ALL(visited) が循環検出の核心
TYO→OSA→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停止
学習ポイント
ツリー vs グラフの本質的な違い:ツリーは各ノードへの到達経路が1つだけなので循環しません。グラフは複数経路・往復ルートがあるため再帰CTEが無限ループする危険があります。グラフ探索では必ず「訪問済みチェック」か「ホップ数制限」の一方または両方を設けます。
PostgreSQL 14+ の CYCLE 句:PostgreSQL 14以降では CYCLE node_col SET is_cycle USING path という標準構文で循環を自動検出できます。配列を手動管理するより簡潔です。ただし CYCLE 句は「循環を検出して停止」するだけで、全経路列挙には引き続き深さ制限が必要です。
MySQL 8 での代替アプローチ:MySQLは配列型がないため、visited を 'TYO,OSA,' のようなCSV文字列として管理し、FIND_IN_SET(r.to_code, p.visited) = 0 で未訪問チェックを行います。パフォーマンスは落ちますがロジックは同一です。
アンチパターン
配列チェックなしでグラフ展開:循環ルートのあるグラフで visited チェックを省略すると再帰が無限ループし、DBがメモリ/タイムアウトエラーを起こします。「これはツリーだから大丈夫」という思い込みは禁物で、データに循環がないことが保証されていない限り防護策を必ず追加します。
最小コスト経路を求めたいなら CTE は不適切:本問では「全経路列挙」が目的のため再帰CTEで問題ありません。しかし「最短/最小コスト経路のみ」を求めるなら、ダイクストラ法などのアルゴリズムをアプリ側で実装するか、pgRouting 拡張を使用する方が効率的です。再帰CTEで全経路展開してから MIN() を取るのは指数的に遅くなります。
実務コラム:グラフ探索の実務適用例
再帰CTEによるグラフ探索はフレンド・オブ・フレンド(友人の友人)探索(SNS)、サプライチェーンの経由地分析、ネットワーク障害の影響範囲追跡など多くの実務シナリオで登場します。ホップ数制限(array_length < N)は「何次のつながりまで追跡するか」の制御です。SNSの友人推薦は通常2〜3次までに制限します。大規模グラフ(数百万ノード)では再帰CTEよりもApache Spark GraphX や Neo4j などのグラフデータベースが現実的な選択肢となります。
QUESTION 3

ロール継承によるパーミッション集計 — ボトムアップ再帰で RBAC の権限チェーンを実装する

WITH RECURSIVEボトムアップ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
)
ボトムアップ基礎との違い:祖先を辿る方向は基礎編のカテゴリパンくずと同じですが、本問では複数のロールが同一の権限を持つ場合の重複除去と、どのロールで付与されたか(audit trail)の記録が追加のポイントになります。
問題

usersuser_id=1(alice) が持つ全パーミッションを、ロール継承チェーンを辿って取得してください。直接ロール・継承ロールの両方のパーミッションを列挙し、取得列は permission_name, granted_by_role, chain_levelchain_level 昇順 → permission_name 昇順でソートしてください。

使用テーブル
▸ roles
role_idrole_nameparent_role_id
1super_adminNULL
2admin1
3editor2
4viewer3
▸ permissions
role_idpermission_name
1system.config
1user.delete
2user.create
2user.edit
3content.edit
3content.publish
4content.read
▸ users
user_idusernamerole_id
1alice3
2bob4
3carol2
入力データの関係と、再帰処理の停止条件を確認してください。
期待出力
permission_namegranted_by_rolechain_level
content.editeditor0
content.publisheditor0
user.createadmin1
user.editadmin1
system.configsuper_admin2
user.deletesuper_admin2
模範解答コード
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. 外側クエリ
  */
解説(テーブル変化・ポイント)
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 = 1 UNION ALL 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 ) 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;
LEGEND
評価対象の列・キー
除外・非表示データ
① アンカー部(JOIN評価)
users と roles を結合して直接ロールを取得対象ユーザー(user_id=1: alice)の情報を users テーブルから取得し、roles テーブルと結合して対象の起点ロール(editor)を特定します。
1 / 7
▸ users (起点ユーザー)
user_idusernamerole_id
1alice3
▸ roles (探索ターゲット)
role_idrole_nameparent_role_id
1super_adminNULL
2admin1
3editor2
4viewer3
評価: users JOIN roles → role_id=3 に一致
ロール継承チェーンの展開フロー(alice の場合)
ANCHOR alice の直接ロール: editor(role_id=3)
取得したロールチェーン
chain_levelrole_nameparent
0editor(3)admin(2)
1admin(2)super_admin(1)
2super_admin(1)NULL → 終了
JOIN permissions → alice が持つ全権限
permissionfrom
content.editeditor(直接)
content.publisheditor(直接)
user.createadmin(継承)
user.editadmin(継承)
system.configsuper_admin(継承)
user.deletesuper_admin(継承)
学習ポイント
chain_level が監査証跡(audit trail)になる:chain_level=0 は直接付与、大きいほど継承元が遠い祖先ロールです。「どのロールから継承された権限か」を記録することで、権限変更の影響範囲トレースや、不審な権限昇格の検出に活用できます。
複数ロールを持つユーザーへの拡張:本問ではユーザーが1つのロールだけを持つ設計ですが、実際の RBAC では1ユーザーが複数ロールを持ちます。その場合は 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 して重複を除去します。
有効な権限チェックのSQL最適化:本問のように全権限を列挙するパターンは権限一覧表示に向いています。一方「特定の権限を持つか否か」の True/False チェックだけなら、WHERE p.permission_name = 'user.create' LIMIT 1 を追加してショートサーキットさせた方が高速です。
アンチパターン
ロール循環(ダイヤモンド継承)への無防備:ロール設計に誤りがあり、roleA→roleB→roleAのような循環が生じるとこのクエリは無限ループします。ロール設計時に循環が生じないことをアプリ側で保証するか、配列技法を組み合わせて循環検出を追加します。
DISTINCT を忘れて重複パーミッションが現れる:複数のロール継承経路が同一のパーミッションに到達すると、同じ permission_name が複数行現れます。外側クエリを SELECT DISTINCT permission_name, ... にするか、MIN(chain_level) で最も直接的な付与元だけを残すようにします。
実務コラム:AWS IAM / Google Cloud IAM との対比
本問のロール継承パターンはAWS IAM のロールとポリシーの継承やGoogle Cloud IAM の階層型権限と本質的に同じ設計です。RDMSベースの認可システムを自社で実装する場合、この WITH RECURSIVE + JOIN permissions パターンは鉄板構成です。実装上のポイントは①権限チェックの頻度が高い場合は マテリアライズドビューや専用の permission_cache テーブルに展開結果を永続化すること、②ロール変更時に自動でキャッシュを無効化するトリガーを設けること、の2点です。階層が5段以上になる場合は再帰の代わりに閉包テーブルパターン(Q5参照)の永続化を検討してください。
QUESTION 4

BOM展開 × 原価積み上げ — 再帰展開と数量乗算で製品の総原価を算出する

WITH RECURSIVE数量乗算累積BOM/製造集計・GROUP BY
前提知識

製造業の「部品表(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)の行だけをコスト計上して最後に集計します。

数量の累積:BOM展開では、親から子へと階層を下るたびに、親の累積数量 * 子の所要量 を計算して引き継ぎます。加算(+)ではなく乗算(*)である点に注意してください。
問題

以下の製品構成を前提に、全部品・サブアセンブリ・製品それぞれの総原価(total_component_cost)を再帰CTEで算出してください。

・集計対象は葉部品(unit_cost IS NOT NULL)のコストのみ。中間アセンブリは自身では原価を持ちません
・数量は親から子への経路全体を乗算して積み上げます(例:完成品Xのネジ使用数 = サブαのネジ数 × サブαの個数)
・出力は part_id, part_name, total_component_cost、part_id 昇順

使用テーブル
▸ parts(NULLはサブアセンブリ/製品)
part_idpart_nameunit_cost
1完成品XNULL
2サブASSY-αNULL
3サブASSY-βNULL
4ネジM35
5ボルトM812
6フレーム200
7基板150
▸ bom(parent→child×quantity)
parent_part_idchild_part_idquantity備考
122完成品X → サブα ×2
131完成品X → サブβ ×1
161完成品X → フレーム ×1
246サブα → ネジ ×6
252サブα → ボルト ×2
344サブβ → ネジ ×4
371サブβ → 基板 ×1
期待出力
part_idpart_nametotal_component_cost
1完成品X478
2サブASSY-α54
3サブASSY-β170
4ネジM35
5ボルトM812
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  → 部品名を付加し昇順出力
  */
解説(テーブル変化・ポイント)
WITH RECURSIVE bom_expand AS ( SELECT p.part_id AS root_id, p.part_id AS current_id, 1 AS accumulated_qty, p.unit_cost FROM parts p UNION ALL 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;
LEGEND
データ取得・読込対象
除外・非表示データ
INPUT
parts テーブル & bom テーブル入力データを確認します。parts の unit_cost が NULL の行はサブアセンブリ/製品(原価を自分では持たない)。bom は親→子の数量関係を表します。
1 / 6
▸ parts(部品マスタ)
part_idpart_nameunit_cost
1完成品XNULL
2サブASSY-αNULL
3サブASSY-βNULL
4ネジM35
5ボルトM812
6フレーム200
7基板150
▸ bom(部品展開表)
parentchildqty
1(完成品)2(サブα)2
1(完成品)3(サブβ)1
1(完成品)6(フレーム)1
2(サブα)4(ネジ)6
2(サブα)5(ボルト)2
3(サブβ)4(ネジ)4
3(サブβ)7(基板)1
parts: 7行 / bom: 7行
BOM展開と数量乗算の仕組み
ANCHOR 全7部品を起点化(root_id = current_id = 自身、qty = 1)
iter1: BOM直接子を展開
rootcurrentqty
完成品Xサブα1×2=2
完成品Xサブβ1×1=1
完成品Xフレーム1×1=1
サブαネジ1×6=6
サブβネジ1×4=4
iter2: 数量を乗算して葉へ
rootcurrentqty×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
学習ポイント
「全部品をアンカーにする」設計の意図:ルート部品だけをアンカーにすると、サブアセンブリや葉部品単体のコストが計算できません。全部品を起点にすることで、葉でも中間でも同一クエリで総原価を算出できます。葉部品は BOM の親として存在しないため再帰が自然に止まります。
accumulated_qty の乗算伝播:be.accumulated_qty * b.quantity により、数量は経路全体で乗算されます。「完成品X→サブα(×2)→ネジ(×6)」ならネジの accumulated_qty は 1×2×6=12。これが BOM 展開の核心です。
WHERE unit_cost IS NOT NULL の役割:中間アセンブリの行(unit_cost=NULL)は SUM に含めると NULL 伝播でエラーの原因になります。葉部品行のみに絞り込んでから集計することで、正確なボトムアップコスト積み上げを実現します。
アンチパターン
数量を SUM で足してしまう(乗算すべき箇所):BOM 展開で数量を be.accumulated_qty + b.quantity と加算すると誤りです。「サブαを2個使い、その中にネジが6本」なら必要なネジは 2×6=12 本であり、乗算でなければ正しい積み上げになりません
BOM に循環がある場合の無限ループ:設計ミスや誤データで部品Aが部品Bの子であり同時に親になる循環が生じると無限ループします。PostgreSQL では CYCLE part_id SET is_cycle USING path 句で循環を自動検出できます。本番では必ずガード条件を追加してください。
実務コラム:SAP/ERP との BOM 展開の共通点
SAP の「多段階 BOM 展開(CS11/CS15)」や Oracle Manufacturing の BOM Explosion は、このクエリが行っている再帰的な数量乗算積み上げと本質的に同じロジックです。ERP では手続き型で実装されていますが、PostgreSQL/SQL Server では WITH RECURSIVE 一本で同等の展開が可能です。実務では①原価バージョン管理(有効期限付きの unit_cost)、②代替部品(alternate BOM)、③生産スクラップ率(yield)の考慮が追加されます。これらは bom テーブルに effective_date・alternate_flag・scrap_rate カラムを追加し、WHERE 条件と qty 計算式を拡張することで対応できます。
QUESTION 5

部門予算の二重再帰集計 — dept_tree + closure の2つのCTEで配下予算を完全集計する

WITH RECURSIVE閉包テーブル組織/階層複数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を一括算出します。

二重再帰(連鎖CTE):PostgreSQL や MySQL 8.0以降では、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 昇順

使用テーブル
▸ departments
dept_iddept_nameparent_idown_budget
1本社NULL5000
2技術部13000
3営業部12000
4バックエンド21500
5フロントエンド21200
6国内営業31800
7海外営業3900
期待出力
dept_iddept_namedepthpathown_budgettotal_budget
1本社0本社500015400
2技術部1本社 > 技術部30005700
3営業部1本社 > 営業部20004700
4バックエンド2本社 > 技術部 > バックエンド15001500
5フロントエンド2本社 > 技術部 > フロントエンド12001200
6国内営業2本社 > 営業部 > 国内営業18001800
7海外営業2本社 > 営業部 > 海外営業900900
模範解答コード
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順に出力
*/
解説(テーブル変化・ポイント)
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 ( SELECT dept_id AS ancestor_id, dept_id AS descendant_id 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 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;
LEGEND
データ取得・読込対象
INPUT
departments テーブル7部門の階層構造。parent_id=NULL が頂点(本社)。
1 / 8
dept_iddept_nameparent_idown_budget
1本社NULL5000
2技術部13000
3営業部12000
4バックエンド21500
5フロントエンド21200
6国内営業31800
7海外営業3900
7行
二重再帰CTEの展開フロー
CTE① dept_tree: パスと深度を付与しながら全7部門を展開
dept_tree 結果(7行)
depthdept_namepath
0本社本社
1技術部本社 > 技術部
1営業部本社 > 営業部
2バックエンド本社 > 技術部 > BE
2国内営業本社 > 営業部 > 国内
+
closure 結果(17ペア)→ SUM
ancestordescendant
本社(1)本社(1)
本社(1)技術部(2)
本社(1)営業部(3)
本社(1)BE(4)
本社(1)FE(5)
…7ペア合算= 15400
学習ポイント
閉包テーブル(Closure Table)の威力:閉包テーブルは「祖先→子孫の全ペア」を保持するため、任意の部門の全配下を O(1) の等結合(=)で取得できます。再帰なしに WHERE ancestor_id = 1 とするだけで本社の全配下が得られ、集計・権限チェック・表示の3用途で特に高速です。
二重 WITH RECURSIVE の注意点(PostgreSQL):PostgreSQL では WITH RECURSIVE は宣言一度で複数の再帰 CTE を同時に定義できます。ただしCTEは順番に定義され、後ろの CTE は前の CTE を参照できますが、前の CTE が後ろを参照することはできません(前方参照不可)。本問では dept_tree を先に、closure を後に置いています。
path 文字列の実用性:dt.path || ' > ' || d.dept_name で生成されたパスはパンくずリスト表示、ソート、前方一致検索(path LIKE '本社 > 技術部%')に直接使えます。ただし dept_name に ' > ' が含まれる場合は区切り文字の衝突が起きるため、実務では区切り文字を工夫するか ltree 型(PostgreSQL 拡張)を使うと安全です。
アンチパターン
closureのアンカーに自己ペアを忘れる:自己ペア(ancestor=descendant=自分自身)がないと、各部門の own_budget が total_budget に含まれません。葉部門(配下なし)の total_budget は own_budget だけになるはずが 0 になります。アンカーに必ず自己ペアを含めるのが閉包テーブルの基本ルールです。
closureを永続テーブルにしないリスク:本問のように WITH RECURSIVE でその場生成するのはデータ量が少い場合に有効ですが、部門数が多い場合はパフォーマンスが劣化します。実務では closure テーブルを専用の永続テーブルとして保持し、部門の追加/移動/削除時にトリガーまたはアプリで更新するパターンが主流です。
実務コラム:階層データの3大格納パターンと使い分け
階層データの格納方式には①隣接リスト(parent_id)—本問の方式。シンプルだが全配下取得に再帰が必要、②閉包テーブル(Closure Table)—全ペアを別テーブルで保持。集計・検索が高速だが書き込みオーバーヘッドあり、③入れ子集合(Nested Sets)—左右値で範囲を表現。読み取りは速いが挿入が複雑、の3パターンがあります。読み取り頻度が高く書き込みが少ない場合は閉包テーブル、シンプルさを重視するなら隣接リスト+WITH RECURSIVEが現代のベストプラクティスです。PostgreSQL では ltree 拡張も選択肢に入ります。