はじめに
EloquentにはwithCount
というのがあって、リレーションの件数だけを求めることができます。(Eloquent:リレーションの「関連するモデルのカウント」参照)
$posts = App\Post::withCount('comments')->get();
foreach ($posts as $post) {
echo $post->comments_count;
}
これはいい感じにcountを使った副問い合わせになるので一回のクエリで処理されます。
select
*,
(
select count(*)
from comment
where ...
) AS comments_count
from post
...
ところが、数まではいらないんだけどあるかないかだけは知りたい、というときがあります。カウントが0かどうかで判定してもいいんですが、mysqlだとcountは数える項目数に比例した時間がかかるのでたくさんぶら下がってると重い処理になってしまいます。existsを使えばそんなことはないので、withCount
と似た働きをするwithExists
みたいのがあって、withExists(['comments'])
とすれば
select
*,
exists (
select count(*)
from comment
where ...
) AS comments_exists
from post
...
のようにやって欲しいのですが、残念ながら今のところそういうものは用意されていません。
withExistsを作る
Eloquentにはマクロがあるのでなければ作って機能を追加すればいいのです。withCount
を参考にして(select count(*) ...)
のかわりにexists (select * ...)
となるように改造してやればいいでしょう。
\Illuminate\Database\Eloquent\Builder::macro('withExists',
function ($relations): Builder {
if (empty($relations)) {
return $this;
}
if ($this->query->columns === null) {
$this->query->select([$this->query->from . '.*']);
}
$relations = \is_array($relations) ? $relations : \func_get_args();
foreach ($this->parseWithRelations($relations) as $name => $constraints) {
// First we will determine if the name has been aliased using an "as" clause on the name
// and if it has we will extract the actual relationship name and the desired name of
// the resulting column. This allows multiple counts on the same relationship name.
$segments = explode(' ', $name);
unset($alias);
if (\count($segments) === 3 && Str::lower($segments[1]) === 'as') {
[$name, $alias] = [$segments[0], $segments[2]];
}
// Here we will get the relationship count query and prepare to add it to the main query
// as a sub-select. First, we'll get the "has" query and use that to get the relation
// count query. We will normalize the relation name then append _count as the name.
// 改造ポイント1: countはいらないので単なるselect文にするクエリビルダを取得
$query = $relation->getRelationExistenceQuery(
$relation->getRelated()->newQuery(),
$this
);
$query->callScope($constraints);
$query->mergeConstraintsFrom($relation->getQuery());
// 改造ポイント2: デフォルトのsuffixを'_exists'にします
$column = $alias ?? Str::snake($name . '_exists');
// 改造ポイント3: existsに展開するselectExistsマクロを使います(後述)
$this->selectExists($query->toBase(), $column);
}
return $this;
});
上で必要になったselectExists
もマクロで追加します。(\Illuminate\Database\Query\Builder::selectSub
をもとに作成)
\Illuminate\Database\Query\Builder::macro('selectExists', function ($query, $as) {
// If the given query is a Closure, we will execute it while passing in a new
// query instance to the Closure. This will give the developer a chance to
// format and work with the query before we cast it to a raw SQL string.
if ($query instanceof \Closure) {
$callback = $query;
$callback($query = $this->forSubQuery());
}
// Here, we will parse this query into an SQL string and an array of bindings
// so we can add it to the query builder using the selectRaw method so the
// query is included in the real SQL generated by this builder instance.
[$query, $bindings] = $this->parseSubSelect($query);
return $this->selectRaw(
// 改造ポイント: EXISTSを追加します
'EXISTS ('.$query.') as '.$this->grammar->wrap($as), $bindings
);
});
以上のマクロ定義をAppServiceProvider
のboot()
などで行えばwithExists
が使えるようになります。
余談
Laravelのマクロはクロージャーの$this
の束縛を変更するので$this
のクラスは変わっているしprivate/protectedなプロパティ/メソッドも使えるのですが、今のところPhpStormにそれを教える術がないので派手に警告が出ます。youtrackにはそれっぽい要望もあがっているので対応されることを期待。