【SQL】再帰クエリーで組織図を表現

こんにちは、knsk765 です。

今回は階層構造をデータベースで表現する方法について説明します。身近な例としてこんな組織図で考えましょう。

階層構造を扱うときに困るのが何階層まであるか制限がないときですよね。2階層までのものもあれば5階層までいくものもある、みたいな。どこが終わりかわからないデータを SQL でどうやってとればいいの?ってなります。
そこで使うのが CTE(共通テーブル式)での再帰クエリーです。

CTE と再帰クエリーについてはこちらの記事で書いているのであわせて見てみていただければ。

blog.ingage.jp

目次

準備

実行環境

PostgreSQL 14.5

テーブル

まずは部署テーブルを作成。使うテーブルはこれ1つです。

CREATE TABLE public.departments (
  id int4 NOT NULL,
  "name" varchar NOT NULL,
  parent_id int4 NULL,
  order_no int2 NOT NULL,
  CONSTRAINT departments_pk PRIMARY KEY (id),
  CONSTRAINT departments_fk FOREIGN KEY (parent_id)
    REFERENCES public.departments(id) ON DELETE CASCADE ON UPDATE CASCADE
);

親の部署ID parent_id を持たせて階層構造の関連を実現します。

一番ルートになる部署のデータはこう。parent_id は NULL です。

id name parent_id order_no
1 管理本部 1
2 営業本部 2
3 開発本部 3

管理本部(id: 1)に属する部署はこうなりますね。

id name parent_id order_no
101 総務人事部 1 1
102 財務部 1 2
103 情報システム室 1 3

というわけで以下の SQL を実行してサンプルデータを登録しましょう。

INSERT INTO departments (id, name, parent_id, order_no) VALUES
(1, '管理本部', null, 1),
(101, '総務人事部', 1, 1),
(10101, '総務課', 101, 1),
(10102, '人事課', 101, 2),
(102, '財務部', 1,  2),
(10201, '経理課', 102, 1),
(103, '情報システム室', 1,  3),
(2, '営業本部', null, 2),
(201, '営業企画部', 2, 1),
(20101, '営業企画課', 201, 1),
(202, '営業部', 2, 2),
(20201, '大阪営業所', 202, 1),
(20202, '東京営業所', 202, 2),
(20203, '名古屋営業所', 202, 3),
(3, '開発本部', null, 3),
(301, '商品開発部', 3, 1),
(30101, '1課', 301, 1),
(30102, '2課', 301, 2),
(3010201, '第一小隊', 30102, 1),
(3010202, '第二小隊', 30102, 2),
(302, '空手部', 3, 2);

ゴール

1回の SELECT SQL で全階層の部署を構造化して取得するのがこの記事のゴールです。

基本編

では、少しずつ SQL を組み立てていってゴールを目指します。

Step 1. 最上位部署の取得

一番上の部署を取得するのは簡単ですね。親部署ID が NULL の部署が対象です。

SELECT id, name, parent_id, order_no
FROM departments WHERE parent_id IS NULL;

結果

id name parent_id order_no
1 管理本部 1
2 営業本部 2
3 開発本部 3

Step 2. 最上位部署を親に持つ部署の取得

次の階層の部署を取得するとなるとこうですね。方法はいろいろありますが、次の説明のために INNER JOIN で実現しています。

SELECT
  c.id, c.name, c.parent_id, c.order_no
FROM
  departments c
  INNER JOIN (
    -- ルート部署
    SELECT * FROM departments WHERE parent_id IS NULL
  ) p ON (p.id = c.parent_id);

結果

id name parent_id order_no
101 総務人事部 1 1
102 財務部 1 2
103 情報システム室 1 3
201 営業企画部 2 1
202 営業部 2 2
301 商品開発部 3 1
302 空手部 3 2

このまままだと以下の問題でつまずきますね。

  • 階層が増えるたびに INNER JOIN を増やすのか?
  • 何階層まであるかわからないときはどうする?

Step 3. 再帰クエリーで全階層の部署を取得

というわけで CTE(共通テーブル式)による再帰クエリーの出番です。

WITH RECURSIVE cte AS (
  -- 最上位階層 ... Step 1. の SQL
  SELECT
    id, name, parent_id, order_no
  FROM departments WHERE parent_id IS NULL
  UNION ALL
  -- 子階層 ... Step 2. の SQL を改良
  SELECT
    c.id, c.name, c.parent_id, c.order_no
  FROM departments c INNER JOIN cte p ON (p.id = c.parent_id)
)
SELECT id, name, parent_id, order_no FROM cte;

最上位階層と次の階層を UNION した cte をもとに次の階層、その次の階層、と再帰的に結果を UNION していくと...

結果

id name parent_id order_no
1 管理本部 1
2 営業本部 2
3 開発本部 3
103 情報システム室 1 3
101 総務人事部 1 1
102 財務部 1 2
202 営業部 2 2
201 営業企画部 2 1
302 空手部 3 2
301 商品開発部 3 1
10101 総務課 101 1
10102 人事課 101 2
10201 経理課 102 1
20101 営業企画課 201 1
20203 名古屋営業所 202 3
20201 大阪営業所 202 1
20202 東京営業所 202 2
30102 2課 301 2
30101 1課 301 1
3010201 第一小隊 30102 1
3010202 第二小隊 30102 2

一番末端の階層までいっきに取得できました!

Step 4. 親階層の名前を連結する

全階層のデータが取得できるようになりましたが、今のままだと親子関係がわからないですよね。次は親部署の名前も連結して表示しましょう。ついでに階層の深さも追加します。

WITH RECURSIVE cte AS (
  -- 最上位階層
  SELECT
    id, name, parent_id, order_no,
    name AS fullname, -- この列を追加(最上位は自分の名前のみ)
    1 AS depth -- 階層の深さ
  FROM departments WHERE parent_id IS NULL
  UNION ALL
  -- 子階層
  SELECT
    c.id, c.name, c.parent_id, c.order_no,
    p.fullname || ' > ' || c.name, -- 親の名前と連結
    p.depth + 1 -- 親階層 + 1
  FROM departments c INNER JOIN cte p ON (p.id = c.parent_id)
)
SELECT id, fullname, parent_id, order_no, depth FROM cte;

結果

id fullname parent_id order_no depth
1 管理本部 1 1
2 営業本部 2 1
3 開発本部 3 1
101 管理本部 > 総務人事部 1 1 2
102 管理本部 > 財務部 1 2 2
103 管理本部 > 情報システム室 1 3 2
202 営業本部 > 営業部 2 2 2
201 営業本部 > 営業企画部 2 1 2
302 開発本部 > 空手部 3 2 2
301 開発本部 > 商品開発部 3 1 2
10102 管理本部 > 総務人事部 > 人事課 101 2 3
10101 管理本部 > 総務人事部 > 総務課 101 1 3
10201 管理本部 > 財務部 > 経理課 102 1 3
20101 営業本部 > 営業企画部 > 営業企画課 201 1 3
20201 営業本部 > 営業部 > 大阪営業所 202 1 3
20202 営業本部 > 営業部 > 東京営業所 202 2 3
20203 営業本部 > 営業部 > 名古屋営業所 202 3 3
30102 開発本部 > 商品開発部 > 2課 301 2 3
30101 開発本部 > 商品開発部 > 1課 301 1 3
3010201 開発本部 > 商品開発部 > 2課 > 第一小隊 30102 1 4
3010202 開発本部 > 商品開発部 > 2課 > 第二小隊 30102 2 4

Step 5. 順番に並べる

だいぶいい感じになってきましたが、ORDER BY をしていないのでちゃんと順番に並んでいないですね。同じ階層でかたまってしまっているのですが、最初にあげた組織図のようにツリー状に並べたいです。 order_no という列に順番を持たせていますが、同じ親をもつ部署間で 1から振っています。これをそのまま使っても思ったように並びませんよね...

WITH RECURSIVE cte AS (
  -- 最上位階層
  SELECT
    id, name, parent_id, order_no,
    name AS fullname,
    -- ソート順 00 書式文字列化
    lpad(cast(order_no as varchar), 2, '0') AS full_order_no
  FROM departments WHERE parent_id IS NULL
  UNION ALL
  -- 子階層
  SELECT
    c.id, c.name, c.parent_id, c.order_no,
    p.fullname || ' > ' || c.name,
    -- 親のソート順と連結
    p.full_order_no || '/' || lpad(cast(c.order_no as varchar), 2, '0')
  FROM departments c INNER JOIN cte p ON (p.id = c.parent_id)
)
SELECT id, fullname, parent_id, order_no, full_order_no
FROM cte
ORDER BY full_order_no;

結果

id fullname parent_id order_no full_order_no
1 管理本部 1 01
101 管理本部 > 総務人事部 1 1 01/01
10101 管理本部 > 総務人事部 > 総務課 101 1 01/01/01
10102 管理本部 > 総務人事部 > 人事課 101 2 01/01/02
102 管理本部 > 財務部 1 2 01/02
10201 管理本部 > 財務部 > 経理課 102 1 01/02/01
103 管理本部 > 情報システム室 1 3 01/03
2 営業本部 2 02
201 営業本部 > 営業企画部 2 1 02/01
20101 営業本部 > 営業企画部 > 営業企画課 201 1 02/01/01
202 営業本部 > 営業部 2 2 02/02
20201 営業本部 > 営業部 > 大阪営業所 202 1 02/02/01
20202 営業本部 > 営業部 > 東京営業所 202 2 02/02/02
20203 営業本部 > 営業部 > 名古屋営業所 202 3 02/02/03
3 開発本部 3 03
301 開発本部 > 商品開発部 3 1 03/01
30101 開発本部 > 商品開発部 > 1課 301 1 03/01/01
30102 開発本部 > 商品開発部 > 2課 301 2 03/01/02
3010201 開発本部 > 商品開発部 > 2課 > 第一小隊 30102 1 03/01/02/01
3010202 開発本部 > 商品開発部 > 2課 > 第二小隊 30102 2 03/01/02/02
302 開発本部 > 空手部 3 2 03/02

というわけで親の階層からすべて連結した並び順文字列 full_order_no をつくってソートしました。

  • 階層を/ で区切る
  • それぞれの階層の並び順は 2桁 0埋めフォーマットする
  • 結果、数値ではなく文字列となる

サンプルなので今回は 2桁固定にしましたが、ここは部署数の要件によります。 1/12/5 のようにそのままの桁数で連結しなかった理由は文字列のソートだからです。

たとえば

順番文字列
1
2
9
11
12
20
100

こんなデータがあったときに数値だと昇順ソートしても上の表と同じ並び方ですよね。でも数字の文字列をソートすると

順番文字列
1
11
12
100
2
20
9

こうなってしまいます。これを回避するために 0埋めフォーマットしたわけです。

めでたく全階層データの表示はできたのでゴールは達成しましたが、もうちょっと試してみたいことがあります。

応用編

Step 6. 抽出もしてみたい

せっかくなのでデータの抽出もしたいですよね。営業部以下の部署を抽出したいのですが WHERE 句にどんな条件を指定すればいい? 親・子ぐらいまでは指定できそうな気もしますが、孫、それよりも下となると...

今回は PostgreSQL なので配列型データを使ってみましょう(データ型としての配列や JSON、XML などはデータベースによって実装が異なります)。

WITH RECURSIVE cte AS (
  -- 最上位階層
  SELECT
    id, name, parent_id, order_no,
    name AS fullname,
    lpad(cast(order_no as varchar), 2, '0') AS full_order_no,
    -- 配列に id を入れる
    ARRAY[id] AS ids
  FROM departments WHERE parent_id IS NULL
  UNION ALL
  -- 子階層
  SELECT
    c.id, c.name, c.parent_id, c.order_no,
    p.fullname || ' > ' || c.name,
    p.full_order_no || '/' || lpad(cast(c.order_no as varchar), 2, '0'),
    -- id 配列に追加
    p.ids || ARRAY[c.id]
  FROM departments c INNER JOIN cte p ON (p.id = c.parent_id)
)
SELECT id, fullname, parent_id, order_no, full_order_no, ids
FROM cte
WHERE ids @> ARRAY[202] -- id: 202 の部署以下を抽出
ORDER BY full_order_no;

結果

id fullname parent_id order_no full_order_no ids(配列)
202 営業本部 > 営業部 2 2 02/02 {2,202}
20201 営業本部 > 営業部 > 大阪営業所 202 1 02/02/01 {2,202,20201}
20202 営業本部 > 営業部 > 東京営業所 202 2 02/02/02 {2,202,20202}
20203 営業本部 > 営業部 > 名古屋営業所 202 3 02/02/03 {2,202,20203}

ARRAY を使うと配列が作れます。まずは最上位階層の id を配列に追加。

ARRAY[id] AS ids

そして再帰的に子の id を追加していきます。|| 演算子で配列の連結ができます。

p.ids || ARRAY[c.id]

そうするとこんな感じで親のidから順番に追加された配列ができあがります。

{2,202,20201}

最後に WHERE 句で指定。

WHERE ids @> ARRAY[202] -- id: 202 の部署以下を抽出

@> が「包含する」なので 202 の id が含まれる配列データを対象に抽出できたということになります。便利ですね。

PostgreSQL の配列についてはこちらを
9.17. 配列関数と演算子

Step 7. ツリー表示もしてみたい

システムを作るときには表示はフロントエンドでいい感じにすると思います。
でもデータベースを直接見て分析するときとかに、ツリー表示されているとわかりやすいですよね。

こんな感じに

部署
└管理本部
 └総務人事部
  └総務課
  └人事課
 └財務部
  └経理課

これは簡単に実現できます。

WITH RECURSIVE cte AS (
  -- 最上位階層
  SELECT
    id, name, parent_id, order_no,
    lpad(cast(order_no as varchar), 2, '0') AS full_order_no,
    1 depth
  FROM departments WHERE parent_id IS NULL
  UNION ALL
  -- 子階層
  SELECT
    c.id, c.name, c.parent_id, c.order_no,
    p.full_order_no || '/' || lpad(cast(c.order_no as varchar), 2, '0'),
    p.depth + 1
  FROM
    departments c
    INNER JOIN cte p ON (p.id = c.parent_id)
)
SELECT
  id,
  repeat(' ', depth - 1) || '' || name tree_name, -- ここ
  parent_id, depth, full_order_no
FROM cte ORDER BY full_order_no;

結果

id tree_name parent_id depth full_order_no
1 └管理本部 1 01
101  └総務人事部 1 2 01/01
10101   └総務課 101 3 01/01/01
10102   └人事課 101 3 01/01/02
102  └財務部 1 2 01/02
10201   └経理課 102 3 01/02/01
103  └情報システム室 1 2 01/03
2 └営業本部 1 02
201  └営業企画部 2 2 02/01
20101   └営業企画課 201 3 02/01/01
202  └営業部 2 2 02/02
20201   └大阪営業所 202 3 02/02/01
20202   └東京営業所 202 3 02/02/02
20203   └名古屋営業所 202 3 02/02/03
3 └開発本部 1 03
301  └商品開発部 3 2 03/01
30101   └1課 301 3 03/01/01
30102   └2課 301 3 03/01/02
3010201    └第一小隊 30102 4 03/01/02/01
3010202    └第二小隊 30102 4 03/01/02/02
302  └空手部 3 2 03/02

Step 4. のときに階層の深さ(depth)を取得していました。その数値に応じて前に空白を足すだけですね。

repeat(' ', depth - 1) || '└' || name tree_name,

Step 8. まとめ

ここまでのをまとめるとこんな感じです。たった 4列の departments テーブルひとつでこんな結果が作れるんだから SQL って面白いですよね。

WITH RECURSIVE cte AS (
  -- 最上位階層
  SELECT
    id, name, parent_id, order_no, name AS fullname,
    1 depth,
    lpad(cast(order_no as varchar), 2, '0') full_order_no,
    ARRAY[id] ids
  FROM departments WHERE parent_id IS NULL
  UNION ALL
  -- 子階層
  SELECT
    c.id, c.name, c.parent_id, c.order_no, p.fullname || ' > ' || c.name,
    p.depth + 1,
    p.full_order_no || '/' || lpad(cast(c.order_no as varchar), 2, '0'),
    p.ids || ARRAY[c.id]
  FROM
    departments c
    INNER JOIN cte p ON (p.id = c.parent_id)
)
SELECT
  id, name, repeat(' ', depth - 1) || '' || name tree_name, fullname,
  parent_id, order_no, depth, full_order_no, ids
FROM cte
--WHERE ids @> ARRAY[202]
ORDER BY full_order_no;

結果

id name tree_name fullname parent_id order_no depth full_order_no ids
1 管理本部 └管理本部 管理本部 1 1 01 {1}
101 総務人事部  └総務人事部 管理本部 > 総務人事部 1 1 2 01/01 {1,101}
10101 総務課   └総務課 管理本部 > 総務人事部 > 総務課 101 1 3 01/01/01 {1,101,10101}
10102 人事課   └人事課 管理本部 > 総務人事部 > 人事課 101 2 3 01/01/02 {1,101,10102}
102 財務部  └財務部 管理本部 > 財務部 1 2 2 01/02 {1,102}
10201 経理課   └経理課 管理本部 > 財務部 > 経理課 102 1 3 01/02/01 {1,102,10201}
103 情報システム室  └情報システム室 管理本部 > 情報システム室 1 3 2 01/03 {1,103}
2 営業本部 └営業本部 営業本部 2 1 02 {2}
201 営業企画部  └営業企画部 営業本部 > 営業企画部 2 1 2 02/01 {2,201}
20101 営業企画課   └営業企画課 営業本部 > 営業企画部 > 営業企画課 201 1 3 02/01/01 {2,201,20101}
202 営業部  └営業部 営業本部 > 営業部 2 2 2 02/02 {2,202}
20201 大阪営業所   └大阪営業所 営業本部 > 営業部 > 大阪営業所 202 1 3 02/02/01 {2,202,20201}
20202 東京営業所   └東京営業所 営業本部 > 営業部 > 東京営業所 202 2 3 02/02/02 {2,202,20202}
20203 名古屋営業所   └名古屋営業所 営業本部 > 営業部 > 名古屋営業所 202 3 3 02/02/03 {2,202,20203}
3 開発本部 └開発本部 開発本部 3 1 03 {3}
301 商品開発部  └商品開発部 開発本部 > 商品開発部 3 1 2 03/01 {3,301}
30101 1課   └1課 開発本部 > 商品開発部 > 1課 301 1 3 03/01/01 {3,301,30101}
30102 2課   └2課 開発本部 > 商品開発部 > 2課 301 2 3 03/01/02 {3,301,30102}
3010201 第一小隊    └第一小隊 開発本部 > 商品開発部 > 2課 > 第一小隊 30102 1 4 03/01/02/01 {3,301,30102,3010201}
3010202 第二小隊    └第二小隊 開発本部 > 商品開発部 > 2課 > 第二小隊 30102 2 4 03/01/02/02 {3,301,30102,3010202}
302 空手部  └空手部 開発本部 > 空手部 3 2 2 03/02 {3,302}

おまけ

ちなみにこんなのも SQL で作れます。
下に兄弟がないときはツリーの縦線 | をなくしたり +-`- にしてます。

どうやって実現するか?気になる答えはまた次の機会に!

id tree_nmae
1 +- 管理本部
101 | +- 総務人事部
10101 | | +- 総務課
10102 | | `- 人事課
102 | +- 財務部
10201 | | `- 経理課
103 | `- 情報システム室
2 +- 営業本部
201 | +- 営業企画部
20101 | | `- 営業企画課
202 | `- 営業部
20201 | +- 大阪営業所
20202 | +- 東京営業所
20203 | `- 名古屋営業所
3 `- 開発本部
301 +- 商品開発部
30101 | +- 1課
30102 | `- 2課
3010201 | +- 第一小隊
3010202 | `- 第二小隊
302 `- 空手部