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

# はじめに

Eloquentには`withCount`というのがあって、リレーションの件数だけを求めることができます。([Eloquent:リレーション](https://readouble.com/laravel/5.7/ja/eloquent-relationships.html)の「関連するモデルのカウント」参照)

```php
$posts = App\Post::withCount('comments')->get();

foreach ($posts as $post) {
    echo $post->comments_count;
}
```

これはいい感じにcountを使った副問い合わせになるので一回のクエリで処理されます。

```sql
    select
        *,
        (
            select count(*)
                from comment
                where ...
        ) AS comments_count
        from post
        ...
```


ところが、数まではいらないんだけどあるかないかだけは知りたい、というときがあります。カウントが0かどうかで判定してもいいんですが、mysqlだとcountは数える項目数に比例した時間がかかるのでたくさんぶら下がってると重い処理になってしまいます。existsを使えばそんなことはないので、`withCount`と似た働きをする`withExists`みたいのがあって、`withExists(['comments'])`とすれば

```sql
    select
        *,
        exists (
            select count(*)
                from comment
                where ...
        ) AS comments_exists
        from post
        ...
```

のようにやって欲しいのですが、残念ながら今のところそういうものは用意されていません。

# withExistsを作る

Eloquentにはマクロがあるのでなければ作って機能を追加すればいいのです。`withCount`を参考にして`(select count(*) ...)`のかわりに`exists (select * ...)`となるように改造してやればいいでしょう。

```php
       \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`をもとに作成)

```php
       \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には[それっぽい要望](https://youtrack.jetbrains.com/issue/WI-29871)もあがっているので対応されることを期待。