PHP
laravel

【Laravel】EagerLoadまとめ。動的プロパティとEloquentリレーションの違いなど

はじめに

Eagerloadを使うとSQLクエリがごっそり減って気持ちいいですよね。
ただ、少し複雑な制約が入るとあれどうするんだっけ?ということがよくあったので、使用方法などを改めてまとめることにしました。
hasManyとは?といったリレーションの基本については説明していません。

Eagerloadの前にリレーションはなにを返すかを知る

公式サイトにものっている次のようなUserモデルとpostsという1対多のリレーションメソッドがあったとします。

Userモデル
<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    /**
     * ユーザーの全ポストの取得
     */
    public function posts()
    {
        return $this->hasMany('App\Post');
    }
}

あるひとりのUserが持つ全てのPostを取得したい場合おそらくこんな感じで取得すると思います。

idが1のUserの全Postを取得
$user = App\User::find(1);
$posts = $user->posts;

この$postsに入っているデータはなにかというとPostモデルのインスタンスのコレクションです。
つまり$user->postsはコレクションを返しています。

リレーションメソッドpostsは、return $this->hasMany('App\Post');としているので、$this->hasMany('App\Post')を返していることは確実です。
では、この$this->hasMany('App\Post')がコレクションを返すのでしょうか?

実はそうではなく、$this->hasMany('App\Post')はHasManyオブジェクトというものを返します。
$user->postsはリレーションメソッドpostsを呼んでいるのではなく、動的プロパティというものを呼び出しています。
よく考えればそうなのですが、posts()としていないので関数は実行されておらず、リレーションメソッドpostsの戻り値が返ってくるわけではないんです。

じゃあ動的プロパティとはなんなんだという疑問が出てきます。

動的プロパティとはなにか?

モデルのインスタンスをdd($user)のような感じで出力してみるとわかりますが、インスタンスのプロパティにはattributesと呼ばれる各レコードのカラムの値の他に、relationsというものもあって、その中身を見ると定義したリレーションメソッド名をkey、リレーションメソッドの制約に従って取得したリレーション先のモデルインスタンスのコレクションをvalueとした連想配列が入っています。
ちなみに、HasManyではコレクションが入りますが、HasOneではひとつのモデルインスタンスが直で入ります。
attiributeは各レコード固定の値になりますが、relations内の値は定義されたリレーションメソッドやリレーション先のテーブル内容によって動的に変わるので、動的プロパティと呼ばれるのだと思います。
なるほど便利〜。

動的プロパティではなくリレーションメソッドを使用するとどうなるか?

先ほどの全Postを取得する場合と少しやることを変えて、有効な全Postを取得するコードを書いてみます。
リレーションメソッドpostsをそのまま使用する場合、次のようなコードになるかと思います。

idが1のUserの有効な全Postを取得
$user = App\User::find(1);
$posts = $user->posts()->where('active', 1)->get();

ここでは$user->postsではなく、$user->posts()となりました。
つまり、動的プロパティではなく、リレーションメソッドが返ります。
どういうことかというと、$user->posts()の時点では、return $this->hasMany('App\Post');の結果のHasManyオブジェクトが返ってきているということになります。

そして、ひとつ重要なことはHasManyオブジェクトなどの Eloquentリレーションオブジェクトはクエリビルダとしても動作する ということです。
クエリビルダはLaravelでSQLの記法を書きやすくするもので、where('active', 1)といった記法をメソッドチェーン的に繋げられます。
コレクションが返ってくる動的プロパティではそのまま繋げられないのですが、Eloquentリレーションオブジェクトであればそのまま繋げて制約を追加することが可能ということですね。

そうなるとEloquentリレーションの方がいいんじゃないの?という気もしてくるのですが、動的プロパティは「遅延ロード」されるという性質を持っています。
遅延ロードとは、アクセスされたときにだけリレーションのデータをロードするというもので、このため、あらかじめアクセスしておいてEagerLoad(熱心なロード)ができるのです。
つまり、ざっくりいうと、 動的プロパティを返すようにしないとEagerLoadが使えない ということです!

ここでようやくEagerLoadのやり方について説明

EagerLoadはN+1問題を解決するために存在しています。
N+1問題とはざっくりいうと、「ループのなかでSQLを都度発行するようなコードだとクエリが膨大になって重くなるよ」という問題です。

例えば、下記のようなBookモデルがあって、各Bookと1対1のAuthorモデルと繋げるリレーションメソッドauthorが定義されていたとします。

Bookモデル
<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Book extends Model
{
    /**
     * この本を書いた著者を取得
     */
    public function author()
    {
        return $this->belongsTo('App\Author');
    }
}

これで、全BookのAuthorの名前を取得する場合、次のように書くとN+1問題にぶつかります。

全Bookを取得し、foreach内で各BookのAuthorを取得する
$books = App\Book::all();

foreach ($books as $book) {
    echo $book->author->name;
}

これを実行すると大体次のようなSQLが発行されるかと思います。

SELECT * FROM books;
SELECT * FROM authors WHERE book_id = 1;
SELECT * FROM authors WHERE book_id = 2;
SELECT * FROM authors WHERE book_id = 3;
.
.

Bookが全部で25レコードあるとしたら、25+1で合計26のクエリが発行されます。なのでN+1問題と呼ばれます。
このうち大部分のSQLはbook_idが違うだけなので無駄ですよね。
これをEagerLoadで削減できます。

全Bookを取得し、authorをEagerLoadしておく
$books = App\Book::with('author')->get();

foreach ($books as $book) {
    echo $book->author->name;
}

with(リレーションメソッド名)の部分がEagerLoadです。
こうすることで、次の2クエリしか発行されないようになります。
Nの数が大きければ大きいほど、重要な削減につながりますね。

SELECT * FROM books;
SELECT * FROM authors WHERE id IN (1, 2, 3, 4, 5, ...)

いろいろなEagerLoad

上の例はかなり簡単なEagerLoadの例ですが、実務ではもっといろいろな制約があったり、ひとつのリレーションでは足りないということが多いので、Laravelもいろいろ用意してくれています。

複数のリレーションに対するEagerLoad

withに渡す引数を配列にするだけです。

複数のリレーションに対するEagerLoad
$books = App\Book::with(['author', 'publisher'])->get();

ネストしたEagerLoad

リレーションで取得した先のモデルからさらに別のリレーションにつなげるような場合です。
ドット記法が使えます。

ネストしたEagerLoad
$books = App\Book::with('author.contacts')->get();

遅延EagerLoad

親のモデルを取得した後に、ある条件によってeagerloadするかを決めたいときなど。
withの代わりにloadを使います。

$books = App\Book::all();

if ($someCondition) {
    $books->load('author', 'publisher');
}

EagerLoadへの制約

使用するリレーションにさらに制約をかける場合。
例では、postのtitleにfirstという言葉を含むという制約をかけています。
whereだけでなくorderByなど他のクエリビルダも使えます。

EagerLoadへの制約
$user = App\User::with(['posts' => function ($query) {
    $query->where('title', 'like', '%first%');
}])->find(1);
$posts = $user->posts;

foreach ($posts as $post) {
    echo $post->title;
}

EagerLoadへの制約の注意点

EagerLoadへの制約はこう書いてはいけません。

EagerLoadへの制約をあとまわしにする
$user = App\User::with('posts')->find(1);;
$posts = $user->posts()->where('title', 'like', '%first%')->get();

foreach ($posts as $post) {
    echo $post->title;
}

たしかにposts()とすればEloquentリレーションが返ってくるのでクエリビルダであるwhereメソッドを繋げられるのですが、
EagerLoadを使用する場合はEloquentリレーションではなく、 全て動的プロパティでデータを取得する必要があります。
上でも軽く説明しましたが、EagerLoadを使い始めたばかりだとここはハマるポイントではないかなと思います。

ただ上記のEagerLoadへの制約のような無名関数で制約をかける方法は他で同じコードを書かないような書捨ての場合に行う方法であって、実務ではpostsとは別のリレーションメソッドを作成してそれを使用するのがよいかと思います。
上記の例だと下記のようなものになります。

Userモデル
<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    /**
     * ユーザーの全ポストの取得
     */
    public function posts()
    {
        return $this->hasMany('App\Post');
    }

    /**
     * タイトルにfirstの文字が入るPostを取得する
     */
    public function postsInTitleFirst()
    {
        return $this->posts()->where('title', 'like', '%first%');
    }
}
EagerLoadへの制約(別のリレーションメソッドを使用)
$user = App\User::with('postsInTitleFirst')->find(1);
$posts = $user->postsInTitleFirst;

foreach ($posts as $post) {
    echo $post->title;
}

このようにすれば動的プロパティを使用できていて、可読性も高いコードになります。

動的プロパティにより複雑な制約をかけたいとき

上記のような例はfirstという文字がはいっているという決め打ちの制約なので、postsInTitleFirstというリレーションメソッドを作ることができましたが、動的に変更される任意の文字が入る制約の場合どうすればいいでしょうか?
その場合、自分は 動的プロパティで返ってきたコレクションに対して制約をかける という方法をとっています。

EagerLoadへの制約をあとまわしにする
$word = '%first%'; // 任意の文字が入る

$user = App\User::with('posts')->find(1);

// filterメソッドで各postのtitleに$wordが入っているもののみのコレクションにするようにする
$posts = $user->posts->filter(function($post) use ($word) {
    return str_contains($post->title, $word);
});

foreach ($posts as $post) {
    echo $post->title;
}

Laravelのコレクションはかなり高機能なので、filterやfirstなどの各コレクションアイテムに制約をかけるメソッドで使えば、動的な制約にも対応できます。
動的プロパティでコレクションが返ってきたあとの話なので、もちろんEagerLoadもちゃんとうまくいきます。
もしもっといい方法があれば教えてください〜。

まとめ

  • 「動的プロパティ」は遅延ロードするからEagerLoadに使える
  • 「Eloquentリレーション」は遅延ロードしないけどクエリビルダに繋げられる
  • EagerLoadを使用する際は「動的プロパティ」でデータを取得するようにする
  • 動的な制約はコレクションに対して行えば対応できる

参考

Eloquent:リレーション 5.5 Laravel