encima-io/albero

Albero 是为 Laravel 的 Eloquent 模型实现嵌套集模式的实现。

1.0.1 2020-05-26 19:57 UTC

This package is auto-updated.

Last update: 2024-08-27 05:41:12 UTC


README

Build Status Latest Version on Packagist License Total Downloads

Albero 是 Laravel 7 的 Eloquent ORM 实现嵌套集模式的实现。

Albero 是一个流行的 Baum 包 的更新版本,该包在过去五年中未更新!

文档

Baum 文档(更新后)

Baum 的变化

  • 本版本使用 orchestra/testbench 进行测试。
  • 实现已移动到 trait,而不是需要扩展的模型。现在使用 "HasNestedSets" trait。
  • 本版本需要 PHP v7.4,因为它使用类型属性(7.4)和返回类型声明(7.1)
  • 默认情况下,本版本使用列 'left''right',而不是 'lft' 和 'rgt'。

示例用法

<?php

namespace Encima\Albero\Models\Category;

use Encima\Albero\HasNestedSets;
use Illuminate\Database\Eloquent\Model;

class Category extends Model
{
    use HasNestedSets;
}

受保护属性

如果您使用的是 Laravel 的受保护属性而不是可填充属性,则可能希望将列放在受保护属性中。

/** @var array */
protected $guarded = ['id', 'parent_id', 'left', 'right', 'depth'];

注意

如果您的模型使用 SoftDeletes 并扩展了另一个 Eloquent 模型,则可能会遇到恢复和已恢复事件未触发的情况,因此您需要在 boot 方法中添加两个监听器。

use Encima\Albero\HasNestedSets;
use Encima\Albero\Models\Category;
use Illuminate\Database\Eloquent\SoftDeletes;

class SoftCategory extends Category
{
    use SoftDeletes, HasNestedSets;

    public static function boot() {
        parent::boot();
        static::restoring(function ($node) {
            $node->shiftSiblingsForRestore();
        });

        static::restored(function ($node) {
            $node->restoreDescendants();
        });
    }
}

关于嵌套集

嵌套集是一种智能方式来实现一个 有序 的树,它允许快速的非递归查询。例如,您可以在单个查询中获取一个节点的所有后代,无论树的深度如何。缺点是插入/移动/删除需要复杂的 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 | left  | right  | 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 left IS BETWEEN parent.left AND parent.right

要获取子节点数,它是

(right - left - 1)/2

要获取一个节点及其所有祖先节点直到根节点,您可以

SELECT * WHERE node.left IS BETWEEN left AND right

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

安装

Baum 与 Laravel 6 和 7 兼容。

composer require encima-io/albero

入门

要使用 Albero,则需要使用 HasNestedSets trait

use Encima\Albero\HasNestedSets;
use Illuminate\Database\Eloquent\Model;

class Category extends Model
{
    use HasNestedSets;
}

模型配置

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

use Encima\Albero\HasNestedSets;
use Illuminate\Database\Eloquent\Model;

class Category extends Model
{
    use HasNestedSets;

    protected $table = 'categories';

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

    // 'left' column name
    protected $leftColumn = 'left_column';

    // 'right' column name
    protected $rightColumn = 'right_column';

    // 'depth' column name
    protected $depthColumn = 'depth_column';
}

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

迁移配置

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

  • parent_id:对父节点的引用(整数)
  • left:左索引边界(整数)
  • right:右索引边界(整数)
  • depth:深度或嵌套级别(整数)

以下是一个示例迁移文件

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateCategoriesTable extends Migration
{
    public function up()
    {
        Schema::create('categories', function (Blueprint $table) {
            $table->increments('id');
            $table->unsignedInteger('parent_id')->nullable();
            $table->unsignedInteger('left')->nullable();
            $table->unsignedInteger('right')->nullable();
            $table->unsignedInteger('depth')->nullable();
            $table->string('name');
            $table->timestamps();
        });
    }

    public function down()
    {
        Schema::dropIfExists('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();

删除节点的后代也将被删除,并且所有leftright边界将被重新计算。请注意,目前不会触发后代模型的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。
  • 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: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(['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, ['id', 'parent_id', 'name']);

自定义排序列

Baum 默认对所有结果按 left 索引列的值进行排序,以确保一致性。

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

protected $orderColumn = 'name';

转储层次树

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

将完整的树层次结构检索到一个普通的 Collection 对象中,其子节点 正确嵌套,非常简单

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

模型事件:movingmoved

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

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

use Encima\Albero\HasNestedSets;
use Illuminate\Database\Eloquent\Model;

class Category extends Model
{
    use HasNestedSets;

    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 模型属性。该属性应包含一个包含要用于限制嵌套集查询的列名(数据库字段)的数组。

use Encima\Albero\HasNestedSets;
use Illuminate\Database\Eloquent\Model;

class Category extends Model
{
    use HasNestedSets;
    protected $scoped = ['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() 静态方法允许您检查您的底层树结构是否正确。它主要检查以下三件事

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

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

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

Category::isValidNestedSet()
=> true

树重建

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

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

当索引值出现严重错误或当需要从其他实现(可能具有 parent_id 列)进行转换时,这可能非常有用。

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

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

Category::rebuild()

根据 isValidNestedSet 方法,有效的树不会重建。要强制索引重建过程,只需将重建方法的第一参数设置为 true

Category::rebuild(true);

软删除

Baum 对软删除操作的支持是 有限的。我所说的 有限 是测试仍然有限,并且即将推出的框架 4.2 版本的 软删除 功能正在变化,因此请谨慎使用此功能。

目前,您可以考虑一个 安全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 = ' ');

一个示例用例

$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'
// );

贡献

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

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

有关扩展指南和建议,请参阅 CONTRIBUTING.md 文件。

许可证

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

Estanislau Trepat (etrepat) 编写并由 Einar Hansen 更新。

关于Encima

Encima(原名E2Consult)是一家位于挪威奥斯陆的Web开发团队。你可以在我们的网站上找到更多关于我们的信息 在这里