class Cake\ORM\Behavior\TreeBehavior

在数据库表中存储层次数据是相当常见的。这种数据的示例可能是无限子类别的类别、与多级菜单系统相关的数据或公司部门等层次结构的字面表示。

关系型数据库通常不适合存储和检索这种类型的数据,但有一些已知技术可以使它们在处理多级信息方面更有效。

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);

所有子节点将被保留,并将为它们分配一个新的父节点。

节点的删除基于实体的 lftrght 值。 在循环遍历节点的各个子节点以进行条件删除时,这一点很重要。

$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 会重新排序表中记录的 lftrght 值。

在上面的示例中,$descendants 内实体的 lftrght 值将不准确。 如果您需要树的准确形状,则需要重新加载现有的实体对象。