Skip to content

[9.x] Allow using a model instance in place of nested model factories - #44107

Merged
taylorotwell merged 4 commits into
9.xfrom
factory-using
Sep 13, 2022
Merged

taylorotwell merged 4 commits into
9.xfrom
factory-using

Conversation

@jessarcher

@jessarcher jessarcher commented Sep 13, 2022 •

Copy link
Copy Markdown
Member

For applications with nested model factories, tests and seeders can get a little overwhelming when you want the nested models to all be related to a specific model.

For example, imagine you have User, Team, and Post models where posts belong to a user and a team, and the team also belongs to a user.

The following factory call would create two user records. One for the post itself, and one for the team that the post factory created.

$post = Post::factory()->create();

To get around this, you might write something like the following:

$user = User::factory()->create();
$post = Post::factory()
    ->for($user)
    ->for(Team::factory()->for($user))
    ->create();

That's still pretty nice! But maybe your posts have images that also belong to a team and/or a user. It can get out of control pretty quickly, and this is far from the most complex nesting I've seen.

This PR introduces a new using method for factories that allows you to provide one or more model instances to be used in place of any nested factories for the given models. What's more, it works recursively, automatically passing the model instances to any nested factories.

For our example above, your code would now look something like this:

$user = User::factory()->create();
$post = Post::factory()
    ->using($user)
    ->create();

In this example, the post and the nested team would all be owned by the provided user instance, with no additional records created. Depending on your application, this will either tidy up your factory calls, or it will prevent superfluous models from being created.

Of course, to take advantage of this feature, your factories must specify other factory instances in their definitions. If your factory definition calls the create method on any nested factories, then the created model will be used instead of anything passed to the using method.

@jessarcher
jessarcher marked this pull request as ready for review September 13, 2022 08:00
@dillingham

Copy link
Copy Markdown
Contributor

This is a huge win for cleanliness.

One thought, maybe "using" could be called "provide". Being a vue user of provide, I personally wouldn't overlook "using" and realize "provide" was for all children.

@rodrigopedra

Copy link
Copy Markdown
Contributor

I prefer to keep ->using() to be consistent on how we define a custom pivot model when defining a belongs to many relationship.

But I agree this PR is a huge time saver.

@taylorotwell

Copy link
Copy Markdown
Member

I agree that using usually has a different meaning in Laravel.

What do you think about:

Post::factory()
        ->recycle($user)
        ->create();

@taylorotwell
taylorotwell merged commit 5bc3c99 into 9.x Sep 13, 2022
@taylorotwell
taylorotwell deleted the factory-using branch September 13, 2022 15:38
@sebdesign

Copy link
Copy Markdown
Contributor

Recycle is not very clear IMO, it could mean that something is trashed and then repurposed, It could be confused with delete/restore. Maybe reuse is more accurate?

@WAZIRI123

Copy link
Copy Markdown

i think reuse is more clear

@basepack

basepack commented Sep 16, 2022 •

Copy link
Copy Markdown

@jessarcher it won't work when following this approach: https://laravel.com/docs/9.x/eloquent-factories#defining-relationships-within-factories

// PostFactory
public function definition()
{
    return [
        'user_id' => User::factory(), 
    ];
}

// in your test
$user = User::factory()->create();
Post::factory()->recycle($user)->create();
Post::factory()->recycle($user)->create();

ray(User::count()); // 2

@jessarcher

jessarcher commented Sep 18, 2022 •

Copy link
Copy Markdown
Member Author

Hi @basepack, I'm unable to replicate this. Using your code in a fresh Laravel project, I only have one User at the end.

Are you sure there's only 1 user in your database just before you call the Post factory? And are you sure that your PostFactory is just returning the User factory, without calling the create method on it?

If so, are you able to create a repo that reproduces this issue as it seems like there might be more to it than the code provided?

@martin-ro

Copy link
Copy Markdown

without calling the create method on it?

That threw me off at first.

@basepack

basepack commented Sep 19, 2022 •

Copy link
Copy Markdown

Hi @jessarcher, @martin-ro, no I do not call ->create(), I will post my complete setup here, maybe that will uncover the issue:

// SiteFactory
namespace Database\Factories;

use App\Models\User;
use Faker\Provider\Uuid;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Site>
 */
class SiteFactory extends Factory
{

    public function definition()
    {
        return [
            'uuid' => Uuid::uuid(),
            'domain_name' => $this->faker->domainName,
        ];
    }
}
// EmailaddressFactory
namespace Database\Factories;

use App\Models\Site;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Emailaddress>
 */
class EmailaddressFactory extends Factory
{
    public function definition()
    {
        return [
            'emailaddress' => $this->faker->unique()->safeEmail,
            'site_id' => Site::factory(),
        ];
    }

    public function withEmailaddress(string $emailaddress): self
    {
        return $this
            ->state([
                'emailaddress' => $emailaddress,
            ])
            ->for(
                Site::factory([
                    'domain_name' => str($emailaddress)->after('@')->toString(),
                ])
            );
    }
}
it('test', function () {
    $site = Site::factory()->create([
        'domain_name' => 'example.com',
    ]);

    $adminEmail = Emailaddress::factory()
        ->recycle($site)
        ->withEmailaddress('info@example.com')
        ->create();

    $userEmail = Emailaddress::factory()
        ->recycle($site)
        ->withEmailaddress('user@example.com')
        ->create();

    ray(Site::count()); // 3 <-- should be 1
    ray(Emailaddress::count()); // 2
});

Edit: removed the UserFactory.

@jessarcher

Copy link
Copy Markdown
Member Author

Thanks @basepack, I was able to replicate it with this setup. It looks like it's the for call in the withEmailaddress state that's triggering the extra site(s) to be created.

I'll dig into this properly tomorrow.

@michal3377

Copy link
Copy Markdown
Contributor

This is a great feature, making factories much easier to write.
However, could we maybe go 1 step further, allowing for providing multiple models of a given type, which would be then randomly picked? This would allow developers to provide their own, already created pool of models, so they could be reused in all nested relations.
For example, we often see that the User model relates to everything - as an author of some content, comments, reactions, performed operations etc. In more complex applications, that nesting can go deep. In that scenario, much better would be seeding a number of users once and then reusing them in other relations (so we don't create a user only to be the author of a single comment). If that nested reuse could be achieved with a one-liner (which seems to be not far away from what this PR achieves), that would be a game changer.

In my opinion, Eloquent Factories is a great concept and the only thing it lacks is better support for model reuse. That enhancement would fix that and make them much more versatile.

@jessarcher

Copy link
Copy Markdown
Member Author

Hey @basepack, I've created #44265 to address your issue 👍

@jessarcher

Copy link
Copy Markdown
Member Author

@michal3377 Feel free to attempt a PR. I can potentially see the benefit in seeders where you might want a handful of users to be used throughout instead of either recycling a single user, or creating a new user for every relationship.

@basepack

Copy link
Copy Markdown

@jessarcher thanks for that, looks good! 👍

@browner12

Copy link
Copy Markdown
Contributor

today I realized recycle is basically the "primer" feature I tried to get merged 5 years ago 😂

#30880

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.