Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support closures for model data #56

Conversation

dbushy727
Copy link
Contributor

@dbushy727 dbushy727 commented Jun 26, 2020

What & Why

Currently in the Laravel factories setup, we are able to use a function when defining a field if we rely on data being dynamically set earlier in the factory.

Screen Shot 2020-06-25 at 9 48 32 PM

https://laravel.com/docs/7.x/database-testing#relationships

In the library as it stands today, it throws an error when trying to use a closure.

I love this library because I was in the process of transitioning my factories into classes and this library made it so much easier, but I rely on closures a lot for dynamically generating content that is dependent on the fields that are created earlier. I think others who also have already been using closures might find this update useful.

Solution

This PR aims to allow closures to be used when setting data in the getDefaults method as well as using a closure to override on make or create.

Comment on lines +14 to +38
private static function isCallable($field): bool
{
return collect($defaultModelFields)
->map(function ($item) {
if ($this->isFactory($item)) {
return $item->create()->getKey();
}

return $item;
})
->toArray();
return is_callable($field) && ! is_string($field) && ! is_array($field);
}

private function transformModelFields(array $defaultModelFields): array
{
foreach ($defaultModelFields as &$field) {
$field = $this->transformField($field, $defaultModelFields);
}

return $defaultModelFields;
}

private function transformField($field, array $defaultModelFields)
{
if ($this->isFactory($field)) {
return $field->create()->getKey();
}

if ($this->isCallable($field)) {
return $field($defaultModelFields);
}

return $field;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This portion was heavily inspired by how Laravel's FactoryBuilder@expandAttributes method works.

The main takeaway here is that we use foreach with a variable passed by reference so with each iteration, the individual field transforms into the closure's return value, thus making it usable in the next factory fields. With map, the fields aren't affected until it loops all the way through.

I can provide screenshots if i didn't explain this part well.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. Except I'm not sure what you meant with map?

For me the tests were passing as well with this:

return collect($defaultModelFields)
    ->map(fn ($field) => $this->transformField($field, $defaultModelFields))
    ->toArray();

Did I miss your point?

Copy link
Contributor Author

@dbushy727 dbushy727 Jun 30, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, i think my test wasnt testing the actual problem with using map. I have updated the case in 627161f

The problem is when i define an array of defaults, for example:

public function getDefaults(Generator $faker): array
{
  return [
    'first_field' => fn (array $factory) => 'some string',
    'second_field' => fn(array $factory) => someFunctionThatRequiresString($factory['first_field']),
  ];
}

In the array above each key's value is a function. When we loop through these one at a time and transform them to the callbacks return value, the first_field key will turn into a string.

When we get to the second field, it's relying on $factory['first_field'] to already be a string, not a closure.

When we use ->map to iterate, we get to the first_field, we transform it into a string, but don't update it yet. Instead, it holds in a different memory location and will switch at the end. Then, when we get to the second_field key, it throws an error because someFunctionThatRequiresString requires a string, but a closure is passed because the first field hasn't been transformed yet.

using foreach with passing $field by reference via &$field, it allows the value to transform on each iteration, rather than waiting to switch the entire array at once to the new array of values.

Here is a printout of what the $defaultModelFields looks like on each iteration using map and foreach:

Using Map

// first iteration
array:2 [
  "name" => Closure(array $ingredient) {#1354
    class: "Christophrumpel\LaravelFactoriesReloaded\Tests\FactoryTest"
    this: Christophrumpel\LaravelFactoriesReloaded\Tests\FactoryTest {#759 …}
  }
  "description" => Closure(array $ingredient) {#1355
    class: "Christophrumpel\LaravelFactoriesReloaded\Tests\FactoryTest"
    this: Christophrumpel\LaravelFactoriesReloaded\Tests\FactoryTest {#759 …}
  }
]

// second iteration
array:2 [
  "name" => Closure(array $ingredient) {#1354
    class: "Christophrumpel\LaravelFactoriesReloaded\Tests\FactoryTest"
    this: Christophrumpel\LaravelFactoriesReloaded\Tests\FactoryTest {#759 …}
  }
  "description" => Closure(array $ingredient) {#1355
    class: "Christophrumpel\LaravelFactoriesReloaded\Tests\FactoryTest"
    this: Christophrumpel\LaravelFactoriesReloaded\Tests\FactoryTest {#759 …}
  }
]

Using Foreach

// first iteration
array:2 [
  "name" => & Closure(array $ingredient) {#1354
    class: "Christophrumpel\LaravelFactoriesReloaded\Tests\FactoryTest"
    this: Christophrumpel\LaravelFactoriesReloaded\Tests\FactoryTest {#759 …}
  }
  "description" => Closure(array $ingredient) {#1355
    class: "Christophrumpel\LaravelFactoriesReloaded\Tests\FactoryTest"
    this: Christophrumpel\LaravelFactoriesReloaded\Tests\FactoryTest {#759 …}
  }
]

// second iteration
array:2 [
  "name" => "Basil"
  "description" => & Closure(array $ingredient) {#1355
    class: "Christophrumpel\LaravelFactoriesReloaded\Tests\FactoryTest"
    this: Christophrumpel\LaravelFactoriesReloaded\Tests\FactoryTest {#759 …}
  }
]

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah got it. Thanks for the details. Then I'm fine with the current implementation 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cool. I prefer moving things to collections as well, so i get the hesitation. I couldnt find a way to update the variables by reference via Collection Methods, so i opted for this. I believe this is how laravel handles it behind the scenes as well.

return is_callable($field) && ! is_string($field) && ! is_array($field);
}

private function transformModelFields(array $defaultModelFields): array
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also renamed this because i felt it wasn't just transforming factories anymore, but not super opinionated on this, so if you feel strongly about the name, i can change it back.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I also think the name is now better 👍

Comment on lines +11 to +13
use ExampleApp\Models\Group;
use ExampleApp\Models\Ingredient;
use ExampleApp\Models\Recipe;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think my IDE just auto-alphabetized these.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok no worries. I will see if I can automate that with the Github Actions as well. I just want them to be consistent, not matter which way.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, im not opinionated on this either tbh. Whenever i auto-import something, it just re-alphabetizes the list. I can manually put it back if you like.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's ok, just leave it 👍

@christophrumpel
Copy link
Owner

Hey, thanks a lot for this PR. Just came back from holidays and need to give a closer look 👍

@dbushy727
Copy link
Contributor Author

Hey, thanks a lot for this PR. Just came back from holidays and need to give a closer look 👍

how dare you take time off from open source 😜 .. ha, of course, take your time.

Copy link
Owner

@christophrumpel christophrumpel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just went through. Looks good to me. Just some small comments.

Thanks a lot for this PR and I'm happy that this package could help you with your projects.

return is_callable($field) && ! is_string($field) && ! is_array($field);
}

private function transformModelFields(array $defaultModelFields): array
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I also think the name is now better 👍

Comment on lines +14 to +38
private static function isCallable($field): bool
{
return collect($defaultModelFields)
->map(function ($item) {
if ($this->isFactory($item)) {
return $item->create()->getKey();
}

return $item;
})
->toArray();
return is_callable($field) && ! is_string($field) && ! is_array($field);
}

private function transformModelFields(array $defaultModelFields): array
{
foreach ($defaultModelFields as &$field) {
$field = $this->transformField($field, $defaultModelFields);
}

return $defaultModelFields;
}

private function transformField($field, array $defaultModelFields)
{
if ($this->isFactory($field)) {
return $field->create()->getKey();
}

if ($this->isCallable($field)) {
return $field($defaultModelFields);
}

return $field;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. Except I'm not sure what you meant with map?

For me the tests were passing as well with this:

return collect($defaultModelFields)
    ->map(fn ($field) => $this->transformField($field, $defaultModelFields))
    ->toArray();

Did I miss your point?

Comment on lines +11 to +13
use ExampleApp\Models\Group;
use ExampleApp\Models\Ingredient;
use ExampleApp\Models\Recipe;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok no worries. I will see if I can automate that with the Github Actions as well. I just want them to be consistent, not matter which way.

@@ -192,6 +193,31 @@ public function it_lets_you_use_faker_for_defining_data(): void
$this->assertIsString($group->mobile);
}

/** @test * */
public function it_lets_you_use_a_closure_for_defining_data(): void
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please change it to it_lets_you_use_a_closure_for_defining_default_data

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in 345e064

}

/** @test * */
public function it_lets_you_use_a_closure_for_overriding_data(): void
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And here please it_lets_you_use_a_closure_for_overriding_default_data

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in 345e064

public function it_lets_you_use_a_closure_for_overriding_default_data(): void
{
$ingredient = IngredientFactoryUsingClosure::new()->create([
'name' => fn (array $ingredient) => 'Basil',
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I updated the test to define two closures. This will accurately test that the second closure can use the value from the first closure.

Copy link
Owner

@christophrumpel christophrumpel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome. Looks good to me! I think we are ready to merge, right?

@dbushy727
Copy link
Contributor Author

Awesome. Looks good to me! I think we are ready to merge, right?

Yep, let’s do it

@christophrumpel christophrumpel merged commit 38d5407 into christophrumpel:master Jul 2, 2020
@christophrumpel
Copy link
Owner

Wohoo. Thanks a lot for the PR. I will update the docs and release a new version today 👍

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.

None yet

2 participants