diff --git a/src/UnitOfWork.php b/src/UnitOfWork.php index 6508bcbbcd9..82a6b01986b 100644 --- a/src/UnitOfWork.php +++ b/src/UnitOfWork.php @@ -630,27 +630,20 @@ public function computeChangeSet(ClassMetadata $class, object $entity): void } } - if (! isset($this->originalEntityData[$oid])) { - // Entity is either NEW or MANAGED but not yet fully persisted (only has an id). - // These result in an INSERT. + if ($this->isNativeLazyObjectWithEmptyOriginalData($class, $entity, $oid)) { + // Native lazy objects can become initialized when Doctrine sets their remaining lazy properties. $this->originalEntityData[$oid] = $actualData; - $changeSet = []; - - foreach ($actualData as $propName => $actualValue) { - if (! isset($class->associationMappings[$propName])) { - $changeSet[$propName] = [null, $actualValue]; - - continue; - } - - $assoc = $class->associationMappings[$propName]; + $changeSet = $this->computeChangeSetFromActualData($class, $actualData, false); - if ($assoc->isToOneOwningSide()) { - $changeSet[$propName] = [null, $actualValue]; - } + if ($changeSet) { + $this->entityChangeSets[$oid] = $changeSet; + $this->entityUpdates[$oid] = $entity; } - - $this->entityChangeSets[$oid] = $changeSet; + } elseif (! isset($this->originalEntityData[$oid])) { + // Entity is either NEW or MANAGED but not yet fully persisted (only has an id). + // These result in an INSERT. + $this->originalEntityData[$oid] = $actualData; + $this->entityChangeSets[$oid] = $this->computeChangeSetFromActualData($class, $actualData); } else { // Entity is "fully" MANAGED: it was already fully persisted before // and we have a copy of the original data @@ -2565,6 +2558,7 @@ public function createEntity(string $className, array $data, array &$hints = []) if ($assoc->inversedBy !== null && $assoc->isOneToOne() && $newValue !== null) { $inverseAssoc = $targetClass->associationMappings[$assoc->inversedBy]; $targetClass->propertyAccessors[$inverseAssoc->fieldName]->setValue($newValue, $entity); + $this->updateOriginalEntityDataIfNativeLazyObjectWasInitialized($targetClass, $newValue); } break; @@ -2967,6 +2961,79 @@ public function registerManaged(object $entity, array $id, array $data): void $this->addToIdentityMap($entity); } + /** + * @param array $actualData + * @phpstan-param ClassMetadata $class + * + * @return array + */ + private function computeChangeSetFromActualData( + ClassMetadata $class, + array $actualData, + bool $includeIdentifierFields = true, + ): array { + $changeSet = []; + + foreach ($actualData as $propName => $actualValue) { + if (! $includeIdentifierFields && $class->isIdentifier($propName)) { + continue; + } + + if (! isset($class->associationMappings[$propName])) { + $changeSet[$propName] = [null, $actualValue]; + + continue; + } + + $assoc = $class->associationMappings[$propName]; + + if ($assoc->isToOneOwningSide()) { + $changeSet[$propName] = [null, $actualValue]; + } + } + + return $changeSet; + } + + /** @phpstan-param ClassMetadata $class */ + private function isNativeLazyObjectWithEmptyOriginalData(ClassMetadata $class, object $entity, int $oid): bool + { + return $this->em->getConfiguration()->isNativeLazyObjectsEnabled() + && isset($this->originalEntityData[$oid]) + && $this->originalEntityData[$oid] === [] + && ! $class->reflClass->isUninitializedLazyObject($entity); + } + + /** @phpstan-param ClassMetadata $class */ + private function updateOriginalEntityDataIfNativeLazyObjectWasInitialized(ClassMetadata $class, object $entity): void + { + $oid = spl_object_id($entity); + + if (! $this->isNativeLazyObjectWithEmptyOriginalData($class, $entity, $oid)) { + return; + } + + $this->originalEntityData[$oid] = $this->getActualEntityData($class, $entity); + } + + /** + * @phpstan-param ClassMetadata $class + * + * @return array + */ + private function getActualEntityData(ClassMetadata $class, object $entity): array + { + $actualData = []; + + foreach ($class->propertyAccessors as $name => $refProp) { + if (( ! $class->isIdentifier($name) || ! $class->isIdGeneratorIdentity()) && ($name !== $class->versionField)) { + $actualData[$name] = $refProp->getValue($entity); + } + } + + return $actualData; + } + /* PropertyChangedListener implementation */ /** diff --git a/tests/Tests/ORM/Functional/Ticket/GH12403Test.php b/tests/Tests/ORM/Functional/Ticket/GH12403Test.php new file mode 100644 index 00000000000..f1bf1be13eb --- /dev/null +++ b/tests/Tests/ORM/Functional/Ticket/GH12403Test.php @@ -0,0 +1,178 @@ +createSchemaForModels( + GH12403Owner::class, + GH12403Scalar::class, + GH12403Target::class, + ); + } + + public function testManagedOneToOneTargetIsEitherUninitializedOrSnapshotted(): void + { + $this->requireNativeLazyObjects(); + + $target = new GH12403Target(10); + $owner = new GH12403Owner(10, $target); + + $this->_em->persist($owner); + $this->_em->flush(); + $this->_em->clear(); + + $owner = $this->_em->find(GH12403Owner::class, 10); + self::assertInstanceOf(GH12403Owner::class, $owner); + + $target = $owner->target(); + self::assertTrue($this->_em->contains($target)); + + $unitOfWork = $this->_em->getUnitOfWork(); + + self::assertTrue( + $unitOfWork->isUninitializedObject($target) || $unitOfWork->getOriginalEntityData($target) !== [], + 'A managed initialized association target must have a UnitOfWork original-data snapshot.', + ); + } + + public function testScalarChangeIsPersistedAfterLazyReferenceInitialization(): void + { + $this->requireNativeLazyObjects(); + + $scalar = new GH12403Scalar(1, 'old name'); + + $this->_em->persist($scalar); + $this->_em->flush(); + $this->_em->clear(); + + $scalar = $this->_em->getReference(GH12403Scalar::class, 1); + $unitOfWork = $this->_em->getUnitOfWork(); + + self::assertTrue($unitOfWork->isUninitializedObject($scalar)); + self::assertSame([], $unitOfWork->getOriginalEntityData($scalar)); + + $this->_em->getClassMetadata(GH12403Scalar::class)->reflClass->markLazyObjectAsInitialized($scalar); + + self::assertFalse($unitOfWork->isUninitializedObject($scalar)); + self::assertSame([], $unitOfWork->getOriginalEntityData($scalar)); + + $scalar->rename('new name'); + + $this->_em->flush(); + $this->_em->clear(); + + self::assertSame( + 'new name', + $this->_em->getConnection()->fetchOne('SELECT name FROM gh12403_scalars WHERE id = 1'), + ); + } + + private function requireNativeLazyObjects(): void + { + if (! $this->_em->getConfiguration()->isNativeLazyObjectsEnabled()) { + self::markTestSkipped('Native lazy objects are required.'); + } + } +} + +#[ORM\Entity] +#[ORM\Table(name: 'gh12403_owners')] +class GH12403Owner +{ + #[ORM\Id] + #[ORM\Column(type: 'integer')] + public int $id; + + #[ORM\OneToOne(targetEntity: GH12403Target::class, inversedBy: 'owner', cascade: ['persist'])] + #[ORM\JoinColumn(name: 'target_id', referencedColumnName: 'id', nullable: false)] + private GH12403Target $target; + + public function __construct(int $id, GH12403Target $target) + { + $this->id = $id; + $this->setTarget($target); + } + + public function target(): GH12403Target + { + return $this->target; + } + + public function setTarget(GH12403Target $target): void + { + if (isset($this->target)) { + $this->target->clearOwner(); + } + + $this->target = $target; + $target->setOwner($this); + } +} + +#[ORM\Entity] +#[ORM\Table(name: 'gh12403_targets')] +class GH12403Target +{ + #[ORM\Id] + #[ORM\Column(type: 'integer')] + public int $id; + + #[ORM\OneToOne(targetEntity: GH12403Owner::class, mappedBy: 'target')] + private GH12403Owner|null $owner = null; + + public function __construct(int $id) + { + $this->id = $id; + } + + public function setOwner(GH12403Owner $owner): void + { + $this->owner = $owner; + } + + public function clearOwner(): void + { + $this->owner = null; + } +} + +#[ORM\Entity] +#[ORM\Table(name: 'gh12403_scalars')] +class GH12403Scalar +{ + #[ORM\Id] + #[ORM\Column(type: 'integer')] + public int $id; + + #[ORM\Column(type: 'string', length: 255)] + private string $name; + + public function __construct(int $id, string $name) + { + $this->id = $id; + $this->name = $name; + } + + public function rename(string $name): void + { + $this->name = $name; + } +}