コレクションを取得したときだけモデルのプライマリーキーを変えてSelectする


なぜしようと思ったか

1対多の関係のテーブルでモデルリレーションを駆使してレコードの集計をしたい。
例えばユーザー(uses)がチケット(tickets)を申し込んだ(applies)情報を3つのテーブルで管理しているケース。

users - (1 対 多) - applies - (多 対 1) - tickets

usersテーブル

    public function up()
    {
        Schema::create('users', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->timestamps();
        });
    }
ticketsテーブル

      public function up()
    {
        Schema::create('tickets', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->timestamps();
        });
    }
appliesテーブル

    public function up()
    {
        Schema::create('applies', function (Blueprint $table) {
            $table->id();
            $table->integer('ticket_id');
            $table->integer('user_id');
            $table->timestamps();
        });
    }
User.php
    public function applies()
    {
        return $this->hasMany(Apply::class);
    }

Ticket.php
    public function applies()
    {
        return $this->hasMany(Apply::class);
    }
Apply.php
    public function user()
    {
        return $this->belongsTo(User::class);
    }

    public function ticket()
    {
        return $this->belongsTo(Ticket::class);
    }

このケースで、ticketsのidが1に紐づくappliesを取得すると申し込んだユーザーがわかるが、そのuser_idのユーザーたちそれぞれの全申し込み件数をcountで集計したい。
具体的には、ticketsのid1に申し込みのusersのidが1、2、3の3人の場合、この3人がticketsのid2にも申し込んでいたら、それぞれcount(user_id)で2を取得したい。

最初に考えた方法

tinker起動
$ticket = Ticket::find(1);

$apply_users = $ticket->applies->pluck('user_id'); //[1, 2, 3]

$applies_count = Apply::selectRaw('user_id, COUNT(user_id) AS total')
                ->whereIn('user_id', $apply_users)
                ->groupBy('user_id')
                ->get();

特に深く考えずにやるとこんな感じ。そのまんまですね。
しかしあんまりスマートじゃない気がするし、ticketインスタンスからメソッドチェーンしてできないだろうか・・・

もう少し考えてみる

リレーションをつかってみる

tinker起動
$ticket = Ticket::find(1);

$apply_users = $ticket->applies->pluck('user_id'); //[1, 2, 3]

$applies_count = $ticket->applies[0]->selectRaw('user_id, COUNT(user_id) AS total')
                        ->whereIn('user_id', $apply_users)
                        ->groupBy('user_id')
                        ->get();

リレーションで取得したモデルのうち、1個目のApplyインスタンスを使ってクエリを発行してみる。インデックス0って書くのは・・・なのでまだどうにかできる気がする。

コレクションでうまいことできないか

Laravelはget()で複数レコード取得するとCollectionというクラスにモデルのインスタンスがまとまって返ってくる。例えるなら、ダンボールに詰められて出荷された野菜たちのイメージ。このダンボール単位で中身の検索やらデータの加工ができる。
具体的にはモデルのリレーションはIlluminate\Database\Eloquent\Collectionを返す。

toQueryを使う

toQueryメソッドは、コレクションモデルの主キーに対するwhereIn制約を含むEloquentクエリビルダインスタンスを返します。

これを使うとコレクションクラスを介して取得したモデルインスタンスのプライマリーキーでwhereInできる。でもApplyのプライマリーキーってuser_idじゃなくてidじゃん・・・。

コレクションのときだけプライマリーキーすり替えてみる

モデルの$primaryKeyプロパティオーバーライドすれば、変えられるけどそれだとcreate()のときなどに影響あるのでしたくない。コレクションのときだけ任意でプライマリーキーすり替えられないかと考えてみた。
モデルクラスではカスタムコレクションが定義できるのでそれを使って実装してみる。
ポイントは
・Applyモデルにセッターメソッドを定義
・Applyモデルにnew Collectionメソッドを定義
・App配下にカスタムコレクションとしてApplyCollectionを作成
・カスタムコレクションにsetPrimaryKeyメソッドを定義

Apply.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;
use App\Collections\ApplyCollection;

class Apply extends Model
{
    use HasFactory;

    public function user()
    {
        return $this->belongsTo(User::class);
    }

    public function ticket()
    {
        return $this->belongsTo(Ticket::class);
    }

    public function newCollection(array $models = []): ApplyCollection
    {
        return new ApplyCollection($models);
    }

    public function __invoke(string $key): void
    {
        $this->primaryKey = $key;
    }

    public function scopeUsersAppliesCount(Builder $query): Builder
    {
        return $query->selectRaw('user_id, COUNT(user_id) AS total')->groupBy('user_id');
    }
}

モデルにはnewCollectionを定義してIlluminate\Database\Eloquent\Collectionを継承したカスタムコレクションをインスタンス化して返するようにする。
加えてカスタムモデル側から呼び出すセッターとして___invokeも定義。ちなみにpublicメソッドであればOK。継承元モデルには_invokeの定義はないようなのでオーバライドの心配はないはず。
ついでにクエリもscopeしてコールできるようにしてみた。

App\Collections\AppyCollection.php
<?php

namespace App\Collections;

use Illuminate\Database\Eloquent\Collection;

class ApplyCollection extends Collection
{
    public function setPrimaryKey(): ApplyCollection
    {
        foreach($this->items as $item) {
            $item('user_id');
        }
        return $this;
    }
}

カスタムコレクション側はこんな感じ。setPrimaryKeyではインスタンスから__invokeしている。引数はuser_id固定だがsetPrimaryKeyコール時から渡しても当然OK。

使ってみる

tinker起動
$ticket = Ticket::find(1);

$applies_count = $ticket->applies //App\Collections\ApplyCollectionが返る
                        ->setPrimaryKey()  //プライマリーキーをuser_idに替える
                        ->toQuery()  //user_idでwhereInする
                        ->usersAppliesCount() //selectgroupByする
                        ->get();

こんな感じのメソッドチェーンになった。toQueryでBuilder呼び出す前にsetPrimaryKeyでモデルの$primaryKeyを意図的に変えている。これでtoQueryにwhereInがidからuser_idに替わるので、結果的にApplyCollectionにいるuser_idだけでWhereInしてcount(user_id)してくれた。
laravelはアイディア次第で結構いろいろできちゃうので、他にもいい方法ありそうだけどこんな感じに落ち着いた。