Repository: zenstruck/foundry (php)

Issue:
`reuse()` method
I do think a `recycle()` method, similar to what [Laravel factories](https://laravel.com/docs/11.x/eloquent-factories#recycling-an-existing-model-for-relationships) provide could be useful.

While reading [some blog posts about it](https://medium.com/@arifulweb/simplifying-model-relationships-in-laravel-with-recycle-a-game-changer-for-my-lms-eff39cdd7a78), I figured out that one of its usage is basically the same thing than  something like `Contact::new(['address' => AddressFactory::createOne())->many(5)->create()`, and I'm not interested about this part of the feature.

What I like about it, is that it forces to reuse **deeply** the same object, for every `ManyToOne` relationship.

Given their example with `Airline`, `Flight`, and `Ticket` models, where the ticket belongs to an airline and a flight, and the flight also belongs to an airline, the following code will create only one `Airline`, and use it both for `Ticket::$airline` relationship and for `Flight::$airline` relationship:
```php
 Ticket::new()
    ->recycle(Airline::createOne())
    ->create();
```

As far as I understand how it works, `recycle()` only applies for `*ToOne` relationships.

It also accepts a collection of objects:
```php
$airlines = Airline::createMany(2);

 Ticket::new()
    ->recycle($airlines)
    ->many(5)
    ->create();
```
 
But I don't know if in this case, we should ensure the integrity of the data and always pick the same airline for both the ticket and its flight. I'm tempted to say "yes", because for me the whole point of this `recycle()` method would be to ensure integrity of the model. As far as I understand, this would differ from Laravel's `recycle()` method, where they always pick data randomly from the recycle collection.

any thought @kbond ?  

Pull request description:
feat: introduce "reuse()" method
fixes #794 

For now, `reuse()` only takes one parameter. It cannot be passed multiple times an object of the same class nor a Factory 

Patch:
diff --git a/config/services.php b/config/services.php
index 8af8ee9..1a6cc45 100644
--- a/config/services.php
+++ b/config/services.php
@@ -38,6 +38,7 @@ return static function (ContainerConfigurator $container): void {
             '%env(default:zenstruck_foundry.faker.seed:int:FOUNDRY_FAKER_SEED)%',
             param('.zenstruck_foundry.validation_enabled'),
             abstract_arg('validation_available'),
+            service('property_info'),
         ])
         ->public()
 
diff --git a/docs/index.rst b/docs/index.rst
index e2293ee..8d5d3eb 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -535,6 +535,10 @@ If you have a collection of values that you want to distribute over a collection
 
         ->create();
 
+.. versionadded::  2.4
+
+    The ``distribute()`` method was added in Foundry 2.4.
+
 Faker
 ~~~~~
 
@@ -944,6 +948,31 @@ The following assumes the ``Post`` entity has a many-to-many relationship with `
     // Example 5: create 3 Posts each with between 0 and 3 unique Tags
     PostFactory::createMany(3, ['tags' => TagFactory::new()->many(0, 3)]);
 
+Reuse Objects in Relationships
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+When creating nested objects, sometimes it can be useful to tell Foundry to always use the same object for a given class.
+It can enforce coherence in your fixtures and avoid creating too many objects.
+
+In order to do this, you can use the ``reuse()`` method: it will force Foundry to use the object passed as parameter in
+all `ManyToOne` and `OneToOne` relationships using the class of this object:
+
+::
+
+    // let's say both Post and Comment classes have a ManyToOne field "author" of class User
+    $user = UserFactory::createOne();
+
+    PostFactory::new([
+        'comments' => CommentFactory::new()->many(5),
+    ])
+        // by calling reuse, the post and all its comments will have the same author
+        ->reuse($user)
+        ->create();
+
+.. versionadded::  2.4
+
+    The ``reuse()`` method was added in Foundry 2.4.
+
 Lazy Values
 ~~~~~~~~~~~
 
@@ -1235,6 +1264,10 @@ Validate your objects
 Foundry can validate your objects automatically after they are instantiated. This can be useful to
 ensure that your objects are in a valid state before they are used in your tests.
 
+.. versionadded::  2.4
+
+    Validation of the objects was added in Foundry 2.4.
+
 You can either enable validation globally:
 
 .. configuration-block::
diff --git a/src/Configuration.php b/src/Configuration.php
index 52c830a..45e32d1 100644
--- a/src/Configuration.php
+++ b/src/Configuration.php
@@ -12,7 +12,13 @@
 namespace Zenstruck\Foundry;
 
 use Faker;
+use Symfony\Component\Cache\Adapter\ArrayAdapter;
 use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+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;
@@ -35,6 +41,8 @@ final class Configuration
      */
     public $instantiator;
 
+    public readonly PropertyInfoExtractorInterface&PropertyInitializableExtractorInterface $propertyInfo;
+
     /**
      * This property is only filled if the PHPUnit extension is used!
      */
@@ -58,10 +66,23 @@ final class Configuration
         ?int $forcedFakerSeed = null,
         public readonly bool $validationEnabled = false,
         public readonly bool $validationAvailable = false,
+        ?PropertyInfoExtractorInterface $propertyInfoExtractor = null,
     ) {
         $this->faker->seed(self::fakerSeed($forcedFakerSeed));
 
         $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()
+        );
     }
 
     public static function fakerSeed(?int $forcedFakerSeed = null): int
diff --git a/src/Factory.php b/src/Factory.php
index 5c5acfa..d830125 100644
--- a/src/Factory.php
+++ b/src/Factory.php
@@ -179,20 +179,29 @@ abstract class Factory
      */
     final protected function normalizeAttributes(array|callable $attributes = []): array
     {
-        $attributes = [$this->defaults(), ...$this->attributes, $attributes];
+        $mergedAttributes = [$this->defaults()];
+
+        // "reused" attributes will override the ones from "defaults()"
+        // but should be overridden by the other states of the factory
+        if ($this instanceof ObjectFactory) {
+            $mergedAttributes[] = $this->reusedAttributes();
+        }
+
+        $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 f6ce885..96d55e8 100644
--- a/src/ObjectFactory.php
+++ b/src/ObjectFactory.php
@@ -36,6 +36,9 @@ abstract class ObjectFactory extends Factory
     /** @phpstan-var InstantiatorCallable|null */
     private $instantiator;
 
+    /** @phpstan-var array<class-string, object> */
+    private array $reusedObjects = [];
+
     private bool $validationEnabled;
 
     /** @var string|GroupSequence|list<string>|null */
@@ -189,6 +192,71 @@ abstract class ObjectFactory extends Factory
         return $this->validationGroups;
     }
 
+    /**
+     * @psalm-return static<T>
+     * @phpstan-return static
+     */
+    final public function reuse(object $object): static
+    {
+        if (isset($this->reusedObjects[$object::class])) {
+            throw new \InvalidArgumentException(\sprintf('An object of class "%s" is already being reused.', $object::class));
+        }
+
+        if ($object instanceof Factory) {
+            throw new \InvalidArgumentException('Cannot reuse a factory.');
+        }
+
+        $clone = clone $this;
+        $clone->reusedObjects[$object::class] = $object;
+
+        return $clone;
+    }
+
+    protected function normalizeParameter(string $field, mixed $value): mixed
+    {
+        if ($value instanceof self) {
+            // propagate "reused" objects
+            foreach ($this->reusedObjects as $item) {
+                // "reused" item in the target factory have priority, if they are of the same type
+                if (!isset($value->reusedObjects[$item::class])) {
+                    $value = $value->reuse($item);
+                }
+            }
+        }
+
+        return parent::normalizeParameter($field, $value);
+    }
+
+    /**
+     * @internal
+     * @phpstan-return Parameters
+     */
+    final protected function reusedAttributes(): 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 (null === $type->getClassName()) {
+                    continue;
+                }
+
+                if (isset($this->reusedObjects[$type->getClassName()])) {
+                    $attributes[$property] = $this->reusedObjects[$type->getClassName()];
+
+                    break;
+                }
+            }
+        }
+
+        return $attributes;
+    }
+
     /**
      * @internal
      */


Test patch:
diff --git a/tests/Unit/ReuseEntityTest.php b/tests/Unit/ReuseEntityTest.php
new file mode 100644
index 0000000..cf3b575
--- /dev/null
+++ b/tests/Unit/ReuseEntityTest.php
@@ -0,0 +1,164 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the zenstruck/foundry package.
+ *
+ * (c) Kevin Bond <kevinbond@gmail.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Zenstruck\Foundry\Tests\Unit;
+
+use PHPUnit\Framework\Attributes\Test;
+use PHPUnit\Framework\TestCase;
+use Zenstruck\Foundry\Test\Factories;
+use Zenstruck\Foundry\Tests\Fixture\Factories\Entity\Address\AddressFactory;
+use Zenstruck\Foundry\Tests\Fixture\Factories\Entity\Category\CategoryFactory;
+use Zenstruck\Foundry\Tests\Fixture\Factories\Entity\Contact\ContactFactory;
+
+final class ReuseEntityTest extends TestCase
+{
+    use Factories;
+
+    /**
+     * @test
+     */
+    #[Test]
+    public function it_can_reuse_an_object(): void
+    {
+        $address = AddressFactory::createOne();
+
+        $contact = ContactFactory::new()
+            ->reuse($address)
+            ->create();
+
+        self::assertSame($address, $contact->getAddress());
+    }
+
+    /**
+     * @test
+     */
+    #[Test]
+    public function it_throws_if_recycling_two_objects_of_same_type(): void
+    {
+        $this->expectException(\InvalidArgumentException::class);
+
+        ContactFactory::new()
+            ->reuse(AddressFactory::createOne())
+            ->reuse(AddressFactory::createOne())
+            ->create();
+    }
+
+    /**
+     * @test
+     */
+    #[Test]
+    public function it_throws_if_recycling_a_factory(): void
+    {
+        $this->expectException(\InvalidArgumentException::class);
+
+        ContactFactory::new()
+            ->reuse(AddressFactory::new())
+            ->create();
+    }
+
+    /**
+     * @test
+     */
+    #[Test]
+    public function it_does_nothing_if_reused_object_is_not_used(): void
+    {
+        ContactFactory::new()
+            ->reuse(new \stdClass())
+            ->create();
+
+        $this->expectNotToPerformAssertions();
+    }
+
+    /**
+     * @test
+     */
+    #[Test]
+    public function it_can_call_reuse_multiple_times(): void
+    {
+        $address = AddressFactory::createOne();
+        $category = CategoryFactory::createOne();
+
+        $contact = ContactFactory::new()
+            ->reuse($address)
+            ->reuse($category)
+            ->create();
+
+        self::assertSame($address, $contact->getAddress());
+        self::assertSame($category, $contact->getCategory());
+    }
+
+    /**
+     * @test
+     */
+    #[Test]
+    public function it_reuse_the_same_object_multiple_times(): void
+    {
+        $category = CategoryFactory::createOne();
+
+        $contact = ContactFactory::new()
+            ->reuse($category)
+            ->create();
+
+        self::assertSame($category, $contact->getCategory());
+        self::assertSame($category, $contact->getSecondaryCategory());
+    }
+
+    /**
+     * @test
+     */
+    #[Test]
+    public function it_propagate_reused_objects(): void
+    {
+        $category = CategoryFactory::createOne();
+
+        $address = AddressFactory::new(['contact' => ContactFactory::new()])
+            ->reuse($category)
+            ->create();
+
+        self::assertSame($category, $address->getContact()?->getCategory());
+        self::assertSame($category, $address->getContact()->getSecondaryCategory());
+    }
+
+    /**
+     * @test
+     */
+    #[Test]
+    public function reused_object_in_sub_factory_has_priority(): void
+    {
+        $category = CategoryFactory::createOne();
+
+        $address = AddressFactory::new([
+                'contact' => ContactFactory::new()->reuse($category2 = CategoryFactory::createOne())
+            ])
+            ->reuse($category)
+            ->create();
+
+        self::assertSame($category2, $address->getContact()?->getCategory());
+        self::assertSame($category2, $address->getContact()->getSecondaryCategory());
+    }
+
+    /**
+     * @test
+     */
+    #[Test]
+    public function reused_object_dont_have_priority_over_states(): void
+    {
+        $address = AddressFactory::createOne();
+
+        $contact = ContactFactory::new()
+            ->reuse($address)
+            ->create(['address' => AddressFactory::new()]);
+
+        self::assertNotSame($address, $contact->getAddress());
+    }
+}