在数据库表中存储层次数据是相当常见的。这种数据的示例可能是无限子类别的类别、与多级菜单系统相关的数据或公司部门等层次结构的字面表示。
关系型数据库通常不适合存储和检索这种类型的数据,但有一些已知技术可以使它们在处理多级信息方面更有效。
TreeBehavior 帮助您在数据库中维护一个层次数据结构,该结构可以在没有太多开销的情况下进行查询,并有助于重建树数据以查找和显示进程。
此行为需要您的表中包含以下列
parent_id
(可为空) 保存父行 ID 的列。此列应被索引。
lft
(整型,带符号) 用于维护树结构。此列应被索引。
rght
(整型,带符号) 用于维护树结构。
如果您需要自定义,可以配置这些字段的名称。有关字段含义及其使用方法的更多信息,请参阅本文,其中介绍了 MPTT 逻辑
警告
TreeBehavior 目前不支持复合主键。
您可以通过将 Tree 行为添加到要存储层次数据的表中来启用它
class CategoriesTable extends Table
{
public function initialize(array $config): void
{
$this->addBehavior('Tree');
}
}
添加后,您可以让 CakePHP 构建内部结构,前提是该表已经包含一些行
// In a controller
$categories = $this->getTableLocator()->get('Categories');
$categories->recover();
您可以通过从表中获取任何行并查询其后代数量来验证它是否有效
$node = $categories->get(1);
echo $categories->childCount($node);
可以使用以下方法获取节点的后代的平面列表
$descendants = $categories->find('children', for: 1);
foreach ($descendants as $category) {
echo $category->name . "\n";
}
如果您需要传递条件,您可以像往常一样执行此操作
$descendants = $categories
->find('children', for: 1)
->where(['name LIKE' => '%Foo%'])
->all();
foreach ($descendants as $category) {
echo $category->name . "\n";
}
如果您需要一个线程列表,其中每个节点的子节点嵌套在层次结构中,您可以堆叠“threaded”查找器
$children = $categories
->find('children', for: 1)
->find('threaded')
->toArray();
foreach ($children as $child) {
echo "{$child->name} has " . count($child->children) . " direct children";
}
而如果您使用的是自定义 parent_id
,则需要将其传递给“threaded”查找器选项(即 parentField
)。
注意
有关“threaded”查找器选项的更多信息,请参阅 查找线程数据逻辑
遍历线程结果通常需要递归函数,但如果您只需要一个包含每个级别单个字段的结果集,这样您就可以显示列表(例如在 HTML select 中),最好使用 treeList
查找器
$list = $categories->find('treeList')->toArray();
// In a CakePHP template file:
echo $this->Form->control('categories', ['options' => $list]);
// Or you can output it in plain text, for example in a CLI script
foreach ($list as $categoryName) {
echo $categoryName . "\n";
}
输出将类似于
My Categories
_Fun
__Sport
___Surfing
___Skating
_Trips
__National
__International
treeList
查找器接受许多选项
keyPath
: 用于获取要用于数组键的字段的点分隔路径,或用于从提供的行返回键的闭包。
valuePath
: 用于获取要用于数组值的字段的点分隔路径,或用于从提供的行返回值的闭包。
spacer
: 用于作为前缀来表示每个项目在树中的深度的字符串
所有选项使用示例如下
$query = $categories->find('treeList',
keyPath: 'url',
valuePath: 'id',
spacer: ' '
);
使用闭包的示例
$query = $categories->find('treeList',
valuePath: function($entity){
return $entity->url . ' ' . $entity->id
}
);
一项非常常见的任务是找到从特定节点到树根的树路径。例如,这对于为菜单结构添加面包屑列表很有用
$nodeId = 5;
$crumbs = $categories->find('path', for: $nodeId)->all();
foreach ($crumbs as $crumb) {
echo $crumb->name . ' > ';
}
使用 TreeBehavior 构建的树不能按除 lft
以外的任何列进行排序,这是因为树的内部表示依赖于这种排序。幸运的是,您可以重新排序同一级别的节点,而无需更改其父级
$node = $categories->get(5);
// Move the node so it shows up one position up when listing children.
$categories->moveUp($node);
// Move the node to the top of the list inside the same level.
$categories->moveUp($node, true);
// Move the node to the bottom.
$categories->moveDown($node, true);
如果此行为使用的默认列名与您自己的架构不匹配,您可以为它们提供别名
public function initialize(array $config): void
{
$this->addBehavior('Tree', [
'parent' => 'ancestor_id', // Use this instead of parent_id
'left' => 'tree_left', // Use this instead of lft
'right' => 'tree_right' // Use this instead of rght
]);
}
当您想要仅检索特定级别的节点时,了解树节点的深度可能很有用,例如,在生成菜单时。您可以使用 level
选项来指定将保存每个节点级别的字段
$this->addBehavior('Tree', [
'level' => 'level', // Defaults to null, i.e. no level saving
]);
如果您不想使用 db 字段缓存级别,您可以使用 TreeBehavior::getLevel()
方法获取节点的级别。
有时您想在同一个表中保留多棵树结构,您可以通过使用“scope”配置来实现。例如,在 locations 表中,您可能希望为每个国家创建一个树
class LocationsTable extends Table
{
public function initialize(array $config): void
{
$this->addBehavior('Tree', [
'scope' => ['country_name' => 'Brazil']
]);
}
}
在前面的示例中,所有树操作都将仅限于具有 country_name
列设置为“Brazil”的行。您可以通过使用“config”函数在运行时更改范围
$this->behaviors()->Tree->setConfig('scope', ['country_name' => 'France']);
或者,您可以通过传递闭包作为范围来更细粒度地控制范围
$this->behaviors()->Tree->setConfig('scope', function ($query) {
$country = $this->getConfigureContry(); // A made-up function
return $query->where(['country_name' => $country]);
});
通过启用 cascadeCallbacks
选项,TreeBehavior
将加载所有将要删除的实体。加载后,这些实体将使用 Table::delete()
单独删除。这使得在删除树节点时可以触发 ORM 回调
$this->addBehavior('Tree', [
'cascadeCallbacks' => true,
]);
默认情况下,recover()
使用主键对项目进行排序。如果这是一个数字(自动递增)列,这非常有用,但如果您使用 UUID,则可能会导致奇怪的结果。
如果您需要对恢复进行自定义排序,可以在配置中设置自定义排序子句
$this->addBehavior('Tree', [
'recoverOrder' => ['country_name' => 'DESC'],
]);
使用 Tree 行为时,您通常不必担心层次结构的内部表示。节点在树中放置的位置是根据每个实体中的 parent_id
列推断出来的
$aCategory = $categoriesTable->get(10);
$aCategory->parent_id = 5;
$categoriesTable->save($aCategory);
在保存或尝试在树中创建循环(使节点成为其自身的子节点)时,提供不存在的父 ID 将引发异常。
您可以通过将 parent_id
列设置为 null 来使节点成为树中的根节点
$aCategory = $categoriesTable->get(10);
$aCategory->parent_id = null;
$categoriesTable->save($aCategory);
新根节点的子节点将被保留。
删除节点及其所有子树(树中任何深度的所有子节点)非常简单
$aCategory = $categoriesTable->get(10);
$categoriesTable->delete($aCategory);
TreeBehavior 会为您处理所有内部删除操作。 您也可以只删除一个节点,并将所有子节点重新分配到树中直接上级的父节点。
$aCategory = $categoriesTable->get(10);
$categoriesTable->removeFromTree($aCategory);
$categoriesTable->delete($aCategory);
所有子节点将被保留,并将为它们分配一个新的父节点。
节点的删除基于实体的 lft
和 rght
值。 在循环遍历节点的各个子节点以进行条件删除时,这一点很重要。
$descendants = $teams->find('children', for: 1)->all();
foreach ($descendants as $descendant) {
$team = $teams->get($descendant->id); // search for the up-to-date entity object
if ($team->expired) {
$teams->delete($team); // deletion reorders the lft and rght of database entries
}
}
当删除节点时,TreeBehavior 会重新排序表中记录的 lft
和 rght
值。
在上面的示例中,$descendants
内实体的 lft
和 rght
值将不准确。 如果您需要树的准确形状,则需要重新加载现有的实体对象。