SQL 再帰CTE — 数列生成・BOM展開・循環検出の基礎

基礎WITH RECURSIVE数列・日付生成BOM・グラフ循環検出PostgreSQL/MySQL8対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 6

数値シーケンス生成と累積和 — テーブルなしで 1〜N の連番と running total を作る

WITH RECURSIVEシーケンス生成累積和テーブルレス
前提知識

再帰CTEはテーブルを持たない「純粋な数値シーケンス生成」にも使えます。アンカー部に定数値を直接書き、再帰部で値をインクリメントするだけで任意の連番が生成できます。generate_series() を持たない DB でも使えるポータブルなパターンです。

WITH RECURSIVE series AS (
  -- ① アンカー部: テーブル参照なし、定数 1 から開始
  SELECT 1 AS n

  UNION ALL

  -- ② 再帰部: n を 1 ずつ加算。WHERE で上限を指定して停止
  SELECT n + 1
  FROM  series
  WHERE n + 1 <= 20
)
終了条件は「次に生成する値」で判定する:WHERE n + 1 <= N と書くことで「次ステップで生成する値が上限以下か」を直接評価できます。こうすると上限ちょうどの行が生成された後に安全に停止します。
問題

1 から 10 の整数シーケンスを WITH RECURSIVE で生成してください。テーブルは一切使用しないこと。取得列は以下の 3 つです。

  • n: 連番(1〜10)
  • parity: '奇数' または '偶数'(n % 2 で判定)
  • running_sum: 1 から n までの累積和(例: n=3 なら 1+2+3=6)

n 昇順でソートしてください。

期待出力
nparityrunning_sum
1奇数1
2偶数3
3奇数6
4偶数10
5奇数15
6偶数21
7奇数28
8偶数36
9奇数45
10偶数55
模範解答コード
WITH RECURSIVE series AS (

  -- ① アンカー部: 定数のみ(テーブル参照なし)
  SELECT
    1  AS n,
    1  AS running_sum

  UNION ALL

  -- ② 再帰部: n に +1、running_sum に次の n を加算
  SELECT
    n + 1,
    running_sum + (n + 1)  -- 累積和 = 前回の累積和 + 次の n
  FROM  series
  WHERE n + 1 <= 10        -- n+1 が 10 以下の間だけ再帰する

)
SELECT
  n,
  CASE WHEN n % 2 = 1 THEN '奇数' ELSE '偶数' END AS parity,
  running_sum
FROM  series
ORDER BY n;

/*
  実行順序(SQLの論理的な評価順):
  1. アンカー部
  2. 再帰1〜8回目
  3. 再帰9回目
  4. 再帰10回目試行
  5. 外側クエリ
  */
解説(テーブル変化・ポイント)
WITH RECURSIVE series AS ( SELECT 1 AS n, 1 AS running_sum UNION ALL SELECT n + 1, running_sum + (n + 1) FROM series WHERE n + 1 <= 10 ) SELECT n, CASE WHEN n % 2 = 1 THEN '奇数' ELSE '偶数' END AS parity, running_sum FROM series ORDER BY n;
LEGEND
データ取得・読込対象
① アンカー部
SELECT 1 AS n, 1 AS running_sumアンカー部は FROM 句を持たず、定数値 n=1 を生成します。ここで生成された行が初回再帰の「ワーキングテーブル(現在の対象)」として設定されます。
1 / 5
状態nrunning_sum
新規追加行(ワーキングテーブル初期化)11
アンカー: 1行
n=1〜10 の再帰展開と累積和の伝播
STEP 1 アンカー部:起点の生成
ワーキングテーブル初期化(次回の対象)
nrunning_sum
11
STEP 2 再帰部:累積和の計算と伝播
ワーキングテーブル(現在の対象: n=1)
nrunning_sum
11
新規追加行(次回の対象: n=2)
n+1running_sum + (n+1)
21 + 2 = 3
新規追加行(次回の対象: n=3)
n+1running_sum + (n+1)
33 + 3 = 6
STEP 3 終了条件の判定
上限到達時の挙動
ワーキング対象 n次に生成する n+1WHERE n+1 <= 10結果
91010 <= 10 (TRUE)n=10 追加
101111 <= 10 (FALSE)追加なし・停止
学習ポイント
アンカー部は FROM なしで書ける:標準 SQL では SELECT 1 AS n のように FROM なしで定数を返せます(PostgreSQL / MySQL / SQLite 共通)。テーブルが存在しなくても再帰の起点を定義できます。
累積和は「前行の値を引き継ぐ」だけ:running_sum + (n + 1) は「直前行の running_sum に今回の n+1 を加える」という意味です。値の伝播が再帰CTEの本質的な機能であり、ウィンドウ関数 SUM() OVER (ORDER BY n) と等価な結果になります。
PostgreSQL では generate_series() が使える:SELECT n, SUM(n) OVER (ORDER BY n) FROM generate_series(1,10) t(n) でも同結果です。移植性が必要な場合や他 DBMS では WITH RECURSIVE が標準的なアプローチです。
アンチパターン
終了条件を外側クエリの LIMIT だけに頼る:WHERE n + 1 <= 10 を省き LIMIT 10 だけにすると、DBMSは内部的に上限以上の行を展開してから切り捨てる実装になる場合があります。最悪 cte_max_recursion_depth エラーになります。必ず再帰部の WHERE に終了条件を書くのが原則です。
アンカーと再帰部で列型・列数が揃っていない:アンカーの SELECT リストと再帰部の SELECT リストは列数・型・順序が完全一致しなければなりません。型が異なると「型の不一致エラー」が発生します。文字列連結などで型が変わる場合は明示的なキャストを追加してください。
実務コラム:数列生成 CTE の 3 大応用パターン
数値シーケンスを生成できると多くの場面で役立ちます。① ピボット列の動的生成:月番号・曜日番号を連番で生成し CASE 式と組み合わせて横展開集計に使います。② テストデータの一括挿入:INSERT INTO orders SELECT n, ... FROM series で大量テストレコードを簡単に作れます。③ 日付・時刻シーケンス:数値に INTERVAL '1 day' を掛け合わせれば任意の日付範囲が生成できます(Q8 参照)。いずれも終了条件さえ明確に書けば安全・効率的に使えます。
QUESTION 7

BOM(部品表)展開と累積コスト計算 — 再帰で製品の全部品と製造コストを算出する

WITH RECURSIVE数量の掛算伝播BOM・製造累積乗算
前提知識

製造業や EC システムでは「製品 A に部品 B が 2 個、部品 B に素材 C が 3 個必要」という部品表(BOM: Bill of Materials)がよく使われます。完成品1個あたりの全部品の必要数を求めるには、親の数量と BOM 数量を掛け算しながら再帰展開する必要があります。

WITH RECURSIVE bom_tree AS (
  -- ① アンカー: 完成品を qty=1 で起点に設定
  SELECT part_id, part_name, unit_cost, 1 AS qty, 0 AS depth
  FROM  parts WHERE part_id = :root_id

  UNION ALL

  -- ② 再帰: bom テーブルで子部品を取得し数量を掛け算で累積
  SELECT p.part_id, p.part_name, p.unit_cost,
         bt.qty * b.qty,          -- ← 親の qty × bom の qty
         bt.depth + 1
  FROM  bom b
  JOIN  bom_tree bt ON b.parent_id = bt.part_id
  JOIN  parts p    ON b.child_id  = p.part_id
)
掛け算の累積が階層を越えた数量伝播の鍵:1段階ずつ bt.qty × b.qty を計算することで「親1個 → 子4個 → 孫8個」のような数量積み上げが自動的に処理されます。加算(depth+1)と乗算(qty×qty)の両方を同時に伝播させるのが BOM 展開の特徴です。
問題

製品の部品構成テーブル(parts, bom)があります。完成品A(part_id=1)の全部品(直接・間接含む)を展開し、depth, path, part_name, qty(累積数量), unit_cost, line_cost(qty × unit_cost) を取得してください。完成品 A 自身は除き、depth, part_id 昇順でソートしてください。

使用テーブル
▸ parts
part_idpart_nameunit_cost
1完成品A0
2フレーム500
3エンジン2000
4タイヤ300
5ピストン200
6バルブ50
▸ bom(部品構成)
parent_idchild_idqty
121
131
144
358
3612
入力データの関係を確認し、再帰中の重複や循環にも注意してください。
期待出力
depthpathpart_nameqtyunit_costline_cost
1完成品A > フレームフレーム1500500
1完成品A > エンジンエンジン120002000
1完成品A > タイヤタイヤ43001200
2完成品A > エンジン > ピストンピストン82001600
2完成品A > エンジン > バルブバルブ1250600
模範解答コード
WITH RECURSIVE bom_tree AS (

  -- ① アンカー部: 完成品A(part_id=1)を qty=1, depth=0 で起点に設定
  SELECT
    p.part_id,
    p.part_name,
    p.unit_cost,
    1                     AS qty,    -- 完成品は1個からスタート
    0                     AS depth,
    p.part_name           AS path
  FROM  parts p
  WHERE p.part_id = 1

  UNION ALL

  -- ② 再帰部: BOM テーブルで子部品を取得し数量を掛け算で累積
  SELECT
    p.part_id,
    p.part_name,
    p.unit_cost,
    bt.qty * b.qty        AS qty,   -- 累積数量 = 親qty × BOM qty
    bt.depth + 1,
    bt.path || ' > ' || p.part_name
  FROM  bom b
  JOIN  bom_tree bt ON b.parent_id = bt.part_id  -- 親のpart_idとBOMのparent_idを結合
  JOIN  parts p    ON b.child_id  = p.part_id   -- 子部品の詳細をpartsから取得

)
SELECT
  depth,
  path,
  part_name,
  qty,
  unit_cost,
  qty * unit_cost         AS line_cost  -- 累積数量 × 単価 = 行合計コスト
FROM  bom_tree
WHERE part_id <> 1                      -- 完成品自身(unit_cost=0)を除外
ORDER BY depth, part_id;

/*
  実行順序(SQLの論理的な評価順):
  1. アンカー部
  2. 再帰1回目
  3. 再帰2回目
  4. 再帰3回目
  5. 外側クエリ
  */
解説(テーブル変化・ポイント)
WITH RECURSIVE bom_tree AS ( SELECT p.part_id, p.part_name, p.unit_cost, 1 AS qty, 0 AS depth, p.part_name AS path FROM parts p WHERE p.part_id = 1 UNION ALL SELECT p.part_id, p.part_name, p.unit_cost, bt.qty * b.qty AS qty, bt.depth + 1, bt.path || ' > ' || p.part_name FROM bom b JOIN bom_tree bt ON b.parent_id = bt.part_id JOIN parts p ON b.child_id = p.part_id ) SELECT depth, path, part_name, qty, unit_cost, qty * unit_cost AS line_cost FROM bom_tree WHERE part_id <> 1 ORDER BY depth, part_id;
LEGEND
データ取得・読込対象
除外・非表示データ
① アンカー部(完成品A)
完成品を qty=1, depth=0 で起点に完成品A(part_id=1)を qty=1(1個)として初期化します。ここで生成された1行が初回再帰の「ワーキングテーブル(現在の対象)」として設定され、BOM展開が始まります。
1 / 4
状態part_idpart_nameunit_costqtydepth
新規追加行(ワーキングテーブル初期化)1完成品A010
アンカー: 1行(出発点)
BOM 展開における数量の掛け算伝播
STEP 1 アンカー部:完成品の準備 (depth=0)
ワーキングテーブル初期化(次回の対象)
part_nameqtydepth
完成品A10
STEP 2 再帰1回目:直下部品の展開 (depth=1)
ワーキングテーブル(現在の対象)
part_name親qty
完成品A1
×
BOM構成
部品BOM qty
フレーム1
エンジン1
タイヤ4
=
新規追加行(次回の対象)
part_nameqty計算累積qty
フレーム1 × 11
エンジン1 × 11
タイヤ1 × 44
STEP 3 再帰2回目:孫部品の展開 (depth=2)
ワーキングテーブル(現在の対象: 子あり)
part_name親qty
エンジン1
×
BOM構成
部品BOM qty
ピストン8
バルブ12
=
新規追加行(次回の対象)
part_nameqty計算累積qty
ピストン1 × 88
バルブ1 × 1212
学習ポイント
JOIN が 2 つある点が通常の階層 CTE との違い:通常の隣接リスト展開は JOIN が1本です。BOM展開では「中間テーブル(bom)」と「詳細テーブル(parts)」の2本をつなぎます。接続テーブルを経由する場合は JOIN が2本になることを覚えておきましょう。
多段BOMでは数量が指数的に増加する:3階層で各段が×4個構成だと最深部は 4×4×4=64 個になります。大規模な BOM では深さ制限(WHERE depth < N)や部分展開の採用を検討してください。
集計で総コスト・最大コスト部品を簡単に取得できる:このクエリを外側で SELECT SUM(line_cost) にするだけで完成品1個あたりの総コストが求まります。WHERE depth = 1 に絞れば「直接調達コスト」、全階層で計算すれば「素材コスト合計」という使い分けも可能です。
アンチパターン
qty を加算してしまう:bt.qty + b.qty(加算)にすると「親1個 + 子4個 = 5個」という意味になり正しい累積数量になりません。BOM展開では必ず乗算(×)で数量を伝播させます。「親 N 個の中に子 M 個 → 完成品1個あたり N×M 個必要」というロジックです。
BOM に循環参照が含まれると無限ループ:「部品 A が部品 B を含み、部品 B が部品 A を含む」という設計ミスがあると再帰が終了しません。実務では WHERE depth < 20 のガードを必ず入れてください(循環検出の詳細は Q10 参照)。
実務コラム:BOM 展開の 3 大バリエーション
BOM展開は製造業・EC・ゲームアイテム合成など幅広い領域で使われます。実務では3つのバリエーションがあります。① フラット展開(本問): 全部品を列挙してコスト集計。② 最深のリーフ部品だけ取得: WHERE part_id NOT IN (SELECT parent_id FROM bom) で葉ノードのみ絞り込み「素材のみの一覧」を作れます。③ 特定の中間部品からの展開: アンカー部の WHERE を変えるだけで「エンジン部分のコスト」だけを部分計算できます。いずれも同じ再帰構造が再利用できるのが WITH RECURSIVE の強みです。
QUESTION 8

日付シーケンス生成とギャップ補完 — カレンダーCTEで売上ゼロ日を検出する

WITH RECURSIVE日付生成ギャップ補完LEFT JOIN
前提知識

レポートや分析では「売上のない日」「ログのない時間帯」を含めて全期間を表示したい場面があります。DB には売上が存在する行しかないため、WITH RECURSIVE で日付シーケンスを生成し、LEFT JOIN で欠損日を補完するのが定番パターンです。

WITH RECURSIVE cal AS (
  -- ① アンカー部: 開始日を指定(テーブル参照なし)
  SELECT DATE '2023-09-01' AS d

  UNION ALL

  -- ② 再帰部: 1日ずつ加算して終了日まで展開
  SELECT (d + INTERVAL '1 day')::date   -- PostgreSQL
  -- SELECT DATE_ADD(d, INTERVAL 1 DAY)    -- MySQL
  FROM  cal
  WHERE d < DATE '2023-09-10'
)
PostgreSQL の日付加算:(d + INTERVAL '1 day')::date は date 型にキャストが必要です(INTERVAL 加算で timestamp になるため)。MySQL では DATE_ADD(d, INTERVAL 1 DAY)、SQLite では DATE(d, '+1 day') を使います。
問題

sales_daily テーブルには売上があった日のレコードだけが存在します。2024-01-01〜2024-01-07 の全7日について売上があった日は金額、なかった日は 0 を返してください。取得列は sale_date, amount, status('有' または '欠損')sale_date 昇順でソートしてください。

使用テーブル
▸ sales_daily
sale_dateamount
2024-01-0115000
2024-01-0223000
2024-01-048000
2024-01-0630000
2024-01-0712000
入力データの関係を確認し、再帰中の重複や循環にも注意してください。
期待出力
sale_dateamountstatus
2024-01-0115000
2024-01-0223000
2024-01-030欠損
2024-01-048000
2024-01-050欠損
2024-01-0630000
2024-01-0712000
模範解答コード
WITH RECURSIVE date_series AS (

  -- ① アンカー部: 開始日 2024-01-01 を設定(テーブル参照なし)
  SELECT DATE '2024-01-01' AS d

  UNION ALL

  -- ② 再帰部: 1日ずつ加算(PostgreSQL の date 型にキャスト)
  -- MySQL: SELECT DATE_ADD(d, INTERVAL 1 DAY) FROM date_series WHERE d < '2024-01-07'
  SELECT (d + INTERVAL '1 day')::date
  FROM   date_series
  WHERE  d < DATE '2024-01-07'    -- 終了日に達したら停止

)
SELECT
  ds.d                                     AS sale_date,
  COALESCE(s.amount, 0)                   AS amount,  -- NULL → 0 に補完
  CASE WHEN s.sale_date IS NULL
       THEN '欠損' ELSE '有'
  END                                      AS status
FROM  date_series ds
LEFT JOIN sales_daily s ON ds.d = s.sale_date  -- 売上なし日はNULLで結合
ORDER BY ds.d;

/*
  実行順序(SQLの論理的な評価順):
  1. アンカー部
  2. 再帰1〜5回目
  3. 再帰6回目
  4. 再帰7回目試行
  5. 外側クエリ
  */
解説(テーブル変化・ポイント)
WITH RECURSIVE date_series AS ( SELECT DATE '2024-01-01' AS d UNION ALL SELECT (d + INTERVAL '1 day')::date FROM date_series WHERE d < DATE '2024-01-07' ) SELECT ds.d AS sale_date, COALESCE(s.amount, 0) AS amount, CASE WHEN s.sale_date IS NULL THEN '欠損' ELSE '有' END AS status FROM date_series ds LEFT JOIN sales_daily s ON ds.d = s.sale_date ORDER BY ds.d;
LEGEND
データ取得・読込対象
① アンカー部
SELECT DATE '2024-01-01' AS dFROM 句なしで開始日を定義します。ここで生成された1日目の日付が、初回再帰の「ワーキングテーブル(現在の対象)」となります。
1 / 6
状態d(生成日付)
新規追加行(ワーキングテーブル初期化)2024-01-01
アンカー: 1行(開始日を設定)
日付生成 → LEFT JOIN による欠損検出の流れ
STEP 1 カレンダーの生成(再帰CTE)
date_series(生成される全日付)
d生成方法
2024-01-01アンカー
2024-01-0201-01 + 1day
2024-01-0301-02 + 1day
......
2024-01-0701-06 + 1day
STEP 2 LEFT JOIN による欠損検出
date_series (左)
d
2024-01-01
2024-01-02
2024-01-03
2024-01-04
2024-01-05
sales_daily (右)
sale_dateamount
2024-01-0115000
2024-01-0223000
(存在しない)-
2024-01-048000
(存在しない)-
=
結合結果と補完
ds.amount補完 (COALESCE)判定 (CASE)
2024-01-011500015000
2024-01-022300023000
2024-01-03NULL0欠損
2024-01-0480008000
2024-01-05NULL0欠損
学習ポイント
LEFT JOIN こそが欠損検出のカギ:date_series を左テーブルにして LEFT JOIN sales_daily すると、売上のない日も date_series の行が必ず残り、sales 側の列が NULL になります。INNER JOIN にすると欠損日が消えてしまうので、全日付を保証したい場合は必ず LEFT JOIN を使います。
COALESCE と CASE の使い分け:COALESCE(amount, 0) は NULL を指定のデフォルト値に変換します。CASE WHEN sale_date IS NULL THEN '欠損' は NULL の存在をフラグ文字列に変換します。数値の補完は COALESCE、フラグ付けは CASE という使い分けが実務の定型です。
月次・年次集計への応用:終了条件を変えるだけで月単位(31日間)や年単位(365日間)の集計軸が作れます。さらに EXTRACT(DOW FROM ds.d) で曜日を付与したり、GROUP BY date_trunc('week', ds.d) で週次集計に変換したりと、カレンダー CTE は分析の基盤として幅広く使えます。
アンチパターン
PostgreSQL で ::date キャストを忘れる:d + INTERVAL '1 day' は PostgreSQL では timestamp を返します。次の再帰ステップで date 型の列と型不一致になりエラーが発生します。必ず ::date でキャストしてください。MySQL や SQLite では DATE 型を返すためキャスト不要です。
終了条件を <= にしてしまう:WHERE d <= '2024-01-07' にすると d = '2024-01-07' のときにも再帰が試みられ、'2024-01-07' + 1day = '2024-01-08' が追加されてしまいます。1日多く生成されるバグになります。終了日の前日まで条件を評価させるために WHERE d < 終了日 と書くのが正しいです。
実務コラム:カレンダー CTE を使った分析パターン集
カレンダー CTE(日付シーケンス)は分析 SQL の定番部品です。① 月次売上推移レポート: 当月の全日付 × 売上 LEFT JOIN で「売上ゼロ日を含む日次グラフ」を作成。② 曜日別集計: EXTRACT(DOW FROM d) で曜日を付与し GROUP BY すると「月〜日別の平均売上」が計算できます。③ 移動平均: 日付シーケンスと自己 JOIN して「過去7日の移動平均」を算出するウィンドウの骨格として使えます。期間の開始日・終了日をパラメータ化すれば汎用的な分析基盤になります。
QUESTION 9

グラフ経路探索と最短ホップ数 — 有向グラフで 2 ノード間の最短経路を求める

WITH RECURSIVEグラフ探索経路・BFS最短ホップ
前提知識

組織のワークフロー、交通網、SNS のフォロー関係など、有向グラフのデータに対して「ノード A から ノード B に到達できるか」「最短何ホップか」を求める場面があります。WITH RECURSIVE は幅優先探索(BFS)に近い形で最短ホップを求めることができます。

WITH RECURSIVE paths AS (
  -- ① アンカー部: 起点から直接到達するエッジを hops=1 で設定
  SELECT from_node, to_node, 1 AS hops,
         from_node || '->' || to_node AS path
  FROM  edges WHERE from_node = :start

  UNION ALL

  -- ② 再帰部: パスを1ホップ延長
  SELECT p.from_node, e.to_node, p.hops + 1,
         p.path || '->' || e.to_node
  FROM  edges e
  JOIN  paths p ON e.from_node = p.to_node
  WHERE p.hops + 1 <= 5   -- 無限ループ防止ガード
)
循環グラフに注意:ループがあるグラフでは同じノードを何度も訪問し無限ループになります。hops <= N のガードか、訪問済みノードを path 文字列で管理して path NOT LIKE '%->X->%' で再訪を防ぐのが実務のパターンです。
問題

ノード間のエッジを持つ有向グラフ edges テーブルがあります。ノード 'A' から到達可能な全ノードとその最短ホップ数、経路文字列を求めてください。取得列は to_node, hops, path(起点 A 自身は除く)、同じ到達先には最短ホップのものだけを返し、hops, to_node 昇順でソートしてください。

使用テーブル
▸ edges
from_nodeto_node
AB
AC
BD
CD
DE
BE
入力データの関係を確認し、再帰中の重複や循環にも注意してください。
期待出力
to_nodehopspath
B1A->B
C1A->C
D2A->B->D
E2A->B->E
模範解答コード
WITH RECURSIVE paths AS (

  -- ① アンカー部: ノード'A'から出るエッジを直接取得(hops=1)
  SELECT
    from_node,
    to_node,
    1                            AS hops,
    from_node || '->' || to_node AS path
  FROM  edges
  WHERE from_node = 'A'

  UNION ALL

  -- ② 再帰部: 現在の to_node から出るエッジを追加して1ホップ延長
  SELECT
    p.from_node,
    e.to_node,
    p.hops + 1,
    p.path || '->' || e.to_node
  FROM  edges e
  JOIN  paths p ON e.from_node = p.to_node   -- 現在の末端ノードから次へ
  WHERE p.hops + 1 <= 5                    -- ループ防止の深さガード
    AND  p.path NOT LIKE '%' || e.to_node || '%'  -- 既訪問ノードへの再訪を防止

)
, ranked AS (
  SELECT
    to_node,
    hops,
    path,
    ROW_NUMBER() OVER (
      PARTITION BY to_node
      ORDER BY hops, path
    ) AS rn  -- 最短ホップ、同率なら辞書順最小を1位にする
  FROM  paths
)
SELECT
  to_node,
  hops,
  path
FROM  ranked
WHERE rn = 1
ORDER BY hops, to_node;

/*
  実行順序(SQLの論理的な評価順):
  1. アンカー部
  2. 再帰1回目
  3. 再帰2回目
  4. 外側クエリ
  */
解説(テーブル変化・ポイント)
WITH RECURSIVE paths AS ( SELECT from_node, to_node, 1 AS hops, from_node || '->' || to_node AS path FROM edges WHERE from_node = 'A' UNION ALL SELECT p.from_node, e.to_node, p.hops + 1, p.path || '->' || e.to_node FROM edges e JOIN paths p ON e.from_node = p.to_node WHERE p.hops + 1 <= 5 AND p.path NOT LIKE '%' || e.to_node || '%' ) , ranked AS ( SELECT to_node, hops, path, ROW_NUMBER() OVER (PARTITION BY to_node ORDER BY hops, path) AS rn FROM paths ) SELECT to_node, hops, path FROM ranked WHERE rn = 1 ORDER BY hops, to_node;
LEGEND
データ取得・読込対象
除外・非表示データ
① アンカー部(起点A)
edges WHERE from_node = 'A'A から直接繋がる B と C を取得し、hops=1 として出発点とします。これら2行が初回再帰の「ワーキングテーブル(現在の対象)」となります。
1 / 4
状態from_nodeto_nodehopspath
新規追加行(ワーキングテーブル初期化)AB1A->B
新規追加行(ワーキングテーブル初期化)AC1A->C
アンカー: 2行(hops=1の直接到達ノード)
グラフ探索のホップ展開プロセス
STEP 1 アンカー部:起点A からの到達 (hops=1)
ワーキングテーブル初期化(次回の対象)
fromtohopspath
AB1A->B
AC1A->C
STEP 2 再帰部:2ホップ先の展開
ワーキングテーブル(現在の対象: B, C)から展開
起点次ノードhopspath
BD2A->B->D
BE2A->B->E
CD2A->C->D
STEP 3 最短経路の選択 (ROW_NUMBER())
ノード D の複数経路(累積結果)
to_nodehopspath
D2A->B->D
D2A->C->D
ROW_NUMBER() = 1 の行を選択
to_nodehopspathrn
D2A->B->D1
学習ポイント
再帰CTE のグラフ探索は BFS に近い:WITH RECURSIVE は各反復でワーキングテーブルを「1ホップ先のノード全体」に置き換えていくため、結果として幅優先探索(BFS)に近い挙動になります。ROW_NUMBER() を hops, path 順で付ければ最短ホップと辞書順最小の経路を同時に選択できます。
path 文字列で再訪防止する:path NOT LIKE '%' || to_node || '%' はパス文字列にそのノードが含まれているかを確認します。ノード名が数字のみの場合は '%->X->%' のように区切り文字付きで検索すると誤マッチを防げます。ノード名が部分文字列にならないよう設計することが重要です。
到達可能性チェックにも応用できる:最短経路を選択する前に WHERE to_node = 'E' で絞り込めば「A から E に到達できるか」「到達できる場合の全経路」を確認できます。グラフ上の可達性分析はアクセス制御・依存関係の確認に実務でよく使われます。
アンチパターン
ループのあるグラフで再訪防止なし:グラフに A→B→A のようなサイクルがあり、AND path NOT LIKE ... を書かないと再帰が終わらず depth ガードに頼るしかありません。必ず path 文字列チェックか depth 制限のどちらかを入れるのがグラフ探索の鉄則です。
大規模グラフへのフルスキャン適用:ノード数が数万〜数百万のグラフに対してこのパターンをそのまま使うと展開行数が爆発します。大規模グラフには PostgreSQL の pgRouting 拡張や専用のグラフDB(Neo4j など)の採用を検討してください。再帰CTEのグラフ探索は中小規模(ノード数〜数千)が実用的な上限です。
実務コラム:グラフ探索の実務応用例
WITH RECURSIVE によるグラフ探索は、特に中規模のネットワーク分析で実用されます。① 承認ワークフローの到達分析: 申請が最終承認者に到達するまでのステップ数と経路を確認します。② 依存関係の解析: ソフトウェアパッケージの依存グラフで「パッケージAがパッケージBに間接的に依存しているか」を検出します。③ SNS の共通フォロワー経路: フォロー関係グラフで「2人のユーザーが何ホップで繋がるか」を算出します。大規模になるほど専用ツールの検討が必要ですが、数百〜数千ノード程度であればSQLだけで十分な分析が可能です。
QUESTION 10

循環参照の検出 — 再帰展開中に is_cycle フラグで無限ループを安全に検出する

WITH RECURSIVE循環検出データ品質CYCLE句・フラグ管理
前提知識

階層データに「自分自身を祖先として持つ」循環参照があると再帰CTEは無限ループになります。実務では2つのアプローチがあります:① PostgreSQL 14+ の CYCLE 句② 訪問済みノード配列・path 文字列によるフラグ管理です。本問では両方を学びます。

-- PG14+ の CYCLE 句(最も簡潔)
WITH RECURSIVE tree AS (
  SELECT id, parent_id, id AS root_id FROM nodes WHERE parent_id IS NULL
  UNION ALL
  SELECT n.id, n.parent_id, t.root_id FROM nodes n JOIN tree t ON n.parent_id = t.id
) CYCLE id SET is_cycle USING path  -- ← PG14+: is_cycle が TRUE で検出
SELECT * FROM tree WHERE is_cycle;

-- 互換性のある path 文字列チェック(全DBMS対応)
WHERE visited_ids NOT LIKE '%,' || child.id || ',%'  -- 未訪問ノードのみ展開
CYCLE 句は PostgreSQL 14+ 専用:MySQL / SQLite / SQL Server では動作しません。移植性が必要な場合は path 文字列チェックを使ってください。本問では移植性の高い path チェック方式を採用します。
問題

nodes テーブルにはデータ不整合で循環参照が含まれています。WITH RECURSIVE で全ノードを展開しながら、訪問済みノードを path 文字列で管理し循環を検出してください。取得列は node_id, name, parent_id, depth, path, is_cycleis_cycle は循環を検出したら TRUE、そうでなければ FALSE)。node_id, depth 昇順でソートしてください。

使用テーブル
▸ nodes
node_idnameparent_id
1RootNULL
2Alpha1
3Beta1
4Gamma2
4Gamma5
5Delta4
6OrphanNULL
入力データの関係を確認し、再帰中の重複や循環にも注意してください。
期待出力
node_idnameparent_iddepthpathis_cycle
1RootNULL0,1,FALSE
2Alpha11,1,2,FALSE
3Beta11,1,3,FALSE
4Gamma22,1,2,4,FALSE
4Gamma54,1,2,4,5,4,TRUE
5Delta43,1,2,4,5,FALSE
6OrphanNULL0,6,FALSE
模範解答コード
WITH RECURSIVE tree AS (

  -- ① アンカー部: ルートノード(parent_id IS NULL)を起点に設定
  SELECT
    node_id,
    name,
    parent_id,
    0                                   AS depth,
    ',' || node_id || ','              AS path,      -- カンマ区切りでIDを記録
    FALSE                               AS is_cycle  -- アンカーは循環なし
  FROM  nodes
  WHERE parent_id IS NULL

  UNION ALL

  -- ② 再帰部: 子ノードを取得し、path に既に含まれていれば is_cycle=TRUE
  SELECT
    n.node_id,
    n.name,
    n.parent_id,
    t.depth + 1,
    t.path || n.node_id || ','         AS path,
    t.path LIKE '%,' || n.node_id || ',%' AS is_cycle  -- path に ',node_id,' が含まれていれば循環を検出
  FROM  nodes n
  JOIN  tree t ON n.parent_id = t.node_id
  WHERE t.depth + 1 <= 10                              -- 深さガード(循環でも最大10階層)
    AND  NOT t.is_cycle                                -- 既に循環検出済みの行は展開しない

)
SELECT
  node_id,
  name,
  parent_id,
  depth,
  path,
  is_cycle
FROM  tree
ORDER BY node_id, depth;

/*
  実行順序(SQLの論理的な評価順):
  1. アンカー部
  2. 再帰1回目
  3. 再帰2回目
  4. 再帰3回目
  5. 循環の検出
  6. 外側クエリ
  */
解説(テーブル変化・ポイント)
WITH RECURSIVE tree AS ( SELECT node_id, name, parent_id, 0 AS depth, ',' || node_id || ',' AS path, FALSE AS is_cycle FROM nodes WHERE parent_id IS NULL UNION ALL SELECT n.node_id, n.name, n.parent_id, t.depth + 1, t.path || n.node_id || ',', t.path LIKE '%,' || n.node_id || ',%' AS is_cycle FROM nodes n JOIN tree t ON n.parent_id = t.node_id WHERE t.depth + 1 <= 10 AND NOT t.is_cycle ) SELECT node_id, name, parent_id, depth, path, is_cycle FROM tree ORDER BY node_id, depth;
LEGEND
データ取得・読込対象
① 前提データ(循環の発生源)
nodes テーブルの異常状態正常な木構造の中に、データ不整合によって「Gamma (id=4) の親が Delta (id=5) である」という異常な重複レコードが混入した状態を想定します。これにより意図せず生じたループパスの検出プロセスを追跡します。
1 / 6
node_idnameparent_id備考
1RootNULLルート
2Alpha1
3Beta1
4Gamma2正常なレコード
4Gamma5★ 異常な重複レコード
5Delta4
6OrphanNULL
入力データ確認
path 文字列による循環検出の仕組み
STEP 1 正常な展開(深さ3まで)
pathの変化と追跡
depthnode_idpath判定: path LIKE '%,'||id||',%'is_cycle
01 (Root),1,-FALSE
12 (Alpha),1,2,',1,' に ',2,' → ✗FALSE
24 (Gamma),1,2,4,',1,2,' に ',4,' → ✗FALSE
35 (Delta),1,2,4,5,',1,2,4,' に ',5,' → ✗FALSE
STEP 2 循環検出のメカニズム(深さ4)
ワーキングテーブル(Delta) から重複 Gamma を再展開
親の path展開ノード判定: LIKE '%,4,%'is_cycleNOT t.is_cycle
,1,2,4,5,4',1,2,4,5,' に ',4,' → ✓ (検出)TRUEFALSE → 展開ブロック
参考 PostgreSQL 14+ CYCLE 句との比較
PG14+ CYCLE 句
特徴内容
構文CYCLE id SET is_cycle USING path
利点簡潔・高速・DBMS最適化
欠点PG14+専用。他DBMSで使えない
vs
path 文字列チェック(本問)
特徴内容
構文path LIKE '%,'||id||',%'
利点全DBMS対応・移植性が高い
欠点ノード名が長いとパフォーマンス低下
学習ポイント
path 文字列の区切り文字設計が重要:','||id||',' のようにカンマで囲む設計にすることで、node_id=12 を探すとき LIKE '%,12,%' が node_id=1 や node_id=2 に誤マッチしません。前後の区切り文字で完全一致を保証するのが安全な設計です。
AND NOT t.is_cycle で展開を即座に停止:is_cycle=TRUE になった行から先の再帰展開はビジネス的にも意味がないため、AND NOT t.is_cycle を再帰部のWHEREに追加することで不要な展開を防げます。フラグ列を条件として使い展開を制御するのはメモリ節約にも有効です。
PostgreSQL 14+ の CYCLE 句が使える場合は積極活用:CYCLE node_id SET is_cycle USING path は DBMS が最適化した形で循環検出を行います。PostgreSQL 専用環境では CYCLE 句を優先し、マルチ DBMS 環境では path 文字列チェックを選択するのが実務の方針です。
アンチパターン
depth ガードだけに頼る:WHERE depth + 1 <= 10 だけでは循環を「検出」できません。どのノードがループを形成しているかが分かりません。循環を「検出して報告」するには path 管理または CYCLE 句が必須です。depth ガードはあくまで「最悪でも N 回以内に終わらせる保険」です。
LIKE パターンでノードIDの誤マッチ:区切り文字なしで LIKE '%12%' にすると node_id=112 や node_id=123 にもマッチしてしまいます。必ずカンマなどの区切り文字で囲んで LIKE '%,12,%' と検索してください。文字列 ID の場合はハイフンなど重複しにくい文字を選ぶと安全です。
実務コラム:循環参照検出の重要性とデータ品質
本問のような循環参照(データの不整合)は、アプリケーションのバグや運用ミスによって意図せず発生します。たとえば組織階層テーブルで「AさんがBさんの上司、BさんがAさんの上司」というデータが入ってしまうケースです。再帰CTEで定期的に循環チェックを実行することで、データ品質の劣化を早期発見できます。PostgreSQL であれば CYCLE 句と合わせて WHERE is_cycle = TRUE の行をアラートとして通知するスクリプトが有効です。さらに根本対策として、アプリケーション側でデータ挿入・更新前に 「新しい parent_id が自分の子孫でないか」を WITH RECURSIVE でチェックしてから INSERT/UPDATE する防衛的設計が推奨されます。