LoginSignup
17
9

More than 3 years have passed since last update.

Laravel の Eloquent Factory で属性の設定にクロージャを使う意味

Last updated at Posted at 2019-02-21

Factory では属性の定義にクロージャが使える

Laravel の Eloquent には Factory という機能があって、公式ドキュメントに書いてある 使い方の説明 を見てみると以下のように書いてあります。

Relations & Attribute Closures

You may also attach relationships to models using Closure attributes in your factory definitions. For example, if you would like to create a new User instance when creating a Post, you may do the following:

$factory->define(App\Post::class, function ($faker) {
    return [
        'title' => $faker->title,
        'content' => $faker->paragraph,
        'user_id' => function () {
            return factory(App\User::class)->create()->id;
        }
    ];
});

These Closures also receive the evaluated attribute array of the factory that defines them:

$factory->define(App\Post::class, function ($faker) {
    return [
        'title' => $faker->title,
        'content' => $faker->paragraph,
        'user_id' => function () {
            return factory(App\User::class)->create()->id;
        },
        'user_type' => function (array $post) {
            return App\User::find($post['user_id'])->type;
        }
    ];
});

ざっくり説明すると「属性の定義にはクロージャを使うこともできて、それを使ってモデルとのリレーションを定義することもできるよ」っていうようなことが書いてあるのですが、なんでクロージャを使う必要があるのかが書かれてません。
Laravel あるあるで、使えないより使える方が便利っていうだけで実装されてる可能性もありますが、わざわざドキュメントに書くくらいなので、何かメリットがありそうです。

ちなみに、属性の定義に使うクロージャーのことを、ドキュメントでは Attribute Closures と呼んだり Closure attributes と呼んだりしています。
英語人の慣習はよくわからないのですが、ここでは仮に「属性クロージャ」と呼ぶことにします。

属性クロージャを使わない場合

ちなみに属性クロージャを使わない場合は以下のようなコードになると思います。

$factory->define(App\Post::class, function ($faker) {
    return [
        'title' => $faker->title,
        'content' => $faker->paragraph,
        'user_id' => factory(App\User::class)->create()->id,
    ];
});
$factory->define(App\Post::class, function ($faker) {
    $user = factory(App\User::class)->create();

    return [
        'title' => $faker->title,
        'content' => $faker->paragraph,
        'user_id' => $user->id,
        'user_type' => $user->type,
    ];
});

うん。わかりやすい。
これと属性クロージャを使った場合で何が違うのか。ソースコードを追って調べてみました。

Factory が Model インスタンスを作るまで

例にも出てきている変数 $factory の中身は Illuminate\Database\Eloquent\Factory クラスのインスタンスです。
この Factory クラスは、Factory の定義をする役割を担っています。

一方、Factory を使う際に使用するヘルパー関数 factory() では、Factory クラスの of() メソッドを呼び出し、Illuminate\Database\Eloquent\FactoryBuilder クラスのインスタンスを生成しています。

vendor/laravel/framework/src/Illuminate/Foundation/helpers.php
    /**
     * Create a model factory builder for a given class, name, and amount.
     *
     * @param  dynamic  class|class,name|class,amount|class,name,amount
     * @return \Illuminate\Database\Eloquent\FactoryBuilder
     */
    function factory()
    {
        $factory = app(EloquentFactory::class);

        $arguments = func_get_args();

        if (isset($arguments[1]) && is_string($arguments[1])) {
            return $factory->of($arguments[0], $arguments[1])->times($arguments[2] ?? null);
        } elseif (isset($arguments[1])) {
            return $factory->of($arguments[0])->times($arguments[1]);
        }

        return $factory->of($arguments[0]);
    }

ということは、この FactoryBuilder の中にいつも使っている make()create() などのメソッドがあるはずです。

Illuminate\Database\Eloquent\FactoryBuilder
    /**
     * Create a collection of models.
     *
     * @param  array  $attributes
     * @return mixed
     */
    public function make(array $attributes = [])
    {
        if ($this->amount === null) {
            return tap($this->makeInstance($attributes), function ($instance) {
                $this->callAfterMaking(collect([$instance]));
            });
        }

        if ($this->amount < 1) {
            return (new $this->class)->newCollection();
        }

        $instances = (new $this->class)->newCollection(array_map(function () use ($attributes) {
            return $this->makeInstance($attributes);
        }, range(1, $this->amount)));

        $this->callAfterMaking($instances);

        return $instances;
    }

make() メソッドを見つけました。
インスタンスを何個作るかが入っている $this->amount は、何も指定しないと null になるのですが、そののときの動作を見てみると makeInstance() というメソッドを呼び出して、make() メソッドに渡された配列 $attributes をそのまま渡しています。

Illuminate\Database\Eloquent\FactoryBuilder
    /**
     * Make an instance of the model with the given attributes.
     *
     * @param  array  $attributes
     * @return \Illuminate\Database\Eloquent\Model
     */
    protected function makeInstance(array $attributes = [])
    {
        return Model::unguarded(function () use ($attributes) {
            $instance = new $this->class(
                $this->getRawAttributes($attributes)
            );

            if (isset($this->connection)) {
                $instance->setConnection($this->connection);
            }

            return $instance;
        });
    }

makeInstance() の中身を見てみると、$this->class に作ろうとしているクラスの名前が入っているのですが、普通に new してインスタンスを作っていることがわかりました。
Eloquent Model のインスタンスを作る際の引数は、代入したい属性が入った配列です。1
ここでは、make() の引数として渡された配列 $attributesgetRawAttributes() というメソッドで処理してから渡しています。

この getRawAttributes() が、Factory で定義したクロージャを呼び出したり、ステートの適用なんかもやっていそうです。

Illuminate\Database\Eloquent\FactoryBuilder
    /**
     * Get a raw attributes array for the model.
     *
     * @param  array  $attributes
     * @return mixed
     *
     * @throws \InvalidArgumentException
     */
    protected function getRawAttributes(array $attributes = [])
    {
        if (! isset($this->definitions[$this->class][$this->name])) {
            throw new InvalidArgumentException("Unable to locate factory with name [{$this->name}] [{$this->class}].");
        }

        $definition = call_user_func(
            $this->definitions[$this->class][$this->name],
            $this->faker, $attributes
        );

        return $this->expandAttributes(
            array_merge($this->applyStates($definition, $attributes), $attributes)
        );
    }

真ん中の call_user_func() で呼び出しているのは、$factory->define() の第二引数に渡したクロージャです。2

そうして Factory で定義した値を $definition として受け取った後、applyStates() でステートで定義した値を適用して、それを expandAttributes() で展開してます。
ぱっと見 expandAttributes() の方が怪しいですが、先に applyStates() の方を見てみましょう。

Illuminate\Database\Eloquent\FactoryBuilder
    /**
     * Apply the active states to the model definition array.
     *
     * @param  array  $definition
     * @param  array  $attributes
     * @return array
     *
     * @throws \InvalidArgumentException
     */
    protected function applyStates(array $definition, array $attributes = [])
    {
        foreach ($this->activeStates as $state) {
            if (! isset($this->states[$this->class][$state])) {
                if ($this->stateHasAfterCallback($state)) {
                    continue;
                }

                throw new InvalidArgumentException("Unable to locate [{$state}] state for [{$this->class}].");
            }

            $definition = array_merge(
                $definition,
                $this->stateAttributes($state, $attributes)
            );
        }

        return $definition;
    }

foreach で有効になっているステートをひとつずつ処理しています。
前半は例外処理なので無視して、後半で stateAttributes() で得た値を、Factory で定義した値である $definition とマージしています。
ということは、この時点ではまだ Factory で定義した属性クロージャは呼び出されてなさそうです。

次に怪しかった expandAttributes() の方を見てみます。

Illuminate\Database\Eloquent\FactoryBuilder
    /**
     * Expand all attributes to their underlying values.
     *
     * @param  array  $attributes
     * @return array
     */
    protected function expandAttributes(array $attributes)
    {
        foreach ($attributes as &$attribute) {
            if (is_callable($attribute) && ! is_string($attribute) && ! is_array($attribute)) {
                $attribute = $attribute($attributes);
            }

            if ($attribute instanceof static) {
                $attribute = $attribute->create()->getKey();
            }

            if ($attribute instanceof Model) {
                $attribute = $attribute->getKey();
            }
        }

        return $attributes;
    }

foreach で属性をひとつずつ処理していますが、ここで属性 $attributeis_callable() なときに、呼び出して値を取得しているのを発見できました。3

つまり、Factory で定義した値に、ステートで定義した値をマージして、make() の引数で渡した値をマージして、最後に値の中の属性クロージャを呼び出し、それを使ってインスタンスを作っています。
この流れがわかったことで、属性クロージャの役割がわかってきました。

わかったこと

属性クロージャを使わない場合は、初めに Factory で定義した値を取得する段階で、モデルとのリレーションが定義されます。

$factory->define(App\Post::class, function ($faker) {
    return [
        'title' => $faker->title,
        'content' => $faker->paragraph,
        'user_id' => factory(App\User::class)->create()->id,
    ];
});

この例でいうと、初めの段階で App\User クラスに対応するレコードが作成されてしまいます。
このレコードが必ず使われるならよいのですが、場合によってはステートや make() に渡す引数で上書きされることもあります。

例えばこんな場合ですね。

$user = factory(App\User::class)->create();
$post = factory(App\User::class)->make([
    'user_id' => $user->id,
]);

この場合は、factory()->make() に渡した方の User は使われますが、Factory で定義した User は破棄されて使われません。
ということは、無駄なレコードが 1 行増えてしまうことになります。

一方で、公式ドキュメントに書かれていた書き方だと、

$factory->define(App\Post::class, function ($faker) {
    return [
        'title' => $faker->title,
        'content' => $faker->paragraph,
        'user_id' => function () {
            return factory(App\User::class)->create()->id;
        }
    ];
});

Factory で定義された属性クロージャは、make() で渡された値とマージする際に上書きされて消えてしまうので、実行されません。
なので、無駄なレコードは作られません。

また、同じ使い方で考えたときに、公式ドキュメントに載っていたもうひとつの例の方の意味もわかってきます。

$factory->define(App\Post::class, function ($faker) {
    return [
        'title' => $faker->title,
        'content' => $faker->paragraph,
        'user_id' => function () {
            return factory(App\User::class)->create()->id;
        },
        'user_type' => function (array $post) {
            return App\User::find($post['user_id'])->type;
        }
    ];
});

こう書けば、ステートや make() に渡す引数で user_id だけを上書きした場合でも、無駄なレコードが作られず、かつ user_type も正しく設定されます。

結論

Factory の定義を書く際に、レコードを作るような、外部に影響をもたらす処理を書きたい場合は、属性クロージャを使って書いた方がよいということがわかりました。
そうすれば、本当に必要になったときだけ処理が呼び出されるので、余計な影響が減らせます。


  1. 公式ドキュメント を見てみてもコンストラクタの引数については書いてなさそうだったのですが、fill() に渡す引数と同じだと思ってもらうとわかりやすいかも? 

  2. 余談ですが、クロージャに $faker が渡されるのは知っていましたが、第二引数には make() に渡した引数 $attributes が渡されていますね。 

  3. 属性クロージャの他にも、ヘルパー関数 factory() で作った FactoryBuilder クラスのインスタンスを設定したり、Eloquent Model のインスタンスを設定することもできたんですね。 

17
9
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
9