staudenmeir/laravel-adjacency-list

使用公共表表达式(CTE)进行递归Laravel Eloquent关系


README

CI Code Coverage Latest Stable Version Total Downloads License

此Laravel Eloquent扩展通过使用公共表表达式(CTE)提供对的递归关系

兼容性

  • MySQL 8.0+
  • MariaDB 10.2+
  • PostgreSQL 9.4+
  • SQLite 3.8.3+
  • SQL Server 2008+
  • SingleStore 8.1+ (仅)
  • Firebird

安装

composer require staudenmeir/laravel-adjacency-list:"^1.0"

如果您在Windows的PowerShell中(例如在VS Code中),请使用此命令

composer require staudenmeir/laravel-adjacency-list:"^^^^1.0"

版本

用法

该包提供递归关系以遍历两种数据结构

树:每个节点一个父节点(一对多)

使用该包遍历具有每个节点一个父节点的树结构。用例可能是递归分类、页面层次结构或嵌套评论。

支持Laravel 5.5+。

入门

考虑以下表架构,用于树的层次数据

Schema::create('users', function (Blueprint $table) {
    $table->id();
    $table->unsignedBigInteger('parent_id')->nullable();
});

在您的模型中使用HasRecursiveRelationships特质来处理递归关系

class User extends Model
{
    use \Staudenmeir\LaravelAdjacencyList\Eloquent\HasRecursiveRelationships;
}

默认情况下,特质期望名为parent_id的父键。您可以通过覆盖getParentKeyName()来自定义它

class User extends Model
{ 
    public function getParentKeyName()
    {
        return 'parent_id';
    }
}

默认情况下,特质使用模型的主键作为本地键。您可以通过覆盖getLocalKeyName()来自定义它

class User extends Model
{
    public function getLocalKeyName()
    {
        return 'id';
    }
}

包含的关系

特质提供各种关系

  • ancestors():模型的递归父级。
  • ancestorsAndSelf():模型的递归父级和自身。
  • bloodline():模型的祖先、后代和自身。
  • children():模型直接的后代。
  • childrenAndSelf():模型的直接后代和自身。
  • descendants():模型的递归后代。
  • descendantsAndSelf():模型的递归后代和自身。
  • parent():模型直接的父亲。
  • parentAndSelf():模型的直接父亲和自身。
  • rootAncestor():模型的最顶层父亲。
  • rootAncestorOrSelf():模型的最顶层父亲或自身。
  • siblings():父亲的其它后代。
  • siblingsAndSelf():父亲的所有后代。
$ancestors = User::find($id)->ancestors;

$users = User::with('descendants')->get();

$users = User::whereHas('siblings', function ($query) {
    $query->where('name', 'John');
})->get();

$total = User::find($id)->descendants()->count();

User::find($id)->descendants()->update(['active' => false]);

User::find($id)->siblings()->delete();

特质提供了tree()查询作用域,从根开始获取所有模型

$tree = User::tree()->get();

treeOf()允许您查询具有根模型自定义约束的树

$constraint = function ($query) {
    $query->whereNull('parent_id')->where('list_id', 1);
};

$tree = User::treeOf($constraint)->get();

您还可以传递最大深度

$tree = User::tree(3)->get();

$tree = User::treeOf($constraint, 3)->get();

过滤器

特质提供了查询作用域,按模型在树中的位置过滤模型

  • hasChildren():有后代的模型。
  • hasParent():有父亲的模型。
  • isLeaf()/doesntHaveChildren():没有后代的模型。
  • isRoot():没有父亲的模型。
$noLeaves = User::hasChildren()->get();

$noRoots = User::hasParent()->get();

$leaves = User::isLeaf()->get();
$leaves = User::doesntHaveChildren()->get();

$roots = User::isRoot()->get();

排序

特质提供了查询作用域,按广度优先或深度优先排序模型

  • breadthFirst():在获取后代之前获取兄弟。
  • depthFirst():先获取子节点,再获取兄弟节点。
$tree = User::tree()->breadthFirst()->get();

$descendants = User::find($id)->descendants()->depthFirst()->get();

深度

祖先、血统、后代和树查询的结果包括一个额外的 depth 列。

它包含模型相对于查询父级的深度。对于后代,深度为正;对于祖先,深度为负

$descendantsAndSelf = User::find($id)->descendantsAndSelf()->depthFirst()->get();

echo $descendantsAndSelf[0]->depth; // 0
echo $descendantsAndSelf[1]->depth; // 1
echo $descendantsAndSelf[2]->depth; // 2

如果您的表已经包含一个 depth 列,可以通过覆盖 getDepthName() 来更改列名

class User extends Model
{
    public function getDepthName()
    {
        return 'depth';
    }
}
深度约束

您可以使用 whereDepth() 查询范围来根据模型的相对深度过滤模型

$descendants = User::find($id)->descendants()->whereDepth(2)->get();

$descendants = User::find($id)->descendants()->whereDepth('<', 3)->get();

带有 whereDepth() 约束的查询,即使限制了最大深度,也会在内部构建整个(子)树。使用 withMaxDepth() 来设置最大深度,这样可以提高查询性能,只需构建树请求的部分

$descendants = User::withMaxDepth(3, function () use ($id) {
    return User::find($id)->descendants;
});

这也适用于负深度(技术上相当于最小值)

$ancestors = User::withMaxDepth(-3, function () use ($id) {
    return User::find($id)->ancestors;
});

路径

祖先、血统、后代和树查询的结果还包括一个额外的 path 列。

它包含从查询父级到模型的点分隔的本地键路径

$descendantsAndSelf = User::find(1)->descendantsAndSelf()->depthFirst()->get();

echo $descendantsAndSelf[0]->path; // 1
echo $descendantsAndSelf[1]->path; // 1.2
echo $descendantsAndSelf[2]->path; // 1.2.3

如果您的表已经包含一个 path 列,可以通过覆盖 getPathName() 来更改列名

class User extends Model
{
    public function getPathName()
    {
        return 'path';
    }

您还可以通过覆盖 getPathSeparator() 来自定义路径分隔符

class User extends Model
{
    public function getPathSeparator()
    {
        return '.';
    }
}

自定义路径

您还可以向查询结果中添加自定义路径列

class User extends Model
{
    public function getCustomPaths()
    {
        return [
            [
                'name' => 'slug_path',
                'column' => 'slug',
                'separator' => '/',
            ],
        ];
    }
}

$descendantsAndSelf = User::find(1)->descendantsAndSelf;

echo $descendantsAndSelf[0]->slug_path; // user-1
echo $descendantsAndSelf[1]->slug_path; // user-1/user-2
echo $descendantsAndSelf[2]->slug_path; // user-1/user-2/user-3

您还可以反转自定义路径

class User extends Model
{
    public function getCustomPaths()
    {
        return [
            [
                'name' => 'reverse_slug_path',
                'column' => 'slug',
                'separator' => '/',
                'reverse' => true,
            ],
        ];
    }
}

嵌套结果

使用结果集合上的 toTree() 方法来生成嵌套树

$users = User::tree()->get();

$tree = $users->toTree();

它递归地设置 children 关系

[
  {
    "id": 1,
    "children": [
      {
        "id": 2,
        "children": [
          {
            "id": 3,
            "children": []
          }
        ]
      },
      {
        "id": 4,
        "children": [
          {
            "id": 5,
            "children": []
          }
        ]
      }
    ]
  }
]

初始查询和递归查询约束

您可以在 CTE 的初始和递归查询中添加自定义约束。考虑一个在跳过非活动用户及其后代的同时遍历树的查询

$tree = User::withQueryConstraint(function (Builder $query) {
   $query->where('users.active', true);
}, function () {
   return User::tree()->get();
});

您还可以使用 withInitialQueryConstraint() / withRecursiveQueryConstraint() 仅在初始或递归查询中添加自定义约束。

其他方法

特质还提供了检查模型之间关系的方法

  • isChildOf(Model $model):检查当前模型是否是给定模型的子模型。
  • isParentOf(Model $model):检查当前模型是否是给定模型的父模型。
  • getDepthRelatedTo(Model $model):返回当前模型相对于给定模型的深度。
$rootUser = User::create(['parent_id' => null]); 
$firstLevelUser = User::create(['parent_id' => $rootUser->id]); 
$secondLevelUser = User::create(['parent_id' => $firstLevelUser->id]);  

$isChildOf = $secondLevelUser->isChildOf($firstLevelUser); // Output: true
$isParentOf = $rootUser->isParentOf($firstLevelUser); // Output: true
$depthRelatedTo = $secondLevelUser->getDepthRelatedTo($rootUser); // Output: 2

自定义关系

您还可以定义自定义关系以递归检索相关模型。

HasManyOfDescendants

考虑 UserPost 之间的 HasMany 关系

class User extends Model
{
    public function posts()
    {
        return $this->hasMany(Post::class);
    }
}

定义一个 HasManyOfDescendants 关系以获取用户的全部帖子及其后代的帖子

class User extends Model
{
    public function recursivePosts()
    {
        return $this->hasManyOfDescendantsAndSelf(Post::class);
    }
}

$recursivePosts = User::find($id)->recursivePosts;

$users = User::withCount('recursivePosts')->get();

使用 hasManyOfDescendants() 仅获取后代的帖子

class User extends Model
{
    public function descendantPosts()
    {
        return $this->hasManyOfDescendants(Post::class);
    }
}
BelongsToManyOfDescendants

考虑 UserRole 之间的 BelongsToMany 关系

class User extends Model
{
    public function roles()
    {
        return $this->belongsToMany(Role::class);
    }
}

定义一个 BelongsToManyOfDescendants 关系以获取用户的全部角色及其后代的角色

class User extends Model
{
    public function recursiveRoles()
    {
        return $this->belongsToManyOfDescendantsAndSelf(Role::class);
    }
}

$recursiveRoles = User::find($id)->recursiveRoles;

$users = User::withCount('recursiveRoles')->get();

使用 belongsToManyOfDescendants() 仅获取后代的角色

class User extends Model
{
    public function descendantRoles()
    {
        return $this->belongsToManyOfDescendants(Role::class);
    }
}
MorphToManyOfDescendants

考虑 UserTag 之间的 MorphToMany 关系

class User extends Model
{
    public function tags()
    {
        return $this->morphToMany(Tag::class, 'taggable');
    }
}

定义一个 MorphToManyOfDescendants 关系以获取用户的全部标签及其后代的标签

class User extends Model
{
    public function recursiveTags()
    {
        return $this->morphToManyOfDescendantsAndSelf(Tag::class, 'taggable');
    }
}

$recursiveTags = User::find($id)->recursiveTags;

$users = User::withCount('recursiveTags')->get();

使用 morphToManyOfDescendants() 仅获取后代的标签

class User extends Model
{
    public function descendantTags()
    {
        return $this->morphToManyOfDescendants(Tag::class, 'taggable');
    }
}
MorphedByManyOfDescendants

考虑 CategoryPost 之间的 MorphedByMany 关系

class Category extends Model
{
    public function posts()
    {
        return $this->morphedByMany(Post::class, 'categorizable');
    }
}

定义一个 MorphedByManyOfDescendants 关系以获取类别及其后代的全部帖子

class Category extends Model
{
    public function recursivePosts()
    {
        return $this->morphedByManyOfDescendantsAndSelf(Post::class, 'categorizable');
    }
}

$recursivePosts = Category::find($id)->recursivePosts;

$categories = Category::withCount('recursivePosts')->get();

使用 morphedByManyOfDescendants() 仅获取后代的帖子

class Category extends Model
{
    public function descendantPosts()
    {
        return $this->morphedByManyOfDescendants(Post::class, 'categorizable');
    }
}
中间作用域

您可以通过添加或删除中间作用域来调整后代查询(例如,子用户)

User::find($id)->recursivePosts()->withTrashedDescendants()->get();

User::find($id)->recursivePosts()->withIntermediateScope('active', new ActiveScope())->get();

User::find($id)->recursivePosts()->withIntermediateScope(
    'depth',
    function ($query) {
        $query->whereDepth('<=', 10);
    }
)->get();

User::find($id)->recursivePosts()->withoutIntermediateScope('active')->get();
Laravel 之外的使用

如果您在 Laravel 之外使用此包或已禁用 staudenmeir/laravel-cte 的包发现,则需要将公共表表达式(CTE)的支持添加到相关模型中

class Post extends Model
{
    use \Staudenmeir\LaravelCte\Eloquent\QueriesExpressions;
}

深度关系连接

您可以通过使用 staudenmeir/eloquent-has-many-deep 将递归关系与其他关系连接起来,从而将其纳入深层关系。这适用于 AncestorsBloodlineDescendants 关系(Laravel 9+)。

考虑一个 HasMany 关系,在 UserPost 之间建立深层关系以获取一个用户的下级所有帖子。

User → 下级 → User → 多对一 → Post

安装额外的包,将 HasRelationships 特性添加到递归模型中,并 定义 深层关系。

class User extends Model
{
    use \Staudenmeir\EloquentHasManyDeep\HasRelationships;
    use \Staudenmeir\LaravelAdjacencyList\Eloquent\HasRecursiveRelationships;

    public function descendantPosts(): \Staudenmeir\EloquentHasManyDeep\HasManyDeep
    {
        return $this->hasManyDeepFromRelations(
            $this->descendants(),
            (new static)->posts()
        );
    }
    
    public function posts()
    {
        return $this->hasMany(Post::class);
    }
}

$descendantPosts = User::find($id)->descendantPosts;

目前,递归关系只能位于深层关系的开始。

  • 支持:User → 下级 → User → 多对一 → Post
  • 不支持:Post → 属于 → User → 下级 → User

已知问题

MariaDB 尚未支持 子查询中的相关CTE。这影响了如 User::whereHas('descendants')User::withCount('descendants') 的查询。

图:每个节点多个父节点(多对多)

您还可以使用此包遍历具有多个父节点的图,这些节点定义在辅助表中。用例可能是物料清单(BOM)或家谱。

支持 Laravel 9+。

入门

考虑以下表架构,用于存储作为节点和边的有向图

Schema::create('nodes', function (Blueprint $table) {
    $table->id();
});

Schema::create('edges', function (Blueprint $table) {
    $table->unsignedBigInteger('source_id');
    $table->unsignedBigInteger('target_id');
    $table->string('label');
    $table->unsignedBigInteger('weight');
});

在您的模型中使用 HasGraphRelationships 特性来处理图关系,并指定辅助表名称

class Node extends Model
{
    use \Staudenmeir\LaravelAdjacencyList\Eloquent\HasGraphRelationships;

    public function getPivotTableName(): string
    {
        return 'edges';
    }
}

默认情况下,特性期望辅助表中有一个名为 parent_id 的父键和一个名为 child_id 的子键。您可以通过重写 getParentKeyName()getChildKeyName() 来自定义它们。

class Node extends Model
{
    public function getParentKeyName(): string
    {
        return 'source_id';
    }
  
    public function getChildKeyName(): string
    {
        return 'target_id';
    }
}

默认情况下,特质使用模型的主键作为本地键。您可以通过覆盖getLocalKeyName()来自定义它

class Node extends Model
{
    public function getLocalKeyName(): string
    {
        return 'id';
    }
}

包含的关系

特质提供各种关系

  • ancestors():节点的递归父级。
  • ancestorsAndSelf():节点的递归父级和自身。
  • children():节点的直接子级。
  • childrenAndSelf():节点的直接子级和自身。
  • descendants():节点的递归子级。
  • descendantsAndSelf():节点的递归子级和自身。
  • parents():节点的直接父级。
  • parentsAndSelf():节点的直接父级和自身。
$ancestors = Node::find($id)->ancestors;

$nodes = Node::with('descendants')->get();

$nodes = Node::has('children')->get();

$total = Node::find($id)->descendants()->count();

Node::find($id)->descendants()->update(['active' => false]);

Node::find($id)->parents()->delete();

辅助列

类似于 BelongsToMany 关系,除了父键和子键之外,您还可以从辅助表检索其他列。

class Node extends Model
{
    public function getPivotColumns(): array
    {
        return ['label', 'weight'];
    }
}

$nodes = Node::find($id)->descendants;

foreach ($nodes as $node) {
    dump(
        $node->pivot->label,
        $node->pivot->weight
    );
}

循环检测

如果您的图包含循环,您需要启用循环检测以防止无限循环。

class Node extends Model
{
    public function enableCycleDetection(): bool
    {
        return true;
    }
}

您还可以检索循环的开始,即第一个重复的节点。使用此选项,查询结果包括一个 is_cycle 列,指示节点是否为循环的一部分。

class Node extends Model
{
    public function enableCycleDetection(): bool
    {
        return true;
    }

    public function includeCycleStart(): bool
    {
        return true;
    }
}

$nodes = Node::find($id)->descendants;

foreach ($nodes as $node) {
    dump($node->is_cycle);
}

子图

特性提供了 subgraph() 查询作用域,以获取自定义约束的子图。

$constraint = function ($query) {
    $query->whereIn('id', $ids);
};

$subgraph = Node::subgraph($constraint)->get();

您可以将最大深度作为第二个参数传递。

$subgraph = Node::subgraph($constraint, 3)->get();

排序

特性提供了查询作用域来按广度优先或深度优先排序节点。

  • breadthFirst():在获取后代之前获取兄弟。
  • depthFirst():先获取子节点,再获取兄弟节点。
$descendants = Node::find($id)->descendants()->breadthFirst()->get();

$descendants = Node::find($id)->descendants()->depthFirst()->get();

深度

祖先、子级和子图查询的结果包含一个额外的 depth 列。

它包含节点相对于查询父级的 相对 深度。对于子级,深度为正,对于祖先,深度为负。

$descendantsAndSelf = Node::find($id)->descendantsAndSelf()->depthFirst()->get();

echo $descendantsAndSelf[0]->depth; // 0
echo $descendantsAndSelf[1]->depth; // 1
echo $descendantsAndSelf[2]->depth; // 2

如果您的表已经包含一个 depth 列,可以通过覆盖 getDepthName() 来更改列名

class Node extends Model
{
    public function getDepthName(): string
    {
        return 'depth';
    }
}
深度约束

您可以使用 whereDepth() 查询作用域通过节点的相对深度过滤节点。

$descendants = Node::find($id)->descendants()->whereDepth(2)->get();

$descendants = Node::find($id)->descendants()->whereDepth('<', 3)->get();

具有 whereDepth() 约束的查询限制最大深度仍然在内部构建整个(子)图。使用 withMaxDepth() 设置一个最大深度,通过仅构建请求的图部分来提高查询性能。

$descendants = Node::withMaxDepth(3, function () use ($id) {
    return Node::find($id)->descendants;
});

这也适用于负深度(技术上相当于最小值)

$ancestors = Node::withMaxDepth(-3, function () use ($id) {
    return Node::find($id)->ancestors;
});

路径

祖先、子级和子图查询的结果包含一个额外的 path 列。

它包含从查询的父节点到节点的点分隔的本地键路径

$descendantsAndSelf = Node::find(1)->descendantsAndSelf()->depthFirst()->get();

echo $descendantsAndSelf[0]->path; // 1
echo $descendantsAndSelf[1]->path; // 1.2
echo $descendantsAndSelf[2]->path; // 1.2.3

如果您的表已经包含一个 path 列,可以通过覆盖 getPathName() 来更改列名

class Node extends Model
{
    public function getPathName(): string
    {
        return 'path';
    }
}

您还可以通过覆盖 getPathSeparator() 来自定义路径分隔符

class Node extends Model
{
    public function getPathSeparator(): string
    {
        return '.';
    }
}

自定义路径

您还可以向查询结果中添加自定义路径列

class Node extends Model
{
    public function getCustomPaths(): array
    {
        return [
            [
                'name' => 'slug_path',
                'column' => 'slug',
                'separator' => '/',
            ],
        ];
    }
}

$descendantsAndSelf = Node::find(1)->descendantsAndSelf;

echo $descendantsAndSelf[0]->slug_path; // node-1
echo $descendantsAndSelf[1]->slug_path; // node-1/node-2
echo $descendantsAndSelf[2]->slug_path; // node-1/node-2/node-3

您还可以反转自定义路径

class Node extends Model
{
    public function getCustomPaths(): array
    {
        return [
            [
                'name' => 'reverse_slug_path',
                'column' => 'slug',
                'separator' => '/',
                'reverse' => true,
            ],
        ];
    }
}

嵌套结果

使用结果集合上的 toTree() 方法来生成嵌套树

$nodes = Node::find($id)->descendants;

$tree = $nodes->toTree();

它递归地设置 children 关系

[
  {
    "id": 1,
    "children": [
      {
        "id": 2,
        "children": [
          {
            "id": 3,
            "children": []
          }
        ]
      },
      {
        "id": 4,
        "children": [
          {
            "id": 5,
            "children": []
          }
        ]
      }
    ]
  }
]

初始查询和递归查询约束

您可以为CTE的初始和递归查询添加自定义约束。考虑一个查询,您想要遍历节点的后代,同时跳过非活动节点及其后代

$descendants = Node::withQueryConstraint(function (Builder $query) {
   $query->where('nodes.active', true);
}, function () {
   return Node::find($id)->descendants;
});

您还可以使用 withInitialQueryConstraint() / withRecursiveQueryConstraint() 仅在初始或递归查询中添加自定义约束。

深度关系连接

您可以通过使用 staudenmeir/eloquent-has-many-deep (Laravel 9+) 将递归关系包含到深层关系中,通过将它们与其他关系连接起来。

考虑 NodePost 之间的 HasMany 关系,并构建深层关系以获取节点后代的全部帖子

Node → 后代 → Node → 多个 → Post

安装额外的包,将 HasRelationships 特性添加到递归模型中,并 定义 深层关系。

class Node extends Model
{
    use \Staudenmeir\EloquentHasManyDeep\HasRelationships;
    use \Staudenmeir\LaravelAdjacencyList\Eloquent\HasGraphRelationships;

    public function descendantPosts(): \Staudenmeir\EloquentHasManyDeep\HasManyDeep
    {
        return $this->hasManyDeepFromRelations(
            $this->descendants(),
            (new static)->posts()
        );
    }
    
    public function posts()
    {
        return $this->hasMany(Post::class);
    }
}

$descendantPosts = Node::find($id)->descendantPosts;

目前,递归关系只能位于深层关系的开始。

  • 支持:Node → 后代 → Node → 多个 → Post
  • 不支持:Post → 属于 → Node → 后代 → Node

已知问题

MariaDB 尚未支持 子查询中的相关CTE。这影响了如 Node::whereHas('descendants')Node::withCount('descendants') 的查询。

包冲突

贡献

请参阅 CONTRIBUTINGCODE_OF_CONDUCT 了解详细信息。