数値シーケンス生成と累積和 — テーブルなしで 1〜N の連番と running total を作る
再帰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 昇順でソートしてください。
| n | parity | running_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. 外側クエリ */
LEGEND
① アンカー部
SELECT 1 AS n, 1 AS running_sumアンカー部は FROM 句を持たず、定数値 n=1 を生成します。ここで生成された行が初回再帰の「ワーキングテーブル(現在の対象)」として設定されます。| 状態 | n | running_sum |
|---|---|---|
| 新規追加行(ワーキングテーブル初期化) | 1 | 1 |
| n | running_sum |
|---|---|
| 1 | 1 |
| n | running_sum |
|---|---|
| 1 | 1 |
| n+1 | running_sum + (n+1) |
|---|---|
| 2 | 1 + 2 = 3 |
| n+1 | running_sum + (n+1) |
|---|---|
| 3 | 3 + 3 = 6 |
| ワーキング対象 n | 次に生成する n+1 | WHERE n+1 <= 10 | 結果 |
|---|---|---|---|
| 9 | 10 | 10 <= 10 (TRUE) | n=10 追加 |
| 10 | 11 | 11 <= 10 (FALSE) | 追加なし・停止 |
SELECT 1 AS n のように FROM なしで定数を返せます(PostgreSQL / MySQL / SQLite 共通)。テーブルが存在しなくても再帰の起点を定義できます。running_sum + (n + 1) は「直前行の running_sum に今回の n+1 を加える」という意味です。値の伝播が再帰CTEの本質的な機能であり、ウィンドウ関数 SUM() OVER (ORDER BY n) と等価な結果になります。SELECT n, SUM(n) OVER (ORDER BY n) FROM generate_series(1,10) t(n) でも同結果です。移植性が必要な場合や他 DBMS では WITH RECURSIVE が標準的なアプローチです。WHERE n + 1 <= 10 を省き LIMIT 10 だけにすると、DBMSは内部的に上限以上の行を展開してから切り捨てる実装になる場合があります。最悪 cte_max_recursion_depth エラーになります。必ず再帰部の WHERE に終了条件を書くのが原則です。INSERT INTO orders SELECT n, ... FROM series で大量テストレコードを簡単に作れます。③ 日付・時刻シーケンス:数値に INTERVAL '1 day' を掛け合わせれば任意の日付範囲が生成できます(Q8 参照)。いずれも終了条件さえ明確に書けば安全・効率的に使えます。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 )
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 昇順でソートしてください。
| part_id | part_name | unit_cost |
|---|---|---|
| 1 | 完成品A | 0 |
| 2 | フレーム | 500 |
| 3 | エンジン | 2000 |
| 4 | タイヤ | 300 |
| 5 | ピストン | 200 |
| 6 | バルブ | 50 |
| parent_id | child_id | qty |
|---|---|---|
| 1 | 2 | 1 |
| 1 | 3 | 1 |
| 1 | 4 | 4 |
| 3 | 5 | 8 |
| 3 | 6 | 12 |
| depth | path | part_name | qty | unit_cost | line_cost |
|---|---|---|---|---|---|
| 1 | 完成品A > フレーム | フレーム | 1 | 500 | 500 |
| 1 | 完成品A > エンジン | エンジン | 1 | 2000 | 2000 |
| 1 | 完成品A > タイヤ | タイヤ | 4 | 300 | 1200 |
| 2 | 完成品A > エンジン > ピストン | ピストン | 8 | 200 | 1600 |
| 2 | 完成品A > エンジン > バルブ | バルブ | 12 | 50 | 600 |
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. 外側クエリ */
LEGEND
① アンカー部(完成品A)
完成品を qty=1, depth=0 で起点に完成品A(part_id=1)を qty=1(1個)として初期化します。ここで生成された1行が初回再帰の「ワーキングテーブル(現在の対象)」として設定され、BOM展開が始まります。| 状態 | part_id | part_name | unit_cost | qty | depth |
|---|---|---|---|---|---|
| 新規追加行(ワーキングテーブル初期化) | 1 | 完成品A | 0 | 1 | 0 |
| part_name | qty | depth |
|---|---|---|
| 完成品A | 1 | 0 |
| part_name | 親qty |
|---|---|
| 完成品A | 1 |
| 部品 | BOM qty |
|---|---|
| フレーム | 1 |
| エンジン | 1 |
| タイヤ | 4 |
| part_name | qty計算 | 累積qty |
|---|---|---|
| フレーム | 1 × 1 | 1 |
| エンジン | 1 × 1 | 1 |
| タイヤ | 1 × 4 | 4 |
| part_name | 親qty |
|---|---|
| エンジン | 1 |
| 部品 | BOM qty |
|---|---|
| ピストン | 8 |
| バルブ | 12 |
| part_name | qty計算 | 累積qty |
|---|---|---|
| ピストン | 1 × 8 | 8 |
| バルブ | 1 × 12 | 12 |
SELECT SUM(line_cost) にするだけで完成品1個あたりの総コストが求まります。WHERE depth = 1 に絞れば「直接調達コスト」、全階層で計算すれば「素材コスト合計」という使い分けも可能です。bt.qty + b.qty(加算)にすると「親1個 + 子4個 = 5個」という意味になり正しい累積数量になりません。BOM展開では必ず乗算(×)で数量を伝播させます。「親 N 個の中に子 M 個 → 完成品1個あたり N×M 個必要」というロジックです。WHERE part_id NOT IN (SELECT parent_id FROM bom) で葉ノードのみ絞り込み「素材のみの一覧」を作れます。③ 特定の中間部品からの展開: アンカー部の WHERE を変えるだけで「エンジン部分のコスト」だけを部分計算できます。いずれも同じ再帰構造が再利用できるのが WITH RECURSIVE の強みです。日付シーケンス生成とギャップ補完 — カレンダーCTEで売上ゼロ日を検出する
レポートや分析では「売上のない日」「ログのない時間帯」を含めて全期間を表示したい場面があります。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' )
(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 昇順でソートしてください。
| sale_date | amount |
|---|---|
| 2024-01-01 | 15000 |
| 2024-01-02 | 23000 |
| 2024-01-04 | 8000 |
| 2024-01-06 | 30000 |
| 2024-01-07 | 12000 |
| sale_date | amount | status |
|---|---|---|
| 2024-01-01 | 15000 | 有 |
| 2024-01-02 | 23000 | 有 |
| 2024-01-03 | 0 | 欠損 |
| 2024-01-04 | 8000 | 有 |
| 2024-01-05 | 0 | 欠損 |
| 2024-01-06 | 30000 | 有 |
| 2024-01-07 | 12000 | 有 |
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. 外側クエリ */
LEGEND
① アンカー部
SELECT DATE '2024-01-01' AS dFROM 句なしで開始日を定義します。ここで生成された1日目の日付が、初回再帰の「ワーキングテーブル(現在の対象)」となります。| 状態 | d(生成日付) |
|---|---|
| 新規追加行(ワーキングテーブル初期化) | 2024-01-01 |
| d | 生成方法 |
|---|---|
| 2024-01-01 | アンカー |
| 2024-01-02 | 01-01 + 1day |
| 2024-01-03 | 01-02 + 1day |
| ... | ... |
| 2024-01-07 | 01-06 + 1day |
| d |
|---|
| 2024-01-01 |
| 2024-01-02 |
| 2024-01-03 |
| 2024-01-04 |
| 2024-01-05 |
| sale_date | amount |
|---|---|
| 2024-01-01 | 15000 |
| 2024-01-02 | 23000 |
| (存在しない) | - |
| 2024-01-04 | 8000 |
| (存在しない) | - |
| d | s.amount | 補完 (COALESCE) | 判定 (CASE) |
|---|---|---|---|
| 2024-01-01 | 15000 | 15000 | 有 |
| 2024-01-02 | 23000 | 23000 | 有 |
| 2024-01-03 | NULL | 0 | 欠損 |
| 2024-01-04 | 8000 | 8000 | 有 |
| 2024-01-05 | NULL | 0 | 欠損 |
LEFT JOIN sales_daily すると、売上のない日も date_series の行が必ず残り、sales 側の列が NULL になります。INNER JOIN にすると欠損日が消えてしまうので、全日付を保証したい場合は必ず LEFT JOIN を使います。COALESCE(amount, 0) は NULL を指定のデフォルト値に変換します。CASE WHEN sale_date IS NULL THEN '欠損' は NULL の存在をフラグ文字列に変換します。数値の補完は COALESCE、フラグ付けは CASE という使い分けが実務の定型です。EXTRACT(DOW FROM ds.d) で曜日を付与したり、GROUP BY date_trunc('week', ds.d) で週次集計に変換したりと、カレンダー CTE は分析の基盤として幅広く使えます。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 < 終了日 と書くのが正しいです。EXTRACT(DOW FROM d) で曜日を付与し GROUP BY すると「月〜日別の平均売上」が計算できます。③ 移動平均: 日付シーケンスと自己 JOIN して「過去7日の移動平均」を算出するウィンドウの骨格として使えます。期間の開始日・終了日をパラメータ化すれば汎用的な分析基盤になります。グラフ経路探索と最短ホップ数 — 有向グラフで 2 ノード間の最短経路を求める
組織のワークフロー、交通網、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 昇順でソートしてください。
| from_node | to_node |
|---|---|
| A | B |
| A | C |
| B | D |
| C | D |
| D | E |
| B | E |
| to_node | hops | path |
|---|---|---|
| B | 1 | A->B |
| C | 1 | A->C |
| D | 2 | A->B->D |
| E | 2 | A->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. 外側クエリ */
LEGEND
① アンカー部(起点A)
edges WHERE from_node = 'A'A から直接繋がる B と C を取得し、hops=1 として出発点とします。これら2行が初回再帰の「ワーキングテーブル(現在の対象)」となります。| 状態 | from_node | to_node | hops | path |
|---|---|---|---|---|
| 新規追加行(ワーキングテーブル初期化) | A | B | 1 | A->B |
| 新規追加行(ワーキングテーブル初期化) | A | C | 1 | A->C |
| from | to | hops | path |
|---|---|---|---|
| A | B | 1 | A->B |
| A | C | 1 | A->C |
| 起点 | 次ノード | hops | path |
|---|---|---|---|
| B | D | 2 | A->B->D |
| B | E | 2 | A->B->E |
| C | D | 2 | A->C->D |
| to_node | hops | path |
|---|---|---|
| D | 2 | A->B->D |
| D | 2 | A->C->D |
| to_node | hops | path | rn |
|---|---|---|---|
| D | 2 | A->B->D | 1 |
path NOT LIKE '%' || to_node || '%' はパス文字列にそのノードが含まれているかを確認します。ノード名が数字のみの場合は '%->X->%' のように区切り文字付きで検索すると誤マッチを防げます。ノード名が部分文字列にならないよう設計することが重要です。WHERE to_node = 'E' で絞り込めば「A から E に到達できるか」「到達できる場合の全経路」を確認できます。グラフ上の可達性分析はアクセス制御・依存関係の確認に実務でよく使われます。AND path NOT LIKE ... を書かないと再帰が終わらず depth ガードに頼るしかありません。必ず path 文字列チェックか depth 制限のどちらかを入れるのがグラフ探索の鉄則です。循環参照の検出 — 再帰展開中に is_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 || ',%' -- 未訪問ノードのみ展開
nodes テーブルにはデータ不整合で循環参照が含まれています。WITH RECURSIVE で全ノードを展開しながら、訪問済みノードを path 文字列で管理し循環を検出してください。取得列は node_id, name, parent_id, depth, path, is_cycle(is_cycle は循環を検出したら TRUE、そうでなければ FALSE)。node_id, depth 昇順でソートしてください。
| node_id | name | parent_id |
|---|---|---|
| 1 | Root | NULL |
| 2 | Alpha | 1 |
| 3 | Beta | 1 |
| 4 | Gamma | 2 |
| 4 | Gamma | 5 |
| 5 | Delta | 4 |
| 6 | Orphan | NULL |
| node_id | name | parent_id | depth | path | is_cycle |
|---|---|---|---|---|---|
| 1 | Root | NULL | 0 | ,1, | FALSE |
| 2 | Alpha | 1 | 1 | ,1,2, | FALSE |
| 3 | Beta | 1 | 1 | ,1,3, | FALSE |
| 4 | Gamma | 2 | 2 | ,1,2,4, | FALSE |
| 4 | Gamma | 5 | 4 | ,1,2,4,5,4, | TRUE |
| 5 | Delta | 4 | 3 | ,1,2,4,5, | FALSE |
| 6 | Orphan | NULL | 0 | ,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. 外側クエリ */
LEGEND
① 前提データ(循環の発生源)
nodes テーブルの異常状態正常な木構造の中に、データ不整合によって「Gamma (id=4) の親が Delta (id=5) である」という異常な重複レコードが混入した状態を想定します。これにより意図せず生じたループパスの検出プロセスを追跡します。| node_id | name | parent_id | 備考 |
|---|---|---|---|
| 1 | Root | NULL | ルート |
| 2 | Alpha | 1 | |
| 3 | Beta | 1 | |
| 4 | Gamma | 2 | 正常なレコード |
| 4 | Gamma | 5 | ★ 異常な重複レコード |
| 5 | Delta | 4 | |
| 6 | Orphan | NULL |
| depth | node_id | path | 判定: path LIKE '%,'||id||',%' | is_cycle |
|---|---|---|---|---|
| 0 | 1 (Root) | ,1, | - | FALSE |
| 1 | 2 (Alpha) | ,1,2, | ',1,' に ',2,' → ✗ | FALSE |
| 2 | 4 (Gamma) | ,1,2,4, | ',1,2,' に ',4,' → ✗ | FALSE |
| 3 | 5 (Delta) | ,1,2,4,5, | ',1,2,4,' に ',5,' → ✗ | FALSE |
| 親の path | 展開ノード | 判定: LIKE '%,4,%' | is_cycle | NOT t.is_cycle |
|---|---|---|---|---|
| ,1,2,4,5, | 4 | ',1,2,4,5,' に ',4,' → ✓ (検出) | TRUE | FALSE → 展開ブロック |
| 特徴 | 内容 |
|---|---|
| 構文 | CYCLE id SET is_cycle USING path |
| 利点 | 簡潔・高速・DBMS最適化 |
| 欠点 | PG14+専用。他DBMSで使えない |
| 特徴 | 内容 |
|---|---|
| 構文 | path LIKE '%,'||id||',%' |
| 利点 | 全DBMS対応・移植性が高い |
| 欠点 | ノード名が長いとパフォーマンス低下 |
','||id||',' のようにカンマで囲む設計にすることで、node_id=12 を探すとき LIKE '%,12,%' が node_id=1 や node_id=2 に誤マッチしません。前後の区切り文字で完全一致を保証するのが安全な設計です。AND NOT t.is_cycle を再帰部のWHEREに追加することで不要な展開を防げます。フラグ列を条件として使い展開を制御するのはメモリ節約にも有効です。CYCLE node_id SET is_cycle USING path は DBMS が最適化した形で循環検出を行います。PostgreSQL 専用環境では CYCLE 句を優先し、マルチ DBMS 環境では path 文字列チェックを選択するのが実務の方針です。WHERE depth + 1 <= 10 だけでは循環を「検出」できません。どのノードがループを形成しているかが分かりません。循環を「検出して報告」するには path 管理または CYCLE 句が必須です。depth ガードはあくまで「最悪でも N 回以内に終わらせる保険」です。LIKE '%12%' にすると node_id=112 や node_id=123 にもマッチしてしまいます。必ずカンマなどの区切り文字で囲んで LIKE '%,12,%' と検索してください。文字列 ID の場合はハイフンなど重複しにくい文字を選ぶと安全です。CYCLE 句と合わせて WHERE is_cycle = TRUE の行をアラートとして通知するスクリプトが有効です。さらに根本対策として、アプリケーション側でデータ挿入・更新前に 「新しい parent_id が自分の子孫でないか」を WITH RECURSIVE でチェックしてから INSERT/UPDATE する防衛的設計が推奨されます。