SQL 再帰CTE — 組織図・カテゴリ階層・ツリー構造の基礎

基礎WITH RECURSIVE階層データ処理組織図 / ツリーPostgreSQL/MySQL8対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 1

組織階層の全展開 — WITH RECURSIVE で部署ツリーを深さ付きで取得する

WITH RECURSIVEアンカー/再帰部組織図depth・path
前提知識

WITH RECURSIVE(再帰CTE)は、自己参照する階層データを反復的に展開するSQL標準の構文です。組織図・カテゴリ階層・コメントツリー・フォルダ構造など「親子関係」を持つデータで必須となります。

再帰CTEはアンカー部(初期行の取得)と再帰部(前回の結果と元テーブルをJOINして次の行を生成)の2パートを UNION ALL でつなぎます。

WITH RECURSIVE cte AS (
  -- ① アンカー部: 起点となる行(根ノード)を取得
  SELECT id, name, parent_id, 0 AS depth
  FROM tree WHERE parent_id IS NULL

  UNION ALL

  -- ② 再帰部: CTEの直前結果(cte)と元テーブルをJOINして子ノードを追加
  SELECT t.id, t.name, t.parent_id, cte.depth + 1
  FROM tree t
  JOIN  cte ON t.parent_id = cte.id  -- 直前結果の id に子をつなぐ
)
SELECT * FROM cte;
反復の仕組み:アンカーの結果をワーキングテーブルとし、再帰部はそのワーキングテーブルと元テーブルをJOINして新たな行を生成します。新たな行がワーキングテーブルになり、また再帰部を実行… これを「追加行0件」になるまで繰り返します。
問題

departments テーブルは会社の部署ツリーを表します。全部署を深さ(depth)・ルートからのパス文字列(path)付きで取得してください。取得列は dept_id, dept_name, parent_id, depth, pathpath はルートから / 区切りで連結した部署名文字列、dept_id 昇順でソートしてください。

使用テーブル
▸ departments
dept_iddept_nameparent_id
1会社NULL
2技術部1
3営業部1
4バックエンド2
5フロントエンド2
6国内営業3
7海外営業3
入力データの階層関係を確認してから、再帰の起点と進行方向を決めてください。
期待出力
dept_iddept_nameparent_iddepthpath
1会社NULL0会社
2技術部11会社/技術部
3営業部11会社/営業部
4バックエンド22会社/技術部/バックエンド
5フロントエンド22会社/技術部/フロントエンド
6国内営業32会社/営業部/国内営業
7海外営業32会社/営業部/海外営業
模範解答コード
WITH RECURSIVE dept_tree AS (

  -- ① アンカー部: parent_id IS NULL → ルートノード(会社)だけを取得
  SELECT
    dept_id,
    dept_name,
    parent_id,
    0                AS depth,  -- ルートの深さ = 0
    dept_name        AS path    -- ルート自身がパスの起点
  FROM  departments
  WHERE parent_id IS NULL

  UNION ALL

  -- ② 再帰部: 直前の dept_tree の各行に子部署をJOINして1階層ずつ下へ
  SELECT
    d.dept_id,
    d.dept_name,
    d.parent_id,
    dt.depth + 1,              -- 親の depth に +1
    dt.path || '/' || d.dept_name  -- 親のpathに子の部署名を連結
  FROM  departments d
  JOIN  dept_tree dt ON d.parent_id = dt.dept_id  -- 子の parent_id = 親の dept_id

)
SELECT
  dept_id,
  dept_name,
  parent_id,
  depth,
  path
FROM  dept_tree
ORDER BY dept_id;

/*
  実行順序(SQLの論理的な評価順):
  1. アンカー部
  2. 再帰1回目
  3. 再帰2回目
  4. 再帰3回目
  5. 外側クエリ
  */
解説(テーブル変化・ポイント)
WITH RECURSIVE dept_tree AS ( SELECT dept_id, dept_name, parent_id, 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, dt.depth + 1, dt.path || '/' || d.dept_name FROM departments d JOIN dept_tree dt ON d.parent_id = dt.dept_id ) SELECT dept_id, dept_name, parent_id, depth, path FROM dept_tree ORDER BY dept_id;
LEGEND
データ取得・読込対象
除外・非表示データ
① アンカー部(初期化)
WHERE parent_id IS NULL → ルートノード取得アンカー部はWHERE parent_id IS NULLでルートノード(会社)だけを取得します。depth=0、path='会社'を初期値として設定します。この1行が最初のワーキングテーブルになります。
1 / 7
dept_iddept_nameparent_iddepthpath
1会社NULL0会社
アンカー: 1行(ワーキングテーブルの初期状態)
再帰CTEの反復プロセス(アンカー→再帰→終了)
ANCHOR アンカー部 — ルートノードを取得(depth=0)
ワーキングテーブル(初期)
dept_iddepthpath
1 会社0会社
次の再帰で使うワーキングテーブル
dept_iddepth
1 会社0
ITER 1 再帰1回目 — parent_id=1 の子を取得(depth=1)
累積結果
dept_iddepthpath
1 会社0会社
2 技術部1会社/技術部
3 営業部1会社/営業部
次の再帰で使うワーキングテーブル
dept_iddepth
2 技術部1
3 営業部1
ITER 2 再帰2回目 — parent_id IN(2,3) の子を取得(depth=2)
累積結果
dept_iddepthpath
1 会社0
2 技術部1
3 営業部1
4 バックエンド2会社/技術部/…
5 フロントエンド2会社/技術部/…
6 国内営業2会社/営業部/…
7 海外営業2会社/営業部/…
次の再帰(追加0件→終了)
理由
dept_id 4〜7の子がないため
追加行=0件→ループ終了
学習ポイント
UNION ALL vs UNION:再帰CTEでは必ず UNION ALL を使います。UNION(重複排除)にすると再帰毎の重複チェックにコストがかかるうえ、意図した階層が削除される可能性があります。同一 id が複数のパスで到達しないことが保証されている階層データでは UNION ALL が正解です。
path の連結はデバッグに有効:path 列はツリーを文字列で表現し、クエリ結果を見るだけで「この行はどのルートから来たか」が分かります。アプリケーションのパンくずリスト(breadcrumb)生成にもそのまま使えます。
MySQL 8.0 / SQLite でも同じ構文:WITH RECURSIVE は SQL:1999 標準で、PostgreSQL / MySQL 8.0+ / SQLite 3.35+ / SQL Server で共通です。Oracle は CONNECT BY という独自構文を持ちますが、Oracle 11gR2 以降は WITH RECURSIVE も使用可能です。
アンチパターン
終了条件がなく無限ループ:テーブルにデータ上の循環(A→B→A)があると再帰が終わらず、DBがメモリ/時間制限でエラーになります。PostgreSQL では RECURSIVE キーワードと組み合わせて CYCLE dept_id SET is_cycle USING path(PG14+)で循環検出できます。または depth < 10 のような上限ガードをWHEREに追加する方法も実務では多用されます。
再帰部でアンカーCTE名を2回参照:再帰部の FROM/JOIN 内で同じCTE名を2回以上参照することはできません(例:FROM dept_tree a JOIN dept_tree b)。再帰部で自己参照できるのは1回だけです。
実務コラム:隣接リストモデル vs 入れ子集合モデル
本問の parent_id 方式は隣接リストモデル(Adjacency List)と呼ばれ、最もシンプルで変更が容易です。WITH RECURSIVE の登場前は、階層を効率よくクエリするために lft/rgt カラムを使う入れ子集合モデル(Nested Set)が主流でしたが、更新コストが高い欠点がありました。現代では WITH RECURSIVE のおかげで隣接リストで十分なケースがほとんどです。ただし数百万行を超える超大規模階層では、ltree(PostgreSQL拡張型)などの専用型の検討も有効です。
QUESTION 2

特定ノードの祖先を全て取得 — ボトムアップ再帰で「カテゴリのパンくず」を生成する

WITH RECURSIVEボトムアップカテゴリ階層パンくず
前提知識

再帰CTEはルート→葉(トップダウン)だけでなく、葉→ルート(ボトムアップ)の逆方向にも展開できます。ECサイトの「現在地→上位カテゴリ一覧(パンくず)」取得がその典型です。

WITH RECURSIVE ancestors AS (
  -- ① アンカー部: 対象ノード(葉)から開始
  SELECT category_id, category_name, parent_id, 0 AS level
  FROM  categories
  WHERE category_id = :target_id  -- 起点の葉ノードを指定

  UNION ALL

  -- ② 再帰部: 現在の parent_id を辿って親を取得(上方向)
  SELECT c.category_id, c.category_name, c.parent_id, a.level + 1
  FROM  categories c
  JOIN  ancestors a ON c.category_id = a.parent_id  -- ← JOINの向きが逆
)
SELECT * FROM ancestors ORDER BY level DESC;
トップダウンとの違いはJOINの向き:トップダウンは d.parent_id = dt.dept_id(子の親=直前行のid)。ボトムアップは c.category_id = a.parent_id(元テーブルのid=直前行の親id)。JOINの左右を入れ替えるだけで方向が逆になります。
問題

ECサイトのカテゴリツリーがあります。category_id=6(メンズスニーカー)の祖先を全て取得し、category_id, category_name, parent_id, level(ルートを最上位)を、ルートが先になるよう level DESC でソートして返してください。

使用テーブル
▸ categories
category_idcategory_nameparent_id
1ALLNULL
2ファッション1
3電子機器1
4メンズ2
5レディース2
6メンズスニーカー4
7メンズジャケット4
入力データの階層関係を確認してから、再帰の起点と進行方向を決めてください。
期待出力
category_idcategory_nameparent_idlevel
1ALLNULL3
2ファッション12
4メンズ21
6メンズスニーカー40
模範解答コード
WITH RECURSIVE ancestors AS (

  -- ① アンカー部: 対象ノード(メンズスニーカー)から開始
  SELECT
    category_id,
    category_name,
    parent_id,
    0 AS level         -- 起点ノードのlevel = 0
  FROM  categories
  WHERE category_id = 6  -- メンズスニーカーを起点に指定

  UNION ALL

  -- ② 再帰部: 現ノードのparent_idをcategory_idとして持つ行(=親)を取得
  SELECT
    c.category_id,
    c.category_name,
    c.parent_id,
    a.level + 1          -- 上に行くほどlevelが増加
  FROM  categories c
  JOIN  ancestors a ON c.category_id = a.parent_id  -- ← ボトムアップの逆JOIN

)
SELECT
  category_id,
  category_name,
  parent_id,
  level
FROM  ancestors
ORDER BY level DESC;  -- levelが大きい(上位)順にソート → ルート優先

/*
  実行順序(SQLの論理的な評価順):
  1. アンカー部
  2. 再帰1回目
  3. 再帰2回目
  4. 再帰3回目
  5. 再帰4回目
  6. 外側クエリ
  */
解説(テーブル変化・ポイント)
WITH RECURSIVE ancestors AS ( SELECT category_id, category_name, parent_id, 0 AS level FROM categories WHERE category_id = 6 UNION ALL SELECT c.category_id, c.category_name, c.parent_id, a.level + 1 FROM categories c JOIN ancestors a ON c.category_id = a.parent_id ) SELECT category_id, category_name, parent_id, level FROM ancestors ORDER BY level DESC;
LEGEND
データ取得・読込対象
① アンカー部(起点: メンズスニーカー)
WHERE category_id=6 → 起点ノード取得category_id=6(メンズスニーカー)を起点として取得します。level=0 を初期値に設定します。このノードから parent_id を辿って上方向に展開します。
1 / 10
category_idcategory_nameparent_idlevel
6メンズスニーカー40
アンカー: 1行(起点ノード)
ボトムアップ反復プロセス(葉→ルート方向)
ANCHOR 起点: category_id=6(メンズスニーカー, level=0)
JOINの方向(ボトムアップ)
方向JOIN条件
トップダウンchild.parent_id = cte.id
ボトムアップ ★tbl.id = cte.parent_id
ボトムアップ反復フロー
取得ノードlevel
anchorメンズスニーカー(6)0
iter1メンズ(4)1
iter2ファッション(2)2
iter3ALL(1)3
iter4(追加0件→終了)
学習ポイント
JOINの向きを入れ替えるだけで方向が変わる:トップダウンは child.parent_id = cte.id、ボトムアップは parent.id = cte.parent_id。SQLの構造はほぼ同じで、JOIN の左右を入れ替えるだけです。「どちらを起点にするか」がアンカーのWHERE句で決まるのがポイントです。
level DESC がパンくずの自然な順:ボトムアップ再帰でlevelを増加させると「起点ノード=0, ルート=最大値」になります。ORDER BY level DESC でルートが先頭になり、パンくずリスト(ホーム > カテゴリ > ... > 現在地)として自然な表示順になります。
アプリ側でのパンくず組み立て:取得した行を level DESC でソートすればそのままパンくずの配列として使えます。SQLで STRING_AGG(category_name, ' > ' ORDER BY level DESC) と組み合わせれば1行の文字列として取得することも可能です。
アンチパターン
アンカーの WHERE を間違える:ボトムアップ再帰では、アンカーに「起点となる葉ノード(または中間ノード)」を指定します。誤ってルートを指定するとトップダウンになってしまいます。「何から遡るか」を明確にしてからアンカーを書くのがコツです。
ORDER BY を省略すると行順序は不定:再帰CTEの結果はワーキングテーブルに追加される順序で返りますが、DBMSの実装や実行計画によって並びが変わります。パンくずの表示順を保証するには必ず ORDER BY level DESC を付けるべきです。
実務コラム:階層データ取得の実務パターン3種
WITH RECURSIVE の主な使いどころは3つです。①全子孫の取得(トップダウン):カテゴリ以下の全製品IDを取得し IN句で絞り込む。②全祖先の取得(ボトムアップ):本問のパンくず生成。③最小共通祖先(LCA)の算出:2つのノードの祖先セットを求め INTERSECT し、最深のものを選ぶ。いずれも WITH RECURSIVE + JOIN方向の切り替えで実装できます。
QUESTION 3

コメントスレッドの深さ制限付き展開 — MAXRECURSION / depth ガードで安全に再帰する

WITH RECURSIVEdepth制限コメントツリー無限ループ対策
前提知識

SNSやフォーラムのコメントは「コメントへの返信→返信への返信」というネスト構造(スレッド)を持ちます。再帰CTEで全スレッドを展開できますが、データに不正な循環や異常な深さがあると無限ループになります。深さ(depth)を列に持たせ、WHERE で上限を設けるのが実務の定番ガードです。

WITH RECURSIVE thread AS (
  SELECT comment_id, content, parent_id, 0 AS depth
  FROM  comments WHERE parent_id IS NULL

  UNION ALL

  SELECT c.comment_id, c.content, c.parent_id, t.depth + 1
  FROM  comments c
  JOIN  thread t ON c.parent_id = t.comment_id
  WHERE t.depth + 1 < 3   -- ← depth が 3 未満の行にしか再帰しない
)
WHERE の位置に注意:深さ制限は再帰部のJOIN後 WHERE t.depth + 1 < N に書きます。外側クエリの WHERE に書いても再帰自体は止まらず、無限ループを防げません。
問題

comments テーブルには記事へのコメントとその返信が格納されています。全コメントをスレッド展開し、深さ3以上(depth >= 3)は取得しないようにしてください。取得列は comment_id, content, parent_id, depth, indentindentはdepth×2スペースのインデント文字列)。comment_id昇順でソートしてください。

使用テーブル
▸ comments
comment_idcontentparent_id
1面白い記事ですNULL
2同意しますNULL
3詳しく教えて1
4参考リンク貼ります1
5ありがとう!3
6私もそう思います3
7さらに深い返信5
入力データの階層関係を確認してから、再帰の起点と進行方向を決めてください。
期待出力
comment_idcontentparent_iddepthindent
1面白い記事ですNULL0
2同意しますNULL0
3詳しく教えて11
4参考リンク貼ります11
5ありがとう!32
6私もそう思います32
模範解答コード
WITH RECURSIVE thread AS (

  -- ① アンカー部: ルートコメント(返信でないもの)を取得
  SELECT
    comment_id,
    content,
    parent_id,
    0                AS depth,
    ''               AS indent  -- ルートのインデントは空文字
  FROM  comments
  WHERE parent_id IS NULL

  UNION ALL

  -- ② 再帰部: 子コメントを取得、ただし depth < 3 の行にのみ再帰
  SELECT
    c.comment_id,
    c.content,
    c.parent_id,
    t.depth + 1,
    REPEAT('  ', t.depth + 1)  -- depth+1 個分の2スペースを生成
  FROM  comments c
  JOIN  thread t ON c.parent_id = t.comment_id
  WHERE t.depth + 1 < 3          -- 深さ3以上への再帰を遮断(ここがガード)

)
SELECT
  comment_id,
  content,
  parent_id,
  depth,
  indent
FROM  thread
ORDER BY comment_id;

/*
  実行順序(SQLの論理的な評価順):
  1. アンカー部
  2. 再帰1回目
  3. 再帰2回目
  4. 再帰3回目
  5. 外側クエリ
  */
解説(テーブル変化・ポイント)
WITH RECURSIVE thread AS ( SELECT comment_id, content, parent_id, 0 AS depth, '' AS indent FROM comments WHERE parent_id IS NULL UNION ALL SELECT c.comment_id, c.content, c.parent_id, t.depth + 1, REPEAT(' ', t.depth + 1) FROM comments c JOIN thread t ON c.parent_id = t.comment_id WHERE t.depth + 1 < 3 ) SELECT comment_id, content, parent_id, depth, indent FROM thread ORDER BY comment_id;
LEGEND
データ取得・読込対象
除外・非表示データ
① アンカー部(depth=0のルートコメント)
WHERE parent_id IS NULL → comment_id=1,2取得親を持たないルートコメント(comment_id=1,2)を取得します。depth=0、indent=''(空文字)を初期値に設定します。
1 / 8
comment_idcontentparent_iddepthindent
1面白い記事ですNULL0''
2同意しますNULL0''
アンカー: 2行
depth ガードの仕組み
WHERE の位置が重要 再帰部の末尾に書くことで「これ以上深く行かない」と指示する
正しい位置(再帰部内)
コード位置効果
JOIN ... WHERE t.depth+1 < 3再帰の実行自体をブロック
vs
誤った位置(外側クエリ)
コード位置効果
外側 WHERE depth < 3再帰は止まらず、結果をフィルタするだけ
(無限ループの危険)
学習ポイント
depth ガードは再帰部のWHEREに書く:外側クエリの WHERE でフィルタしても「再帰が止まる」わけではありません。再帰部のJOIN後に WHERE t.depth + 1 < N を書くことで、その深さ以上への再帰展開自体をブロックできます。深さ制限は再帰部に書くが鉄則です。
REPEAT(str, n) でインデントを生成:REPEAT(' ', depth) は PostgreSQL / MySQL 共通の関数で、文字列を n 回繰り返します。SQLの結果セット上でツリー構造の視覚的な表示に使えます。アプリ側でインデントをレンダリングする場合は depth 列だけ渡して UI側で処理するのが一般的です。
PostgreSQL の CYCLE 句(PG14+):循環グラフ対策として CYCLE comment_id SET is_cycle USING path を末尾に追加すると、すでに訪問済みのノードを自動検出して無限ループを防げます。depth制限と組み合わせると二重のガードになります。
アンチパターン
深さ制限なしで本番環境に投入:開発データが浅くてもユーザーが意図的に深いスレッドを作ると、再帰が数千〜数万行まで展開されパフォーマンスが劣化します。再帰CTEには必ずdepth制限か行数制限(LIMIT)を設けるのがベストプラクティスです。
MySQL 8.0 の recursion_depth 設定:MySQL には cte_max_recursion_depth(デフォルト1000)がありますが、これはエラー保護であって意図的な制限ではありません。アプリケーションのビジネスロジックとして深さ制限を SQL に書くべきです。
実務コラム:スレッド展開の実務設計
本問のようなコメントスレッド展開では、実務上よく「最大N件だけ取得し、それ以上は「〇件の返信を見る」ボタンで遅延ロード」という設計が取られます。SQLレベルでは WITH RECURSIVE に depth 制限を掛けて取得し、各ノードの子件数を COUNT(*) OVER (PARTITION BY parent_id) で付与する組み合わせが有効です。また Reddit のようなシステムでは深い再帰を避けるため「マテリアライズドパス」方式(pathカラムに '1/2/3/' のように祖先IDを記録)を採用しており、LIKE '1/%' で子孫を一括取得できます。
QUESTION 4

フォルダのサイズ集計 — 再帰CTE + 集計で親フォルダに子のサイズを積み上げる

WITH RECURSIVE集計フォルダ構造サイズ積み上げ
前提知識

ファイルシステムのフォルダは階層構造を持ち、各フォルダのサイズは直下のファイルだけでなく、全子孫フォルダのファイルサイズの合計になります。これは再帰CTEで全パスを展開→GROUP BY で集計するパターンで実装できます。

考え方:各ファイルは「自分が属するフォルダ」だけでなく「その全祖先フォルダ」にも寄与するため、先にボトムアップ再帰で(ファイル, 祖先フォルダ)の対応表を作り、GROUP BYで合算します。

-- ステップ1: 各ファイルの (folder_id, ancestor_folder_id) を展開
WITH RECURSIVE folder_ancestors AS ( ... )
-- ステップ2: ancestor_folder_idごとにファイルサイズを合計
SELECT ancestor_folder_id, SUM(file_size) AS total_size
FROM  folder_ancestors
GROUP BY ancestor_folder_id;
問題

フォルダとファイルを管理する2つのテーブルがあります。各フォルダの合計サイズ(配下の全ファイルサイズ合計)を求めてください。取得列は folder_id, folder_name, total_size_kbfolder_id 昇順でソートしてください。

使用テーブル
▸ folders
folder_idfolder_nameparent_id
1RootNULL
2Documents1
3Images1
4Work2
5Personal2
▸ files
file_idfile_namefolder_idsize_kb
1report.pdf4500
2notes.txt410
3resume.docx5200
4photo1.jpg31500
5photo2.jpg32000
期待出力
folder_idfolder_nametotal_size_kb
1Root4210
2Documents710
3Images3500
4Work510
5Personal200
模範解答コード
WITH RECURSIVE folder_tree AS (

  -- ① アンカー部: 各フォルダ自身を「自分の祖先」として登録
  SELECT
    folder_id AS descendant_id,  -- 子孫側のフォルダID
    folder_id AS ancestor_id     -- 自分自身も「祖先」に含める
  FROM  folders

  UNION ALL

  -- ② 再帰部: 子フォルダ → 親フォルダへの (descendant, ancestor) ペアを生成
  SELECT
    ft.descendant_id,              -- 元々の子孫フォルダIDはそのまま
    f.parent_id AS ancestor_id    -- 親フォルダが新たな「祖先」になる
  FROM  folder_tree ft
  JOIN  folders f ON f.folder_id = ft.ancestor_id
  WHERE f.parent_id IS NOT NULL   -- ルートに到達したら停止

)
SELECT
  fo.folder_id,
  fo.folder_name,
  SUM(fi.size_kb) AS total_size_kb  -- 祖先フォルダに紐づく全ファイルサイズを合算
FROM  folder_tree ft
JOIN  files fi ON fi.folder_id = ft.descendant_id  -- ファイルは descendant 側に属する
JOIN  folders fo ON fo.folder_id = ft.ancestor_id  -- フォルダ名は ancestor 側から取得
GROUP BY fo.folder_id, fo.folder_name
ORDER BY fo.folder_id;

/*
  実行順序(SQLの論理的な評価順):
  1. アンカー部
  2. 再帰1回目
  3. 再帰2回目
  4. 再帰3回目: 全てNULL
  5. 外側クエリ
  */
解説(テーブル変化・ポイント)
WITH RECURSIVE folder_tree AS ( SELECT folder_id AS descendant_id, folder_id AS ancestor_id FROM folders UNION ALL SELECT ft.descendant_id, f.parent_id AS ancestor_id FROM folder_tree ft JOIN folders f ON f.folder_id = ft.ancestor_id WHERE f.parent_id IS NOT NULL ) SELECT fo.folder_id, fo.folder_name, SUM(fi.size_kb) AS total_size_kb FROM folder_tree ft JOIN files fi ON fi.folder_id = ft.descendant_id JOIN folders fo ON fo.folder_id = ft.ancestor_id GROUP BY fo.folder_id, fo.folder_name ORDER BY fo.folder_id;
LEGEND
データ取得・読込対象
① アンカー部(自己参照ペアの生成)
全フォルダを (descendant_id=self, ancestor_id=self) として登録各フォルダは自分自身を「自分の祖先」として登録します。(1,1)(2,2)(3,3)(4,4)(5,5)の5ペアが初期ワーキングテーブルになります。これにより「フォルダ自身のファイル」も最終集計に含まれます。
1 / 9
descendant_id(子孫)ancestor_id(祖先)
11
22
33
44
55
アンカー: 5ペア(自己参照)
(descendant, ancestor) ペアとファイル集計の関係
再帰の基本ルール 新しく生成された行だけが、次回のワーキングテーブル(入力)になる
生成される (descendant, ancestor) ペア
descendantancestor追加タイミング
4(Work)4アンカー
4(Work)2再帰1回目
4(Work)1再帰2回目
Workフォルダのファイルが寄与するフォルダ
ファイルsize_kb寄与先フォルダ
report.pdf500Work, Documents, Root
notes.txt10Work, Documents, Root
学習ポイント
閉包テーブル(Closure Table)パターン:今回のCTEが生成する (descendant_id, ancestor_id) のペア集合は「閉包テーブル」と呼ばれる設計パターンです。階層が頻繁に変わらない場合は、このペア表を実テーブルとしてマテリアライズ(永続化)しておくと、集計クエリが単純なJOIN+GROUP BYで完結して非常に高速になります。
アンカーに「自己参照」を含める理由:folder_id AS descendant_id, folder_id AS ancestor_id のように自分自身を祖先に含めることで、「自フォルダ直下のファイル」もSUM集計に参加できます。自己参照を入れないと葉フォルダのファイルが計上されません。
ファイルのないフォルダは結果に現れない:本クエリは files に INNER JOIN しているため、ファイルが1件もない空フォルダは結果に含まれません。空フォルダも含めたい場合は LEFT JOIN files + COALESCE(SUM(size_kb), 0) に変更します。
アンチパターン
自己参照を忘れてファイルが抜ける:アンカーで descendant_id ≠ ancestor_id になる書き方をすると、葉フォルダ(Work, Personal, Images)の直下ファイルが集計に参加しません。「自フォルダのファイルも祖先として集計する」ために自己参照のアンカーが必要です。
ルート到達後の無限ループ:WHERE f.parent_id IS NOT NULL を忘れると、ルートノード(parent_id=NULL)でも再帰が試みられ、NULLとのJOINで結果0件になるだけですが、DBMSによっては警告やタイムアウトの原因になります。ルート到達の停止条件は明示的に書くのが安全です。
実務コラム:再帰集計 vs アプリ側集計の使い分け
フォルダサイズ集計のような「階層を辿って集計する」処理は、アプリ側で再帰関数を書いて計算することもできます。SQLの再帰CTEの利点はラウンドトリップなしに1クエリで完結できる点ですが、階層が深くなると展開行数が多くなりメモリ使用量が増加します。数千ノード・10階層以下であればCTEが実用的です。それを超える場合はマテリアライズドパスや閉包テーブルを事前に永続化するアーキテクチャを検討してください。
QUESTION 5

組織の部下全員の集計 — 再帰CTE + 外部クエリで上司別の直属・全体人数を比較する

WITH RECURSIVECOUNT集計組織図管理スパン分析
前提知識

マネージャー(上司)の下に部下が連鎖する組織図では、「直属の部下数」と「全直接・間接の部下数(管理スパン)」を区別する必要があります。WITH RECURSIVE で全部下を展開してからCOUNT集計するパターンがこれに対応します。

WITH RECURSIVE subordinates AS (
  -- アンカー: 各社員を起点に設定(manager_idが一致する行を展開)
  SELECT emp_id, manager_id, 0 AS depth, emp_id AS root_manager_id
  FROM  employees
  UNION ALL
  SELECT e.emp_id, e.manager_id, s.depth + 1, s.root_manager_id
  FROM  employees e JOIN subordinates s ON e.manager_id = s.emp_id
)
SELECT root_manager_id, COUNT(*) - 1 AS total_subordinates  -- -1は自分自身を除く
FROM  subordinates
GROUP BY root_manager_id;
「root_manager_id」列を使った多起点展開:全社員をアンカーとし、各社員を「根」として部下を展開します。root_manager_id は「この行が誰の部下ツリーから来たか」を示すラベルで、後続の GROUP BY で活用します。
問題

employees テーブルには社員と上司の関係が格納されています。各マネージャーの直属部下数(direct_reports)と全部下数(total_subordinates、直接・間接含む)を求めてください。取得列は emp_id, emp_name, direct_reports, total_subordinates、直属部下が1人以上いる社員のみ、emp_id 昇順でソートしてください。

使用テーブル
▸ employees
emp_idemp_namemanager_id
1Alice(CEO)NULL
2Bob1
3Carol1
4Dave2
5Eve2
6Frank3
入力データの階層関係を確認してから、再帰の起点と進行方向を決めてください。
期待出力
emp_idemp_namedirect_reportstotal_subordinates
1Alice(CEO)25
2Bob22
3Carol11
模範解答コード
WITH RECURSIVE sub_tree AS (

  -- ① アンカー部: 全社員を「自分を根とするツリーの起点」として登録
  SELECT
    emp_id,
    manager_id,
    emp_id AS root_id,  -- この行がどの上司ツリーに属するかのラベル
    0      AS depth
  FROM  employees

  UNION ALL

  -- ② 再帰部: 直前のワーキングテーブルの emp_id を manager_id として持つ社員を追加
  SELECT
    e.emp_id,
    e.manager_id,
    s.root_id,           -- root_id は伝播させる(誰のツリーか変えない)
    s.depth + 1
  FROM  employees e
  JOIN  sub_tree s ON e.manager_id = s.emp_id

),
direct AS (                                -- ③ 直属部下数: manager_id で直接カウント
  SELECT
    manager_id,
    COUNT(*) AS direct_cnt
  FROM  employees
  WHERE manager_id IS NOT NULL
  GROUP BY manager_id
),
total AS (                                 -- ④ 全部下数: sub_tree から depth > 0(自分自身を除く)でカウント
  SELECT
    root_id,
    COUNT(*) AS total_cnt
  FROM  sub_tree
  WHERE depth > 0                          -- depth=0は自分自身なので除外
  GROUP BY root_id
)
SELECT
  e.emp_id,
  e.emp_name,
  d.direct_cnt  AS direct_reports,
  t.total_cnt   AS total_subordinates
FROM  employees e
JOIN  direct d ON d.manager_id = e.emp_id  -- 直属部下あり = 管理職のみ
JOIN  total  t ON t.root_id   = e.emp_id
ORDER BY e.emp_id;

/*
  実行順序(SQLの論理的な評価順):
  1. sub_tree CTE(再帰)
  2. direct CTE
  3. total CTE
  4. 外側クエリ
  */
解説(テーブル変化・ポイント)
WITH RECURSIVE sub_tree AS ( SELECT emp_id, manager_id, emp_id AS root_id, 0 AS depth FROM employees UNION ALL SELECT e.emp_id, e.manager_id, s.root_id, s.depth + 1 FROM employees e JOIN sub_tree s ON e.manager_id = s.emp_id ), direct AS ( SELECT manager_id, COUNT(*) AS direct_cnt FROM employees WHERE manager_id IS NOT NULL GROUP BY manager_id ), total AS ( SELECT root_id, COUNT(*) AS total_cnt FROM sub_tree WHERE depth > 0 GROUP BY root_id ) SELECT e.emp_id, e.emp_name, d.direct_cnt AS direct_reports, t.total_cnt AS total_subordinates FROM employees e JOIN direct d ON d.manager_id = e.emp_id JOIN total t ON t.root_id = e.emp_id ORDER BY e.emp_id;
LEGEND
データ取得・読込対象
除外・非表示データ
① アンカー部(全社員を起点として登録)
全社員を root_id=自分自身, depth=0 でワーキングテーブルに登録全6社員を「自分を根とするツリーの起点」として登録します。root_idは「この行が誰の部下ツリーに属するか」のラベルです。アンカーでは全員 root_id = 自分自身です。
1 / 10
emp_idemp_namemanager_idroot_iddepth
1AliceNULL10
2Bob120
3Carol130
4Dave240
5Eve250
6Frank360
アンカー: 6行(各社員が全ツリーの起点)
root_id伝播による多起点展開のイメージ
多起点の考え方 全社員をアンカーとし、root_idを伝播させて各ツリーを同時展開
sub_treeの主要行(depth>0 の部分)
emp_idroot_iddepth
Bob(2)1(Alice)1
Carol(3)1(Alice)1
Dave(4)1(Alice)2
Eve(5)1(Alice)2
Frank(6)1(Alice)2
Dave(4)2(Bob)1
Eve(5)2(Bob)1
Frank(6)3(Carol)1
root_idごとのCOUNT(depth>0)
root_idCOUNT全部下
1(Alice)5Bob,Carol,Dave,Eve,Frank
2(Bob)2Dave,Eve
3(Carol)1Frank
学習ポイント
root_id の伝播が「多起点展開」の鍵:アンカーで全社員を起点とし、再帰部では s.root_id をそのまま引き継ぐことで、「この行はどの上司ツリー由来か」が追跡できます。最後にroot_idでGROUP BYすれば全上司の「全部下数」が一発で求まります。
直属部下数は再帰不要:直属部下は単純な GROUP BY manager_id で得られます。「直属」は再帰不要、「全子孫」は再帰が必要という使い分けが重要です。両者を別CTEに分けて最後にJOINする構造が読みやすいです。
depth=0の自己参照を COUNT から除外:アンカーで「自分自身(depth=0)」も登録しているため、total CTEでは WHERE depth > 0 で自己参照を除外しないと、全部下数が1多くカウントされます。-1 するか WHERE depth>0 で除外するかどちらでも同じ結果になります。
アンチパターン
再帰展開が行数爆発する:本問では6人の組織でも sub_tree の展開行数はアンカー6行+再帰5行+3行=14行になります。1000人規模の組織では展開行数が数万行になることがあります。非常に大きな組織階層でこのパターンを使う場合はインデックスとメモリ設定の確認が必要です。
INNER JOIN で部下なし社員が消える:外側クエリで JOIN direct を INNER JOIN にしているため、部下を持たない社員(Dave,Eve,Frank)が自然に除外されます。これは意図通りですが、全社員の管理スパンを0含めて見たい場合は LEFT JOIN に変更し COALESCE(direct_cnt, 0) を使います。
実務コラム:管理スパン(Span of Control)分析の実務応用
本問で求めた「直属部下数」と「全部下数」の比較は管理スパン分析(Span of Control Analysis)と呼ばれ、組織設計の基本指標です。直属部下数が多すぎるマネージャーはマネジメント過負荷、少なすぎると組織が細分化されすぎている可能性があります。また total / direct の比が大きいマネージャーは「深い階層を一手に管理している」ことを意味し、ボトルネックになりやすいです。HRデータベースではこのようなクエリを定期的に実行してダッシュボードに表示し、組織の健全性を監視することがあります。