PHP
MySQL
SQL
laravel
Eloquent

【Laravel】 第2回 Eloquent ソースコードリーディング - リレーションの取得

構成

予備知識

リレーションを自作するときに定義する必要があるメソッド

リレーションを自作する際は Relation クラス,あるいはそれを継承して作られたクラスを継承することになるが,まず抽象クラス Relation がどのようなメソッドの実装を要求しているのか見てみよう。

    /**
     * Set the base constraints on the relation query.
     *
     * @return void
     */
    abstract public function addConstraints();

    /**
     * Set the constraints for an eager load of the relation.
     *
     * @param  array  $models
     * @return void
     */
    abstract public function addEagerConstraints(array $models);

    /**
     * Initialize the relation on a set of models.
     *
     * @param  array   $models
     * @param  string  $relation
     * @return array
     */
    abstract public function initRelation(array $models, $relation);

    /**
     * Match the eagerly loaded results to their parents.
     *
     * @param  array   $models
     * @param  \Illuminate\Database\Eloquent\Collection  $results
     * @param  string  $relation
     * @return array
     */
    abstract public function match(array $models, Collection $results, $relation);

    /**
     * Get the results of the relationship.
     *
     * @return mixed
     */
    abstract public function getResults();
メソッド名 ローディング戦略 概要
addConstraints Lazy Loading
Eager Loading
ローディング戦略共通の制約と
Lazy Loading のみに使用する制約をどちらも付与する。
addEagerConstraints Eager Loading Eager Loading のみに使用する制約を付与する。
initRelation Eager Loading 1件も取得されなかったときのために
あらかじめ空値を埋める。
match Eager Loading 取得結果から外部キーを手がかりにして,
所属する Model を割り出してセットする。
getResults Lazy Loading クエリの実行と結果の整形を行う。

基本的には書いてあるとおりなのだが, addConstraints() のみ少々特殊な書き方が必要となる。以下が定石だ。

    public function addConstraints()
    {
        // ローディング戦略共通の制約

        if (static::$constraints) {
            // Lazy Loading のみに使用する制約
        }
    }

    public function addEagerConstraints(array $models)
    {
        // Eager Loading のみに使用する制約
    }

正直これに関しては,わざわざこう書かずとも

    public function addConstraints()
    {
        // ローディング戦略共通の制約
        // Lazy Loading のみに使用する制約
    }

    public function addEagerConstraints(array $models)
    {
        // ローディング戦略共通の制約
        // Eager Loading のみに使用する制約
    }

とか

    public function addBaseConstraints()
    {
        // ローディング戦略共通の制約
    }

    public function addLazyConstraints()
    {
        // Lazy Loading のみに使用する制約
    }

    public function addEagerConstraints(array $models)
    {
        // Eager Loading のみに使用する制約
    }

のように書かせたほうがいい気がするが,現状では最初のように if 分岐が必須となっている。

さて,以下にて Laravel によって実装されている HasMany の例を紹介する。クラス継承によって共通化されている記述もあるので,厳密にソースコードをそのまま貼るのではなく,「実質こう書かれているのと同じ」という建前で少々編集を加えている。

HasMany の実装例

    public function addConstraints()
    {
        // 共通制約は無し
        if (static::$constraints) {
            // Lazy Loading では WHERE user_id = 1 AND user_id NOT NULL のような制約を付与する
            $this->query->where($this->foreignKey, '=', $this->getParentKey());
            $this->query->whereNotNull($this->foreignKey);
        }
    }

    public function addEagerConstraints(array $models)
    {
        // Eager Loading では WHERE user_id IN (1, 2, 3) のような制約を付与する
        $this->query->whereIn(
            $this->foreignKey, $this->getKeys($models, $this->localKey)
        );
    }

    public function initRelation(array $models, $relation)
    {
        foreach ($models as $model) {
            // 空コレクションで初期化する
            $model->setRelation($relation, $this->related->newCollection());
        }
        return $models;
    }

    public function match(array $models, Collection $results, $relation)
    {
        $foreign = $this->getForeignKeyName();

        // [Post.user_id => [Post1, Post2, Post3, ...]] のような形式の辞書を定義
        $dictionary = $results->mapToDictionary(function ($result) use ($foreign) {
            return [$result->{$foreign} => $result];
        })->all();

        // User.id と Post.user_id が一致するものがあれば User に対して Post のコレクションをセットする
        foreach ($models as $model) {
            if (isset($dictionary[$key = $model->getAttribute($this->localKey)])) {
                $model->setRelation(
                    $relation, $this->related->newCollection($dictionary[$key])
                );
            }
        }

        return $models;
    }

    public function getResults()
    {
        // 普通にクエリを実行してそのまま返すだけ
        return $this->query->get();
    }
}

リレーションを取得するときに何が起こっているか?

定義

app/User.php
<?php

namespace App;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class User extends Model
{
    protected $with = ['posts'];

    public function posts(): HasMany
    {
        return $this->hasMany(Post::class);
    }

    public function comments(): HasMany
    {
        return $this->hasMany(Comment::class);
    }

    public function profiles(): HasMany
    {
        return $this->hasMany(Profile::class);
    }
}

今回はここで

$users = User::all();
foreach ($users as $user) {
    $user->comments;
}
$users->load('profiles');

を実行した場合に何が起こるかを追ってみよう。

(1) モデルの取得

$users = User::all();

最初は前回の復習だ。前回はIDを指定して1件のみの取得を行ったが,今回は複数取得する。

Model::all() の実装は以下のようになっている。まずIDを持たないモデルインスタンスが生成され,続いて posts リレーションを既定の Eager Loading としてセットされた Eloquent Builder が生成される。そして Eloquent\Builder::get() によって Model を要素として持つ Eloquent Collection が取得される。

    /**
     * Get all of the models from the database.
     *
     * @param  array|mixed  $columns
     * @return \Illuminate\Database\Eloquent\Collection|static[]
     */
    public static function all($columns = ['*'])
    {
        return (new static)->newQuery()->get(
            is_array($columns) ? $columns : func_get_args()
        );
    }
    /**
     * Execute the query as a "select" statement.
     *
     * @param  array  $columns
     * @return \Illuminate\Database\Eloquent\Collection|static[]
     */
    public function get($columns = ['*'])
    {
        $builder = $this->applyScopes();

        // If we actually found models we will also eager load any relationships that
        // have been specified as needing to be eager loaded, which will solve the
        // n+1 query issue for the developers to avoid running a lot of queries.
        if (count($models = $builder->getModels($columns)) > 0) {
            $models = $builder->eagerLoadRelations($models);
        }

        return $builder->getModel()->newCollection($models);
    }

(2) 既定の Eager Loading の読み込み

導入

前回はここの細部を省略したが,今回は Eloquent\Builder::eagerLoadRelations() の実装を追ってみよう。

    /**
     * Eager load the relationships for the models.
     *
     * @param  array  $models
     * @return array
     */
    public function eagerLoadRelations(array $models)
    {
        foreach ($this->eagerLoad as $name => $constraints) {
            // For nested eager loads we'll skip loading them here and they will be set as an
            // eager load on the query to retrieve the relation so that they will be eager
            // loaded on that query, because that is where they get hydrated as models.
            if (strpos($name, '.') === false) {
                $models = $this->eagerLoadRelation($models, $name, $constraints);
            }
        }

        return $models;
    }

Eloquent\Builder::$eagerLoad は, リレーション名 => 制約付与クロージャ という形式になっている。これは Eloquent\Builder::with() によって,最初の Eloquent Builder 生成時にすでにセットされているものである。

    /**
     * Set the relationships that should be eager loaded.
     *
     * @param  mixed  $relations
     * @return $this
     */
    public function with($relations)
    {
        $eagerLoad = $this->parseWithRelations(is_string($relations) ? func_get_args() : $relations);>

        $this->eagerLoad = array_merge($this->eagerLoad, $eagerLoad);

        return $this;
    }
    /**
     * Parse a list of relations into individuals.
     *
     * @param  array  $relations
     * @return array
     */
    protected function parseWithRelations(array $relations)
    {
        $results = [];

        foreach ($relations as $name => $constraints) {
            // If the "relation" value is actually a numeric key, we can assume that no
            // constraints have been specified for the eager load and we'll just put
            // an empty Closure with the loader so that we can treat all the same.
            if (is_numeric($name)) {
                $name = $constraints;

                list($name, $constraints) = Str::contains($name, ':')
                            ? $this->createSelectWithConstraint($name)
                            : [$name, function () {
                                //
                            }];
            }

            // We need to separate out any nested includes. Which allows the developers
            // to load deep relationships using "dots" without stating each level of
            // the relationship with its own key in the array of eager load names.
            $results = $this->addNestedWiths($name, $results);

            $results[$name] = $constraints;
        }

        return $results;
    }

User::with(['posts' => function ($query) { $query->where(...); }])

ソースを見れば分かる通り,このように制約をクロージャで付与することができるが,既定の Eager Loading の場合は単に配列がそのまま渡される形になる。また posts:id,text のように,特にカラム名の指定なども行っていない場合は

User::with(['posts' => function () {}])

と何もしないクロージャを指定することだけをやっているのに等しい。この後,もしネストしたリレーションを posts.user のようなドットチェインで表現していた場合は Eloquent\Builder::addNestedWith() によって分解されるが,今回は割愛する。

では,もとの処理に戻ろう。 今回の場合,

            if (strpos($name, '.') === false) {
                $models = $this->eagerLoadRelation($models, $name, $constraints);
            }

の部分は

            if (strpos('posts', '.') === false) {
                $models = $this->eagerLoadRelation($models, 'posts', function () {});
            }

として実行されることになる。取得時,ドットチェイン表現のネストしたリレーションは後回しにするようになっているが,今回は無関係なので割愛する。

Eloquent\Builder::eagerLoadRelation()

さて,リレーションの動きを理解する上で最も重要なメソッドが Eloquent\Builder::eagerLoadRelation() だ。

    /**
     * Eagerly load the relationship on a set of models.
     *
     * @param  array  $models
     * @param  string  $name
     * @param  \Closure  $constraints
     * @return array
     */
    protected function eagerLoadRelation(array $models, $name, Closure $constraints)
    {
        // First we will "back up" the existing where conditions on the query so we can
        // add our eager constraints. Then we will merge the wheres that were on the
        // query back to it in order that any where conditions might be specified.
        $relation = $this->getRelation($name);

        $relation->addEagerConstraints($models);

        $constraints($relation);

        // Once we have the results, we just match those back up to their parent models
        // using the relationship instance. Then we just return the finished arrays
        // of models which have been eagerly hydrated and are readied for return.
        return $relation->match(
            $relation->initRelation($models, $name),
            $relation->getEager(), $name
        );
    }

最初の Eloquent\Builder::getRelation() の実装はこのようになっている。

    /**
     * Get the relation instance for the given relation name.
     *
     * @param  string  $name
     * @return \Illuminate\Database\Eloquent\Relations\Relation
     */
    public function getRelation($name)
    {
        // We want to run a relationship query without any constrains so that we will
        // not have to remove these where clauses manually which gets really hacky
        // and error prone. We don't want constraints because we add eager ones.
        $relation = Relation::noConstraints(function () use ($name) {
            try {
                return $this->getModel()->newInstance()->$name();
            } catch (BadMethodCallException $e) {
                throw RelationNotFoundException::make($this->getModel(), $name);
            }
        });

        $nested = $this->relationsNestedUnder($name);

        // If there are nested relationships set on the query, we will put those onto
        // the query instances so that they can be handled after this relationship
        // is loaded. In this way they will all trickle down as they are loaded.
        if (count($nested) > 0) {
            $relation->getQuery()->with($nested);
        }

        return $relation;
    }

まだ情報量が多い。ネストしたリレーションのことはいいので,もっと絞ろう。定義されていないリレーションメソッドを呼んだときに発生する BadMethodCallException もラップする例外を作っているだけなので省く。実質やっていることはこれだけだ。

    /**
     * Get the relation instance for the given relation name.
     *
     * @param  string  $name
     * @return \Illuminate\Database\Eloquent\Relations\Relation
     */
    public function getRelation($name)
    {
        // We want to run a relationship query without any constrains so that we will
        // not have to remove these where clauses manually which gets really hacky
        // and error prone. We don't want constraints because we add eager ones.
        return Relation::noConstraints(function () use ($name) {
            return $this->getModel()->newInstance()->$name();
        });
    }

Relation::noConstraints() では,一時的にクラス変数を切り替えた上でリレーションの取得を行っている。

    /**
     * Indicates if the relation is adding constraints.
     *
     * @var bool
     */
    protected static $constraints = true;

    /**
     * Run a callback with constraints disabled on the relation.
     *
     * @param  \Closure  $callback
     * @return mixed
     */
    public static function noConstraints(Closure $callback)
    {
        $previous = static::$constraints;

        static::$constraints = false;

        // When resetting the relation where clause, we want to shift the first element
        // off of the bindings, leaving only the constraints that the developers put
        // as "extra" on the relationships, and not original relation constraints.
        try {
            return call_user_func($callback);
        } finally {
            static::$constraints = $previous;
        }
    }

これにより, 「ローディング戦略共通の制約条件」のみが付与された HasMany オブジェクトが取得できるが,補足程度にその過程も一応調べておこう。まず自分で実装した User::posts() が,IDを持たないモデルインスタンスの上において実行される。Model::hasMany() を呼び出しているが,これは実際にはトレイト HasRelationships::hasMany() として実装されている。

    /**
     * Define a one-to-many relationship.
     *
     * @param  string  $related
     * @param  string  $foreignKey
     * @param  string  $localKey
     * @return \Illuminate\Database\Eloquent\Relations\HasMany
     */
    public function hasMany($related, $foreignKey = null, $localKey = null)
    {
        $instance = $this->newRelatedInstance($related);

        $foreignKey = $foreignKey ?: $this->getForeignKey();

        $localKey = $localKey ?: $this->getKeyName();

        return $this->newHasMany(
            $instance->newQuery(), $this, $instance->getTable().'.'.$foreignKey, $localKey
        );
    }
    /**
     * Instantiate a new HasMany relationship.
     *
     * @param  \Illuminate\Database\Eloquent\Builder  $query
     * @param  \Illuminate\Database\Eloquent\Model  $parent
     * @param  string  $foreignKey
     * @param  string  $localKey
     * @return \Illuminate\Database\Eloquent\Relations\HasMany
     */
    protected function newHasMany(Builder $query, Model $parent, $foreignKey, $localKey)
    {
        return new HasMany($query, $parent, $foreignKey, $localKey);
    }

HasMany::__construct() の実装は以下のようになっている。冒頭では端折ったが, HasManyHasOneOrMany の継承クラスとして実装されており,コンストラクタは共通化されている。

    /**
     * Create a new has one or many relationship instance.
     *
     * @param  \Illuminate\Database\Eloquent\Builder  $query
     * @param  \Illuminate\Database\Eloquent\Model  $parent
     * @param  string  $foreignKey
     * @param  string  $localKey
     * @return void
     */
    public function __construct(Builder $query, Model $parent, $foreignKey, $localKey)
    {
        $this->localKey = $localKey;
        $this->foreignKey = $foreignKey;

        parent::__construct($query, $parent);
    }

そして Relation::__construct() はこうなっている。

    /**
     * Create a new relation instance.
     *
     * @param  \Illuminate\Database\Eloquent\Builder  $query
     * @param  \Illuminate\Database\Eloquent\Model  $parent
     * @return void
     */
    public function __construct(Builder $query, Model $parent)
    {
        $this->query = $query;
        $this->parent = $parent;
        $this->related = $query->getModel();

        $this->addConstraints();
    }

つまり, HasMany インスタンスの生成と同時に HasMany::addConstraints() は呼ばれているのだ。

続いて, HasMany::addEagerConstraints() の呼出しによって「ローディング戦略共通の制約条件」に加えて「Eager Loading のみに使用する制約条件」が加わった HasMany オブジェクトが取得できる。

    /**
     * Set the constraints for an eager load of the relation.
     *
     * @param  array  $models
     * @return void
     */
    public function addEagerConstraints(array $models)
    {
        $this->query->whereIn(
            $this->foreignKey, $this->getKeys($models, $this->localKey)
        );
    }

その下で $constraints($relation) が呼び出されているが,今回は何もしないクロージャが実行されるだけである。

そして結果取得のための下準備として, HasMany::initRelation() が実行される。冒頭で紹介してあるように,空コレクションでリレーションを埋めるだけだ。

    /**
     * Initialize the relation on a set of models.
     *
     * @param  array   $models
     * @param  string  $relation
     * @return array
     */
    public function initRelation(array $models, $relation)
    {
        foreach ($models as $model) {
            $model->setRelation($relation, $this->related->newCollection());
        }

        return $models;
    }

準備の後に, HasMany::getEager() が実行される。このクラスでは特にこのメソッドの継承は行われていないので, Relation クラスのものが実行される。単に結果を取得するだけだ。

    /**
     * Get the relationship for eager loading.
     *
     * @return \Illuminate\Database\Eloquent\Collection
     */
    public function getEager()
    {
        return $this->get();
    }

仕上げに, HasMany::match() が実行される。冒頭で紹介したように辞書の作成を行った上でのマッチングを行っている。(詳細は割愛)

以上だ。大変長くなったが,既定の Eager Loading の読み込み処理はこれで完結した。ここから先は大した情報量が無いのでクールダウン感覚で読んでいただきたい。

(3) Lazy Loading の読み込み

foreach ($users as $user) {
    $user->comments;
}

未ロードのリレーションに対して $user->comments のように取得を試みた場合, Lazy Loading が実行される。

まず実行されるのは PHPer ならお馴染みマジックメソッド __get() であり,これは HasAttributes::getAttribute() を呼び出している。

    /**
     * Dynamically retrieve attributes on the model.
     *
     * @param  string  $key
     * @return mixed
     */
    public function __get($key)
    {
        return $this->getAttribute($key);
    }
    /**
     * Get an attribute from the model.
     *
     * @param  string  $key
     * @return mixed
     */
    public function getAttribute($key)
    {
        if (! $key) {
            return;
        }

        // If the attribute exists in the attribute array or has a "get" mutator we will
        // get the attribute's value. Otherwise, we will proceed as if the developers
        // are asking for a relationship's value. This covers both types of values.
        if (array_key_exists($key, $this->attributes) ||
            $this->hasGetMutator($key)) {
            return $this->getAttributeValue($key);
        }

        // Here we will determine if the model base class itself contains this given key
        // since we don't want to treat any of those methods as relationships because
        // they are all intended as helper methods and none of these are relations.
        if (method_exists(self::class, $key)) {
            return;
        }

        return $this->getRelationValue($key);
    }

モデルがDB上でテーブルのフィールドとして持つ持つ通常の属性ではなく,リレーションである場合には HasAttributes::getRelationValue() に到達する。

    /**
     * Get a relationship.
     *
     * @param  string  $key
     * @return mixed
     */
    public function getRelationValue($key)
    {
        // If the key already exists in the relationships array, it just means the
        // relationship has already been loaded, so we'll just return it out of
        // here because there is no need to query within the relations twice.
        if ($this->relationLoaded($key)) {
            return $this->relations[$key];
        }

        // If the "attribute" exists as a method on the model, we will just assume
        // it is a relationship and will load and return results from the query
        // and hydrate the relationship's value on the "relationships" array.
        if (method_exists($this, $key)) {
            return $this->getRelationshipFromMethod($key);
        }
    }
    /**
     * Determine if the given relation is loaded.
     *
     * @param  string  $key
     * @return bool
     */
    public function relationLoaded($key)
    {
        return array_key_exists($key, $this->relations);
    }

ここでリレーションがすでに読み込み済みである場合には User::$relations 上にキャッシュされている値を使用し,未取得の場合のみ実際にロードが行われる。 null は読み込み済みである ことに注意。 isset() じゃなくて array_key_exists() を使っているのにはちゃんと訳がある。

    /**
     * Get a relationship value from a method.
     *
     * @param  string  $method
     * @return mixed
     *
     * @throws \LogicException
     */
    protected function getRelationshipFromMethod($method)
    {
        $relation = $this->$method();

        if (! $relation instanceof Relation) {
            throw new LogicException(sprintf(
                '%s::%s must return a relationship instance.', static::class, $method
            ));
        }

        return tap($relation->getResults(), function ($results) use ($method) {
            $this->setRelation($method, $results);
        });
    }

ここでまた同じように HasMany インスタンスを取得しているが, 今回は Relation::noConstraints() を呼んでいない。 そのため, Lazy Loading のための制約条件が付与される。

    public function addConstraints()
    {
        if (static::$constraints) {
            $this->query->where($this->foreignKey, '=', $this->getParentKey());
            $this->query->whereNotNull($this->foreignKey);
        }
    }
    /**
     * Get the results of the relationship.
     *
     * @return mixed
     */
    public function getResults()
    {
        return $this->query->get();
    }

最終的に HasMany::getResults() が呼び出されてめでたしめでたし。

(4) コレクションに対する Eager Loading の読み込み

$users->load('profiles');

結果取得後に後から読み込む Eager Loading なので, Lazy Eager Loading とか呼ばれているらしい (なんじゃそりゃ)

では Eloquent\Collection::load() の実装を見てみよう。

    /**
     * Load a set of relationships onto the collection.
     *
     * @param  array|string  $relations
     * @return $this
     */
    public function load($relations)
    {
        if ($this->isNotEmpty()) {
            if (is_string($relations)) {
                $relations = func_get_args();
            }

            $query = $this->first()->newQueryWithoutRelationships()->with($relations);

            $this->items = $query->eagerLoadRelations($this->items);
        }

        return $this;
    }

といってももう説明することが無い。 Eloquent\Builder::eagerLoadRelations() についても,さっきの既定の Eager Loading の処理と全く同じである。

$query = $this->first()->newQueryWithoutRelationships()->with($relations);

注意すべきはこの1行。 Eloquent Collection は基本的に 1 種類のモデルだけを含んでいる ことが想定されており,このメソッドもそれが前提となっている。リレーションを取得するために先頭1インスタンスがサンプルとして利用されている。

またここでは第1回で紹介した Model::newQueryWithoutRelationships() が利用されている。既定の $with がセットされることを防ぐためだ。

あとがき

この記事を書くきっかけとなったのは,「プロダクトで自分が書いた自作リレーションを他の人が読めない」という問題を危惧していたことである。公式マニュアルのどこにも書いてなくて,Laravel 本体のソースコードを読んだ人だけが手にいられれる知識。これを仕事仲間に読んでもらえば自分も一安心(たぶん)

さあ皆さんも,Laravel組み込みのリレーションだけで要件を満たせないときは,積極的にリレーション自作していきましょう!諦めてコントローラにクエリのべた書き始めるのはまだ早い!