RDBで階層構造をあつかう
例えばTODOアプリにおいて、親タスク、子タスク、孫タスク...のようにタスクを作成できる機能があるとする。その関係性をどうDBに保存するのかいまいちわからなかったので調べた。
よく知られている手法としては以下の4つがある
- 隣接リスト
- 経路列挙
- 閉包テーブル
- 入れ子集合
それぞれの手法において、タスクの取得、追加、削除がどういうクエリになるのか見ていく。
隣接リスト
自分の親タスクのIDを保持する設計
| id | name | parent_id |
|---|---|---|
| 1 | 買い物 | null |
| 2 | じゃがいも | 1 |
| 3 | にんじん | 1 |
| 4 | 掃除 | null |
| 5 | 風呂 | 4 |
| 6 | 浴槽 | 5 |
| 7 | 換気扇 | 5 |
| 8 | 洗面台 | 4 |
| 9 | 読書 | null |
タスクの取得
例えば掃除タスク(id = 4)に紐づくタスクの取得は以下のようなクエリになる
SELECT
*
FROM
tasks t1
LEFT OUTER JOIN tasks t2 ON t1.id = t2.parent_id
LEFT OUTER JOIN tasks t3 ON t2.id = t3.parent_id
WHERE
t1.id = 4;
このクエリは、そのタスクが最大何階層あるのかを事前に知っておく必要がある、という問題がある。子タスクのネストに制限がない場合、このクエリは現実的でない。つまり、1クエリで全取得はできない。
事前に何階層あるかを知らない場合、parent_idにヒットしたレコードのidをいれてヒットしなくなるまで検索していく方法で取得できる。
select * from tasks where parent_id = 4; -- id 5, 8がヒット
select * from tasks where parent_id in (5, 8); -- id 6, 7がヒット
select * from tasks where parent_id in (6, 7); -- ヒットなし
この方法では、階層が増えれば増えるほどクエリ実行が多くなってしまう。
タスクの追加
親を指定して足すだけなので特に面倒なポイントはない。
親タスクの追加
insert into tasks (name, parent_id)
values("勉強", NULL);
子タスクの追加
insert into tasks (name, parent_id)
values("排水溝", 5);
タスクの更新
親タスクを変更する場合、parent_idを書き換えるだけなので面倒ポイントなし。
update tasks set parent_id = 4 where id = 11
タスクの削除
子を持つ親タスクの削除で、子も含めて削除する場合を考える。そのタスクが持つ子タスクを知る必要があるので、取得と同様、ヒットしなくなるまで検索して子タスクのidを調べる必要がある。
select * from tasks where parent_id = 4; -- id 5, 8がヒット
select * from tasks where parent_id in (5, 8); -- id 6, 7がヒット
select * from tasks where parent_id in (6, 7); -- ヒットなし
delete from tasks where id in (4, 5, 8, 6, 7);
ただし、parent_idに外部キー制約でON DELETE CASCADEを設定しておけば、親を消した段階ですべて消える
ALTER TABLE tasks
ADD FOREIGN KEY (`parent_id`) REFERENCES tasks (`id`) ON DELETE CASCADE;
delete from tasks where id = 4;
再帰クエリ
再帰クエリを利用すれば、取得クエリの数は減る。詳細は後日書く。
経路列挙
rootまでの経路を/などの文字列で区切って保持する設計
| id | name | path |
|---|---|---|
| 1 | 買い物 | 1 |
| 2 | じゃがいも | 1/2 |
| 3 | にんじん | 1/3 |
| 4 | 掃除 | 4 |
| 5 | 風呂 | 4/5 |
| 6 | 浴槽 | 4/5/6 |
| 7 | 換気扇 | 4/5/7 |
| 8 | 洗面台 | 4/8 |
| 9 | 読書 | 9 |
タスクの取得
例えば掃除タスク(id = 4)に紐づくタスクの取得は以下のようなクエリになる
SELECT
*
FROM
tasks2
WHERE
`path` LIKE '4'
OR `path` LIKE '4/%'
タスクの追加
親タスクの追加。idがauto_incrementの場合、まずはpathなしでinsertし、採番されたIDを確認してupdateをかける方法が考えられる。(他になにかいい感じにやれる方法あるんだろうか?)
INSERT INTO tasks2 (`name`)
VALUES("親タスク"); -- id 10が採番された
UPDATE tasks2 SET `path` = '10' WHERE id = 10;
子タスク追加は、親になるタスクのpathに自分のidを追加して保存すれば良い。クエリは↑と同様。
タスクの削除
親とそれに関連する子すべてを削除する場合を考える。
DELETE FROM tasks2
WHERE `path` = '4'
OR `path` LIKE '4/%'
手軽に消せる。
閉包テーブル
親子関係を専用のテーブルで管理する設計。具体的には、以下のようなタスクがあるとき
| id | name |
|---|---|
| 1 | 買い物 |
| 2 | じゃがいも |
| 3 | にんじん |
| 4 | 掃除 |
| 5 | 風呂 |
| 6 | 浴槽 |
| 7 | 換気扇 |
| 8 | 洗面台 |
| 9 | 読書 |
親子関係全パターン(直接の親子だけではない)を管理するテーブルを用意する。
| parent_id | child_id |
|---|---|
| 1 | 1 |
| 1 | 2 |
| 1 | 3 |
| 4 | 4 |
| 4 | 5 |
| 4 | 6 |
| 4 | 7 |
| 4 | 8 |
| 5 | 5 |
| 5 | 6 |
| 5 | 7 |
| 6 | 6 |
| 7 | 7 |
| 8 | 8 |
| 9 | 9 |
タスクの取得
掃除タスク(id = 4)に紐づくタスクの取得
SELECT
*
FROM
tasks3 AS t
INNER JOIN task_hierarchie AS h ON t.id = h.child_id
WHERE
h.parent_id = 4;
閉包テーブル(task_hierarchie)のparent_id=4を探すだけでいい。
タスク追加
一番上の階層にタスクを足す場合
INSERT INTO tasks (`name`)
VALUES("散歩");
INSERT INTO task_hierarchie
VALUES(10, 10);
子タスクの追加。子タスク自身のidと、子を足す対象の親全てに子のidを足す。例えば、掃除/風呂(id=5)の子に排水溝を足す場合、親である、掃除(id=4)と風呂(id=5)それぞれに排水溝のidのパターンを追加する。また
対象のすべての親を探すとき、task_hierarchieのchild_idが対象idと一致の条件で検索すればよい。
INSERT INTO tasks (`name`)
VALUES("排水溝"); -- id=10
INSERT INTO task_hierarchie
SELECT
h.parent_id,
10
FROM
task_hierarchie AS h
WHERE
h.child_id = 5
UNION ALL
SELECT
10,
10;
タスク削除
掃除タスク(id=4)を削除する場合
DELETE FROM tasks3
WHERE id IN(
SELECT
child_id FROM task_hierarchie
WHERE
parent_id = 4);
DELETE FROM task_hierarchie
WHERE parent_id = 4;
入れ子集合
後で書く。
SQLで木と階層構造のデータを扱う(1)―― 入れ子集合モデル
まとめ
隣接リストは、階層上限がある、かつ深くならない場合において利用できる。が、しかし上限を深くしたくなったときにキツイ。
経路列挙は経路が文字列で、正しくないパスがinsertされてしまう可能性を考えるとキツイ。また、auto_incrementのとき一度insertしてidを確認してupdateする必要があるのも面倒。
閉包テーブルはデータ量が多くなってしまいがちだが、手間なく各種操作ができるので有用に感じた。
個人的に作成しているアプリケーションで階層構造を考える必要があり、調べてみた。要件として上限が決まっている、かつ浅いので隣接リストか閉包テーブルを利用するのが妥当と判断。使い勝手と今後上限をなくしても問題ないことから、最終的には閉包テーブルを選択した。