diff --git a/config/services.php b/config/services.php index 94b92aa01..86e2471de 100644 --- a/config/services.php +++ b/config/services.php @@ -33,6 +33,7 @@ service('.zenstruck_foundry.story_registry'), service('.zenstruck_foundry.persistence_manager')->nullOnInvalid(), service('event_dispatcher'), + service('property_info'), ]) ->public() ; diff --git a/src/Configuration.php b/src/Configuration.php index 3bf880287..f68587095 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -12,7 +12,14 @@ namespace Zenstruck\Foundry; use Faker; +use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\PropertyInfo\Extractor\PhpStanExtractor; +use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; +use Symfony\Component\PropertyInfo\PropertyInfoCacheExtractor; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; +use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; +use Symfony\Component\PropertyInfo\PropertyInitializableExtractorInterface; use Zenstruck\Foundry\Exception\FactoriesTraitNotUsed; use Zenstruck\Foundry\Exception\FoundryNotBooted; use Zenstruck\Foundry\Exception\PersistenceDisabled; @@ -43,6 +50,8 @@ final class Configuration /** @var \Closure():self|self|null */ private static \Closure|self|null $instance = null; + public readonly PropertyInfoExtractorInterface&PropertyInitializableExtractorInterface $propertyInfo; + /** * @phpstan-param InstantiatorCallable $instantiator */ @@ -53,8 +62,21 @@ public function __construct( public readonly StoryRegistry $stories, private readonly ?PersistenceManager $persistence = null, private readonly ?EventDispatcherInterface $eventDispatcher = null, + PropertyInfoExtractorInterface|null $propertyInfoExtractor = null, ) { $this->instantiator = $instantiator; + + // @phpstan-ignore assign.propertyType (DNF wa shipped in PHP 8.2, so we cannot make the parameter nullable and intersection) + $this->propertyInfo = $propertyInfoExtractor ?? new PropertyInfoCacheExtractor( + new PropertyInfoExtractor( + [$reflectionExtractor = new ReflectionExtractor()], + [$reflectionExtractor], + [$reflectionExtractor], + [$reflectionExtractor], + [$reflectionExtractor], + ), + new ArrayAdapter() + ); } /** diff --git a/src/Factory.php b/src/Factory.php index c4489582a..52c234911 100644 --- a/src/Factory.php +++ b/src/Factory.php @@ -174,20 +174,29 @@ protected function initialize(): static */ final protected function normalizeAttributes(array|callable $attributes = []): array { - $attributes = [$this->defaults(), ...$this->attributes, $attributes]; + $mergedAttributes = [$this->defaults()]; + + // we need to pick "recycled" attributes just after "defaults()" ones + // in order to be able to override them + if ($this instanceof ObjectFactory) { + $mergedAttributes[] = $this->recycledAttributes(); + } + + $mergedAttributes = [...$mergedAttributes, ...$this->attributes, $attributes]; + $index = 1; // find if an index was set by factory collection - foreach ($attributes as $i => $attr) { + foreach ($mergedAttributes as $i => $attr) { if (\is_array($attr) && isset($attr['__index'])) { $index = $attr['__index']; - unset($attributes[$i]); + unset($mergedAttributes[$i]); break; } } return \array_merge( - ...\array_map(static fn(array|callable $attr) => \is_callable($attr) ? $attr($index) : $attr, $attributes) + ...\array_map(static fn(array|callable $attr) => \is_callable($attr) ? $attr($index) : $attr, $mergedAttributes) ); } diff --git a/src/ObjectFactory.php b/src/ObjectFactory.php index 4abf7c485..e6daee0b8 100644 --- a/src/ObjectFactory.php +++ b/src/ObjectFactory.php @@ -11,6 +11,7 @@ namespace Zenstruck\Foundry; +use Symfony\Component\TypeInfo\Type; use Zenstruck\Foundry\Object\Event\AfterInstantiate; use Zenstruck\Foundry\Object\Event\BeforeInstantiate; use Zenstruck\Foundry\Object\Instantiator; @@ -35,6 +36,9 @@ abstract class ObjectFactory extends Factory /** @phpstan-var InstantiatorCallable|null */ private $instantiator; + /** @phpstan-var array> */ + private array $recycled = []; + /** * @return class-string */ @@ -105,6 +109,57 @@ public function afterInstantiate(callable $callback): static return $clone; } + /** + * @psalm-return static + * @phpstan-return static + */ + final public function recycle(object ...$objects): static + { + $clone = clone $this; + foreach ($objects as $object) { + $clone->recycled[$object::class] ??= []; + $clone->recycled[$object::class][] = $object; + } + + return $clone; + } + + protected function normalizeParameter(string $field, mixed $value): mixed + { + if ($value instanceof self) { + // propagate "recycled" objects + $value = $value->recycle(...array_merge(...array_values($this->recycled))); + } + + return parent::normalizeParameter($field, $value); + } + + /** + * @internal + * @phpstan-return Parameters + */ + final protected function recycledAttributes(): array + { + $attributes = []; + + $propertyInfo = Configuration::instance()->propertyInfo; + + $properties = $propertyInfo->getProperties(static::class()); + foreach ($properties ?? [] as $property) { + $types = $propertyInfo->getTypes(static::class(), $property); + + foreach ($types ?? [] as $type) { + if (isset($this->recycled[$type->getClassName()]) && count($this->recycled[$type->getClassName()])) { + $attributes[$property] = self::faker()->randomElement($this->recycled[$type->getClassName()]); + + break; + } + } + } + + return $attributes; + } + /** * @internal */ diff --git a/tests/Unit/RecycleTest.php b/tests/Unit/RecycleTest.php new file mode 100644 index 000000000..89ec90c2f --- /dev/null +++ b/tests/Unit/RecycleTest.php @@ -0,0 +1,90 @@ +recycle($address) + ->create(); + + self::assertSame($address, $contact->getAddress()); + } + + public function test_it_can_recycle_several_objects(): void + { + $address = AddressFactory::createOne(); + $category = CategoryFactory::createOne(); + + $contact = ContactFactory::new() + ->recycle($address, $category) + ->create(); + + self::assertSame($address, $contact->getAddress()); + self::assertSame($category, $contact->getCategory()); + } + + public function test_it_can_call_recycle_multiple_times(): void + { + $address = AddressFactory::createOne(); + $category = CategoryFactory::createOne(); + + $contact = ContactFactory::new() + ->recycle($address) + ->recycle($category) + ->create(); + + self::assertSame($address, $contact->getAddress()); + self::assertSame($category, $contact->getCategory()); + } + + public function test_it_recycle_the_same_object_multiple_times(): void + { + $category = CategoryFactory::createOne(); + + $contact = ContactFactory::new() + ->recycle($category) + ->create(); + + self::assertSame($category, $contact->getCategory()); + self::assertSame($category, $contact->getSecondaryCategory()); + } + + public function test_it_propagate_recycled_objects(): void + { + $category = CategoryFactory::createOne(); + + $address = AddressFactory::new(['contact' => ContactFactory::new()]) + ->recycle($category) + ->create(); + + self::assertSame($category, $address->getContact()?->getCategory()); + self::assertSame($category, $address->getContact()->getSecondaryCategory()); + } + + public function test_it_takes_randomly_an_item_when_multiple_recycled_objects_with_same_class_exist(): void + { + $categories = CategoryFactory::createMany(2); + + $address = AddressFactory::new(['contact' => ContactFactory::new()]) + ->recycle(...$categories) + ->create(); + + self::assertContains($address->getContact()?->getCategory(), $categories); + self::assertContains($address->getContact()?->getSecondaryCategory(), $categories); + } +}