
本文详细介绍了在 laravel 递归关系中,如何高效地查询并排除指定父级及其所有子孙节点的数据。通过利用 laravel 的模型关系和自定义查询作用域,结合一个辅助的扁平化函数,本教程提供了一种实用的解决方案,用于处理层级数据结构中复杂的排除逻辑,确保精准获取所需数据。
在构建具有层级结构的应用时,例如分类、标签或评论系统,我们经常会遇到需要处理递归关系的情况。Laravel 提供了强大的 Eloquent ORM,通过自关联关系可以很好地管理这类数据。本教程将深入探讨如何在一个递归模型中,实现一个高级查询,即排除指定父级及其所有子孙节点的数据。
理解递归关系模型
首先,我们定义一个名为 Hobbies 的模型,它代表一个具有层级关系的兴趣爱好列表。该模型通过 parent_id 字段指向其父级爱好,从而形成一个树状结构。
// app/Models/Hobbies.php
hasMany(Hobbies::class, 'parent_id');
}
/**
* 获取当前爱好的父爱好。
*/
public function parent_hobbies()
{
return $this->belongsTo(Hobbies::class, 'parent_id');
}
/**
* 递归获取当前爱好的所有子孙爱好。
*/
public function allsub()
{
return $this->sub_hobbies()->with('allsub');
}
/**
* 递归获取当前爱好的所有祖先爱好。
*/
public function allparent()
{
return $this->parent_hobbies()->with('allparent');
}
// ... 其他方法和作用域将在此处添加
}在上述模型中:
- sub_hobbies 定义了“一对多”的子级关系。
- parent_hobbies 定义了“多对一”的父级关系。
- allsub 和 allparent 是递归关系,它们通过 with('allsub') 和 with('allparent') 实现了加载所有子孙或祖先节点的功能。
核心需求分析
我们的目标是:给定一个特定爱好的 ID,我们希望查询出所有 不属于 该爱好及其任何子孙节点(包括子节点、孙节点等)的爱好。
例如,如果我们的爱好结构如下:
- 爱好 1
- 爱好 11
- 爱好 12
- 爱好 121
- 爱好 122
- 爱好 13
- 爱好 2
- 爱好 21
- 爱好 22
- 爱好 221
- 爱好 222
- 爱好 23
- 爱好 3
- 爱好 31
- 爱好 32
- 爱好 321
- 爱好 322
- 爱好 33如果我们提供“爱好 1”的 ID,我们希望得到的结果是除了“爱好 1”、“爱好 11”、“爱好 12”、“爱好 121”、“爱好 122”、“爱好 13”之外的所有爱好。
实现方案:自定义查询作用域
为了实现这一需求,我们将通过在 Hobbies 模型中添加一个自定义查询作用域(Scope)和辅助方法来完成。
// app/Models/Hobbies.php (续)
flatten($item));
}
}
// 过滤掉空的数组项,确保只返回有效的模型数据
return array_filter($result);
}
/**
* 查询所有不属于给定爱好及其子孙线的爱好。
*
* @param Builder $query Eloquent 查询构建器实例。
* @param int $id 要排除的父级爱好的ID。
* @return Builder 修改后的查询构建器。
*/
public function scopeIsNotLine(Builder $query, int $id): Builder
{
// 1. 获取指定爱好及其所有子孙爱好
// 使用 with('allsub') 递归加载所有子孙节点
$hobbiesToExclude = Hobbies::with('allsub')->where('id', $id)->get()->toArray();
// 2. 将嵌套结果扁平化,并提取所有需要排除的爱好ID
// 调用 flatten 辅助方法将嵌套的爱好数据转换为扁平数组
$flattenedHobbies = collect($this->flatten($hobbiesToExclude));
// 从扁平化后的数据中提取所有爱好的 ID
$excludeIds = $flattenedHobbies->map(function ($item) {
return $item['id'] ?? null; // 确保 id 存在
})->filter()->unique()->all(); // 过滤空值并去重
// 3. 使用 whereNotIn 条件排除这些 ID
$query->whereNotIn('id', $excludeIds);
// 4. (可选) 添加其他过滤条件,例如排除已归档的爱好
// 这里的 is_archive 关系是一个假设,你需要根据实际模型定义
// return $query->whereDoesntHave('is_archive');
return $query; // 如果没有 is_archive 关系,直接返回 $query
}
}flatten 辅助函数详解
flatten 方法是解决问题的关键之一。由于 with('allsub') 加载的数据是一个嵌套的数组结构(因为关系是递归的),我们需要一个方法来遍历这个嵌套结构,并提取出所有独立的爱好项,无论它们位于哪一层。
- 它递归地遍历输入的数组。
- 对于每个数组项,如果它本身是一个数组(意味着它可能包含嵌套关系),它会:
- 提取当前项中所有非数组/非对象的值(即当前模型自身的属性,如 id, name, parent_id)。
- 然后递归调用自身处理当前项的子数组(即嵌套关系的数据),并将结果合并到 result 数组中。
- 最终,它返回一个扁平化的数组,其中包含了所有层级的爱好数据。
scopeIsNotLine 作用域实现
这个作用域是主要的查询逻辑:
获取要排除的爱好及其子孙线: Hobbies::with('allsub')->where('id', $id)->get()->toArray(); 这一步首先通过 with('allsub') 加载指定 ID 的爱好及其所有递归子孙。.get()->toArray() 将 Eloquent 集合转换为 PHP 数组,以便后续的扁平化处理。
扁平化结果并提取 ID: $flattenedHobbies = collect($this->flatten($hobbiesToExclude));$excludeIds = $flattenedHobbies->map(...)->filter()->unique()->all(); 这里利用 flatten 方法将复杂的嵌套数组结构转换为一个简单的数组,其中每个元素都是一个独立的爱好数据数组。接着,通过 Laravel 的集合方法 map 提取出每个爱好的 id,filter 移除可能存在的空值,unique 确保 ID 不重复,最后 all() 转换为纯 PHP 数组。
使用 whereNotIn 排除: $query->whereNotIn('id', $excludeIds); 这是 Eloquent 的核心功能,它会修改当前查询,使其只返回 ID 不在 $excludeIds 列表中的爱好。
额外过滤条件(可选): $query->whereDoesntHave('is_archive'); 原始问题中包含了一个 whereDoesntHave('is_archive') 的条件。这表示在排除指定父子线的同时,还希望排除那些具有 is_archive 关系(例如,表示已归档)的爱好。如果你的模型没有这个关系,或者不需要这个过滤,可以将其移除。
使用方法
一旦 scopeIsNotLine 作用域被定义,你就可以像使用任何其他 Eloquent 作用域一样来使用它:
use App\Models\Hobbies;
// 假设要排除 ID 为 1 的爱好及其所有子孙
$targetId = 1;
$filteredHobbies = Hobbies::isNotLine($targetId)->get();
// $filteredHobbies 将包含所有不属于 ID 为 1 的爱好及其子孙的爱好
foreach ($filteredHobbies as $hobby) {
echo $hobby->name . "\n";
}注意事项与优化
-
性能考虑:
- 对于非常深或非常宽的递归层级,with('allsub') 会导致大量的 JOIN 操作,可能影响查询性能。
- 将 Eloquent 集合转换为数组 (toArray()) 并在 PHP 中进行扁平化处理,对于大规模数据集可能会消耗较多内存和 CPU 资源。
- 如果性能成为瓶颈,可以考虑在数据库层面进行优化,例如使用 递归 CTE (Common Table Expressions)。MySQL 8+、PostgreSQL 和 SQL Server 都支持 CTE,可以在数据库层面直接生成排除列表,从而减少 PHP 端的处理负担。
flatten 方法的健壮性: 当前 flatten 方法假设模型属性是标量或简单的数组。如果模型关系中包含更复杂的对象或集合,可能需要调整 array_filter 的条件。
替代方案:闭包表或路径枚举: 对于非常复杂的递归关系查询,更专业的解决方案是使用 闭包表 (Closure Table) 或 路径枚举 (Path Enumeration) 模式。这些模式通过额外的辅助表或字段来存储节点间的路径信息,从而将递归查询转换为简单的非递归查询,极大地提高查询效率和灵活性。Laravel 社区也有一些包实现了这些模式。
总结
本教程提供了一种在 Laravel 中处理递归关系并排除特定分支的实用方法。通过结合 Eloquent 的递归关系加载、自定义的扁平化函数和查询作用域,我们能够有效地实现复杂的层级数据过滤需求。尽管这种 PHP 端的处理方式对于中小型数据集非常有效,但在处理大规模或深度层级数据时,建议考虑数据库层面的优化方案以获得更好的性能。










