Eager loading get n related models per parent in Laravel

Đặt vấn đề

Yêu cầu đặt ra khá đơn giản là mình có 1 bảng Post và 1 bảng Comment, 1 post có nhiều comments, bây giờ mình muốn lấy tất cả các bài post và mỗi bài post mình muốn lấy 1 comment mới nhất sử dụng Eager loading.

Post::with([
    'comments' => function ($query) {
          $query->orderByDesc('created_at')->take(1);
    },
])
->get();

Nó query như này

select * from `posts` limit 1
select * from `posts`
select * from `comments` where `comments`.`post_id` in ('1', '2', '3') order by `created_at` desc limit 1

Nhìn query trên thì chắc chắn bạn đã đoán ra kết quả như thế nào rồi đấy, nó chỉ lấy duy nhất 1 comment của một trong 3 bài post có id 1,2,3 mà có created_at mới nhất mà thôi, cách này không giải quyết được vấn đề rồi.

Giải pháp

Cách đơn giản nhất để giải quyết vấn đề trên là mình sẽ tạo thêm một relation hasOne trong model Post để lấy ra comment mới nhất của bài post đó như sau:

public function latestComment()
{
    return $this->hasOne(Comment::class)->latest();
}

Mình chỉnh lại đoạn trên như sau:

Post::with('latestComment')->get();

Bây giờ thì ta sẽ lấy được những comments mới nhất của các bài post. Tuy nhiên nếu muốn lấy 2 hoặc 3 comments mới nhất của từng bài post thì phải làm thế nào, lúc này chắc chắn không thể sử dụng quan hệ hasOne ở trên rồi. Mình tạo class AbstractEloquent với scope scopeNPerItem như sau:

<?php

namespace App\Eloquent;

use Illuminate\Database\Eloquent\Model;
use DB;

abstract class AbstractEloquent extends Model
{
    public function scopeNPerItem($query, $group, $n = 5)
    {
        $table = ($this->getTable());

        $query->from(DB::raw("(SELECT @rank:=0, @group:=0) as vars, {$table}"));

        if ( ! $query->getQuery()->columns)
        {
        	$query->select("{$table}.*");
        }

        $groupAlias = 'group_' . md5(time());
        $rankAlias  = 'rank_' . md5(time());

        $query->addSelect(DB::raw(
        	"@rank := IF(@group = {$group}, @rank+1, 1) as {$rankAlias}, @group := {$group} as {$groupAlias}"
        ));

        $query->getQuery()->orders = (array) $query->getQuery()->orders;
        array_unshift($query->getQuery()->orders, [
            'column' => $group,
            'direction' => 'asc',
        ]);
        $subQuery = $query->toSql();

        $newBase = $this->newQuery()
        	->from(DB::raw("({$subQuery}) as {$table}"))
        	->mergeBindings($query->getQuery())
        	->where($rankAlias, '<=', $n)
        	->getQuery();

        $query->setQuery($newBase);
    }
}

Bây giờ model Post mình sẽ extends AbstractEloquent thay vì Model. Mình thêm một function trong model Post để lấy ra 2 comments mới nhất như sau:

public function takeComments()
{
    return $this->comments()->nPerItem('post_id', 2)->latest();
}

Bây giờ muốn lấy 2 comments mới nhất cho mỗi bài post sử dụng eager loading sẽ như sau:

Post::with('takeComments')->get();

Kết luận

Hi vọng bài viết sẽ giúp ích gì đó cho bạn. Cảm ơn.