diff --git a/src/Factory.php b/src/Factory.php index 54e8ce06..de8add1b 100644 --- a/src/Factory.php +++ b/src/Factory.php @@ -235,7 +235,7 @@ protected function normalizeParameter(string $field, mixed $value): mixed ); } - return \is_object($value) ? $this->normalizeObject($value) : $value; + return \is_object($value) ? $this->normalizeObject($field, $value) : $value; } /** @@ -253,7 +253,7 @@ protected function normalizeCollection(string $field, FactoryCollection $collect /** * @internal */ - protected function normalizeObject(object $object): object + protected function normalizeObject(string $field, object $object): object { return $object; } diff --git a/src/ORM/OrmV2PersistenceStrategy.php b/src/ORM/OrmV2PersistenceStrategy.php index 7e2ae2a2..564aeec4 100644 --- a/src/ORM/OrmV2PersistenceStrategy.php +++ b/src/ORM/OrmV2PersistenceStrategy.php @@ -16,7 +16,9 @@ use Doctrine\ORM\Mapping\ClassMetadataInfo; use Doctrine\ORM\Mapping\MappingException as ORMMappingException; use Doctrine\Persistence\Mapping\MappingException; -use Zenstruck\Foundry\Persistence\InverseRelationshipMetadata; +use Zenstruck\Foundry\Persistence\Relationship\OneToManyRelationship; +use Zenstruck\Foundry\Persistence\Relationship\OneToOneRelationship; +use Zenstruck\Foundry\Persistence\Relationship\RelationshipMetadata; /** * @internal @@ -25,49 +27,39 @@ */ final class OrmV2PersistenceStrategy extends AbstractORMPersistenceStrategy { - public function inversedRelationshipMetadata(string $parent, string $child, string $field): ?InverseRelationshipMetadata + public function inversedRelationshipMetadata(string $parent, string $child, string $field): ?RelationshipMetadata { - $metadata = $this->classMetadata($child); + $associationMapping = $this->getAssociationMapping($parent, $child, $field); - $inversedAssociation = $this->getAssociationMapping($parent, $child, $field); - - if (null === $inversedAssociation || !$metadata instanceof ClassMetadataInfo) { + if (null === $associationMapping) { return null; } if (!\is_a( $child, - $inversedAssociation['targetEntity'], + $associationMapping['targetEntity'], allow_string: true )) { // is_a() handles inheritance as well throw new \LogicException("Cannot find correct association named \"{$field}\" between classes [parent: \"{$parent}\", child: \"{$child}\"]"); } - // exclude "owning" side of the association (owning OneToOne or ManyToOne) - if (!\in_array( - $inversedAssociation['type'], - [ClassMetadataInfo::ONE_TO_MANY, ClassMetadataInfo::ONE_TO_ONE], - true - ) - || !isset($inversedAssociation['mappedBy']) - ) { - return null; - } + $inverseField = $associationMapping['isOwningSide'] ? $associationMapping['inversedBy'] ?? null : $associationMapping['mappedBy'] ?? null; - $association = $metadata->getAssociationMapping($inversedAssociation['mappedBy']); - - // only keep *ToOne associations - if (!$metadata->isSingleValuedAssociation($association['fieldName'])) { + if (null === $inverseField) { return null; } - $inversedAssociationMetadata = $this->classMetadata($inversedAssociation['sourceEntity']); - - return new InverseRelationshipMetadata( - inverseField: $association['fieldName'], - isCollection: $inversedAssociationMetadata->isCollectionValuedAssociation($inversedAssociation['fieldName']), - collectionIndexedBy: $inversedAssociation['indexBy'] ?? null - ); + return match (true) { + ClassMetadataInfo::ONE_TO_MANY === $associationMapping['type'] => new OneToManyRelationship( + inverseField: $inverseField, + collectionIndexedBy: $associationMapping['indexBy'] ?? null + ), + ClassMetadataInfo::ONE_TO_ONE === $associationMapping['type'] => new OneToOneRelationship( + inverseField: $inverseField, + isOwning: $associationMapping['isOwningSide'] ?? false + ), + default => null, + }; } /** diff --git a/src/ORM/OrmV3PersistenceStrategy.php b/src/ORM/OrmV3PersistenceStrategy.php index e105b176..196df921 100644 --- a/src/ORM/OrmV3PersistenceStrategy.php +++ b/src/ORM/OrmV3PersistenceStrategy.php @@ -14,50 +14,49 @@ namespace Zenstruck\Foundry\ORM; use Doctrine\ORM\Mapping\AssociationMapping; -use Doctrine\ORM\Mapping\ClassMetadata; -use Doctrine\ORM\Mapping\InverseSideMapping; use Doctrine\ORM\Mapping\MappingException as ORMMappingException; -use Doctrine\ORM\Mapping\ToManyAssociationMapping; +use Doctrine\ORM\Mapping\OneToManyAssociationMapping; +use Doctrine\ORM\Mapping\OneToOneAssociationMapping; use Doctrine\Persistence\Mapping\MappingException; -use Zenstruck\Foundry\Persistence\InverseRelationshipMetadata; +use Zenstruck\Foundry\Persistence\Relationship\OneToManyRelationship; +use Zenstruck\Foundry\Persistence\Relationship\OneToOneRelationship; +use Zenstruck\Foundry\Persistence\Relationship\RelationshipMetadata; final class OrmV3PersistenceStrategy extends AbstractORMPersistenceStrategy { - public function inversedRelationshipMetadata(string $parent, string $child, string $field): ?InverseRelationshipMetadata + public function inversedRelationshipMetadata(string $parent, string $child, string $field): ?RelationshipMetadata { - $metadata = $this->classMetadata($child); + $associationMapping = $this->getAssociationMapping($parent, $child, $field); - $inversedAssociation = $this->getAssociationMapping($parent, $child, $field); - - if (null === $inversedAssociation || !$metadata instanceof ClassMetadata) { + if (null === $associationMapping) { return null; } if (!\is_a( $child, - $inversedAssociation->targetEntity, + $associationMapping->targetEntity, allow_string: true )) { // is_a() handles inheritance as well throw new \LogicException("Cannot find correct association named \"{$field}\" between classes [parent: \"{$parent}\", child: \"{$child}\"]"); } - // exclude "owning" side of the association (owning OneToOne or ManyToOne) - if (!$inversedAssociation instanceof InverseSideMapping) { - return null; - } - - $association = $metadata->getAssociationMapping($inversedAssociation->mappedBy); + $inverseField = $associationMapping->isOwningSide() ? $associationMapping->inversedBy : $associationMapping->mappedBy; - // only keep *ToOne associations - if (!$metadata->isSingleValuedAssociation($association->fieldName)) { + if (null === $inverseField) { return null; } - return new InverseRelationshipMetadata( - inverseField: $association->fieldName, - isCollection: $inversedAssociation instanceof ToManyAssociationMapping, - collectionIndexedBy: $inversedAssociation->isIndexed() ? $inversedAssociation->indexBy() : null - ); + return match (true) { + $associationMapping instanceof OneToManyAssociationMapping => new OneToManyRelationship( + inverseField: $inverseField, + collectionIndexedBy: $associationMapping->isIndexed() ? $associationMapping->indexBy() : null + ), + $associationMapping instanceof OneToOneAssociationMapping => new OneToOneRelationship( + inverseField: $inverseField, + isOwning: $associationMapping->isOwningSide() + ), + default => null, + }; } /** diff --git a/src/Persistence/PersistenceManager.php b/src/Persistence/PersistenceManager.php index 8a39e611..895f7844 100644 --- a/src/Persistence/PersistenceManager.php +++ b/src/Persistence/PersistenceManager.php @@ -19,6 +19,7 @@ use Zenstruck\Foundry\ORM\AbstractORMPersistenceStrategy; use Zenstruck\Foundry\Persistence\Exception\NoPersistenceStrategy; use Zenstruck\Foundry\Persistence\Exception\RefreshObjectFailed; +use Zenstruck\Foundry\Persistence\Relationship\RelationshipMetadata; use Zenstruck\Foundry\Persistence\ResetDatabase\ResetDatabaseManager; /** @@ -31,9 +32,14 @@ final class PersistenceManager private bool $flush = true; private bool $persist = true; + /** @var list */ + private array $objectsToPersist = []; + /** @var list */ private array $afterPersistCallbacks = []; + private bool $transactionStarted = false; + /** * @param iterable $strategies */ @@ -75,18 +81,48 @@ public function save(object $object): object $om->persist($object); $this->flush($om); - if ($this->afterPersistCallbacks) { - $afterPersistCallbacks = $this->afterPersistCallbacks; - $this->afterPersistCallbacks = []; + return $object; + } - foreach ($afterPersistCallbacks as $afterPersistCallback) { - $afterPersistCallback(); + /** + * We're using so-called "transactions" to group multiple persist/flush operations + * This prevents such code to persist the whole batch of objects in the normalization phase: + * ```php + * SomeFactory::createOne(['item' => lazy(fn() => OtherFactory::createOne())]); + * ```. + */ + public function startTransaction(): void + { + $this->transactionStarted = true; + } + + public function isTransactionStarted(): bool + { + return $this->transactionStarted; + } + + public function commit(): void + { + $objectManagers = []; + + $objectsToPersist = $this->objectsToPersist; + $this->objectsToPersist = []; + $this->transactionStarted = false; + + foreach ($objectsToPersist as $object) { + $om = $this->strategyFor($object::class)->objectManagerFor($object::class); + $om->persist($object); + + if (!\in_array($om, $objectManagers, true)) { + $objectManagers[] = $om; } + } - $this->save($object); + foreach ($objectManagers as $om) { + $this->flush($om); } - return $object; + $this->callPostPersistCallbacks(); } /** @@ -103,25 +139,16 @@ public function scheduleForInsert(object $object, array $afterPersistCallbacks = $object = unproxy($object); } - $om = $this->strategyFor($object::class)->objectManagerFor($object::class); - $om->persist($object); + $this->objectsToPersist[] = $object; - $this->afterPersistCallbacks = [...$this->afterPersistCallbacks, ...$afterPersistCallbacks]; + $this->afterPersistCallbacks = [ + ...$this->afterPersistCallbacks, + ...$afterPersistCallbacks, + ]; return $object; } - public function forget(object $object): void - { - if ($this->isPersisted($object)) { - throw new \LogicException('Cannot forget an object already persisted.'); - } - - $om = $this->strategyFor($object::class)->objectManagerFor($object::class); - - $om->detach($object); - } - /** * @template T * @@ -137,11 +164,9 @@ public function flushAfter(callable $callback): mixed $this->flush = true; - foreach ($this->strategies as $strategy) { - foreach ($strategy->objectManagers() as $om) { - $this->flush($om); - } - } + $this->flushAllStrategies(); + + $this->callPostPersistCallbacks(); return $result; } @@ -280,7 +305,7 @@ public function repositoryFor(string $class): ObjectRepository * @param class-string $parent * @param class-string $child */ - public function inverseRelationshipMetadata(string $parent, string $child, string $field): ?InverseRelationshipMetadata + public function inverseRelationshipMetadata(string $parent, string $child, string $field): ?RelationshipMetadata { $parent = unproxy($parent); $child = unproxy($child); @@ -372,6 +397,31 @@ public static function isOrmOnly(): bool })(); } + private function flushAllStrategies(): void + { + foreach ($this->strategies as $strategy) { + foreach ($strategy->objectManagers() as $om) { + $this->flush($om); + } + } + } + + private function callPostPersistCallbacks(): void + { + if (!$this->flush || [] === $this->afterPersistCallbacks) { + return; + } + + $afterPersistCallbacks = $this->afterPersistCallbacks; + $this->afterPersistCallbacks = []; + + foreach ($afterPersistCallbacks as $afterPersistCallback) { + $afterPersistCallback(); + } + + $this->flushAllStrategies(); + } + /** * @param class-string $class * diff --git a/src/Persistence/PersistenceStrategy.php b/src/Persistence/PersistenceStrategy.php index 85607ccd..ed3e36c9 100644 --- a/src/Persistence/PersistenceStrategy.php +++ b/src/Persistence/PersistenceStrategy.php @@ -15,6 +15,7 @@ use Doctrine\Persistence\Mapping\ClassMetadata; use Doctrine\Persistence\Mapping\MappingException; use Doctrine\Persistence\ObjectManager; +use Zenstruck\Foundry\Persistence\Relationship\RelationshipMetadata; /** * @author Kevin Bond @@ -63,7 +64,7 @@ public function objectManagers(): array * @param class-string $parent * @param class-string $child */ - public function inversedRelationshipMetadata(string $parent, string $child, string $field): ?InverseRelationshipMetadata + public function inversedRelationshipMetadata(string $parent, string $child, string $field): ?RelationshipMetadata { return null; } diff --git a/src/Persistence/PersistentObjectFactory.php b/src/Persistence/PersistentObjectFactory.php index 1a9f6ac2..8ec70765 100644 --- a/src/Persistence/PersistentObjectFactory.php +++ b/src/Persistence/PersistentObjectFactory.php @@ -22,6 +22,8 @@ use Zenstruck\Foundry\ObjectFactory; use Zenstruck\Foundry\Persistence\Exception\NotEnoughObjects; use Zenstruck\Foundry\Persistence\Exception\RefreshObjectFailed; +use Zenstruck\Foundry\Persistence\Relationship\OneToManyRelationship; +use Zenstruck\Foundry\Persistence\Relationship\OneToOneRelationship; use function Zenstruck\Foundry\get; use function Zenstruck\Foundry\set; @@ -44,6 +46,8 @@ abstract class PersistentObjectFactory extends ObjectFactory /** @var list */ private array $tempAfterInstantiate = []; + private bool $isRootFactory = true; + /** * @phpstan-param mixed|Parameters $criteriaOrId * @@ -196,6 +200,13 @@ final public static function truncate(): void */ public function create(callable|array $attributes = []): object { + $transactionStarted = false; + + if (Configuration::isBooted() && PersistMode::PERSIST === $this->persistMode() && $this->isRootFactory) { + $transactionStarted = Configuration::instance()->persistence()->isTransactionStarted(); + Configuration::instance()->persistence()->startTransaction(); + } + $object = parent::create($attributes); foreach ($this->tempAfterInstantiate as $callback) { @@ -206,7 +217,7 @@ public function create(callable|array $attributes = []): object $this->throwIfCannotCreateObject(); - if (PersistMode::PERSIST !== $this->persistMode()) { + if ($transactionStarted || PersistMode::PERSIST !== $this->persistMode() || !$this->isRootFactory) { return $object; } @@ -216,7 +227,7 @@ public function create(callable|array $attributes = []): object throw new \LogicException('Persistence cannot be used in unit tests.'); } - $configuration->persistence()->save($object); + $configuration->persistence()->commit(); return $object; } @@ -290,30 +301,31 @@ protected function normalizeParameter(string $field, mixed $value): mixed if ($value instanceof self) { $pm = Configuration::instance()->persistence(); - $inversedRelationshipMetadata = $pm->inverseRelationshipMetadata(static::class(), $value::class(), $field); + $relationshipMetadata = $pm->inverseRelationshipMetadata(static::class(), $value::class(), $field); // handle inversed OneToOne - if ($inversedRelationshipMetadata && !$inversedRelationshipMetadata->isCollection) { - $inverseField = $inversedRelationshipMetadata->inverseField; + if ($relationshipMetadata instanceof OneToOneRelationship && !$relationshipMetadata->isOwning) { + $inverseField = $relationshipMetadata->inverseField(); - $inversedObject = $value->withPersistMode( - $this->isPersisting() ? PersistMode::NO_PERSIST_BUT_SCHEDULE_FOR_INSERT : PersistMode::WITHOUT_PERSISTING - ) + // we need to handle the circular dependency involved by inversed one-to-one relationship: + // a placeholder object is used, which will be replaced by the real object, after its instantiation + $inverseObjectPlaceholder = (new \ReflectionClass($value::class()))->newInstanceWithoutConstructor(); - // we need to handle the circular dependency involved by inversed one-to-one relationship: - // a placeholder object is used, which will be replaced by the real object, after its instantiation - ->create([ - $inverseField => $placeholder = (new \ReflectionClass(static::class()))->newInstanceWithoutConstructor(), - ]); + $this->tempAfterInstantiate[] = function(object $object) use ($value, $inverseField, $field) { + $inverseObject = $value->withPersistMode( + $this->isPersisting() ? PersistMode::NO_PERSIST_BUT_SCHEDULE_FOR_INSERT : PersistMode::WITHOUT_PERSISTING + ) + ->notRootFactory() + ->create([$inverseField => $object]); - $inversedObject = unproxy($inversedObject, withAutoRefresh: false); + $inverseObject = unproxy($inverseObject, withAutoRefresh: false); - $this->tempAfterInstantiate[] = static function(object $object) use ($inversedObject, $inverseField, $pm, $placeholder) { - $pm->forget($placeholder); - set($inversedObject, $inverseField, $object); + set($object, $field, $inverseObject); }; - return $inversedObject; + return $inverseObjectPlaceholder; + } else { + $value = $value->notRootFactory(); } } @@ -330,9 +342,9 @@ protected function normalizeCollection(string $field, FactoryCollection $collect $inverseRelationshipMetadata = $pm->inverseRelationshipMetadata(static::class(), $collection->factory::class(), $field); - if ($inverseRelationshipMetadata && $inverseRelationshipMetadata->isCollection) { + if ($inverseRelationshipMetadata instanceof OneToManyRelationship) { $this->tempAfterInstantiate[] = function(object $object) use ($collection, $inverseRelationshipMetadata, $field) { - $inverseField = $inverseRelationshipMetadata->inverseField; + $inverseField = $inverseRelationshipMetadata->inverseField(); $inverseObjects = $collection->withPersistMode( $this->isPersisting() ? PersistMode::NO_PERSIST_BUT_SCHEDULE_FOR_INSERT : PersistMode::WITHOUT_PERSISTING @@ -364,26 +376,43 @@ protected function normalizeCollection(string $field, FactoryCollection $collect * * @internal */ - protected function normalizeObject(object $object): object + protected function normalizeObject(string $field, object $object): object { $configuration = Configuration::instance(); - if ( - !$this->isPersisting() - || !$configuration->isPersistenceAvailable() - ) { + $object = unproxy($object, withAutoRefresh: false); + + if (!$configuration->isPersistenceAvailable()) { return $object; } - $object = unproxy($object, withAutoRefresh: false); - $persistenceManager = $configuration->persistence(); + if (!$persistenceManager->hasPersistenceFor($object)) { return $object; } + $inverseRelationship = $persistenceManager->inverseRelationshipMetadata(static::class(), $object::class, $field); + + if ($inverseRelationship instanceof OneToOneRelationship) { + $this->tempAfterInstantiate[] = static function(object $newObject) use ($object, $inverseRelationship) { + try { + set($object, $inverseRelationship->inverseField(), $newObject); + } catch (\Throwable) { + } + }; + } + + if ( + !$this->isPersisting() + ) { + return $object; + } + if (!$persistenceManager->isPersisted($object)) { $persistenceManager->scheduleForInsert($object); + + return $object; } try { @@ -426,7 +455,16 @@ static function(object $object, array $parameters, PersistentObjectFactory $fact Configuration::instance()->persistence()->scheduleForInsert($object, $afterPersistCallbacks); } - ); + ) + ->afterPersist( + static function(object $object): void { + try { + Configuration::instance()->persistence()->refresh($object); + } catch (RefreshObjectFailed) { + } + } + ) + ; } private function throwIfCannotCreateObject(): void @@ -450,4 +488,12 @@ private function throwIfCannotCreateObject(): void throw new \LogicException(\sprintf('Cannot create object in a data provider for non-proxy factories. Transform your factory into a "%s", or call "create()" method in the test. See https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#phpunit-data-providers', PersistentProxyObjectFactory::class)); } + + private function notRootFactory(): static + { + $clone = clone $this; + $clone->isRootFactory = false; + + return $clone; + } } diff --git a/src/Persistence/Relationship/OneToManyRelationship.php b/src/Persistence/Relationship/OneToManyRelationship.php new file mode 100644 index 00000000..e15a4c47 --- /dev/null +++ b/src/Persistence/Relationship/OneToManyRelationship.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Persistence\Relationship; + +/** + * @author Nicolas PHILIPPE + * + * @internal + */ +final class OneToManyRelationship implements RelationshipMetadata +{ + public function __construct( + private readonly string $inverseField, + public readonly ?string $collectionIndexedBy, + ) { + } + + public function inverseField(): string + { + return $this->inverseField; + } +} diff --git a/src/Persistence/Relationship/OneToOneRelationship.php b/src/Persistence/Relationship/OneToOneRelationship.php new file mode 100644 index 00000000..a1c388a6 --- /dev/null +++ b/src/Persistence/Relationship/OneToOneRelationship.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Persistence\Relationship; + +/** + * @author Nicolas PHILIPPE + * + * @internal + */ +final class OneToOneRelationship implements RelationshipMetadata +{ + public function __construct( + private readonly string $inverseField, + public readonly bool $isOwning, + ) { + } + + public function inverseField(): string + { + return $this->inverseField; + } +} diff --git a/src/Persistence/InverseRelationshipMetadata.php b/src/Persistence/Relationship/RelationshipMetadata.php similarity index 53% rename from src/Persistence/InverseRelationshipMetadata.php rename to src/Persistence/Relationship/RelationshipMetadata.php index 1287b8f5..83ad64f2 100644 --- a/src/Persistence/InverseRelationshipMetadata.php +++ b/src/Persistence/Relationship/RelationshipMetadata.php @@ -9,19 +9,14 @@ * file that was distributed with this source code. */ -namespace Zenstruck\Foundry\Persistence; +namespace Zenstruck\Foundry\Persistence\Relationship; /** * @author Kevin Bond * * @internal */ -final class InverseRelationshipMetadata +interface RelationshipMetadata { - public function __construct( - public readonly string $inverseField, - public readonly bool $isCollection, - public readonly ?string $collectionIndexedBy, - ) { - } + public function inverseField(): string; } diff --git a/tests/Fixture/Entity/EdgeCases/InversedOneToOneWithManyToOne/InverseSide.php b/tests/Fixture/Entity/EdgeCases/InversedOneToOneWithManyToOne/InverseSide.php new file mode 100644 index 00000000..46bb9540 --- /dev/null +++ b/tests/Fixture/Entity/EdgeCases/InversedOneToOneWithManyToOne/InverseSide.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Tests\Fixture\Entity\EdgeCases\InversedOneToOneWithManyToOne; + +use Doctrine\ORM\Mapping as ORM; +use Zenstruck\Foundry\Tests\Fixture\Model\Base; + +/** + * @author Nicolas PHILIPPE + */ +#[ORM\Entity] +#[ORM\Table('inversed_one_to_one_with_many_to_one_inverse_side')] +class InverseSide extends Base +{ + #[ORM\OneToOne(mappedBy: 'inverseSide')] + public ?OwningSide $owningSide = null; + + #[ORM\ManyToOne()] + public ?Item $item = null; + + public function __construct( + #[ORM\Column()] + public string $mandatoryField, + ) { + } +} diff --git a/tests/Fixture/Entity/EdgeCases/InversedOneToOneWithManyToOne/Item.php b/tests/Fixture/Entity/EdgeCases/InversedOneToOneWithManyToOne/Item.php new file mode 100644 index 00000000..cbd15e0e --- /dev/null +++ b/tests/Fixture/Entity/EdgeCases/InversedOneToOneWithManyToOne/Item.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Tests\Fixture\Entity\EdgeCases\InversedOneToOneWithManyToOne; + +use Doctrine\ORM\Mapping as ORM; +use Zenstruck\Foundry\Tests\Fixture\Model\Base; + +/** + * @author Nicolas PHILIPPE + */ +#[ORM\Entity] +#[ORM\Table('inversed_one_to_one_with_many_to_one_item_if_collection')] +class Item extends Base +{ +} diff --git a/tests/Fixture/Entity/EdgeCases/InversedOneToOneWithManyToOne/OwningSide.php b/tests/Fixture/Entity/EdgeCases/InversedOneToOneWithManyToOne/OwningSide.php new file mode 100644 index 00000000..11b0b225 --- /dev/null +++ b/tests/Fixture/Entity/EdgeCases/InversedOneToOneWithManyToOne/OwningSide.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Tests\Fixture\Entity\EdgeCases\InversedOneToOneWithManyToOne; + +use Doctrine\ORM\Mapping as ORM; +use Zenstruck\Foundry\Tests\Fixture\Model\Base; + +/** + * @author Nicolas PHILIPPE + */ +#[ORM\Entity] +#[ORM\Table('inversed_one_to_one_with_many_to_one_owning_side')] +class OwningSide extends Base +{ + #[ORM\OneToOne(inversedBy: 'owningSide')] + public ?InverseSide $inverseSide = null; +} diff --git a/tests/Fixture/Entity/EdgeCases/InversedOneToOneWithNonNullableOwning/InverseSide.php b/tests/Fixture/Entity/EdgeCases/InversedOneToOneWithNonNullableOwning/InverseSide.php index f93863fb..c90cf744 100644 --- a/tests/Fixture/Entity/EdgeCases/InversedOneToOneWithNonNullableOwning/InverseSide.php +++ b/tests/Fixture/Entity/EdgeCases/InversedOneToOneWithNonNullableOwning/InverseSide.php @@ -25,7 +25,18 @@ class InverseSide extends Base { public function __construct( #[ORM\OneToOne(mappedBy: 'inverseSide')] // @phpstan-ignore doctrine.associationType - public OwningSide $owningSide, + private OwningSide $owningSide, ) { } + + public function getOwningSide(): OwningSide + { + return $this->owningSide; + } + + public function setOwningSide(OwningSide $owningSide): void + { + $this->owningSide = $owningSide; + $owningSide->inverseSide = $this; + } } diff --git a/tests/Fixture/Entity/EdgeCases/InversedOneToOneWithoutAutoGeneratedId/InverseSide.php b/tests/Fixture/Entity/EdgeCases/InversedOneToOneWithoutAutoGeneratedId/InverseSide.php new file mode 100644 index 00000000..c4240c45 --- /dev/null +++ b/tests/Fixture/Entity/EdgeCases/InversedOneToOneWithoutAutoGeneratedId/InverseSide.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Tests\Fixture\Entity\EdgeCases\InversedOneToOneWithoutAutoGeneratedId; + +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Uid\Uuid; + +/** + * @author Nicolas PHILIPPE + */ +#[ORM\Entity] +#[ORM\Table('inversed_one_to_one_with_custom_id_inverse')] +class InverseSide +{ + #[ORM\Id] + #[ORM\Column(type: 'uuid')] + public Uuid $id; + + public function __construct( + #[ORM\OneToOne(mappedBy: 'inverseSide', cascade: ['persist'])] // @phpstan-ignore doctrine.associationType + public OwningSide $owningSide, + ) { + $this->id = Uuid::v7(); + } +} diff --git a/tests/Fixture/Entity/EdgeCases/InversedOneToOneWithoutAutoGeneratedId/OwningSide.php b/tests/Fixture/Entity/EdgeCases/InversedOneToOneWithoutAutoGeneratedId/OwningSide.php new file mode 100644 index 00000000..46504419 --- /dev/null +++ b/tests/Fixture/Entity/EdgeCases/InversedOneToOneWithoutAutoGeneratedId/OwningSide.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Tests\Fixture\Entity\EdgeCases\InversedOneToOneWithoutAutoGeneratedId; + +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Uid\Uuid; + +/** + * @author Nicolas PHILIPPE + */ +#[ORM\Entity] +#[ORM\Table('inversed_one_to_one_with_custom_id_owning')] +class OwningSide +{ + #[ORM\Id] + #[ORM\Column(type: 'uuid')] + public Uuid $id; + + #[ORM\OneToOne(inversedBy: 'owningSide', cascade: ['persist'])] + public ?InverseSide $inverseSide = null; + + public function __construct() + { + $this->id = Uuid::v7(); + } +} diff --git a/tests/Integration/ORM/EdgeCasesRelationshipTest.php b/tests/Integration/ORM/EdgeCasesRelationshipTest.php index 82b47243..8796a42f 100644 --- a/tests/Integration/ORM/EdgeCasesRelationshipTest.php +++ b/tests/Integration/ORM/EdgeCasesRelationshipTest.php @@ -23,8 +23,10 @@ use Zenstruck\Foundry\Tests\Fixture\DoctrineCascadeRelationship\ChangesEntityRelationshipCascadePersist; use Zenstruck\Foundry\Tests\Fixture\DoctrineCascadeRelationship\UsingRelationships; use Zenstruck\Foundry\Tests\Fixture\Entity\EdgeCases\IndexedOneToMany; +use Zenstruck\Foundry\Tests\Fixture\Entity\EdgeCases\InversedOneToOneWithManyToOne; use Zenstruck\Foundry\Tests\Fixture\Entity\EdgeCases\InversedOneToOneWithNonNullableOwning; use Zenstruck\Foundry\Tests\Fixture\Entity\EdgeCases\InversedOneToOneWithOneToMany; +use Zenstruck\Foundry\Tests\Fixture\Entity\EdgeCases\InversedOneToOneWithoutAutoGeneratedId; use Zenstruck\Foundry\Tests\Fixture\Entity\EdgeCases\InversedOneToOneWithSetter; use Zenstruck\Foundry\Tests\Fixture\Entity\EdgeCases\ManyToOneToSelfReferencing; use Zenstruck\Foundry\Tests\Fixture\Entity\EdgeCases\OneToManyWithUnionType; @@ -75,7 +77,7 @@ public function inverse_one_to_one_with_non_nullable_inverse_side(): void $owningSideFactory::assert()->count(1); $inverseSideFactory::assert()->count(1); - self::assertSame($inverseSide, $inverseSide->owningSide->inverseSide); + self::assertSame($inverseSide, $inverseSide->getOwningSide()->inverseSide); } /** @test */ @@ -101,7 +103,7 @@ public function inverse_one_to_one_with_both_nullable(): void #[DataProvider('provideCascadeRelationshipsCombinations')] #[UsingRelationships(InversedOneToOneWithOneToMany\OwningSide::class, ['inverseSide'])] #[UsingRelationships(InversedOneToOneWithOneToMany\Item::class, ['owningSide'])] - #[RequiresPhpunit('^11.4')] + #[RequiresPhpunit('>=11.4')] public function inverse_one_to_one_with_one_to_many(): void { $inverseSideFactory = persistent_factory(InversedOneToOneWithOneToMany\InverseSide::class); @@ -142,7 +144,7 @@ public function many_to_many_to_self_referencing_inverse_side(): void #[Test] #[DataProvider('provideCascadeRelationshipsCombinations')] #[UsingRelationships(IndexedOneToMany\ParentEntity::class, ['items'])] - #[RequiresPhpunit('^11.4')] + #[RequiresPhpunit('>=11.4')] public function indexed_one_to_many(): void { $parentFactory = persistent_factory(IndexedOneToMany\ParentEntity::class); @@ -162,7 +164,56 @@ public function indexed_one_to_many(): void /** @test */ #[Test] - public function object_with_union_type(): void + #[DataProvider('provideCascadeRelationshipsCombinations')] + #[UsingRelationships(InversedOneToOneWithManyToOne\InverseSide::class, ['owningSide', 'item'])] + #[RequiresPhpunit('>=11.4')] + public function inversed_one_to_one_can_be_used_after_other_relationship(): void + { + $inverseSideFactory = persistent_factory(InversedOneToOneWithManyToOne\InverseSide::class); + $owningSideFactory = persistent_factory(InversedOneToOneWithManyToOne\OwningSide::class); + $itemFactory = persistent_factory(InversedOneToOneWithManyToOne\Item::class); + + $inverseSide = $inverseSideFactory->create( + [ + 'mandatoryField' => 'foo', + 'owningSide' => $owningSideFactory, + 'item' => $itemFactory, + ] + ); + + $inverseSideFactory::assert()->count(1); + $owningSideFactory::assert()->count(1); + $itemFactory::assert()->count(1); + + self::assertNotNull($inverseSide->owningSide); + self::assertSame($inverseSide, $inverseSide->owningSide->inverseSide); + self::assertNotNull($inverseSide->item); + } + + /** @test */ + #[Test] + #[DataProvider('provideCascadeRelationshipsCombinations')] + #[UsingRelationships(InversedOneToOneWithoutAutoGeneratedId\OwningSide::class, ['inverseSide'])] + #[RequiresPhpunit('>=11.4')] + public function inverse_one_to_one_with_custom_id(): void + { + $owningSideFactory = persistent_factory(InversedOneToOneWithoutAutoGeneratedId\OwningSide::class); + $inverseSideFactory = persistent_factory(InversedOneToOneWithoutAutoGeneratedId\InverseSide::class); + + $inverseSide = $inverseSideFactory->create(['owningSide' => $owningSideFactory]); + + $owningSideFactory::assert()->count(1); + $inverseSideFactory::assert()->count(1); + + self::assertSame($inverseSide, $inverseSide->owningSide->inverseSide); + } + + /** @test */ + #[Test] + #[DataProvider('provideCascadeRelationshipsCombinations')] + #[UsingRelationships(OneToManyWithUnionType\OwningSideEntity::class, ['item'])] + #[RequiresPhpunit('>=11.4')] + public function after_instantiate_flushing_using_current_object_in_relationship_one_to_one(): void { $owningSideFactory = persistent_factory(OneToManyWithUnionType\OwningSideEntity::class); $hasOneToManyWithUnionTypeFactory = persistent_factory(OneToManyWithUnionType\HasOneToManyWithUnionType::class); @@ -180,6 +231,56 @@ public function object_with_union_type(): void self::assertInstanceOf(Collection::class, $object->collection); } + /** @test */ + #[Test] + #[DataProvider('provideCascadeRelationshipsCombinations')] + #[UsingRelationships(InversedOneToOneWithNonNullableOwning\OwningSide::class, ['inverseSide'])] + #[RequiresPhpunit('>=11.4')] + public function after_instantiate_flushing_using_current_object_in_relationship_inversed_one_to_one(): void + { + $owningSideFactory = persistent_factory(InversedOneToOneWithNonNullableOwning\OwningSide::class); + $inverseSideFactory = persistent_factory(InversedOneToOneWithNonNullableOwning\InverseSide::class); + + $owningSide = $owningSideFactory + ->afterInstantiate( + static function(InversedOneToOneWithNonNullableOwning\OwningSide $o) use ($inverseSideFactory) { + $inverseSideFactory->create(['owningSide' => $o]); + } + ) + ->create(); + + $owningSideFactory::assert()->count(1); + $inverseSideFactory::assert()->count(1); + + self::assertNotNull($owningSide->inverseSide); + self::assertSame($owningSide, $owningSide->inverseSide->getOwningSide()); + } + + /** @test */ + #[Test] + #[DataProvider('provideCascadeRelationshipsCombinations')] + #[UsingRelationships(InversedOneToOneWithNonNullableOwning\OwningSide::class, ['inverseSide'])] + #[RequiresPhpunit('>=11.4')] + public function can_create_one_to_one(): void + { + $owningSideFactory = persistent_factory(InversedOneToOneWithNonNullableOwning\OwningSide::class); + $inverseSideFactory = persistent_factory(InversedOneToOneWithNonNullableOwning\InverseSide::class); + + $owningSide = $owningSideFactory + ->afterInstantiate( + static function(InversedOneToOneWithNonNullableOwning\OwningSide $o) use ($inverseSideFactory): void { + $inverseSideFactory->create(['owningSide' => $o]); + } + ) + ->create(); + + $owningSideFactory::assert()->count(1); + $inverseSideFactory::assert()->count(1); + + self::assertNotNull($owningSide->inverseSide); + self::assertSame($owningSide, $owningSide->inverseSide->getOwningSide()); + } + /** * @test */ diff --git a/tests/Integration/ORM/EntityRelationship/EntityFactoryRelationshipTestCase.php b/tests/Integration/ORM/EntityRelationship/EntityFactoryRelationshipTestCase.php index 844b74a9..ba1ed9e0 100644 --- a/tests/Integration/ORM/EntityRelationship/EntityFactoryRelationshipTestCase.php +++ b/tests/Integration/ORM/EntityRelationship/EntityFactoryRelationshipTestCase.php @@ -31,6 +31,7 @@ use Zenstruck\Foundry\Tests\Fixture\Entity\Contact; use Zenstruck\Foundry\Tests\Fixture\Entity\Tag; +use function Zenstruck\Foundry\lazy; use function Zenstruck\Foundry\Persistence\refresh; use function Zenstruck\Foundry\Persistence\unproxy; @@ -505,6 +506,8 @@ public function it_uses_after_persist_with_inversed_one_to_one(): void /** @test */ #[Test] + #[DataProvider('provideCascadeRelationshipsCombinations')] + #[UsingRelationships(Contact::class, ['category'])] public function can_call_create_in_after_persist_callback(): void { $category = static::categoryFactory()::new() @@ -519,6 +522,156 @@ public function can_call_create_in_after_persist_callback(): void self::assertSame(unproxy($category), $category->getContacts()[0]?->getCategory()); } + /** @test */ + #[Test] + #[DataProvider('provideCascadeRelationshipsCombinations')] + #[UsingRelationships(Contact::class, ['address'])] + public function can_use_nested_after_persist_callback(): void + { + $contact = static::contactFactory()::createOne( + [ + 'address' => static::addressFactory() + ->afterPersist(function(Address $address) { + $address->setCity('city from after persist'); + }), + ] + ); + + self::assertSame('city from after persist', $contact->getAddress()->getCity()); + } + + /** @test */ + #[Test] + #[DataProvider('provideCascadeRelationshipsCombinations')] + #[UsingRelationships(Contact::class, ['category'])] + public function can_call_create_in_nested_after_persist_callback(): void + { + $contact = static::contactFactory()::createOne( + [ + 'category' => static::categoryFactory() + ->afterPersist(function(Category $category) { + $category->addSecondaryContact( + unproxy(static::contactFactory()::createOne()) + ); + }), + ] + ); + + self::assertCount(1, $contact->getCategory()?->getSecondaryContacts() ?? []); + } + + /** @test */ + #[Test] + #[DataProvider('provideCascadeRelationshipsCombinations')] + #[UsingRelationships(Address::class, ['contact'])] + #[UsingRelationships(Contact::class, ['category'])] + public function inverse_one_to_one_with_flush_in_before_instantiate(): void + { + $address = static::addressFactory()::createOne( + [ + 'contact' => static::contactFactory() + ->beforeInstantiate( + function(array $attributes): array { + $attributes['category'] = static::categoryFactory()->create(); + + return $attributes; + } + ), + ] + ); + + static::addressFactory()::assert()->count(1); + static::contactFactory()::assert()->count(1); + static::categoryFactory()::assert()->count(1); + + self::assertNotNull($address->getContact()); + self::assertNotNull($address->getContact()->getCategory()); + } + + /** @test */ + #[Test] + #[DataProvider('provideCascadeRelationshipsCombinations')] + #[UsingRelationships(Address::class, ['contact'])] + #[UsingRelationships(Contact::class, ['category'])] + public function inverse_one_to_one_with_lazy_flush(): void + { + $address = static::addressFactory()::createOne( + [ + 'contact' => static::contactFactory()->with([ + 'category' => lazy(fn() => static::categoryFactory()->create()), + ]), + ] + ); + + static::addressFactory()::assert()->count(1); + static::contactFactory()::assert()->count(1); + static::categoryFactory()::assert()->count(1); + + self::assertNotNull($address->getContact()); + self::assertNotNull($address->getContact()->getCategory()); + } + + /** @test */ + #[Test] + #[DataProvider('provideCascadeRelationshipsCombinations')] + #[UsingRelationships(Contact::class, ['category'])] + public function after_instantiate_flushing_using_current_object_in_relationship_many_to_one(): void + { + $category = static::categoryFactory() + ->afterInstantiate( + static function(Category $c): void { + static::contactFactory()->create(['category' => $c]); + } + ) + ->create(); + + static::contactFactory()::assert()->count(1); + static::categoryFactory()::assert()->count(1); + + self::assertCount(1, $category->getContacts()); + self::assertNotNull($category->getContacts()[0] ?? null); + } + + /** @test */ + #[Test] + #[DataProvider('provideCascadeRelationshipsCombinations')] + #[UsingRelationships(Contact::class, ['category'])] + public function after_instantiate_flushing_using_current_object_in_relationship_one_to_many(): void + { + $contact = static::contactFactory() + ->afterInstantiate( + static function(Contact $c): void { + static::categoryFactory()->create(['contacts' => [$c]]); + } + ) + ->create(['category' => null]); + + static::contactFactory()::assert()->count(1); + static::categoryFactory()::assert()->count(1); + + self::assertNotNull($contact->getCategory()); + self::assertCount(1, $contact->getCategory()->getContacts()); + } + + /** @test */ + #[Test] + #[DataProvider('provideCascadeRelationshipsCombinations')] + #[UsingRelationships(Contact::class, ['address'])] + public function after_instantiate_flushing_using_current_object_in_relationship_one_to_one(): void + { + $address = static::addressFactory() + ->afterInstantiate( + static function(Address $a): void { + static::contactFactory()->create(['address' => $a]); + } + )->create(); + + static::contactFactory()::assert()->count(1); + static::addressFactory()::assert()->count(1); + + self::assertNotNull($address->getContact()); + } + /** @return PersistentObjectFactory */ protected static function contactFactoryWithoutCategory(): PersistentObjectFactory { diff --git a/tests/Integration/Persistence/GenericFactoryTestCase.php b/tests/Integration/Persistence/GenericFactoryTestCase.php index 4163cbad..15d26968 100644 --- a/tests/Integration/Persistence/GenericFactoryTestCase.php +++ b/tests/Integration/Persistence/GenericFactoryTestCase.php @@ -11,6 +11,7 @@ namespace Zenstruck\Foundry\Tests\Integration\Persistence; +use PHPUnit\Framework\Attributes\Test; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Zenstruck\Foundry\Configuration; use Zenstruck\Foundry\Exception\PersistenceDisabled; @@ -564,6 +565,25 @@ public function can_use_after_persist_with_attributes(): void $this->assertSame(1, $object->getPropInteger()); } + /** + * @test + */ + #[Test] + public function it_actually_calls_post_persist_hook_after_persist_when_in_flush_after(): void + { + $object = flush_after( + function() { + return static::factory()->afterPersist( + static function(GenericModel $o) { + $o->setProp1((string) $o->id); + } + )->create(); + } + ); + + self::assertSame((string) $object->id, $object->getProp1()); + } + /** * @return class-string */