From 61e3e5776d9cadac25cf9dd6f7939f6da85f71b5 Mon Sep 17 00:00:00 2001 From: Nicolas PHILIPPE Date: Sun, 2 Feb 2025 22:13:54 +0100 Subject: [PATCH 1/2] feat: introduce "recycle()" method --- config/services.php | 1 + src/Configuration.php | 22 +++++++++++ src/Factory.php | 17 +++++++-- src/ObjectFactory.php | 57 ++++++++++++++++++++++++++++ tests/Unit/RecycleTest.php | 78 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 171 insertions(+), 4 deletions(-) create mode 100644 tests/Unit/RecycleTest.php 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..44f0d08cf 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 list */ + private array $recycled = []; + /** * @return class-string */ @@ -105,6 +109,59 @@ public function afterInstantiate(callable $callback): static return $clone; } + /** + * @param T $objects + * + * @psalm-return static + * @phpstan-return static + */ + final public function recycle(object ...$objects): static + { + $clone = clone $this; + $clone->recycled = [...$clone->recycled, ...array_values($objects)]; + + return $clone; + } + + protected function normalizeParameter(string $field, mixed $value): mixed + { + if ($value instanceof self) { + // propagate "recycled" objects + $value = $value->recycle(...$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 ($this->recycled as $recycledItem) { + + foreach ($properties ?? [] as $property) { + $types = $propertyInfo->getTypes(static::class(), $property); + + foreach ($types ?? [] as $type) { + if ($type->getClassName() === $recycledItem::class) { + $attributes[$property] = $recycledItem; + + break; + } + } + } + } + + return $attributes; + } + /** * @internal */ diff --git a/tests/Unit/RecycleTest.php b/tests/Unit/RecycleTest.php new file mode 100644 index 000000000..827035e91 --- /dev/null +++ b/tests/Unit/RecycleTest.php @@ -0,0 +1,78 @@ +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()); + } +} From b2dba5bf4d29db5d45ec6b99e458d0a53db95d55 Mon Sep 17 00:00:00 2001 From: Nicolas PHILIPPE Date: Sun, 2 Feb 2025 22:26:44 +0100 Subject: [PATCH 2/2] feat: can randomly recycle if multiple objects of same class --- src/ObjectFactory.php | 26 ++++++++++++-------------- tests/Unit/RecycleTest.php | 14 +++++++++++++- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/ObjectFactory.php b/src/ObjectFactory.php index 44f0d08cf..e6daee0b8 100644 --- a/src/ObjectFactory.php +++ b/src/ObjectFactory.php @@ -36,7 +36,7 @@ abstract class ObjectFactory extends Factory /** @phpstan-var InstantiatorCallable|null */ private $instantiator; - /** @phpstan-var list */ + /** @phpstan-var array> */ private array $recycled = []; /** @@ -110,15 +110,16 @@ public function afterInstantiate(callable $callback): static } /** - * @param T $objects - * * @psalm-return static * @phpstan-return static */ final public function recycle(object ...$objects): static { $clone = clone $this; - $clone->recycled = [...$clone->recycled, ...array_values($objects)]; + foreach ($objects as $object) { + $clone->recycled[$object::class] ??= []; + $clone->recycled[$object::class][] = $object; + } return $clone; } @@ -127,7 +128,7 @@ protected function normalizeParameter(string $field, mixed $value): mixed { if ($value instanceof self) { // propagate "recycled" objects - $value = $value->recycle(...$this->recycled); + $value = $value->recycle(...array_merge(...array_values($this->recycled))); } return parent::normalizeParameter($field, $value); @@ -144,17 +145,14 @@ final protected function recycledAttributes(): array $propertyInfo = Configuration::instance()->propertyInfo; $properties = $propertyInfo->getProperties(static::class()); - foreach ($this->recycled as $recycledItem) { - - foreach ($properties ?? [] as $property) { - $types = $propertyInfo->getTypes(static::class(), $property); + foreach ($properties ?? [] as $property) { + $types = $propertyInfo->getTypes(static::class(), $property); - foreach ($types ?? [] as $type) { - if ($type->getClassName() === $recycledItem::class) { - $attributes[$property] = $recycledItem; + 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; - } + break; } } } diff --git a/tests/Unit/RecycleTest.php b/tests/Unit/RecycleTest.php index 827035e91..89ec90c2f 100644 --- a/tests/Unit/RecycleTest.php +++ b/tests/Unit/RecycleTest.php @@ -72,7 +72,19 @@ public function test_it_propagate_recycled_objects(): void ->recycle($category) ->create(); - self::assertSame($category, $address->getContact()->getCategory()); + 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); + } }