Baum 是一种为 Eloquent 模型实现嵌套集模式的实现。

3.2.2 2024-07-04 12:17 UTC

README

支持的 Laravel 版本

Laravel 5.8 Laravel 6 Laravel 7 Laravel 8 Laravel 9 Laravel 10 Laravel 11

包信息

Latest Stable Version Total Downloads Latest Unstable Version License PHP Version Require

构建/代码覆盖率

Tests Coverage Status

Laravel 的嵌套集实现

Baum 是为 Laravel Eloquent ORM 实现的 嵌套集 模式。

使用嵌套集模式的关键考虑因素

  1. 当树元素和一两个属性是唯一的数据时,嵌套集模式是合适的。
  2. 当树元素存在更复杂的关联数据时,嵌套集模式是一个 较差的选择
  3. 当需要比修改树更频繁地查询树时,嵌套集模式 最佳

如果您发现一个错误提交一个问题,并提交一个带有失败单元测试的 pull 请求。

文档

关于嵌套集

嵌套集是一种智能的方法来实现一个 有序的 树,它允许快速、非递归查询。例如,您可以在单个查询中检索节点的所有后代,无论树有多深。缺点是插入/移动/删除需要复杂的 SQL,但这个包在幕后处理了这一点!

嵌套集适用于有序树(例如,菜单、商业类别)和必须高效查询的大树(例如,线程化帖子)。

有关更多信息,请参阅嵌套集的维基百科条目

理论背后的,一个 TL;DR 版本

通过考虑一个父实体包围所有其子实体,以及其父实体包围它等,可以轻松地可视化嵌套集的工作方式。所以这个树

root
  |_ Child 1
    |_ Child 1.1
    |_ Child 1.2
  |_ Child 2
    |_ Child 2.1
    |_ Child 2.2

可以像这样可视化

 ___________________________________________________________________
|  Root                                                             |
|    ____________________________    ____________________________   |
|   |  Child 1                  |   |  Child 2                  |   |
|   |   __________   _________  |   |   __________   _________  |   |
|   |  |  C 1.1  |  |  C 1.2 |  |   |  |  C 2.1  |  |  C 2.2 |  |   |
1   2  3_________4  5________6  7   8  9_________10 11_______12 13  14
|   |___________________________|   |___________________________|   |
|___________________________________________________________________|

数字代表左边界和右边界。然后表可能看起来像这样

id | parent_id | lft  | rgt  | depth | data
 1 |           |    1 |   14 |     0 | root
 2 |         1 |    2 |    7 |     1 | Child 1
 3 |         2 |    3 |    4 |     2 | Child 1.1
 4 |         2 |    5 |    6 |     2 | Child 1.2
 5 |         1 |    8 |   13 |     1 | Child 2
 6 |         5 |    9 |   10 |     2 | Child 2.1
 7 |         5 |   11 |   12 |     2 | Child 2.2

要获取一个 节点的所有子节点,您

SELECT * WHERE lft IS BETWEEN parent.lft AND parent.rgt

要获取子节点数,它是

(right - left - 1)/2

要获取一个节点及其所有祖先(回溯到根节点),它是

SELECT * WHERE node.lft IS BETWEEN lft AND rgt

如您所见,通常在普通树上递归且非常慢的查询现在突然变得非常快。妙极了,不是吗?

安装

您可以使用以下方法将其添加到项目中

composer require toponepercent/baum

入门

包正确安装后,开始的最简单方法是运行提供的生成器

php artisan make:baum {model_name}

将模型替换为您计划用于嵌套集模型的类名。

生成器将在您的应用程序中创建一个模型文件,该文件配置为使用 Baum 提供的嵌套集行为。您应该查看这些文件,因为每个文件都描述了它们可以如何定制。

接下来,您可能会运行 artisan migrate 来应用迁移。

模型配置

为了与 Baum 一起使用,您必须确保您的模型类扩展 Baum\Node

这可能是最简单的方式

class Category extends Baum\Node {

}

这是一个 稍微复杂一些的 示例,其中我们自定义了列名

class Dictionary extends Baum\Node {

  protected $table = 'dictionary';

  // 'parent_id' column name
  protected $parentColumn = 'parent_id';

  // 'lft' column name
  protected $leftColumn = 'lidx';

  // 'rgt' column name
  protected $rightColumn = 'ridx';

  // 'depth' column name
  protected $depthColumn = 'nesting';

  // guard attributes from mass-assignment
  protected $guarded = array('id', 'parent_id', 'lidx', 'ridx', 'nesting');

}

请记住,显然,列名必须与数据库表中的列名匹配。

迁移配置

您必须确保支持您的Baum模型的数据库表具有以下列

  • parent_id:指向父节点(int)
  • lft:左索引边界(int)
  • rgt:右索引边界(int)
  • depth:深度或嵌套级别(int)

以下是一个示例迁移文件

class Category extends Migration {

  public function up() {
    Schema::create('categories', function(Blueprint $table) {
      $table->increments('id');

      $table->integer('parent_id')->nullable();
      $table->integer('lft')->nullable();
      $table->integer('rgt')->nullable();
      $table->integer('depth')->nullable();

      $table->string('name', 255);

      $table->timestamps();
    });
  }

  public function down() {
    Schema::drop('categories');
  }

}

您可以自由修改列名,前提是在迁移和模型中同时更改它们。

使用

在配置您的模型并运行迁移后,现在您可以使用Baum与您的模型一起使用。以下是一些示例。

创建根节点

默认情况下,所有节点都创建为根节点

$root = Category::create(['name' => 'Root category']);

或者,您可能需要将现有的节点转换为根节点

$node->makeRoot();

您还可以将其parent_id列设置为null以实现相同的行为

// This works the same as makeRoot()
$node->parent_id = null;
$node->save();

插入节点

// Directly with a relation
$child1 = $root->children()->create(['name' => 'Child 1']);

// with the `makeChildOf` method
$child2 = Category::create(['name' => 'Child 2']);
$child2->makeChildOf($root);

删除节点

$child1->delete();

被删除节点的后代也将被删除,所有lftrgt边界将重新计算。请注意,目前不会触发后代deletingdeleted模型事件。

获取节点的嵌套级别

getLevel()方法将返回节点的当前嵌套级别或深度。

$node->getLevel() // 0 when root

移动节点

Baum提供了一些方法来移动节点

  • moveLeft():找到左兄弟并将其移动到其左侧。
  • moveRight():找到右兄弟并将其移动到其右侧。
  • moveToLeftOf($otherNode):移动到...左侧的节点
  • moveToRightOf($otherNode):移动到...右侧的节点
  • makeNextSiblingOf($otherNode)moveToRightOf的别名。
  • makeSiblingOf($otherNode)makeNextSiblingOf的别名。
  • makePreviousSiblingOf($otherNode)moveToLeftOf的别名。
  • makeChildOf($otherNode):将节点设置为...的子节点。
  • makeFirstChildOf($otherNode):将节点设置为...的第一个子节点。
  • makeLastChildOf($otherNode)makeChildOf的别名。
  • makeRoot():将当前节点设置为根节点。

例如

$root = Creatures::create(['name' => 'The Root of All Evil']);

$dragons = Creatures::create(['name' => 'Here Be Dragons']);
$dragons->makeChildOf($root);

$monsters = new Creatures(['name' => 'Horrible Monsters']);
$monsters->save();

$monsters->makeSiblingOf($dragons);

$demons = Creatures::where('name', '=', 'demons')->first();
$demons->moveToLeftOf($dragons);

向节点提问

您可以向Baum节点提出一些问题

  • isRoot():如果这是一个根节点,则返回true。
  • isLeaf():如果这是一个叶子节点(分支的末尾),则返回true。
  • isChild():如果这是一个子节点,则返回true。
  • isChildOf($other):如果此节点是其他节点的子节点,则返回true。
  • isDescendantOf($other):如果节点是其他节点的后代,则返回true。
  • isSelfOrDescendantOf($other):如果节点是自身或后代,则返回true。
  • isAncestorOf($other):如果节点是其他节点的祖先,则返回true。
  • isSelfOrAncestorOf($other):如果节点是自身或祖先,则返回true。
  • equals($node):当前节点实例等于其他节点。
  • insideSubtree($node):检查给定节点是否在由左和右索引定义的子树中。
  • inSameScope($node):如果给定节点与当前节点在scoped属性中的每个列都具有相同的值,则返回true。

使用上一个示例中的节点

$demons->isRoot(); // => false

$demons->isDescendantOf($root) // => true

关系

Baum为您的节点提供了两个自引用Eloquent关系:parentchildren

$parent = $node->parent()->get();

$children = $node->children()->get();

根和叶子作用域

Baum提供了一些基本的查询作用域,用于访问根和叶子节点

// Query scope which targets all root nodes
Category::roots()

// All leaf nodes (nodes at the end of a branch)
Category:allLeaves()

您可能还感兴趣的是仅第一个根

$firstRootNode = Category::root();

访问祖先/后代链

Baum提供了几种方法来访问嵌套集树中节点的祖先/后代链。需要记住的主要事情是它们以两种方式提供

首先作为 查询范围,返回一个 Illuminate\Database\Eloquent\Builder 实例以继续进一步查询。要从这些中获取 实际 的结果,请记住调用 get()first()

  • ancestorsAndSelf():针对包括当前节点在内的所有祖先链节点。
  • ancestors():查询不包括当前节点的祖先链节点。
  • siblingsAndSelf():实例范围,针对父节点的所有子节点,包括自身。
  • siblings():实例范围,针对父节点的所有子节点,但不包括自身。
  • leaves():实例范围,针对所有没有子节点的嵌套子节点。
  • descendantsAndSelf():范围针对自身及其所有嵌套子节点。
  • descendants():所有子节点和嵌套子节点集合。
  • immediateDescendants():所有子节点集合(非递归)。

其次,作为返回实际 Baum\Node 实例(在合适的 Collection 对象中)的方法

  • getRoot():返回从当前节点开始的根节点。
  • getAncestorsAndSelf():检索包括当前节点在内的所有祖先链。
  • getAncestorsAndSelfWithoutRoot():所有祖先(包括当前节点)但不包括根节点。
  • getAncestors():从数据库中获取不包括当前节点的所有祖先链。
  • getAncestorsWithoutRoot():不包括当前节点和根节点的所有祖先。
  • getSiblingsAndSelf():获取节点的所有兄弟节点,包括节点本身。
  • getSiblings():获取节点的所有兄弟节点,但不包括当前节点。
  • getLeaves():返回所有没有子节点的嵌套子节点。
  • getDescendantsAndSelf():检索所有嵌套子节点和自身。
  • getDescendants():检索所有子节点和嵌套子节点。
  • getImmediateDescendants():检索所有子节点(非递归)。

以下是一个迭代节点子节点的简单示例(假设有一个名称属性可用)

$node = Category::where('name', '=', 'Books')->first();

foreach($node->getDescendantsAndSelf() as $descendant) {
  echo "{$descendant->name}";
}

限制返回子节点级别

在某些情况下,如果层次结构的深度非常大,可能希望限制返回的子节点级别(深度)。您可以在 Baum 中使用 limitDepth 查询范围来实现这一点。

以下代码片段将获取当前节点的子节点,最多向下5个深度级别

$node->descendants()->limitDepth(5)->get();

同样,您可以通过提供所需的深度限制作为第一个参数来限制 getDescendantsgetDescendantsAndSelf 方法的子代级别。

// This will work without depth limiting
// 1. As usual
$node->getDescendants();
// 2. Selecting only some attributes
$other->getDescendants(array('id', 'parent_id', 'name'));
...
// With depth limiting
// 1. A maximum of 5 levels of children will be returned
$node->getDescendants(5);
// 2. A max. of 5 levels of children will be returned selecting only some attrs
$other->getDescendants(5, array('id', 'parent_id', 'name'));

自定义排序列

默认情况下,在 Baum 中,所有结果都是按 lft 索引列的值排序以保持一致性。

如果您希望更改此默认行为,您需要在模型中指定要用于排序结果的列名,如下所示

protected $orderColumn = 'name';

转储层次树

Baum 扩展了默认的 Eloquent\Collection 类,并为其提供了 toHierarchy 方法,该方法返回表示查询树的嵌套集合。

将完整的树层次结构检索到具有正确嵌套子节点的常规 Collection 对象中非常简单

$tree = Category::where('name', '=', 'Books')->first()->getDescendantsAndSelf()->toHierarchy();

模型事件:movingmoved

Baum 模型在每次节点在嵌套集树中 移动 时都会触发以下事件:movingmoved。这允许您在节点移动过程中挂钩到这些点。与正常的 Eloquent 模型事件一样,如果 moving 事件返回 false,则移动操作将被取消。

挂钩到这些事件的推荐方法是使用模型的 boot 方法

class Category extends Baum\Node {

  public static function boot() {
    parent::boot();

    static::moving(function($node) {
      // Before moving the node this function will be called.
    });

    static::moved(function($node) {
      // After the move operation is processed this function will be
      // called.
    });
  }

}

作用域支持

Baum 提供了一个简单的方法来提供嵌套集 "范围",这限制了我们将什么视为嵌套集树的一部分。这应该允许在同一个数据库表中存在多个嵌套集树。

要使用范围功能,您可以在您的子类中重写 scoped 模型属性。此属性应包含一个包含要用于限制嵌套集查询的列名(数据库字段)的数组

class Category extends Baum\Node {
  ...
  protected $scoped = array('company_id');
  ...
}

在上一个示例中,company_id有效地限制了(或“范围”)嵌套集合树。因此,对于该字段的每个值,我们可能能够构建一个完全不同的树。

$root1 = Category::create(['name' => 'R1', 'company_id' => 1]);
$root2 = Category::create(['name' => 'R2', 'company_id' => 2]);

$child1 = Category::create(['name' => 'C1', 'company_id' => 1]);
$child2 = Category::create(['name' => 'C2', 'company_id' => 2]);

$child1->makeChildOf($root1);
$child2->makeChildOf($root2);

$root1->children()->get(); // <- returns $child1
$root2->children()->get(); // <- returns $child2

所有询问或遍历嵌套集合树的方法都将使用scoped属性(如果提供)。

请注意,目前不支持在不同范围之间移动节点。

验证

::isValidNestedSet()静态方法允许您检查您的基础树结构是否正确。它主要检查以下三件事

  • 检查绑定索引lftrgt是否不为空,rgt值大于lft,并且(如果设置了父节点)在父节点的范围内。
  • 没有重复的lftrgt列值。
  • 由于第一个检查实际上并没有检查根节点,请检查每个根节点是否有其子节点的lftrgt索引在范围内。

所有检查都是范围感知的,如果需要,将分别检查每个范围。

示例用法,给定一个Category节点类

Category::isValidNestedSet()
=> true

树重建

Baum支持通过::rebuild()静态方法完成树结构的完全重建(或重新索引)。

此方法将重新索引所有lftrgtdepth列值,仅从父节点与子节点关系的角度检查您的树。这意味着您只需要一个正确填充的parent_id列,Baum将尽力重新计算其余部分。

当索引值出现问题或需要从另一个实现(可能有一个parent_id列)转换时,这可能会非常有用。

此操作也是范围感知的,如果定义了范围,将分别重建所有范围。

简单示例用法,给定一个Category节点类

Category::rebuild()

不会检查树是否已经有效,这意味着调用重建将始终重建树,无论其是否有效。如果不想这样做,当isValidNestedSet返回true时不要调用rebuild。

软删除

使用软删除/restore()不被推荐,如果树在软删除操作后被修改,可能会引起问题。

初始化/大量赋值

由于嵌套集合结构通常涉及许多方法调用以构建层次结构(这会导致多个数据库查询),Baum提供了两个方便的方法,可以将提供的节点属性数组映射到数据库中

  • buildTree($nodeList):(静态方法)将提供的节点属性数组映射到数据库中。
  • makeTree($nodeList):(实例方法)使用当前节点实例作为提供的子树的父节点,将提供的节点属性数组映射到数据库中。

这两个方法将在主键未提供时创建新节点,在提供时更新或创建,并删除在影响范围中不存在的所有节点。理解影响范围对于buildTree静态方法是整个嵌套集合树,对于makeTree实例方法是当前节点的所有后代。

例如,假设我们想将以下类别层次结构映射到我们的数据库中

  • 电视 & 家庭影院
  • 平板电脑 & 电子阅读器
  • 电脑
    • 笔记本电脑
      • PC笔记本电脑
      • Macbooks(Air/Pro)
    • 台式机
    • 显示器
  • 手机

这可以通过以下代码轻松完成

$categories = [
  ['id' => 1, 'name' => 'TV & Home Theather'],
  ['id' => 2, 'name' => 'Tablets & E-Readers'],
  ['id' => 3, 'name' => 'Computers', 'children' => [
    ['id' => 4, 'name' => 'Laptops', 'children' => [
      ['id' => 5, 'name' => 'PC Laptops'],
      ['id' => 6, 'name' => 'Macbooks (Air/Pro)']
    ]],
    ['id' => 7, 'name' => 'Desktops'],
    ['id' => 8, 'name' => 'Monitors']
  ]],
  ['id' => 9, 'name' => 'Cell Phones']
];

Category::buildTree($categories) // => true

之后,我们可能只需要根据需要更新层次结构

$categories = [
  ['id' => 1, 'name' => 'TV & Home Theather'],
  ['id' => 2, 'name' => 'Tablets & E-Readers'],
  ['id' => 3, 'name' => 'Computers', 'children' => [
    ['id' => 4, 'name' => 'Laptops', 'children' => [
      ['id' => 5, 'name' => 'PC Laptops'],
      ['id' => 6, 'name' => 'Macbooks (Air/Pro)']
    ]],
    ['id' => 7, 'name' => 'Desktops', 'children' => [
      // These will be created
      ['name' => 'Towers Only'],
      ['name' => 'Desktop Packages'],
      ['name' => 'All-in-One Computers'],
      ['name' => 'Gaming Desktops']
    ]]
    // This one, as it's not present, will be deleted
    // ['id' => 8, 'name' => 'Monitors'],
  ]],
  ['id' => 9, 'name' => 'Cell Phones']
];

Category::buildTree($categories); // => true

makeTree实例方法的工作方式类似。唯一的区别是它只会在调用节点实例的后代上执行操作。

所以现在假设我们已经在数据库中有了以下层次结构

  • 电子产品
  • 健康健身 & 美容
  • 小型家电
  • 大型家电

如果我们执行以下代码

$children = [
  ['name' => 'TV & Home Theather'],
  ['name' => 'Tablets & E-Readers'],
  ['name' => 'Computers', 'children' => [
    ['name' => 'Laptops', 'children' => [
      ['name' => 'PC Laptops'],
      ['name' => 'Macbooks (Air/Pro)']
    ]],
    ['name' => 'Desktops'],
    ['name' => 'Monitors']
  ]],
  ['name' => 'Cell Phones']
];

$electronics = Category::where('name', '=', 'Electronics')->first();
$electronics->makeTree($children); // => true

将会得到以下结果

  • 电子产品
    • 电视 & 家庭影院
    • 平板电脑 & 电子阅读器
    • 电脑
      • 笔记本电脑
        • PC笔记本电脑
        • Macbooks(Air/Pro)
      • 台式机
      • 显示器
    • 手机
  • 健康健身 & 美容
  • 小型家电
  • 大型家电

更新和删除子树中的节点的方式相同。

其他/实用函数

节点提取查询范围

Baum提供了一些查询范围,可用于从当前结果集中提取(删除)选定的节点。

  • withoutNode(node):从当前结果集中提取指定的节点。
  • withoutSelf():从当前结果集中提取自身。
  • withoutRoot():从结果集中提取当前根节点。
$node = Category::where('name', '=', 'Some category I do not want to see.')->first();

$root = Category::where('name', '=', 'Old boooks')->first();
var_dump($root->descendantsAndSelf()->withoutNode($node)->get());
... // <- This result set will not contain $node

获取嵌套列表的列值

::getNestedList()静态方法返回一个表示节点深度的键值对数组。对于填充select元素等很有用。

它期望返回列名,可选的:用于数组键的列(如果没有提供,则使用id)和/或分隔符

public static function getNestedList($column, $key = null, $seperator = ' ', $symbol = '');

一个示例用例

$nestedList = Category::getNestedList('name');
// $nestedList will contain an array like the following:
// array(
//   1 => 'Root 1',
//   2 => ' Child 1',
//   3 => ' Child 2',
//   4 => '  Child 2.1',
//   5 => ' Child 3',
//   6 => 'Root 2'
// );

贡献

考虑贡献?也许你发现了一些讨厌的bug或想添加新功能?这是个好消息!

请参阅CONTRIBUTING.md文件以获取更详细的指南和建议。

许可证

Baum遵循MIT许可证条款(有关详细信息,请参阅LICENSE文件)。