Previously I showed you how to get single related model per each parent, while eager loading the hasMany
relation. That was pretty easy and we just made the most of the Eloquent
.
However, most likely the time will come, when you need several related models per parent.
It won’t be as easy, but it’s far from impossible, so let’s do it!
Imagine classic example:
Post hasMany Comments
First, let me show you how to achieve this in MySQL
We need to group the results by foreign key. For this let’s use MySQL variables:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
mysql> set @group = 0; Query OK, 0 rows affected (0.00 sec) mysql> select id, created_at, post_id, @group := post_id as grp from comments order by post_id limit 10; +-----+---------------------+---------+-----+ | id | created_at | post_id | grp | +-----+---------------------+---------+-----+ | 344 | 2014-08-17 21:25:46 | 1 | 1 | | 320 | 2014-08-17 21:25:45 | 1 | 1 | | 4 | 2014-08-17 21:25:26 | 1 | 1 | | 72 | 2014-08-17 21:25:29 | 1 | 1 | | 158 | 2014-08-17 21:25:37 | 2 | 2 | | 423 | 2014-08-17 21:25:50 | 2 | 2 | | 59 | 2014-08-17 21:25:29 | 2 | 2 | | 227 | 2014-08-17 21:25:40 | 2 | 2 | | 308 | 2014-08-17 21:25:45 | 3 | 3 | | 34 | 2014-08-17 21:25:28 | 3 | 3 | +-----+---------------------+---------+-----+ 10 rows in set (0.00 sec) |
Note: you can’t use group
as an alias, for it’s reserved word of course.
Next add simple rank column:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
mysql> set @rank = 0; Query OK, 0 rows affected (0.00 sec) mysql> select id, created_at, post_id, @rank := @rank+1 as rank, @group := post_id as grp from comments order by post_id limit 10; +-----+---------------------+---------+------+-----+ | id | created_at | post_id | rank | grp | +-----+---------------------+---------+------+-----+ | 344 | 2014-08-17 21:25:46 | 1 | 1 | 1 | | 320 | 2014-08-17 21:25:45 | 1 | 2 | 1 | | 4 | 2014-08-17 21:25:26 | 1 | 3 | 1 | | 72 | 2014-08-17 21:25:29 | 1 | 4 | 1 | | 158 | 2014-08-17 21:25:37 | 2 | 5 | 2 | | 423 | 2014-08-17 21:25:50 | 2 | 6 | 2 | | 59 | 2014-08-17 21:25:29 | 2 | 7 | 2 | | 227 | 2014-08-17 21:25:40 | 2 | 8 | 2 | | 308 | 2014-08-17 21:25:45 | 3 | 9 | 3 | | 34 | 2014-08-17 21:25:28 | 3 | 10 | 3 | +-----+---------------------+---------+------+-----+ 10 rows in set (0.00 sec) |
Then adjust @rank
so it applies to each group:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
mysql> set @rank = 0, @group = 0; Query OK, 0 rows affected (0.00 sec) mysql> select id, created_at, post_id, @rank := IF(@group=post_id, @rank+1, 1) as rank, @group := post_id as grp from comments order by post_id limit 10; +-----+---------------------+---------+------+-----+ | id | created_at | post_id | rank | grp | +-----+---------------------+---------+------+-----+ | 344 | 2014-08-17 21:25:46 | 1 | 1 | 1 | | 320 | 2014-08-17 21:25:45 | 1 | 2 | 1 | | 4 | 2014-08-17 21:25:26 | 1 | 3 | 1 | | 72 | 2014-08-17 21:25:29 | 1 | 4 | 1 | | 158 | 2014-08-17 21:25:37 | 2 | 1 | 2 | | 423 | 2014-08-17 21:25:50 | 2 | 2 | 2 | | 59 | 2014-08-17 21:25:29 | 2 | 3 | 2 | | 227 | 2014-08-17 21:25:40 | 2 | 4 | 2 | | 308 | 2014-08-17 21:25:45 | 3 | 1 | 3 | | 34 | 2014-08-17 21:25:28 | 3 | 2 | 3 | +-----+---------------------+---------+------+-----+ 10 rows in set (0.00 sec) |
This way we can work with rank
and use it to limit the result to N per group
, which is exactly what we need.
You have surely noticed that each query is preceeded by a SET statement. That’s basic method to set a variable in MySQL, however it’s rather tedious, and it will require another DB::statement
call in our code. Instead I will use handy inline variables setting:
SELECT @group := 0, @rank := 0
So, let’s look at the final query that we will execute to fetch 2 latest comments per post:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
mysql> select * from ( -> select id, created_at, post_id, @rank := IF(@group=post_id, @rank+1, 1) as rank, @group := post_id as grp -> from comments, (select @rank := 0, @group := 0) as vars -> order by post_id asc, created_at desc -> ) as comments where rank <= 2 limit 10; +-----+---------------------+---------+------+-----+ | id | created_at | post_id | rank | grp | +-----+---------------------+---------+------+-----+ | 344 | 2014-08-17 21:25:46 | 1 | 1 | 1 | | 320 | 2014-08-17 21:25:45 | 1 | 2 | 1 | | 423 | 2014-08-17 21:25:50 | 2 | 1 | 2 | | 227 | 2014-08-17 21:25:40 | 2 | 2 | 2 | | 308 | 2014-08-17 21:25:45 | 3 | 1 | 3 | | 83 | 2014-08-17 21:25:30 | 3 | 2 | 3 | | 428 | 2014-08-17 21:25:51 | 4 | 1 | 4 | | 351 | 2014-08-17 21:25:47 | 5 | 1 | 5 | | 276 | 2014-08-17 21:25:43 | 5 | 2 | 5 | | 444 | 2014-08-17 21:25:51 | 6 | 1 | 6 | +-----+---------------------+---------+------+-----+ 10 rows in set (0.01 sec) |
Finally translate it to Eloquent
, for example as a BaseModel scope:
Here’s all the code in single method, nothing extracted for presentation purpose
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
<?php class BaseModel extends \Eloquent { /** * query scope nPerGroup * * @return void */ public function scopeNPerGroup($query, $group, $n = 10) { // queried table $table = ($this->getTable()); // initialize MySQL variables inline $query->from( DB::raw("(SELECT @rank:=0, @group:=0) as vars, {$table}") ); // if no columns already selected, let's select * if ( ! $query->getQuery()->columns) { $query->select("{$table}.*"); } // make sure column aliases are unique $groupAlias = 'group_'.md5(time()); $rankAlias = 'rank_'.md5(time()); // apply mysql variables $query->addSelect(DB::raw( "@rank := IF(@group = {$group}, @rank+1, 1) as {$rankAlias}, @group := {$group} as {$groupAlias}" )); // make sure first order clause is the group order $query->getQuery()->orders = (array) $query->getQuery()->orders; array_unshift($query->getQuery()->orders, ['column' => $group, 'direction' => 'asc']); // prepare subquery $subQuery = $query->toSql(); // prepare new main base Query\Builder $newBase = $this->newQuery() ->from(DB::raw("({$subQuery}) as {$table}")) ->mergeBindings($query->getQuery()) ->where($rankAlias, '<=', $n) ->getQuery(); // replace underlying builder to get rid of previous clauses $query->setQuery($newBase); } } |
And extending it Post
model:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
<?php class Post extends BaseModel { /** * Get latest 5 comments from hasMany relation. * * @return Illuminate\Database\Eloquent\Relations\HasMany */ public function latestComments() { return $this->comments()->latest()->nPerGroup('post_id', 5); } /** * Post has many Comments * * @return Illuminate\Database\Eloquent\Relations\HasMany */ public function comments() { return $this->hasMany('Comment'); } } |
Now we can fetch 5 comments per post with eager loading as easily as this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
[1] > $posts = Post::with('latestComments')->get(); // object(Illuminate\Database\Eloquent\Collection)( // // ) [2] > DB::getQueryLog(); // array( // 0 => array( // 'query' => 'select * from `posts`', // 'bindings' => array( // // ), // 'time' => 1.46 // ), // 1 => array( // 'query' => 'select * from (select `comments`.*, @rank := IF(@group = post_id, @rank+1, 1) as rank_643b92db067a46e286c8e914151584a1, @group := post_id as group_643b92db067a46e286c8e914151584a1 from (SELECT @rank:=0, @group:=0) as vars, comments order by `post_id` asc, `created_at` desc) as comments where `rank_643b92db067a46e286c8e914151584a1` <= ? and `comments`.`post_id` in (?, ?, ... ?, ?)', // 'bindings' => array( // 0 => 5, // 1 => '1', // 2 => '2', ... // 150 => '150' // ), // 'time' => 12.6 // ) // ) // Let's check if actually there are no more comments for any post: [3] > $posts->reduce(function($result, $post) { return max($result, $post->latestComments->count()); }); // 5 |
DONE!
Now you have a handy method for getting N per parent
for hasMany
relation like above.
Share your thoughts about it in the comments.