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

1.3.8 2022-07-03 11:12 UTC

README

gazsp/baum 分支继承 - 修复了与错误数据库事务相关的关键漏洞。

修复了当多个 INSERTDELETE 操作同时运行时破坏嵌套集的漏洞。

描述和重现都非常困难,但需要重建树的操作在同时进行时可能会遇到错误。

在修复漏洞之前,只有树的重建在事务内部。实际的操作,例如插入或删除节点,不在事务内部。

假设一个节点被删除,重建正在进行中... 然后,几乎在同一个时间,第二个节点也被删除,第二个重建因为表锁定而停止。然后树被破坏。

错误

应用程序日志显示以下错误。

SQLSTATE[40001]: Serialization failure: 1213 Deadlock found when trying to get lock; try restarting transaction
SQLSTATE[HY000]: General error: 2014 Cannot execute queries while other unbuffered queries are active.  Consider using PDOStatement::fetchAll().  Alternatively, if your code is only ever going to run against mysql, you may enable query buffering by setting the PDO::MYSQL_ATTR_USE_BUFFERED_QUERY attribute.

解决方案

我们在第一次写入数据库之前启动了一个新的后续事务。我们还需要稍后提交这些事务。因此,我们覆盖了类 Node 中的 deletefinishSave 方法。

etrepat/baum 分支继承 - 继续开发并修复 Laravel 5.x 上失败的单元测试。

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

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

对于 Laravel 4.2.x 兼容性,请检查 1.0.x 分支 或使用最新的 1.0.x 标签版本

文档

关于嵌套集

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

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

有关嵌套集的更多信息,请参阅 维基百科条目。此外,这是一篇很好的入门教程: http://www.evanpetersen.com/item/nested-sets.html

背后的理论,一个 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

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

安装

Baum 与 Laravel 5 及更高版本一起工作。您可以使用以下方式将其添加到您的 composer.json 文件中

"hungnm144/baum": "dev-master"

运行 composer install 来安装它。

与大多数Laravel 5包一样,接下来你需要注册Baum 服务提供者。要做到这一点,转到你的 config/app.php 文件,并在 providers 数组中添加以下行

'Baum\Providers\BaumServiceProvider',

入门指南

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

php artisan baum:install MODEL

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

生成器将在你的应用程序中安装迁移和模型文件,配置为与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): 如果给定的节点与当前节点处于同一作用域中,则返回 true。也就是说,如果 每个scoped 属性中的列在这两个节点中都有相同的值。

使用前面的示例中的节点

$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:leaves()

您可能只对第一个根节点感兴趣。

$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() 静态方法允许您检查您的底层树结构是否正确。它主要检查以下 3 件事

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

所有检查都是 作用域感知 的,如果需要,将单独检查每个作用域。

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

Category::isValidNestedSet()
=> true

重建树

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

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

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

此操作也是 作用域感知 的,如果定义了作用域,将单独重新构建所有作用域。

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

Category::rebuild()

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

软删除

不建议使用软删除 / restore(),如果树在软删除操作之后已被修改,可能会导致问题。

播种/大量分配

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

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

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

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

  • 电视 & 家庭影院
  • 平板电脑 & 电子阅读器
  • 电脑
    • 笔记本电脑
      • PC 笔记本
      • Macbook(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 笔记本
        • Macbook(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'
// );

更多信息

您可以在wiki中找到有关Baum的更多信息、使用示例和/或常见问题。

在完成本README之后,请随意浏览wiki

https://github.com/etrepat/baum/wiki

贡献

想要贡献?也许你发现了一些讨厌的bug?这是个好消息!

  1. 分支并克隆项目:git clone git@github.com:your-username/baum.git
  2. 运行测试并确保它们通过你的设置:phpunit
  3. 创建你的bugfix/feature分支并编写你的更改。为你的更改添加测试。
  4. 确保所有测试仍然通过:phpunit
  5. 推送到你的分支并提交新的pull请求。

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

许可证

Baum在MIT许可证的条款下授权(有关详细信息,请参阅LICENSE文件)。

Estanislau Trepat (etrepat)编写。我还在twitter上@etrepat