【Laravel】Eager-Loadingでlimitを使ってはいけない

概要

いけないというか、Eager-Loadingでlimitを利用する際は挙動をちゃんと理解していないと思わぬ動作をする。

本題

以下のようなテーブルがあった際に、Bookに紐づくChapterをEager-Loadingを利用して、それぞれの本の最後の章を取得したい場合を考える。

Books
--------------
| id |  name |
|----|-------|
|  1 | book1 |
|  2 | book2 |
|  3 | book3 |
-------------

Chapters
------------------------------
| id | book_id | chapter_num |
|----|---------|-------------|
|  1 |       1 |           1 |
|  2 |       1 |           2 |
|  3 |       2 |           1 |
|  4 |       2 |           2 |
|  5 |       3 |           1 |
------------------------------

そのためにまず、Book-Chapterのテーブル結合を行う。

public function lastChapter()
{
  return $this->hasMany(Chapter::class)
  ->orderBy('chapter_num', 'desc');
}
$books = Book::with(['lastChapter',])->get()

ここで、私は最後の章だけを取得したいが、現在のlastChapterではchapter_num順に並び替えられているだけなので、 limit(1)を付与すれば、各Bookの最終章を取れるのではないか、と考えたのでlimit(1)を付与した。

public function lastChapter()
{
  return $this->hasMany(Chapter::class)
  ->orderBy('chapter_num', 'desc')
  ->limit(1);
}

しかしながら、limit(1)を付与すると3つのBook中2つのBookのlastChapterが空っぽだった。 ここで、Eager-Loadingの挙動について調べてみると、

Eager-Loadingによりlimit(1)を付与する前のlastChapterは以下のように変換される。

SELECT 
* 
FROM `chapters` 
WHERE `chapters`.`book_id` IN ('1', '2', '3')
 ORDER BY `id` DESC

なんと、内部的にはjoinしているのではなく、Chaptersのテーブルから含まれるBookのidをIN句で取得していた。 なるほど、ということはBooksとChaptersをjoinするのではなく、BooksとChaptersを別々に取得して 各Collectionオブジェクトを結合しているらしい。

そのため、limit(1)を付与すると以下のクエリが発行されるが、Chaptersのデータが1行しか取れずに、 Booksの結果CollectionとChaptersの結果Collectionで結合できるのが1行のデータ分しか結合できないため、 3つ中2つのBookのlastChapterが空っぽになるというわけだ。

SELECT 
* 
FROM `chapters` 
WHERE `chapters`.`book_id` IN ('1', '2', '3')
 ORDER BY `id` DESC limit 1

そのため、最終章を取得するためにはEager-Loadingは利用せずにクエリビルダーを使って最適なクエリを自前で記述するか、 Eager-Loadingを利用して最終章以外のデータも取得されてしまうが、それを受け入れて以下のように処理するのが良いだろう。

foreach($books as $book){
  $book->lastChapter->first()
}