LoginSignup
5
3

More than 5 years have passed since last update.

Eloquentでrelationのレコードの有無だけをサブクエリで取得したい

Last updated at Posted at 2018-10-24

はじめに

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
            );
        });

以上のマクロ定義をAppServiceProviderboot()などで行えばwithExistsが使えるようになります。

余談

Laravelのマクロはクロージャーの$thisの束縛を変更するので$thisのクラスは変わっているしprivate/protectedなプロパティ/メソッドも使えるのですが、今のところPhpStormにそれを教える術がないので派手に警告が出ます。youtrackにはそれっぽい要望もあがっているので対応されることを期待。

5
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
3