【Laravel】EloquentのCollectionクラスでリレーション的なモノのロードを低負荷で簡単に書く

 Laravel は PHP のフレームワークでよく web アプリケーションを作るために用いられます。Laravel には RDB(Relational Database) と PHP クラスを紐づける ORM(Object-relational mapping)機能を持つクラスである Eloqeunt があります。
 RDBではよく複数のテーブル間に関係があるデータを持たせます。ER図的には次な感じです。

 これを Laravel ではテーブルに紐づく Eloquent クラスである Member, Post, PostComment を用意して次の様に使えます。

/** @var Post[] 会員に紐づく投稿ら */
$posts = $member->posts;
foreach($posts as $post){
  /** @var PostComment[] 投稿に紐づくコメントら */
  $comments = $post->post_comments;
};

 これを雑に使うと N+1 問題を起こしてひどい低パフォーマンスを起こします。これを防ぐために Laravel では with, load といったメソッドがあります。
 あらかじめ呼び出す時には次の様に with メソッドを使うことで少ないクエリ数で必要なレコードを集められます。
 Eloquent:リレーション 6.x Laravel#Eagerロード

// with('posts.comments') とリレーションを指定すると適した形でリレーション先をあらかじめロードしてくれます。
Member::with('posts.comments')->get()->map(static function(Member $member){
    return $member->posts->sum(fn($post)=>$post->comments->count());
});

 また、既にモデルインスタンスがある場合は次の様に load メソッドで同様のリレーション先のロードができます。
Eloquent:リレーション 6.x Laravel#遅延Eagerロード

/** @var Member $member */
$member->load('posts.comments');

 便利ですが、時には生クエリ的に近い Eloquent から離れたものを書くときがあります。そういった時、クエリビルダからの返り値は stdobject 的になります。これにリレーションのロードを with, load 的に行うためには次の様に Collection ないし、配列をまとめてフェッチと割り当てするコードが便利です。

    /**
     * @param object[]|Collection $members member_idプロパティを持つオブジェクトの配列
     */
    public static function loadPosts(Collection $members): Collection
    {
        // 紐づけ用に主キーをCollectionのキーにします。(元のキーが大事な場合はもっと工夫が必要)
        $members = $members->keyBy('member_id');
        // 主キーである member_id の値を集めます
        $memberIds = $members->map->member_id;

        $posts = Post::whereIn('member_id', $memberIds)// members と紐づいているキーで where in
            ->get();// 必要な Post を一度のクエリで全取得

        $posts->each(static function (Post $p) use ($members) {
            $member = $members[$p->member_id];// ↑で主キーをキーにしたCollectionにすることで直ぐに呼べます
            if(!isset($member->posts)){
                $member->posts = collect();// null なら空のCollectionを用意
            }
            $members->posts->push($p);// postsプロパティに Post インスタンスを追加
        });

        // posts を付け終わった members を返却
        return $members;
    }

 ロードすべきものが少ない場合は上記の様に static メソッドで Eloquent クラス内に生やすのがベターです。無用にクラスを増やしすと参照すべき場所が増えすぎて不便になり、何のためにモノリスなコードから構造的プログラミングになったのか分からず本末転倒です。
 そういった時は次のように配列なりコレクションなりを加工するクラスを作ると便利です。


use DB;
use Illuminate\Support\Collection;

class PostCollectionLoader
{
    /** @var object[]|Collection post_idプロパティを持つオブジェクトの配列 */
    private $items;

    /**
     * CoinCollection constructor.
     * @param object[]|Collection $posts post_idプロパティを持つオブジェクトの配列
     */
    public function __construct($posts)
    {
        $this->posts = $posts instanceof Collection ? $posts : collect($posts);
    }

    public function all(): Collection
    {
        return $this->posts;
    }

    /**
     * $this->posts にいいねの総数を追加
     * @return PostCollectionLoader
     */
    public function loadGoodCount(): self
    {
        $postIds = $this->posts->map(static fn ($n) => $n->post_id);

        // いいね数をRDBから取得
        $goods = PostGood::select(['post_id', DB::raw('count(*) as goodCount')])
            ->whereIn('post_id', $postIds)
            ->groupBy('post_id')
            ->get(['post_id'])
            ->keyBy('post_id');

        // post_id のみで with, load 的にロード
        $this->posts->map(static function ($post) use ($goods) {
            if (! isset($goods[$post->post_id])) {
                $post->goodCount = 0;
            } else {
                $post->goodCount = $goods[$post->post_id]->goodCount;
            }

            return $post;
        });

        return $this;
    }

    /**
     * $this->posts にいいねの済みか否かを追加
     * @param  int|string $memberId 既にいいねしたか否かを知りたい会員のID
     * @return self
     */
    public function loadAlreadyGood($memberId): self
    {
        $postIds = $this->posts->map(static fn ($n) => $n->post_id ?? $n->coinId);

        // 該当会員について {[p: post_id]: any} ないいねの存在確認用結果を得るクエリ
        $goods = self::whereMemberId($memberId)
            ->whereIn('post_id', $postIds)
            ->get(['post_id'])
            ->keyBy('post_id');

        // post_id のみで with, load 的にロード
        $this->posts->map(static function ($post) use ($goods) {
            $post->alreadyGood = isset($goods[$post->post_id]);

            return $post;
        });
        return $this;
    }
}

 こうすると呼び出し側からは

$posts = (new PostCollectionLoader($posts))
    ->loadGoodCount()
    ->loadAlreadyGood(auth()->id());// auth()->id()でログインしているユーザのIDを取得

といった感じに書け、使いまわしがしやすいくらい簡単に必要な情報を後付けかつ低負荷でロードできるようになります。
例では Illuminate\Support\Collection を使っているため Laravel のコードとなりますが、特別な機能を使っているわけではないため他フレームワークや生PHPでも応用が利きます。

>株式会社シーポイントラボ

株式会社シーポイントラボ

TEL:053-543-9889
営業時間:9:00~18:00(月〜金)
住所:〒432-8003
   静岡県浜松市中央区和地山3-1-7
   浜松イノベーションキューブ 315
※ご来社の際はインターホンで「316」をお呼びください

CTR IMG