【Oracle】再帰WITH句の使い方|SEARCH・CYCLE・CONNECT BYとの違い

【PL/SQL】再帰WITH句と階層問い合わせの実用パターン PL/SQL

組織図、カテゴリツリー、部品表(BOM)、承認経路のような親子構造は、Oracleの再帰WITH句(recursive subquery factoring)で展開できます。アンカー部で起点を決め、再帰部で次の行へ進むため、複数テーブルをまたぐ探索や業務条件を含む経路計算をSQLとして明示できます。

Oracleでは、PostgreSQLやMySQLのようにWITH RECURSIVEとは書きません。通常のWITH句で問い合わせ名を再帰部から参照します。また、列別名リストとUNION ALLが必要です。

対応バージョン
本記事の再帰WITH句、SEARCH句、CYCLE句はOracle Database 11g Release 2以降を対象にしています。利用中のバージョンはSELECT banner FROM v$version;などで確認してください。
先に結論

  • Oracleの再帰問い合わせはWITH RECURSIVEではなくWITHで記述します。
  • アンカー部を先、再帰部を後に置き、UNION ALLで接続します。
  • 探索結果の表示順はSEARCH句で管理し、最後に生成された順序列で並べます。
  • 循環する可能性があるデータにはCYCLE句と深さ上限を用意します。
  • 単一表の定型的な階層取得はCONNECT BY、複合的な経路探索は再帰WITH句が向いています。

Oracle固有の階層問い合わせを詳しく確認したい場合は、START WITH・CONNECT BYの使い方完全ガイドを参照してください。通常のWITH句によるサブクエリ整理は、WITH句(共通表式)の使い方完全ガイドで解説しています。

スポンサーリンク

Oracle再帰WITH句の基本構文

再帰WITH句は、起点行を返すアンカー部と、直前の結果を参照して次の行を返す再帰部で構成します。問い合わせ名を自分自身から1回参照することで、次の階層へ進みます。

recursive-with-template.sql
WITH query_name (id, parent_id, depth) AS (
  -- アンカー部: 探索の起点
  SELECT id, parent_id, 1
  FROM target_table
  WHERE parent_id IS NULL

  UNION ALL

  -- 再帰部: 1階層下へ進む
  SELECT child.id, child.parent_id, parent.depth + 1
  FROM target_table child
  JOIN query_name parent
    ON child.parent_id = parent.id
)
SELECT id, parent_id, depth
FROM query_name;

Oracleで書く際は、次の4点を外さないようにします。

  • 問い合わせ名の後ろに列別名リストを指定する
  • アンカー部を再帰部より前に置く
  • アンカー部と再帰部をUNION ALLでつなぐ
  • 再帰部から問い合わせ名を1回だけ参照する
WITH RECURSIVEはOracleの構文ではありません
MySQLやPostgreSQL向けの例をそのまま移植してWITH RECURSIVE tree AS (...)と書くと、Oracleでは構文エラーになります。OracleではRECURSIVEキーワードを付けません。

検証用の社員データを準備する

以降の組織ツリーを手元で確認できるよう、最小構成の社員テーブルとテストデータを用意します。実務テーブルへ読み替える場合は、主キー、親を示す外部キー、表示順に使う列を対応させてください。

create-sample-employees.sql
CREATE TABLE app.employees (
  employee_id   NUMBER       CONSTRAINT pk_employees PRIMARY KEY,
  manager_id    NUMBER,
  employee_name VARCHAR2(100) NOT NULL,
  CONSTRAINT fk_employees_manager
    FOREIGN KEY (manager_id)
    REFERENCES app.employees (employee_id)
);

CREATE INDEX app.idx_employees_manager
    ON app.employees (manager_id);

INSERT INTO app.employees VALUES (1, NULL, '社長');
INSERT INTO app.employees VALUES (2, 1,    '営業部長');
INSERT INTO app.employees VALUES (3, 1,    '開発部長');
INSERT INTO app.employees VALUES (4, 2,    '営業担当A');
INSERT INTO app.employees VALUES (5, 2,    '営業担当B');
INSERT INTO app.employees VALUES (6, 3,    '開発担当A');
COMMIT;

このデータは「社長」をルートとし、その下に営業部長と開発部長、さらに各担当者が続く3階層の構造です。manager_idがNULLの行を起点にすると、全社員を上から下へ展開できます。

組織ツリーを上司から部下へ展開する

社員テーブルのmanager_idが上司のemployee_idを参照している例です。深さ、ルート社員、表示用パスを再帰中に保持します。パス列は連結で長くなるため、CASTで十分な長さを確保します。

organization-tree.sql
WITH org_tree (
  employee_id,
  manager_id,
  employee_name,
  root_employee_id,
  depth,
  path_text
) AS (
  SELECT
    e.employee_id,
    e.manager_id,
    e.employee_name,
    e.employee_id,
    1,
    CAST(e.employee_name AS VARCHAR2(4000))
  FROM app.employees e
  WHERE e.manager_id IS NULL

  UNION ALL

  SELECT
    child.employee_id,
    child.manager_id,
    child.employee_name,
    parent.root_employee_id,
    parent.depth + 1,
    parent.path_text || ' > ' || child.employee_name
  FROM app.employees child
  JOIN org_tree parent
    ON child.manager_id = parent.employee_id
)
SEARCH DEPTH FIRST BY employee_name, employee_id SET search_order
CYCLE employee_id SET is_cycle TO 'Y' DEFAULT 'N'
SELECT
  employee_id,
  manager_id,
  employee_name,
  root_employee_id,
  depth,
  path_text,
  is_cycle
FROM org_tree
WHERE is_cycle = 'N'
ORDER BY search_order;

SEARCH DEPTH FIRSTは深さ優先探索の順序列search_orderを生成します。ただし、SEARCH句を指定しただけでは最終結果の表示順は保証されません。最後にORDER BY search_orderを指定します。

同名社員が存在しても順序を安定させるため、BY employee_name, employee_idのように一意キーまで含めます。幅優先で表示したい場合はSEARCH BREADTH FIRSTへ変更します。

テストデータに対する主な結果は次のとおりです。深さ優先探索では、各部門の配下をたどってから次の兄弟ノードへ進みます。

  • 社長:depth=1、path=社長
  • 営業部長:depth=2、path=社長 > 営業部長
  • 営業担当A・営業担当B:depth=3、営業部長の配下
  • 開発部長:depth=2、path=社長 > 開発部長
  • 開発担当A:depth=3、開発部長の配下

テストでは、6行すべてが1回ずつ返ること、root_employee_idが全行で1になること、is_cycleがすべてNになることも確認します。

CYCLE句で循環を検出する

人事データの誤登録でAの上司がB、Bの上司がAになっていると、親子探索は循環します。CYCLE句を指定すると、探索経路内で同じキーが再登場した行へマークを付け、無限再帰を防げます。

cycle-detection.sql
WITH org_tree (employee_id, manager_id, depth) AS (
  SELECT employee_id, manager_id, 1
  FROM app.employees
  WHERE employee_id = :start_employee_id

  UNION ALL

  SELECT
    child.employee_id,
    child.manager_id,
    parent.depth + 1
  FROM app.employees child
  JOIN org_tree parent
    ON child.manager_id = parent.employee_id
  WHERE parent.depth < 100
)
CYCLE employee_id SET is_cycle TO 'Y' DEFAULT 'N'
SELECT employee_id, manager_id, depth, is_cycle
FROM org_tree
ORDER BY depth, employee_id;

CYCLEは指定列が同じ探索経路内で再登場したことを検出します。別経路から同じノードへ到達する有向グラフでは、業務上必要な経路まで重複と見なさないよう、CYCLE列の選び方を検討してください。

データ不備だけでなく想定外の深さにも備えるため、再帰部へparent.depth < 100のような上限を併用しています。上限値は実際の最大階層と運用ルールに合わせます。

部品表(BOM)の必要数量と原価を展開する

部品表では、上位部品から末端部品までの必要数量を掛け合わせます。ここではbom(parent_item, child_item, quantity)を展開し、末端部品だけを原価集計します。

bom-cost-rollup.sql
WITH bom_tree (
  root_item,
  item,
  required_qty,
  depth,
  path_text
) AS (
  SELECT
    b.parent_item,
    b.child_item,
    b.quantity,
    1,
    CAST(
      b.parent_item || '/' || b.child_item
      AS VARCHAR2(4000)
    )
  FROM app.bom b
  WHERE b.parent_item = :root_item

  UNION ALL

  SELECT
    parent.root_item,
    b.child_item,
    parent.required_qty * b.quantity,
    parent.depth + 1,
    parent.path_text || '/' || b.child_item
  FROM app.bom b
  JOIN bom_tree parent
    ON b.parent_item = parent.item
  WHERE parent.depth < 50
)
CYCLE item SET is_cycle TO 'Y' DEFAULT 'N'
SELECT
  tree.root_item,
  SUM(tree.required_qty * cost.unit_cost) AS rolled_up_cost
FROM bom_tree tree
JOIN app.item_cost cost
  ON cost.item = tree.item
WHERE tree.is_cycle = 'N'
  AND NOT EXISTS (
    SELECT 1
    FROM app.bom child
    WHERE child.parent_item = tree.item
  )
GROUP BY tree.root_item;

中間組立品にも原価が登録されている場合、すべての階層を合計すると二重計上になる可能性があります。この例ではNOT EXISTSで子を持たない末端部品に限定しています。中間組立費も加算する設計なら、原価区分を用意して集計対象を明示してください。

同じ部品が複数経路に登場するBOMでは、それぞれの経路に必要数量があるため、単純なDISTINCTで重複排除してはいけません。経路ごとの数量を展開してから、部品単位またはルート単位で集計します。

承認経路から最初の承認者を取得する

承認経路テーブルで、現在の社員からnext_employee_idをたどり、最初に到達する承認者を取得します。再帰部の結合条件は、現在行が示す「次の社員」と次行の社員IDを結ぶ必要があります。

first-approver-in-route.sql
WITH approval_route (
  employee_id,
  role_code,
  next_employee_id,
  depth,
  path_text
) AS (
  SELECT
    f.employee_id,
    f.role_code,
    f.next_employee_id,
    1,
    CAST('/' || f.employee_id || '/' AS VARCHAR2(4000))
  FROM app.approval_flow f
  WHERE f.employee_id = :requester_id

  UNION ALL

  SELECT
    next_step.employee_id,
    next_step.role_code,
    next_step.next_employee_id,
    current_step.depth + 1,
    current_step.path_text || next_step.employee_id || '/'
  FROM approval_route current_step
  JOIN app.approval_flow next_step
    ON next_step.employee_id = current_step.next_employee_id
  WHERE current_step.role_code <> 'APPROVER'
    AND current_step.depth < 20
)
CYCLE employee_id SET is_cycle TO 'Y' DEFAULT 'N'
SELECT employee_id, role_code, depth, path_text
FROM (
  SELECT
    route.*,
    ROW_NUMBER() OVER (
      ORDER BY depth, employee_id
    ) AS row_no
  FROM approval_route route
  WHERE role_code = 'APPROVER'
    AND is_cycle = 'N'
)
WHERE row_no = 1;

ROWNUM = 1だけで取得すると、どの承認者が選ばれるか保証されません。外側でROW_NUMBERを使い、経路の深さと一意キーで取得順を固定します。分岐する承認経路で「最短経路」を求める場合にも、同じ考え方を使えます。

CONNECT BYと再帰WITH句を使い分ける

CONNECT BYが向くケース

親子関係が1表で完結し、LEVEL、CONNECT_BY_ROOT、SYS_CONNECT_BY_PATH、CONNECT_BY_ISLEAFを使って短く書きたい場合。

再帰WITH句が向くケース

複数表を結合する、数量や状態を次階層へ引き継ぐ、SEARCH/CYCLEで探索を制御する、再帰部へ複合的な停止条件を入れる場合。

connect-by-equivalent.sql
-- CONNECT BYで組織ツリーを展開する例
SELECT
  employee_id,
  manager_id,
  employee_name,
  LEVEL AS depth,
  CONNECT_BY_ROOT employee_id AS root_employee_id,
  SYS_CONNECT_BY_PATH(employee_name, ' > ') AS path_text
FROM app.employees
START WITH manager_id IS NULL
CONNECT BY NOCYCLE PRIOR employee_id = manager_id
ORDER SIBLINGS BY employee_name, employee_id;

NOCYCLEは循環時にも結果を返せるようにする機能であり、一般的な重複行や複数経路を自動的に排除する機能ではありません。循環した行を確認する場合はCONNECT_BY_ISCYCLEをSELECT句へ追加します。

単純な自己参照表ならCONNECT BYの方が読みやすいことがあります。機能の新しさだけで再帰WITH句へ置き換えず、保守するチームが意図を読み取れる方を選びます。

逆方向にたどって祖先を取得する

指定社員から上司、さらにその上司へ進む場合は、再帰部の結合方向を逆にします。アンカー部を対象社員にし、現在行のmanager_idと上司のemployee_idを結びます。

walk-to-ancestors.sql
WITH ancestors (
  employee_id,
  manager_id,
  employee_name,
  distance_from_start
) AS (
  SELECT
    employee_id,
    manager_id,
    employee_name,
    0
  FROM app.employees
  WHERE employee_id = :employee_id

  UNION ALL

  SELECT
    manager.employee_id,
    manager.manager_id,
    manager.employee_name,
    child.distance_from_start + 1
  FROM ancestors child
  JOIN app.employees manager
    ON manager.employee_id = child.manager_id
)
CYCLE employee_id SET is_cycle TO 'Y' DEFAULT 'N'
SELECT employee_id, employee_name, distance_from_start
FROM ancestors
WHERE is_cycle = 'N'
ORDER BY distance_from_start;

性能を安定させる設計ポイント

再帰問い合わせの負荷は、起点数、各ノードの子の数、階層の深さで大きく変わります。アンカー部で対象を絞り、再帰部の結合列に適切な索引を用意することが基本です。

  • トップダウン探索では子側のmanager_idparent_itemへ索引を作る
  • ボトムアップ探索では親を検索する主キー・一意キーを利用する
  • 不要な列や長いパス文字列を再帰中に持ち回らない
  • 深さ上限を設け、異常データによる行数増加を抑える
  • 再帰後の集計で二重計上が起きない粒度を確認する
  • 本番相当件数で実行計画、論理読取り、一時領域、返却行数を測定する
index-for-recursion.sql
-- 上司から部下へたどる結合を支える
CREATE INDEX app.idx_employees_manager
    ON app.employees (manager_id);

-- 親部品から子部品へたどる結合を支える
CREATE INDEX app.idx_bom_parent
    ON app.bom (parent_item);

索引があっても、起点が多く分岐数も大きい場合は大量の行が生成されます。性能問題を索引だけで解決しようとせず、起点条件、停止条件、必要な探索範囲を先に見直してください。SQLの実測方法はDBMS_XPLANで実行計画を確認する方法も参考になります。

テストで確認するデータパターン

正常な小さなツリーだけでは、循環や多重経路による不具合を発見できません。次のデータを意図的に用意して結果を確認します。

  • ルートが1件、複数件、0件のケース
  • 子を持たない孤立ノードと末端ノード
  • 同じ親の下に同名ノードがあるケース
  • A→B→Aの循環と、自分自身を参照する循環
  • 同じノードへ複数経路から到達するケース
  • 想定する最大深さと、それを1階層超えるケース
  • BOMで中間品と末端品の両方に原価があるケース

探索順に意味がある場合は、返却行だけでなく順序も期待値として固定します。集計結果は、小規模データに対する手計算や非再帰SQLの結果と突き合わせると安全です。

まとめ

Oracleの再帰WITH句は、アンカー部と再帰部をUNION ALLでつなぎ、親子関係を繰り返し展開する機能です。OracleではWITH RECURSIVEと書かないこと、列別名リストを指定すること、探索順にはSEARCH句とORDER BYを組み合わせることが基本になります。

循環にはCYCLE句と深さ上限を用意し、BOMでは数量の経路と原価の集計粒度を分けて考えます。単一表の定型処理はCONNECT BY、複数表や状態を引き継ぐ探索は再帰WITH句という基準で選ぶと、正確で保守しやすい階層SQLを設計できます。