問題
Eager Loading + LIMIT という組み合わせは,Laravel 上で Eloquent ビルダを使う以上そのままでは実現できない。
- Eager-loading with a limit on collection only loads for last element in collection · Issue #18014 · laravel/framework
- 【Laravel】Eager-Loadingでlimitを使ってはいけない - mk-toolブログ
上記の例で books
を全件とってきて,それぞれの本に対する chapters
の先頭5件 を取りたいとき
こうじゃなくて
SELECT * FROM `chapters`
WHERE `chapters`.`book_id` IN (1, 2, 3)
ORDER BY `id`
LIMIT 5
こうしてほしい!
SELECT * FROM `chapters`
WHERE `chapters`.`book_id` = 1
ORDER BY `id`
LIMIT 5
UNION ALL
SELECT * FROM `chapters`
WHERE `chapters`.`book_id` = 2
ORDER BY `id`
LIMIT 5
UNION ALL
SELECT * FROM `chapters`
WHERE `chapters`.`book_id` = 3
ORDER BY `id`
LIMIT 5
SELECT 回数は N+1 になってしまうが,クエリ発行回数自体は1回で済むため,通常よりもある程度は高速化される。N が十分小さければ大きな問題にはならないはずだ。
なお説明するまでもないと思うが,BelongsToMany
など多対多リレーションでは正常に機能しないことに注意されたい。 HasMany
MorphMany
などに限られる。
解決策
マクロの定義
以下のサービスプロバイダを作成して config/app.php
で登録。
app/Providers/MacroServiceProvider.php
<?php
declare(strict_types=1);
namespace App\Providers;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
class MacroServiceProvider extends ServiceProvider
{
/**
* マクロを登録します。
*/
public function boot(): void
{
Collection::macro('loadUsingLimit', function ($relations): Collection {
/** @var Collection $collection */
$collection = $this;
return MacroServiceProvider::loadUsingLimit($collection, is_string($relations) ? func_get_args() : $relations);
});
}
/**
* EagerLoading を IN 句ではなく UNION ALL 句を使って行います。
* LIMIT 句をエンティティごとに設けることができます。
*
* @param Collection $collection
* @param array $relations
* @return Collection
*/
public static function loadUsingLimit(Collection $collection, array $relations): Collection
{
foreach ($relations as $name => $constraints) {
// 数値添字配列形式にも対応
if (is_int($name)) {
$name = $constraints;
if (Str::contains($name, ':')) {
$name = explode(':', 'name', 2)[0];
$constraints = function (Relation $query) use ($name) {
$query->select(explode(',', explode(':', $name, 3)[1]));
};
} else {
$constraints = function () {};
}
}
if ($collection->isNotEmpty()) {
/** @var Relation $relation */
$relation = $collection
->map(function (Model $model) use ($name) {
return $model->{$name}();
})
->each(function (Relation $relation) use ($constraints) {
$constraints($relation);
})
->reduce(function (?Relation $carry, Relation $query) {
return $carry ? $carry->unionAll($query) : $query;
});
$relation->match(
$relation->initRelation($collection->all(), $name),
$relation->get(), $name
);
}
}
return $collection;
}
}
使用例
名前空間などは省略
制約条件を取得時に定義
class Book extends Model
{
public function chapters(): HasMany
{
return $this->hasMany(Chapter::class);
}
}
class Chapter extends Model { }
$books = Book::all()->loadUsingLimit([
'chapters' => function (HasMany $query) {
$query->orderBy('id')->limit(5);
},
]);
制約条件をリレーションとして定義
class Book extends Model
{
public function chapters(): HasMany
{
return $this->hasMany(Chapter::class);
}
public function leadingChapters(int $count = 5): HasMany
{
return $this->chapters()->orderBy('id')->limit($count);
}
}
class Chapter extends Model { }
$books = Book::all()->loadUsingLimit('leadingChapters');