diff --git a/src/ORM/OrmV2PersistenceStrategy.php b/src/ORM/OrmV2PersistenceStrategy.php index 649110c82..27df8acff 100644 --- a/src/ORM/OrmV2PersistenceStrategy.php +++ b/src/ORM/OrmV2PersistenceStrategy.php @@ -52,6 +52,7 @@ public function relationshipMetadata(string $parent, string $child, string $fiel return new RelationshipMetadata( isCascadePersist: $association['isCascadePersist'], inverseField: $metadata->isSingleValuedAssociation($association['fieldName']) ? $association['fieldName'] : null, + isCollection: $metadata->isCollectionValuedAssociation($association['fieldName']), ); } diff --git a/src/ORM/OrmV3PersistenceStrategy.php b/src/ORM/OrmV3PersistenceStrategy.php index 047208da2..9a6e539ef 100644 --- a/src/ORM/OrmV3PersistenceStrategy.php +++ b/src/ORM/OrmV3PersistenceStrategy.php @@ -17,6 +17,7 @@ use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Mapping\InverseSideMapping; use Doctrine\ORM\Mapping\MappingException as ORMMappingException; +use Doctrine\ORM\Mapping\ToManyAssociationMapping; use Doctrine\Persistence\Mapping\MappingException; use Zenstruck\Foundry\Persistence\RelationshipMetadata; @@ -47,8 +48,9 @@ public function relationshipMetadata(string $parent, string $child, string $fiel } return new RelationshipMetadata( - isCascadePersist: $association->isCascadePersist(), + isCascadePersist: ($inversedAssociation ?? $association)->isCascadePersist(), inverseField: $metadata->isSingleValuedAssociation($association->fieldName) ? $association->fieldName : null, + isCollection: ($inversedAssociation ?? $association) instanceof ToManyAssociationMapping ); } diff --git a/src/Persistence/PersistentObjectFactory.php b/src/Persistence/PersistentObjectFactory.php index 2bd83ffd8..de6733f13 100644 --- a/src/Persistence/PersistentObjectFactory.php +++ b/src/Persistence/PersistentObjectFactory.php @@ -275,8 +275,25 @@ protected function normalizeParameter(string $field, mixed $value): mixed $value->persist = $this->persist; // todo - breaks immutability } - if ($value instanceof self && Configuration::instance()->persistence()->relationshipMetadata(static::class(), $value::class(), $field)?->isCascadePersist) { - $value->persist = false; + if ($value instanceof self) { + $pm = Configuration::instance()->persistence(); + + $relationshipMetadata = $pm->relationshipMetadata($value::class(), static::class(), $field); + + // handle inversed OneToOne + if ($relationshipMetadata && !$relationshipMetadata->isCollection && $inverseField = $relationshipMetadata->inverseField) { + $this->tempAfterPersist[] = static function(object $object) use ($value, $inverseField, $pm) { + $value->create([$inverseField => $object]); + $pm->refresh($object); + }; + + // creation delegated to afterPersist hook - return empty array here + return null; + } + + if (Configuration::instance()->persistence()->relationshipMetadata(static::class(), $value::class(), $field)?->isCascadePersist) { + $value->persist = false; + } } return unproxy(parent::normalizeParameter($field, $value)); diff --git a/src/Persistence/RelationshipMetadata.php b/src/Persistence/RelationshipMetadata.php index 6ea69f926..fb9829ab4 100644 --- a/src/Persistence/RelationshipMetadata.php +++ b/src/Persistence/RelationshipMetadata.php @@ -21,6 +21,7 @@ final class RelationshipMetadata public function __construct( public readonly bool $isCascadePersist, public readonly ?string $inverseField, + public readonly bool $isCollection, ) { } } diff --git a/tests/Fixture/Entity/Address.php b/tests/Fixture/Entity/Address.php index 9cac62894..b8798067c 100644 --- a/tests/Fixture/Entity/Address.php +++ b/tests/Fixture/Entity/Address.php @@ -20,6 +20,8 @@ #[ORM\MappedSuperclass] abstract class Address extends Base { + protected Contact|null $contact = null; + #[ORM\Column(length: 255)] private string $city; @@ -28,6 +30,16 @@ public function __construct(string $city) $this->city = $city; } + public function getContact(): Contact|null + { + return $this->contact; + } + + public function setContact(Contact|null $contact): void + { + $this->contact = $contact; + } + public function getCity(): string { return $this->city; diff --git a/tests/Fixture/Entity/Address/CascadeAddress.php b/tests/Fixture/Entity/Address/CascadeAddress.php index 4eb75e627..1f6a9eda7 100644 --- a/tests/Fixture/Entity/Address/CascadeAddress.php +++ b/tests/Fixture/Entity/Address/CascadeAddress.php @@ -13,6 +13,8 @@ use Doctrine\ORM\Mapping as ORM; use Zenstruck\Foundry\Tests\Fixture\Entity\Address; +use Zenstruck\Foundry\Tests\Fixture\Entity\Contact; +use Zenstruck\Foundry\Tests\Fixture\Entity\Contact\CascadeContact; /** * @author Kevin Bond @@ -20,4 +22,6 @@ #[ORM\Entity] class CascadeAddress extends Address { + #[ORM\OneToOne(targetEntity: CascadeContact::class, mappedBy: 'address', cascade: ['persist', 'remove'])] + protected Contact|null $contact = null; } diff --git a/tests/Fixture/Entity/Address/StandardAddress.php b/tests/Fixture/Entity/Address/StandardAddress.php index 5c64946db..9ef5e7d69 100644 --- a/tests/Fixture/Entity/Address/StandardAddress.php +++ b/tests/Fixture/Entity/Address/StandardAddress.php @@ -13,6 +13,8 @@ use Doctrine\ORM\Mapping as ORM; use Zenstruck\Foundry\Tests\Fixture\Entity\Address; +use Zenstruck\Foundry\Tests\Fixture\Entity\Contact; +use Zenstruck\Foundry\Tests\Fixture\Entity\Contact\StandardContact; /** * @author Kevin Bond @@ -20,4 +22,6 @@ #[ORM\Entity] class StandardAddress extends Address { + #[ORM\OneToOne(targetEntity: StandardContact::class, mappedBy: 'address')] + protected Contact|null $contact = null; } diff --git a/tests/Fixture/Entity/Contact/CascadeContact.php b/tests/Fixture/Entity/Contact/CascadeContact.php index f0f4f1357..21b58e3f7 100644 --- a/tests/Fixture/Entity/Contact/CascadeContact.php +++ b/tests/Fixture/Entity/Contact/CascadeContact.php @@ -40,7 +40,7 @@ class CascadeContact extends Contact #[ORM\JoinTable(name: 'category_tag_cascade_secondary')] protected Collection $secondaryTags; - #[ORM\OneToOne(targetEntity: CascadeAddress::class, cascade: ['persist', 'remove'])] + #[ORM\OneToOne(targetEntity: CascadeAddress::class, inversedBy: 'contact', cascade: ['persist', 'remove'])] #[ORM\JoinColumn(nullable: false)] protected Address $address; } diff --git a/tests/Integration/ORM/EntityFactoryRelationshipTestCase.php b/tests/Integration/ORM/EntityFactoryRelationshipTestCase.php index a786f217d..ba0b6d338 100644 --- a/tests/Integration/ORM/EntityFactoryRelationshipTestCase.php +++ b/tests/Integration/ORM/EntityFactoryRelationshipTestCase.php @@ -252,6 +252,22 @@ public function inverse_many_to_many_with_two_relationships_same_entity(): void $this->contactFactory()::assert()->count(6); } + /** + * @test + */ + public function inversed_one_to_one(): void + { + $addressFactory = $this->addressFactory(); + $contactFactory = $this->contactFactory(); + + $address = $addressFactory->create(['contact' => $contactFactory]); + + self::assertNotNull($address->getContact()); + + $addressFactory::assert()->count(1); + $contactFactory::assert()->count(1); + } + /** * @test */