返回

Laravel多对多关系:用groupBy按中间表字段分组

php

Laravel 多对多关系:按中间表字段(周)分组获取数据

咱们在用 Laravel 开发时,处理数据库关系是家常便饭,特别是多对多(Many-to-Many)关系。但有时,简单的 belongsToMany 关联之后,想按中间表的某个字段来组织数据,比如按“周”(week)来分组,可能会稍微卡一下壳。

别担心,这事儿不复杂。咱们来看看具体场景和怎么搞定。

问题来了:按周分组展示训练计划

假设我们有三个模型和对应的表:

  1. Routine (训练计划): routines 表 (id, plan_id, user_id)
  2. Exercise (训练动作): exercises 表 (id, name, description, image)
  3. 中间表 exercise_routine: (id, routine_id, exercise_id, week, day, completed)

这个中间表 exercise_routine 不仅连接了 routinesexercises,还带了额外信息,比如这个动作属于第几周(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 来构建这个结构,虽然思路直接,但不够优雅,而且效率可能不高,尤其当数据量大的时候。

为啥会卡住?分析一下

手动循环处理数据主要有几个小问题:

  1. 代码冗余 : 需要自己维护状态(比如 $previous_week),逻辑写起来容易绕。
  2. 可读性差 : 对比 Laravel 提供的集合方法,手动循环构建复杂结构通常没那么直观。
  3. 潜在性能问题 : 如果在循环内部还有数据库查询(虽然这个例子里没有直接体现),很容易触发 N+1 查询问题。即使没有 N+1,PHP 层面的循环处理大量数据通常不如数据库层面或优化的集合操作快。

Laravel 的 Eloquent ORM 和其强大的集合(Collection)类,就是为了解决这类问题而生的,让数据处理更流畅、更声明式。

解决方案:利用 Eloquent 集合搞定

核心思路是:先通过 Eloquent 关系获取所有关联的 Exercise (以及它们的 pivot 数据),得到一个集合(Collection),然后利用集合提供的 groupBy 方法来按 pivot 表中的 week 字段进行分组。

方案详解:groupBy 集合方法

这个方法简直是为这种场景量身定做的。

  1. 原理和作用 :
    当你通过 $routine->exercises 访问关系时,Laravel 会执行查询,获取所有关联的 Exercise 模型,并将它们连同 withPivot 指定的中间表字段一起,放入一个 Illuminate\Database\Eloquent\Collection 对象中。这个集合对象继承自 Illuminate\Support\Collection,拥有许多方便的数据处理方法,groupBy 就是其中之一。groupBy 方法接收一个回调函数或者一个属性名(支持点语法访问嵌套属性或 pivot 数据),它会遍历集合中的每个元素,根据回调或属性的值将元素分配到不同的组里,最后返回一个新的集合,其中键是分组依据的值,值是包含该组所有元素的子集合。

  2. 代码示例 :

    // 假设 $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);
    }
    
  3. 操作步骤 :

    • 获取你需要处理的 Routine 模型实例。
    • 访问 .exercises 关系(最好通过 load('exercises') 预加载来提高效率)。
    • 在获取到的 $routine->exercises 集合上调用 ->groupBy('pivot.week')
    • 根据需要,可以选择性地使用集合的 mapmapWithKeys 方法来进一步调整输出的数据结构和内容。
  4. 安全建议 :

    • 数据验证:确保传入的 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": ...} }
      
  5. 进阶使用技巧 :

    • 按多个 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 表字段
              ];
          });
      });
      

代码实战整合

把上面讨论的最佳实践整合到一个控制器方法里可能看起来像这样:

<?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.phproutes/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 的强大功能,代码会简洁很多!