組織階層の全展開 — WITH RECURSIVE で部署ツリーを深さ付きで取得する
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;
departments テーブルは会社の部署ツリーを表します。全部署を深さ(depth)・ルートからのパス文字列(path)付きで取得してください。取得列は dept_id, dept_name, parent_id, depth, path、path はルートから / 区切りで連結した部署名文字列、dept_id 昇順でソートしてください。
| dept_id | dept_name | parent_id |
|---|---|---|
| 1 | 会社 | NULL |
| 2 | 技術部 | 1 |
| 3 | 営業部 | 1 |
| 4 | バックエンド | 2 |
| 5 | フロントエンド | 2 |
| 6 | 国内営業 | 3 |
| 7 | 海外営業 | 3 |
| dept_id | dept_name | parent_id | depth | path |
|---|---|---|---|---|
| 1 | 会社 | NULL | 0 | 会社 |
| 2 | 技術部 | 1 | 1 | 会社/技術部 |
| 3 | 営業部 | 1 | 1 | 会社/営業部 |
| 4 | バックエンド | 2 | 2 | 会社/技術部/バックエンド |
| 5 | フロントエンド | 2 | 2 | 会社/技術部/フロントエンド |
| 6 | 国内営業 | 3 | 2 | 会社/営業部/国内営業 |
| 7 | 海外営業 | 3 | 2 | 会社/営業部/海外営業 |
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. 外側クエリ */
LEGEND
① アンカー部(初期化)
WHERE parent_id IS NULL → ルートノード取得アンカー部はWHERE parent_id IS NULLでルートノード(会社)だけを取得します。depth=0、path='会社'を初期値として設定します。この1行が最初のワーキングテーブルになります。| dept_id | dept_name | parent_id | depth | path |
|---|---|---|---|---|
| 1 | 会社 | NULL | 0 | 会社 |
| dept_id | depth | path |
|---|---|---|
| 1 会社 | 0 | 会社 |
| dept_id | depth |
|---|---|
| 1 会社 | 0 |
| dept_id | depth | path |
|---|---|---|
| 1 会社 | 0 | 会社 |
| 2 技術部 | 1 | 会社/技術部 |
| 3 営業部 | 1 | 会社/営業部 |
| dept_id | depth |
|---|---|
| 2 技術部 | 1 |
| 3 営業部 | 1 |
| dept_id | depth | path |
|---|---|---|
| 1 会社 | 0 | … |
| 2 技術部 | 1 | … |
| 3 営業部 | 1 | … |
| 4 バックエンド | 2 | 会社/技術部/… |
| 5 フロントエンド | 2 | 会社/技術部/… |
| 6 国内営業 | 2 | 会社/営業部/… |
| 7 海外営業 | 2 | 会社/営業部/… |
| 理由 |
|---|
| dept_id 4〜7の子がないため 追加行=0件→ループ終了 |
UNION ALL を使います。UNION(重複排除)にすると再帰毎の重複チェックにコストがかかるうえ、意図した階層が削除される可能性があります。同一 id が複数のパスで到達しないことが保証されている階層データでは UNION ALL が正解です。path 列はツリーを文字列で表現し、クエリ結果を見るだけで「この行はどのルートから来たか」が分かります。アプリケーションのパンくずリスト(breadcrumb)生成にもそのまま使えます。WITH RECURSIVE は SQL:1999 標準で、PostgreSQL / MySQL 8.0+ / SQLite 3.35+ / SQL Server で共通です。Oracle は CONNECT BY という独自構文を持ちますが、Oracle 11gR2 以降は WITH RECURSIVE も使用可能です。RECURSIVE キーワードと組み合わせて CYCLE dept_id SET is_cycle USING path(PG14+)で循環検出できます。または depth < 10 のような上限ガードをWHEREに追加する方法も実務では多用されます。FROM dept_tree a JOIN dept_tree b)。再帰部で自己参照できるのは1回だけです。parent_id 方式は隣接リストモデル(Adjacency List)と呼ばれ、最もシンプルで変更が容易です。WITH RECURSIVE の登場前は、階層を効率よくクエリするために lft/rgt カラムを使う入れ子集合モデル(Nested Set)が主流でしたが、更新コストが高い欠点がありました。現代では WITH RECURSIVE のおかげで隣接リストで十分なケースがほとんどです。ただし数百万行を超える超大規模階層では、ltree(PostgreSQL拡張型)などの専用型の検討も有効です。特定ノードの祖先を全て取得 — ボトムアップ再帰で「カテゴリのパンくず」を生成する
再帰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;
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 でソートして返してください。
| category_id | category_name | parent_id |
|---|---|---|
| 1 | ALL | NULL |
| 2 | ファッション | 1 |
| 3 | 電子機器 | 1 |
| 4 | メンズ | 2 |
| 5 | レディース | 2 |
| 6 | メンズスニーカー | 4 |
| 7 | メンズジャケット | 4 |
| category_id | category_name | parent_id | level |
|---|---|---|---|
| 1 | ALL | NULL | 3 |
| 2 | ファッション | 1 | 2 |
| 4 | メンズ | 2 | 1 |
| 6 | メンズスニーカー | 4 | 0 |
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. 外側クエリ */
LEGEND
① アンカー部(起点: メンズスニーカー)
WHERE category_id=6 → 起点ノード取得category_id=6(メンズスニーカー)を起点として取得します。level=0 を初期値に設定します。このノードから parent_id を辿って上方向に展開します。| category_id | category_name | parent_id | level |
|---|---|---|---|
| 6 | メンズスニーカー | 4 | 0 |
| 方向 | JOIN条件 |
|---|---|
| トップダウン | child.parent_id = cte.id |
| ボトムアップ ★ | tbl.id = cte.parent_id |
| 回 | 取得ノード | level |
|---|---|---|
| anchor | メンズスニーカー(6) | 0 |
| iter1 | メンズ(4) | 1 |
| iter2 | ファッション(2) | 2 |
| iter3 | ALL(1) | 3 |
| iter4 | (追加0件→終了) | — |
child.parent_id = cte.id、ボトムアップは parent.id = cte.parent_id。SQLの構造はほぼ同じで、JOIN の左右を入れ替えるだけです。「どちらを起点にするか」がアンカーのWHERE句で決まるのがポイントです。ORDER BY level DESC でルートが先頭になり、パンくずリスト(ホーム > カテゴリ > ... > 現在地)として自然な表示順になります。level DESC でソートすればそのままパンくずの配列として使えます。SQLで STRING_AGG(category_name, ' > ' ORDER BY level DESC) と組み合わせれば1行の文字列として取得することも可能です。ORDER BY level DESC を付けるべきです。コメントスレッドの深さ制限付き展開 — MAXRECURSION / depth ガードで安全に再帰する
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 t.depth + 1 < N に書きます。外側クエリの WHERE に書いても再帰自体は止まらず、無限ループを防げません。comments テーブルには記事へのコメントとその返信が格納されています。全コメントをスレッド展開し、深さ3以上(depth >= 3)は取得しないようにしてください。取得列は comment_id, content, parent_id, depth, indent(indentはdepth×2スペースのインデント文字列)。comment_id昇順でソートしてください。
| comment_id | content | parent_id |
|---|---|---|
| 1 | 面白い記事です | NULL |
| 2 | 同意します | NULL |
| 3 | 詳しく教えて | 1 |
| 4 | 参考リンク貼ります | 1 |
| 5 | ありがとう! | 3 |
| 6 | 私もそう思います | 3 |
| 7 | さらに深い返信 | 5 |
| comment_id | content | parent_id | depth | indent |
|---|---|---|---|---|
| 1 | 面白い記事です | NULL | 0 | |
| 2 | 同意します | NULL | 0 | |
| 3 | 詳しく教えて | 1 | 1 | |
| 4 | 参考リンク貼ります | 1 | 1 | |
| 5 | ありがとう! | 3 | 2 | |
| 6 | 私もそう思います | 3 | 2 |
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. 外側クエリ */
LEGEND
① アンカー部(depth=0のルートコメント)
WHERE parent_id IS NULL → comment_id=1,2取得親を持たないルートコメント(comment_id=1,2)を取得します。depth=0、indent=''(空文字)を初期値に設定します。| comment_id | content | parent_id | depth | indent |
|---|---|---|---|---|
| 1 | 面白い記事です | NULL | 0 | '' |
| 2 | 同意します | NULL | 0 | '' |
| コード位置 | 効果 |
|---|---|
| JOIN ... WHERE t.depth+1 < 3 | 再帰の実行自体をブロック |
| コード位置 | 効果 |
|---|---|
| 外側 WHERE depth < 3 | 再帰は止まらず、結果をフィルタするだけ (無限ループの危険) |
WHERE t.depth + 1 < N を書くことで、その深さ以上への再帰展開自体をブロックできます。深さ制限は再帰部に書くが鉄則です。REPEAT(' ', depth) は PostgreSQL / MySQL 共通の関数で、文字列を n 回繰り返します。SQLの結果セット上でツリー構造の視覚的な表示に使えます。アプリ側でインデントをレンダリングする場合は depth 列だけ渡して UI側で処理するのが一般的です。CYCLE comment_id SET is_cycle USING path を末尾に追加すると、すでに訪問済みのノードを自動検出して無限ループを防げます。depth制限と組み合わせると二重のガードになります。cte_max_recursion_depth(デフォルト1000)がありますが、これはエラー保護であって意図的な制限ではありません。アプリケーションのビジネスロジックとして深さ制限を SQL に書くべきです。COUNT(*) OVER (PARTITION BY parent_id) で付与する組み合わせが有効です。また Reddit のようなシステムでは深い再帰を避けるため「マテリアライズドパス」方式(pathカラムに '1/2/3/' のように祖先IDを記録)を採用しており、LIKE '1/%' で子孫を一括取得できます。フォルダのサイズ集計 — 再帰CTE + 集計で親フォルダに子のサイズを積み上げる
ファイルシステムのフォルダは階層構造を持ち、各フォルダのサイズは直下のファイルだけでなく、全子孫フォルダのファイルサイズの合計になります。これは再帰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_kb、folder_id 昇順でソートしてください。
| folder_id | folder_name | parent_id |
|---|---|---|
| 1 | Root | NULL |
| 2 | Documents | 1 |
| 3 | Images | 1 |
| 4 | Work | 2 |
| 5 | Personal | 2 |
| file_id | file_name | folder_id | size_kb |
|---|---|---|---|
| 1 | report.pdf | 4 | 500 |
| 2 | notes.txt | 4 | 10 |
| 3 | resume.docx | 5 | 200 |
| 4 | photo1.jpg | 3 | 1500 |
| 5 | photo2.jpg | 3 | 2000 |
| folder_id | folder_name | total_size_kb |
|---|---|---|
| 1 | Root | 4210 |
| 2 | Documents | 710 |
| 3 | Images | 3500 |
| 4 | Work | 510 |
| 5 | Personal | 200 |
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. 外側クエリ */
LEGEND
① アンカー部(自己参照ペアの生成)
全フォルダを (descendant_id=self, ancestor_id=self) として登録各フォルダは自分自身を「自分の祖先」として登録します。(1,1)(2,2)(3,3)(4,4)(5,5)の5ペアが初期ワーキングテーブルになります。これにより「フォルダ自身のファイル」も最終集計に含まれます。| descendant_id(子孫) | ancestor_id(祖先) |
|---|---|
| 1 | 1 |
| 2 | 2 |
| 3 | 3 |
| 4 | 4 |
| 5 | 5 |
| descendant | ancestor | 追加タイミング |
|---|---|---|
| 4(Work) | 4 | アンカー |
| 4(Work) | 2 | 再帰1回目 |
| 4(Work) | 1 | 再帰2回目 |
| ファイル | size_kb | 寄与先フォルダ |
|---|---|---|
| report.pdf | 500 | Work, Documents, Root |
| notes.txt | 10 | Work, Documents, Root |
folder_id AS descendant_id, folder_id AS ancestor_id のように自分自身を祖先に含めることで、「自フォルダ直下のファイル」もSUM集計に参加できます。自己参照を入れないと葉フォルダのファイルが計上されません。LEFT JOIN files + COALESCE(SUM(size_kb), 0) に変更します。descendant_id ≠ ancestor_id になる書き方をすると、葉フォルダ(Work, Personal, Images)の直下ファイルが集計に参加しません。「自フォルダのファイルも祖先として集計する」ために自己参照のアンカーが必要です。組織の部下全員の集計 — 再帰CTE + 外部クエリで上司別の直属・全体人数を比較する
マネージャー(上司)の下に部下が連鎖する組織図では、「直属の部下数」と「全直接・間接の部下数(管理スパン)」を区別する必要があります。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;
employees テーブルには社員と上司の関係が格納されています。各マネージャーの直属部下数(direct_reports)と全部下数(total_subordinates、直接・間接含む)を求めてください。取得列は emp_id, emp_name, direct_reports, total_subordinates、直属部下が1人以上いる社員のみ、emp_id 昇順でソートしてください。
| emp_id | emp_name | manager_id |
|---|---|---|
| 1 | Alice(CEO) | NULL |
| 2 | Bob | 1 |
| 3 | Carol | 1 |
| 4 | Dave | 2 |
| 5 | Eve | 2 |
| 6 | Frank | 3 |
| emp_id | emp_name | direct_reports | total_subordinates |
|---|---|---|---|
| 1 | Alice(CEO) | 2 | 5 |
| 2 | Bob | 2 | 2 |
| 3 | Carol | 1 | 1 |
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. 外側クエリ */
LEGEND
① アンカー部(全社員を起点として登録)
全社員を root_id=自分自身, depth=0 でワーキングテーブルに登録全6社員を「自分を根とするツリーの起点」として登録します。root_idは「この行が誰の部下ツリーに属するか」のラベルです。アンカーでは全員 root_id = 自分自身です。| emp_id | emp_name | manager_id | root_id | depth |
|---|---|---|---|---|
| 1 | Alice | NULL | 1 | 0 |
| 2 | Bob | 1 | 2 | 0 |
| 3 | Carol | 1 | 3 | 0 |
| 4 | Dave | 2 | 4 | 0 |
| 5 | Eve | 2 | 5 | 0 |
| 6 | Frank | 3 | 6 | 0 |
| emp_id | root_id | depth |
|---|---|---|
| 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 | 全部下 |
|---|---|---|
| 1(Alice) | 5 | Bob,Carol,Dave,Eve,Frank |
| 2(Bob) | 2 | Dave,Eve |
| 3(Carol) | 1 | Frank |
s.root_id をそのまま引き継ぐことで、「この行はどの上司ツリー由来か」が追跡できます。最後にroot_idでGROUP BYすれば全上司の「全部下数」が一発で求まります。GROUP BY manager_id で得られます。「直属」は再帰不要、「全子孫」は再帰が必要という使い分けが重要です。両者を別CTEに分けて最後にJOINする構造が読みやすいです。WHERE depth > 0 で自己参照を除外しないと、全部下数が1多くカウントされます。-1 するか WHERE depth>0 で除外するかどちらでも同じ結果になります。JOIN direct を INNER JOIN にしているため、部下を持たない社員(Dave,Eve,Frank)が自然に除外されます。これは意図通りですが、全社員の管理スパンを0含めて見たい場合は LEFT JOIN に変更し COALESCE(direct_cnt, 0) を使います。total / direct の比が大きいマネージャーは「深い階層を一手に管理している」ことを意味し、ボトルネックになりやすいです。HRデータベースではこのようなクエリを定期的に実行してダッシュボードに表示し、組織の健全性を監視することがあります。