Laravel多对多关系:用groupBy按中间表字段分组
2025-04-22 09:25:00
Laravel 多对多关系:按中间表字段(周)分组获取数据
咱们在用 Laravel 开发时,处理数据库关系是家常便饭,特别是多对多(Many-to-Many)关系。但有时,简单的 belongsToMany
关联之后,想按中间表的某个字段来组织数据,比如按“周”(week)来分组,可能会稍微卡一下壳。
别担心,这事儿不复杂。咱们来看看具体场景和怎么搞定。
问题来了:按周分组展示训练计划
假设我们有三个模型和对应的表:
Routine
(训练计划):routines
表 (id, plan_id, user_id)Exercise
(训练动作):exercises
表 (id, name, description, image)- 中间表
exercise_routine
: (id, routine_id, exercise_id, week, day, completed)
这个中间表 exercise_routine
不仅连接了 routines
和 exercises
,还带了额外信息,比如这个动作属于第几周(week
)、星期几(day
)以及是否完成(completed
)。
关系在模型里已经定义好了:
Routine 模型
// app/Models/Routine.php
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Routine extends Model
{
public function exercises(): BelongsToMany
{
// 注意这里 orderBy 了 pivot 表的 week,确保了原始获取时按周有序
return $this->belongsToMany(Exercise::class, 'exercise_routine')
->withPivot('week', 'day', 'completed')
->orderBy('pivot_week', 'asc'); // 注意: orderBy Pivot 列要用 pivot_前缀 或 在 withPivot 后用 ->orderByPivot
// 或者更可靠的 ->orderByRaw('exercise_routine.week ASC'); 如果 orderByPivot 不起作用
}
}
Exercise 模型
// app/Models/Exercise.php
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Exercise extends Model
{
public function routines(): BelongsToMany
{
return $this->belongsToMany(Routine::class, 'exercise_routine')
->withPivot('week', 'day', 'completed');
}
}
小提示:在 orderBy
中间表字段时,直接写 orderBy('week', 'asc')
可能不行,需要写成 orderBy('pivot_week', 'asc')
或者指定全名 orderBy('exercise_routine.week', 'asc')
。在新版 Laravel 中 orderByPivot('week', 'asc')
是更推荐的方式。
现在的目标是,拿到一个 Routine
对象后,获取它关联的所有 Exercise
,并且按照 week
字段组织成类似下面这样的 JSON 结构:
{
"weeks": {
"1": [
{
"id": 10,
"name": "俯卧撑",
"pivot": {
"routine_id": 1,
"exercise_id": 10,
"week": 1,
"day": 1,
"completed": false
}
},
{
"id": 15,
"name": "深蹲",
"pivot": {
"routine_id": 1,
"exercise_id": 15,
"week": 1,
"day": 2,
"completed": false
}
}
],
"2": [
{
"id": 12,
"name": "引体向上",
"pivot": {
"routine_id": 1,
"exercise_id": 12,
"week": 2,
"day": 1,
"completed": false
}
}
]
// ... 更多周
}
}
或者,如果只需要练习的名称,可以进一步简化:
{
"weeks": {
"1": [
{ "name": "俯卧撑" },
{ "name": "深蹲" }
],
"2": [
{ "name": "引体向上" }
]
// ... 更多周
}
}
用户提供的代码尝试用 foreach
循环和手动 array_push
来构建这个结构,虽然思路直接,但不够优雅,而且效率可能不高,尤其当数据量大的时候。
为啥会卡住?分析一下
手动循环处理数据主要有几个小问题:
- 代码冗余 : 需要自己维护状态(比如
$previous_week
),逻辑写起来容易绕。 - 可读性差 : 对比 Laravel 提供的集合方法,手动循环构建复杂结构通常没那么直观。
- 潜在性能问题 : 如果在循环内部还有数据库查询(虽然这个例子里没有直接体现),很容易触发 N+1 查询问题。即使没有 N+1,PHP 层面的循环处理大量数据通常不如数据库层面或优化的集合操作快。
Laravel 的 Eloquent ORM 和其强大的集合(Collection)类,就是为了解决这类问题而生的,让数据处理更流畅、更声明式。
解决方案:利用 Eloquent 集合搞定
核心思路是:先通过 Eloquent 关系获取所有关联的 Exercise
(以及它们的 pivot
数据),得到一个集合(Collection),然后利用集合提供的 groupBy
方法来按 pivot
表中的 week
字段进行分组。
方案详解:groupBy
集合方法
这个方法简直是为这种场景量身定做的。
-
原理和作用 :
当你通过$routine->exercises
访问关系时,Laravel 会执行查询,获取所有关联的Exercise
模型,并将它们连同withPivot
指定的中间表字段一起,放入一个Illuminate\Database\Eloquent\Collection
对象中。这个集合对象继承自Illuminate\Support\Collection
,拥有许多方便的数据处理方法,groupBy
就是其中之一。groupBy
方法接收一个回调函数或者一个属性名(支持点语法访问嵌套属性或pivot
数据),它会遍历集合中的每个元素,根据回调或属性的值将元素分配到不同的组里,最后返回一个新的集合,其中键是分组依据的值,值是包含该组所有元素的子集合。 -
代码示例 :
// 假设 $routine 是你已经获取到的 Routine 模型实例 // 例如:$routine = Routine::find(1); if ($routine) { // 1. 获取关联的 exercises 集合 (包含 pivot 数据) // 推荐使用 load() 预加载,避免 N+1 问题,特别是处理多个 Routine 时 $routine->load('exercises'); $exercises = $routine->exercises; // 这是 Eloquent Collection // 2. 使用 groupBy 按 pivot 表的 week 字段分组 // $groupedExercises 现在是一个以 week 值为键, // 包含对应 Exercise 模型的 Collection 为值的 Collection $groupedByWeek = $exercises->groupBy('pivot.week'); // 3. (可选) 调整结构以匹配期望的 JSON 格式 // groupBy 的结果已经是按 week 分组的集合了 // 如果需要更精确的结构,比如 key 是字符串 'week',内部是周数字 -> exercises 数组 // 可以稍微转换一下,但通常 groupBy 的结果已经很接近需求了 // 直接使用 $groupedByWeek 就可以得到类似下面的结构: // Illuminate\Support\Collection { // 1 => Illuminate\Database\Eloquent\Collection { ... Exercises for week 1 ... }, // 2 => Illuminate\Database\Eloquent\Collection { ... Exercises for week 2 ... }, // ... // } // 如果需要文章开头提到的那种 {"weeks": { "1": [...], "2": [...] }} 格式 $formattedWeeks = ['weeks' => $groupedByWeek]; // 如果还需要对每个周内的 exercise 数据进行简化,比如只留 name $simplifiedGroupedByWeek = $groupedByWeek->map(function ($weekExercises) { // $weekExercises 是每周对应的 Exercise 集合 return $weekExercises->map(function ($exercise) { // 只返回需要的数据,比如 name return ['name' => $exercise->name]; // 或者可以返回更多信息: // return [ // 'id' => $exercise->id, // 'name' => $exercise->name, // 'day' => $exercise->pivot->day, // 获取 pivot 数据 // ]; }); }); // 那么最终格式就是 $formattedWeeksSimplified = ['weeks' => $simplifiedGroupedByWeek]; // 返回 JSON 响应 // return response()->json($formattedWeeks); // 或者 // return response()->json($formattedWeeksSimplified); } else { // 处理 Routine 不存在的情况 // return response()->json(['error' => 'Routine not found'], 404); }
-
操作步骤 :
- 获取你需要处理的
Routine
模型实例。 - 访问
.exercises
关系(最好通过load('exercises')
预加载来提高效率)。 - 在获取到的
$routine->exercises
集合上调用->groupBy('pivot.week')
。 - 根据需要,可以选择性地使用集合的
map
或mapWithKeys
方法来进一步调整输出的数据结构和内容。
- 获取你需要处理的
-
安全建议 :
- 数据验证:确保传入的
routine_id
是有效的,并且用户有权限访问这个Routine
。可以使用 Laravel 的授权策略(Policies)或中间件来做权限检查。 - 防止 N+1 查询:当需要处理多个
Routine
并获取它们按周分组的Exercise
时,务必使用预加载(Eager Loading)。例如,如果你要获取用户的所有 Routines 及其分组后的 Exercises:$routines = Routine::where('user_id', auth()->id()) ->with('exercises') // 预加载 Exercises ->get(); $results = $routines->mapWithKeys(function ($routine) { $groupedExercises = $routine->exercises->groupBy('pivot.week'); // 可选:简化数据结构 $simplifiedGrouped = $groupedExercises->map(function ($weekExercises) { return $weekExercises->map(function($exercise) { return ['name' => $exercise->name]; }); }); return [$routine->id => ['weeks' => $simplifiedGrouped]]; }); // $results 结构: { routine_id_1: {"weeks": ...}, routine_id_2: {"weeks": ...} }
- 数据验证:确保传入的
-
进阶使用技巧 :
- 按多个 Pivot 字段分组 : 如果你想先按周(
week
)再按天(day
)分组,可以这样:$groupedByWeekAndDay = $routine->exercises ->groupBy(['pivot.week', 'pivot.day'], preserveKeys: true); // preserveKeys: true 可以让内层分组的键也保留 (即 day 的值) // 结果类似: // { // 1: { // Week 1 // 1: [ ... exercises for week 1, day 1 ... ], // 2: [ ... exercises for week 1, day 2 ... ] // }, // 2: { // Week 2 // 1: [ ... exercises for week 2, day 1 ... ] // } // }
- 自定义分组键 :
groupBy
方法可以接受一个回调函数,允许你更灵活地定义分组逻辑或修改分组键。$grouped = $routine->exercises->groupBy(function ($exercise) { return 'Week ' . $exercise->pivot->week; // 自定义分组键,比如 "Week 1", "Week 2" });
- 只选择需要的字段 : 为了减少内存占用和提高效率,可以在查询时就只选择需要的字段,包括
pivot
字段。这稍微复杂一点,因为belongsToMany
默认会选择主表所有字段。你可以通过在belongsToMany
定义时附加select
,或者在获取关系后映射数据。// 映射数据的例子 (更常用,在 groupBy 后处理) $simplifiedGroupedByWeek = $groupedByWeek->map(function ($weekExercises) { return $weekExercises->map(function ($exercise) { return [ 'name' => $exercise->name, // Exercise 表字段 'day' => $exercise->pivot->day, // Pivot 表字段 'completed' => $exercise->pivot->completed // Pivot 表字段 ]; }); });
- 按多个 Pivot 字段分组 : 如果你想先按周(
代码实战整合
把上面讨论的最佳实践整合到一个控制器方法里可能看起来像这样:
<?php
namespace App\Http\Controllers;
use App\Models\Routine;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class RoutineExerciseController extends Controller
{
public function showExercisesByWeek(Routine $routine): JsonResponse
{
// 可以在路由模型绑定时或之前进行权限检查
// 预加载 exercises 关系及其 pivot 数据
$routine->load('exercises');
// 按 pivot 表的 week 字段分组
$groupedByWeek = $routine->exercises->groupBy('pivot.week');
// (可选) 格式化输出,只包含所需信息
$formattedWeeks = $groupedByWeek->mapWithKeys(function ($exercisesInWeek, $weekNumber) {
// $exercisesInWeek 是这一周的所有 Exercise 模型的集合
// $weekNumber 是周数 (分组的键)
// 遍历这周的每个 exercise,提取需要的数据
$formattedExercises = $exercisesInWeek->map(function ($exercise) {
return [
'id' => $exercise->id,
'name' => $exercise->name,
// 你可以从 $exercise->pivot 访问所有在 withPivot 定义的字段
'day' => $exercise->pivot->day,
'completed' => $exercise->pivot->completed,
// 如果需要其他 Exercise 表的字段也可以直接加
// 'description' => $exercise->description,
// 'image' => $exercise->image,
];
})->values(); // 使用 values() 移除 map 产生的数字索引,得到一个纯数组
// 返回键值对,键是周数,值是该周格式化后的 exercises 数组
return [$weekNumber => $formattedExercises];
});
// 组装成最终需要的 {"weeks": {...}} 结构
$responseData = ['weeks' => $formattedWeeks];
return response()->json($responseData);
}
}
路由定义示例 (在 routes/api.php
或 routes/web.php
):
use App\Http\Controllers\RoutineExerciseController;
// 使用了路由模型绑定,Laravel 会自动根据 ID 查找 Routine 实例
Route::get('/routines/{routine}/exercises-by-week', [RoutineExerciseController::class, 'showExercisesByWeek']);
注意点 & 性能考量
- N+1 问题是老朋友了 : 千万记得用
load()
或with()
来预加载关系。如果忘了,当你处理很多Routine
时,程序会为每个Routine
单独去查询它的Exercises
,导致数据库压力山大。 - 超大数据量 : 如果
exercise_routine
中间表的数据量达到几十万甚至上百万条,直接load()
全部数据到内存再groupBy
可能不是最优选择。这种极端情况下,可能需要考虑更复杂的策略,比如:- 在数据库层面进行分组聚合(可能需要写更复杂的 Eloquent 查询或使用原生 SQL)。
- 分页处理数据,或者只加载特定周的数据。
- 数据转换时机 : 通常建议先获取完整的 Eloquent 模型集合,然后利用集合方法(
map
,filter
,reduce
,mapWithKeys
等)来塑造成最终需要的结构。这样做代码更清晰,也更容易维护。
现在,你应该能轻松应对 Laravel 多对多关系中按中间表字段分组的需求了。利用好 Eloquent 和 Collection 的强大功能,代码会简洁很多!