Blog スタッフブログ

Laravel PHP システム開発

[Laravel]Eloquentの構文とPHPマジックメソッドの関連性についての備忘録

こんにちは、株式会社MIXシステム開発担当のBloomです。

今回はEloquentを利用する際に利用する構文がどのようにしてPHPの言語仕様に基づいて実行されているのか、調査した内容を共有させていただきたいと思います。

Laravel 10.xに基づいた内容であり、仕様はソースおよびこちらのドキュメントを参照しています。

Eloquentのリレーション構文の実現

Illuminate/Database/Eloquent/Modelを継承したモデルクラスでテーブルやリレーションなどを定義した後、モデルクラスに対してアロー演算子でのアクセスが可能になります。

// コード例
class User extends Model
{
    public function posts()
    {
        return $this->hasMany(Post::class);
    }
}
class AnyController {
    public function example()
    {
        $user = User::first();
        $user->posts; // hasManyで定義し、ユーザモデルと1-n関係の投稿モデルを取得
    }
}

アロー演算子はインスタンスが持つメソッドの呼び出しやプロパティへのアクセスを行いますが、モデルクラス側で明示的に宣言せずともプロパティへのアクセスが行えているようです。

これはModel.phpで__getのマジックメソッドがオーバーライドされていることで実現されている構文です。

abstract class Model implements Arrayable, ArrayAccess, CanBeEscapedWhenCastToString, HasBroadcastChannel, Jsonable, JsonSerializable, QueueableEntity, UrlRoutable
{
    use Concerns\HasAttributes,
        /* 略 */
        ForwardsCalls;
        /* 略 */
    public function __get($key)
    {
        return $this->getAttribute($key);
    }
trait HasAttributes
{
    /* 略 */
    /**
     * 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) ||
            array_key_exists($key, $this->casts) ||
            $this->hasGetMutator($key) ||
            $this->hasAttributeMutator($key) ||
            $this->isClassCastable($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 $this->throwMissingAttributeExceptionIfApplicable($key);
        }

        // 筆者注: ここで値の返却
        return $this->isRelation($key) || $this->relationLoaded($key)
                    ? $this->getRelationValue($key)
                    : $this->throwMissingAttributeExceptionIfApplicable($key);
    }
}

ModelクラスにはトレイトでHasAttributesのメソッドが継承されています。これで宣言がされていないプロパティへアクセスされた時、マジックメソッドの__get($key)から宣言してあるリレーションに基づいてモデルの取得ができるようになっています。

さて、上のコード例ではもう一つマジックメソッドによって動作している部分があります。User::first();によるレコードの1件取得処理も__callStatic($method)を経由して行われています。

    public function __call($method, $parameters)
    {
        if (in_array($method, ['increment', 'decrement', 'incrementQuietly', 'decrementQuietly'])) {
            return $this->$method(...$parameters);
        }

        if ($resolver = $this->relationResolver(static::class, $method)) {
            return $resolver($this);
        }

        if (Str::startsWith($method, 'through') &&
            method_exists($this, $relationMethod = Str::of($method)->after('through')->lcfirst()->toString())) {
            return $this->through($relationMethod);
        }

        return $this->forwardCallTo($this->newQuery(), $method, $parameters);
    }

    public static function __callStatic($method, $parameters)
    {
        return (new static)->$method(...$parameters);
    }
    /**
     * Get a new query builder for the model's table.
     *
     * @return \Illuminate\Database\Eloquent\Builder
     */
    public function newQuery()
    {
        return $this->registerGlobalScopes($this->newQueryWithoutScopes());
    }
trait ForwardsCalls
{
    protected function forwardCallTo($object, $method, $parameters)
    {
        try {
            return $object->{$method}(...$parameters);
        } catch (Error|BadMethodCallException $e) {
            $pattern = '~^Call to undefined method (?P<class>[^:]+)::(?P<method>[^\(]+)\(\)$~';

            if (! preg_match($pattern, $e->getMessage(), $matches)) {
                throw $e;
            }

            if ($matches['class'] != get_class($object) ||
                $matches['method'] != $method) {
                throw $e;
            }

            static::throwBadMethodCallException($method);
        }
    }
}

まず、User::first()を実行すると__callStatic($method, $parameters)が実行され、new staticによって呼び出し元クラスのインスタンスを生成した上で(メソッドをそのまま実行することで)マジックメソッドの__call($method, $parameters)を実行します。

__call($method, $parameters)ではnewQuery()メソッドを実行することで自身のテーブルに関するクエリを発行し、そのクエリに対してfirst()を実行しています。クエリクラスの定義と実装を引用します。

class Builder implements BuilderContract
{
    use BuildsQueries, ForwardsCalls, QueriesRelationships {
        BuildsQueries::sole as baseSole;
/* firstOrFailなどはここで記述されています */
    public function firstOrFail($columns = ['*'])
    {
        if (! is_null($model = $this->first($columns))) {
            return $model;
        }

        throw (new ModelNotFoundException)->setModel(get_class($this->model));
    }
}
trait BuildsQueries
{
// firstはトレイトを利用してこの実装が実行されます
    public function first($columns = ['*'])
    {
        return $this->take(1)->get($columns)->first();
    }
}

これでfirstメソッドをモデル上で定義しておらず、Modelクラス自体から継承しているわけではなくてもfirst()などが利用できるようになっているようです。

一旦ここまでが調査した内容となります。これらの言語仕様を利用して平易な構文でモデル操作ができるように設計してあるのでしょう。良かったですね。

参考文献

Eloquent: Getting Started – laravel.coml

非公式日本語訳 – readouble.com