何年かWEBシステム開発をしているほとんどの方は階層構造に触れたことがあるのではないでしょうか。そして、階層構造の要件を表現する際に頭を悩ませた経験がある方も少なくないでしょう。
最近私が関わった案件で階層構造を利用することになり、設計チームが頭を悩ませていたので本記事を執筆することにしました。
いざ自分が階層構造を設計する立場になったとき適切な階層構造を選択できるよう、本記事では主要な階層構造モデルの紹介と、それぞれの利点と欠点を比較します。
さらに、具体的なユースケースに基づいて最適な階層構造を選択するためのガイドラインを提供します。
階層構造とは、データが親子関係を持つツリー状の構造を指します。
一般的なユースケースには、組織の部門構造、ファイルシステムのディレクトリ構造、カテゴリとサブカテゴリの関係などがあります。
以下の表に、主要な階層構造モデルとその概要を示します。
モデル名 | 概要 | 利点 | 欠点 |
---|---|---|---|
隣接リストモデル (Adjacency List Model) | 各ノードが親ノードのIDを持つ | シンプルで理解しやすい | 階層全体の取得が複雑 |
経路列挙モデル (Path Enumeration Model) | 各ノードがルートからそのノードまでのパスを保持 | 階層全体の取得が容易 | パスの更新が必要 |
閉包テーブルモデル (Closure Table Model) | 全てのノード間の親子関係を別のテーブルで管理 | 階層全体の取得が効率的 | 挿入や削除が複雑 |
他にもネストセットモデル、束縛モデル、祖先カラムモデルや再帰的共通テーブル式などありますが、頻出度を理由に絞ってあります。
例えばネストセットモデルは初見だと理解と実装の難易度が高めなので、保守性的な意味でほぼ採用することはありません。(私が関わってきたプロジェクトでは)
ちなみにほぼ変更がないとか、変更があっても1年に1回とかで、かつ、大規模な階層構造はJSONでDBかファイルに保持しとけばいいと思ってます。
隣接リストモデルでは、各ノードが親ノードのIDを持ちます。このモデルは非常にシンプルで直感的にも理解しやすいです。tableA.parent_id = tableB.id
と連結を表現でき、体感ではWEBシステム開発で最も頻出の階層構造です。
1
├── 2 (parent_id = 1)
└── 3 (parent_id = 1)
├── 4 (parent_id = 3)
└── 5 (parent_id = 3)
まず、Category
モデルに親子関係を定義するメソッドを追加します。
class Category extends Model
{
public function parent()
{
return $this->belongsTo(Category::class, 'parent_id');
}
public function children()
{
return $this->hasMany(Category::class, 'parent_id');
}
public function childrenRecursive()
{
return $this->children()->with('childrenRecursive');
}
}
$parent = Category::find($categoryId)->parent;
$children = Category::where('parent_id', $categoryId)->get();
$categories = Category::with(['children.children.children'])
->where('id', $categoryId)
->get();
$categories = Category::with('childrenRecursive')->where('id', $categoryId)->get();
$category = Category::find($categoryId);
$category->parent_id = $newParentId;
$category->save();
ノードを削除する際には、そのノードに紐づく子ノードも一緒に削除する必要があります。
$category = Category::find($categoryId);
if ($category) {
// 子ノードを再帰的に削除
$category->childrenRecursive()->each(function ($child) {
$child->delete();
});
// 自身を削除
$category->delete();
}
上記のコードでは、childrenRecursive
メソッドを使用して子ノードを再帰的に取得し、それらを削除しています。その後、対象のノード自身を削除します。
経路列挙モデルでは、各ノードがルートからそのノードまでのパスを保持します。パスは通常、文字列として保存されます。
1
├── 1/2
└── 1/3
├── 1/3/4
└── 1/3/5
1/3
が 1/3_
に変更された場合、子要素は 1/3_/4
, 1/3_/5
に更新される必要があります。$category = Category::find($categoryId);
$pathParts = explode('/', $category->path); // '/'で分割した配列の数=階層の要素数
$parentId = $pathParts[count($pathParts) - 2]; // 階層の要素数-1が$category。階層の要素数-2が$categoryの親要素
$parent = Category::find($parentId);
$children = Category::where('path', 'LIKE', "$path/%")->get();
$categories = Category::where('path', 'LIKE', "$path/%")
->whereRaw('LENGTH(path) - LENGTH(REPLACE(path, "/", "")) <= 3')
->get();
$categories = Category::where('path', 'LIKE', "%")->get();
$oldPath = $oldParent->path;
$newPath = $newParent->path;
Category::where('path', 'LIKE', "$oldPath/%")
->update(['path' => DB::raw("REPLACE(path, '$oldPath', '$newPath')")]);
ノードを削除する際には、そのノードに紐づく子ノードも一緒に削除する必要があります。
$category = Category::find($categoryId);
if ($category) {
// 子ノードを再帰的に削除
Category::where('path', 'LIKE', "$category->path/%")->delete();
// 自身を削除
$category->delete();
}
閉包テーブルモデルでは、全てのノード間の親子関係を別のテーブルで管理します。このテーブルには、各ノードの祖先と子孫の関係が保存されます。
1
├── 2
└── 3
├── 4
└── 5
Closure Table:
ancestor | descendant | depth
------------|----------------|------
1 | 1 | 0
1 | 2 | 1
1 | 3 | 1
1 | 4 | 2
1 | 5 | 2
2 | 2 | 0
3 | 3 | 0
3 | 4 | 1
3 | 5 | 1
4 | 4 | 0
5 | 5 | 0
ancestorは先祖、descendantは子孫、depthは深さという意味です。
まずはCategory
モデルとCategoryClosure
モデルの定義を示します。
class Category extends Model
{
public function ancestors()
{
return $this->belongsToMany(Category::class, 'category_closure', 'descendant', 'ancestor')
->withPivot('depth');
}
public function descendants()
{
return $this->belongsToMany(Category::class, 'category_closure', 'ancestor', 'descendant')
->withPivot('depth');
}
}
class CategoryClosure extends Model
{
protected $fillable = ['ancestor', 'descendant', 'depth']; // この名前のカラムがある、という理解でOK
}
$parent = Category::whereHas('ancestors', function ($query) use ($categoryId) {
$query->where('descendant', $categoryId)->where('depth', 1);
})->first();
$children = Category::whereHas('descendants', function ($query) use ($categoryId) {
$query->where('ancestor', $categoryId)->where('depth', 1);
})->get();
$categories = Category::whereHas('descendants', function ($query) use ($ancestorId) {
$query->where('ancestor', $ancestorId)->where('depth', '<=', 3);
})->get();
$category = Category::find($categoryId);
$categories = $category->descendants;
// まず、古い関係を削除
CategoryClosure::where('descendant', $descendantId)->delete();
// 次に、新しい関係を挿入
$supertree = CategoryClosure::where('descendant', $newAncestorId)->get();
$subtree = CategoryClosure::where('ancestor', $descendantId)->get();
foreach ($supertree as $super) {
foreach ($subtree as $sub) {
CategoryClosure::create([
'ancestor' => $super->ancestor,
'descendant' => $sub->descendant,
'depth' => $super->depth + $sub->depth + 1
]);
}
}
$category = Category::find($categoryId);
if ($category) {
// 子孫ノードを再帰的に削除
$descendants = Category::whereHas('ancestors', function ($query) use ($categoryId) {
$query->where('ancestor', $categoryId);
})->get();
foreach ($descendants as $descendant) {
$descendant->delete();
}
// 自身を削除
$category->delete();
CategoryClosure::where('descendant', $categoryId)->delete();
}
SELECT sibling.*
FROM categories AS sibling
JOIN category_closure AS parent_closure ON sibling.id = parent_closure.descendant
JOIN category_closure AS target_closure ON parent_closure.ancestor = target_closure.ancestor
WHERE target_closure.descendant = :targetId
AND parent_closure.depth = 1
AND sibling.id != :targetId;
このクエリは、特定のノードのすべての兄弟ノードを取得するために、閉包テーブルモデルを2回結合しています。このようなクエリは、閉包テーブルモデルのサイズが大きくなるとパフォーマンス低下の可能性があります。
一般的なORMフレームワークには、関連データを効率的に取得するための2つの主要な手法があります。
それが「Eager Load」と「Lazy Load」です。
Eager Loadは、最初のクエリで関連データを一括して取得する手法です。これにより、N+1問題を回避できます。
N+1問題はリレーション先のレコードを複数表示する場合に頻出する問題で、「この大量のSQLログはなんだろう」という経験をした方も多いのではないでしょうか。
N+1問題についての詳しい解説はこの記事の題材と少しずれるので、他の記事におまかせします。
// Eager Loadを使用して親カテゴリとその子カテゴリを一括取得
$categories = Category::with('children')->get();
Lazy Loadは、関連データが実際にアクセスされたときに初めて取得する手法です。これにより、必要なデータだけを取得できます。
// Lazy Loadを使用して親カテゴリを取得し、その後に子カテゴリを取得
$category = Category::find($categoryId);
$children = $category->children; // このタイミングで子要素を取得するSQLが実行される
以下のマトリクス表に、各階層構造モデルが特定の操作に対してどれだけ適しているかを示します。
操作 | 隣接リストモデル | 経路列挙モデル | 閉包テーブルモデル |
---|---|---|---|
特定のノードの親要素を取得 | ◎ | ○ | ◎ |
特定のノードの子要素を取得 | ◎ | ◎ | ◎ |
特定のノードから3階層分取得 | △ | ◎ | ◎ |
全階層を取得 | △ | ◎ | ◎ |
特定のノードを別のノードに紐づくよう更新 | ◎ | △ | △ |
特定のノードを削除 | ◎ | ○ | △ |
ORMとの親和性 | ◎ | ○ | △ |
例えば、頻繁な更新が必要な場合は隣接リストモデルが適しており、読み取りが多い場合は経路列挙モデルが適しています。閉包テーブルモデルは大規模な階層構造や複雑なクエリが必要な場合に有効です。
それぞれのモデルには利点と欠点があります。
具体的なユースケースや要件に応じて適切なモデルを選択する際の助けになれば幸いです。