Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 85 additions & 18 deletions src/UnitOfWork.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -2967,6 +2961,79 @@ public function registerManaged(object $entity, array $id, array $data): void
$this->addToIdentityMap($entity);
}

/**
* @param array<string, mixed> $actualData
* @phpstan-param ClassMetadata<object> $class
*
* @return array<string, array{mixed, mixed}>
*/
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<object> $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<object> $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<object> $class
*
* @return array<string, mixed>
*/
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 */

/**
Expand Down
178 changes: 178 additions & 0 deletions tests/Tests/ORM/Functional/Ticket/GH12403Test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
<?php

declare(strict_types=1);

namespace Doctrine\Tests\ORM\Functional\Ticket;

use Doctrine\ORM\Mapping as ORM;
use Doctrine\Tests\OrmFunctionalTestCase;

use function getenv;

use const PHP_VERSION_ID;

class GH12403Test extends OrmFunctionalTestCase
{
protected function setUp(): void
{
if (PHP_VERSION_ID < 80400 || getenv('ENABLE_NATIVE_LAZY_OBJECTS') === '0') {
self::markTestSkipped('Native lazy objects are required.');
}

parent::setUp();

$this->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;
}
}
Loading