想像してみて:君のネットショップには何千もの商品があって、ちゃんとカテゴリ、サブカテゴリ、サブサブカテゴリに分かれてる。サイト上ではキレイなドロップダウンメニューだけど、DBだと超ややこしい。たとえば「エレクトロニクス → スマートフォン → アクセサリー」みたいな枝を一発で取り出したい時、どうする?各カテゴリのネストレベルを数えたい時は?普通のJOINじゃ無理ゲー — ここで再帰の出番だ!
再帰的CTEで商品カテゴリ構造を作る
リレーショナルDBでよくある課題の一つが、階層構造の扱い。たとえば、商品カテゴリのツリー:メインカテゴリ、サブカテゴリ、さらにその下…みたいな感じ。例を挙げると:
エレクトロニクス
└── スマートフォン
└── アクセサリー
└── ノートパソコン
└── ゲーミング
└── フォトとビデオ
この構造はネットショップのUIでは簡単だけど、DBでどう保存してどう取り出す?そこで再帰的CTEの出番!
カテゴリの元テーブル
まずはcategoriesテーブルを作るよ。ここに商品カテゴリのデータを入れる:
CREATE TABLE categories (
category_id SERIAL PRIMARY KEY, -- カテゴリのユニークID
category_name TEXT NOT NULL, -- カテゴリ名
parent_category_id INT -- 親カテゴリ(メインカテゴリはNULL)
);
このテーブルに追加するデータ例:
INSERT INTO categories (category_name, parent_category_id) VALUES
('エレクトロニクス', NULL),
('スマートフォン', 1),
('アクセサリー', 2),
('ノートパソコン', 1),
('ゲーミング', 4),
('フォトとビデオ', 1);
ここで何が起きてるか:
エレクトロニクス— これはメインカテゴリ(親なし、parent_category_id = NULL)。スマートフォンはエレクトロニクスの中。アクセサリーはスマートフォンの中。- 他のカテゴリも同じ感じ。
今のcategoriesテーブルのデータ構造はこんな感じ:
| category_id | category_name | parent_category_id |
|---|---|---|
| 1 | エレクトロニクス | NULL |
| 2 | スマートフォン | 1 |
| 3 | アクセサリー | 2 |
| 4 | ノートパソコン | 1 |
| 5 | ゲーミング | 4 |
| 6 | フォトとビデオ | 1 |
再帰的CTEでカテゴリツリーを作る
今度は、カテゴリの階層とネストレベルを全部取り出したい。そこで再帰的CTEを使うよ。
WITH RECURSIVE category_tree AS (
-- ベースクエリ:親がNULLのルートカテゴリを選ぶ
SELECT
category_id,
category_name,
parent_category_id,
1 AS depth -- 最初のネストレベル
FROM categories
WHERE parent_category_id IS NULL
UNION ALL
-- 再帰クエリ:各カテゴリのサブカテゴリを探す
SELECT
c.category_id,
c.category_name,
c.parent_category_id,
ct.depth + 1 AS depth -- ネストレベルを増やす
FROM categories c
INNER JOIN category_tree ct
ON c.parent_category_id = ct.category_id
)
-- 最終クエリ:CTEから結果を取り出す
SELECT
category_id,
category_name,
parent_category_id,
depth
FROM category_tree
ORDER BY depth, parent_category_id, category_id;
結果:
| category_id | category_name | parentcategoryid | depth |
|---|---|---|---|
| 1 | エレクトロニクス | NULL | 1 |
| 2 | スマートフォン | 1 | 2 |
| 4 | ノートパソコン | 1 | 2 |
| 6 | フォトとビデオ | 1 | 2 |
| 3 | アクセサリー | 2 | 3 |
| 5 | ゲーミング | 4 | 3 |
ここで何が起きてる?
- まずベースクエリ(
SELECT … FROM categories WHERE parent_category_id IS NULL)でメインカテゴリを選ぶ。今回はエレクトロニクスだけでdepth = 1。 - 次に再帰クエリで
INNER JOINを使ってサブカテゴリを追加、ネストレベル(depth + 1)を増やす。 - この処理を、全てのレベルのサブカテゴリが見つかるまで繰り返す。
便利なアレンジ
基本の例は動くけど、実際のプロジェクトだともっと色々必要。たとえばパンくずリストを作りたいとか、どのカテゴリにサブカテゴリが一番多いかマネージャーに見せたいとか。いくつか実用的な改良例を見てみよう。
- カテゴリのフルパスを追加
たとえばエレクトロニクス > スマートフォン > アクセサリーみたいに、カテゴリのフルパスを表示したい時がある。これは文字列の連結で実現できる:
WITH RECURSIVE category_tree AS (
SELECT
category_id,
category_name,
parent_category_id,
category_name AS full_path,
1 AS depth
FROM categories
WHERE parent_category_id IS NULL
UNION ALL
SELECT
c.category_id,
c.category_name,
c.parent_category_id,
ct.full_path || ' > ' || c.category_name AS full_path, -- 文字列を連結
ct.depth + 1
FROM categories c
INNER JOIN category_tree ct
ON c.parent_category_id = ct.category_id
)
SELECT
category_id,
category_name,
parent_category_id,
full_path,
depth
FROM category_tree
ORDER BY depth, parent_category_id, category_id;
結果:
| category_id | category_name | parentcategoryid | full_path | depth |
|---|---|---|---|---|
| 1 | エレクトロニクス | NULL | エレクトロニクス | 1 |
| 2 | スマートフォン | 1 | エレクトロニクス > スマートフォン | 2 |
| 4 | ノートパソコン | 1 | エレクトロニクス > ノートパソコン | 2 |
| 6 | フォトとビデオ | 1 | エレクトロニクス > フォトとビデオ | 2 |
| 3 | アクセサリー | 2 | エレクトロニクス > スマートフォン > アクセサリー | 3 |
| 5 | ゲーミング | 4 | エレクトロニクス > ノートパソコン > ゲーミング | 3 |
これで各カテゴリにネストを示すフルパスが付いた!
- サブカテゴリ数のカウント
各カテゴリにいくつサブカテゴリがあるか知りたい時は?
WITH RECURSIVE category_tree AS (
SELECT
category_id,
parent_category_id
FROM categories
UNION ALL
SELECT
c.category_id,
c.parent_category_id
FROM categories c
INNER JOIN category_tree ct
ON c.parent_category_id = ct.category_id
)
SELECT
parent_category_id,
COUNT(*) AS subcategory_count
FROM category_tree
WHERE parent_category_id IS NOT NULL
GROUP BY parent_category_id
ORDER BY parent_category_id;
結果:
| parentcategoryid | subcategory_count |
|---|---|
| 1 | 3 |
| 2 | 1 |
| 4 | 1 |
このテーブルを見ると、エレクトロニクスには3つ(スマートフォン、ノートパソコン、フォトとビデオ)、スマートフォンとノートパソコンには1つずつサブカテゴリがある。
再帰的CTEを使う時の注意点とよくあるミス
無限再帰:もしデータにループ(たとえばカテゴリが自分自身を親にしてる)があると、クエリが無限ループになる。これを防ぐにはWHERE depth < Nとかリミットを使おう。
パフォーマンス最適化:再帰的CTEは大量データだと遅くなることも。parent_category_idにインデックスを貼ると速くなるよ。
UNIONとUNION ALLの間違い:再帰的CTEでは必ずUNION ALLを使おう。UNIONだとPostgreSQLが重複排除しようとして遅くなる。
この例で、再帰的CTEが階層構造の扱いにどれだけ便利かわかったよね。DBから階層を取り出すスキルは、サイトのメニュー作りや組織構造の分析、グラフ処理など色んな現場で役立つ。これでどんな課題もバッチリ対応できるはず!
GO TO FULL VERSION