Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a safeguard against multiple objects competing for the same identity map entry #10785

Merged
Merged
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
54 changes: 30 additions & 24 deletions lib/Doctrine/ORM/UnitOfWork.php
Original file line number Diff line number Diff line change
Expand Up @@ -1591,6 +1591,30 @@ public function addToIdentityMap($entity)
$className = $classMetadata->rootEntityName;

if (isset($this->identityMap[$className][$idHash])) {
if ($this->identityMap[$className][$idHash] !== $entity) {
throw new RuntimeException(sprintf(
mpdude marked this conversation as resolved.
Show resolved Hide resolved
<<<'EXCEPTION'
While adding an entity of class %s with an ID hash of "%s" to the identity map,
another object of class %s was already present for the same ID. This exception
is a safeguard against an internal inconsistency - IDs should uniquely map to
entity object instances. This problem may occur if:

- you use application-provided IDs and reuse ID values;
- database-provided IDs are reassigned after truncating the database without
clearing the EntityManager;
- you might have been using EntityManager#getReference() to create a reference
for a nonexistent ID that was subsequently (by the RDBMS) assigned to another
entity.

Otherwise, it might be an ORM-internal inconsistency, please report it.
EXCEPTION
,
mpdude marked this conversation as resolved.
Show resolved Hide resolved
get_class($entity),
$idHash,
get_class($this->identityMap[$className][$idHash])
));
}

return false;
}

Expand Down Expand Up @@ -2811,25 +2835,20 @@ public function createEntity($className, array $data, &$hints = [])
}

$this->originalEntityData[$oid] = $data;

if ($entity instanceof NotifyPropertyChanged) {
$entity->addPropertyChangedListener($this);
}
greg0ire marked this conversation as resolved.
Show resolved Hide resolved
} else {
$entity = $this->newInstance($class);
$oid = spl_object_id($entity);

$this->entityIdentifiers[$oid] = $id;
$this->entityStates[$oid] = self::STATE_MANAGED;
$this->originalEntityData[$oid] = $data;

$this->identityMap[$class->rootEntityName][$idHash] = $entity;
$this->registerManaged($entity, $id, $data);

if (isset($hints[Query::HINT_READ_ONLY])) {
$this->readOnlyObjects[$oid] = true;
}
}

if ($entity instanceof NotifyPropertyChanged) {
$entity->addPropertyChangedListener($this);
}

foreach ($data as $field => $value) {
if (isset($class->fieldMappings[$field])) {
$class->reflFields[$field]->setValue($entity, $value);
Expand Down Expand Up @@ -2987,20 +3006,7 @@ public function createEntity($className, array $data, &$hints = [])
break;
}

// PERF: Inlined & optimized code from UnitOfWork#registerManaged()
$newValueOid = spl_object_id($newValue);
$this->entityIdentifiers[$newValueOid] = $associatedId;
$this->identityMap[$targetClass->rootEntityName][$relatedIdHash] = $newValue;

if (
$newValue instanceof NotifyPropertyChanged &&
( ! $newValue instanceof Proxy || $newValue->__isInitialized())
) {
$newValue->addPropertyChangedListener($this);
}

$this->entityStates[$newValueOid] = self::STATE_MANAGED;
// make sure that when an proxy is then finally loaded, $this->originalEntityData is set also!
$this->registerManaged($newValue, $associatedId, []);
break;
}

Expand Down
30 changes: 30 additions & 0 deletions tests/Doctrine/Tests/ORM/Functional/BasicFunctionalTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use Doctrine\Tests\Models\CMS\CmsUser;
use Doctrine\Tests\OrmFunctionalTestCase;
use InvalidArgumentException;
use RuntimeException;

use function get_class;

Expand Down Expand Up @@ -1291,4 +1292,33 @@ public function testWrongAssociationInstance(): void

$this->_em->flush();
}

public function testItThrowsWhenReferenceUsesIdAssignedByDatabase(): void
{
$user = new CmsUser();
$user->name = 'test';
$user->username = 'test';
$this->_em->persist($user);
$this->_em->flush();

// Obtain a reference object for the next ID. This is a user error - references
// should be fetched only for existing IDs
$ref = $this->_em->getReference(CmsUser::class, $user->id + 1);

$user2 = new CmsUser();
$user2->name = 'test2';
$user2->username = 'test2';

// Now the database will assign an ID to the $user2 entity, but that place
// in the identity map is already taken by user error.
$this->expectException(RuntimeException::class);
$this->expectExceptionMessageMatches('/another object .* was already present for the same ID/');

// depending on ID generation strategy, the ID may be asssigned already here
// and the entity be put in the identity map
$this->_em->persist($user2);

// post insert IDs will be assigned during flush
$this->_em->flush();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ public function testEntityWithIdentifier(): void
*/
public function testProxyAsDqlParameterPersist(): void
{
$proxy = $this->_em->getProxyFactory()->getProxy(CmsUser::class, ['id' => $this->user->getId()]);
$proxy = $this->_em->getReference(CmsUser::class, ['id' => $this->user->getId()]);
$proxy->id = $this->user->getId();
$result = $this
->_em
Expand Down
2 changes: 1 addition & 1 deletion tests/Doctrine/Tests/ORM/Functional/Ticket/DDC1238Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public function testIssueProxyClear(): void

$user2 = $this->_em->getReference(DDC1238User::class, $userId);

$user->__load();
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@beberlei Do you still remember what you intended to do here? Why should it be possible or allowed to load an entity through a reference when the entity manager has been clear()ed in the meantime?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this tests that the proxy generation has a shortcut for getId methods that returns the value passed to getReference(.., $id);

I am not sure this needs the calls to __load anymore, but really don't know anymore.

Copy link
Contributor Author

@mpdude mpdude Jul 1, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think it should be possible to initialize a proxy after the EM it was obtained from has been cleared?

I don’t think so, the EM has forgotten about that proxy instance.

//$user->__load();

self::assertIsInt($user->getId(), 'Even if a proxy is detached, it should still have an identifier');

Expand Down
2 changes: 1 addition & 1 deletion tests/Doctrine/Tests/ORM/Functional/Ticket/GH7869Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ public function getClassMetadata($className): ClassMetadata
$uow->clear();
$uow->triggerEagerLoads();

self::assertSame(2, $em->getClassMetadataCalls);
self::assertSame(4, $em->getClassMetadataCalls);
}
}

Expand Down
22 changes: 22 additions & 0 deletions tests/Doctrine/Tests/ORM/UnitOfWorkTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
use Doctrine\Tests\OrmTestCase;
use Doctrine\Tests\PHPUnitCompatibility\MockBuilderCompatibilityTools;
use PHPUnit\Framework\MockObject\MockObject;
use RuntimeException;
use stdClass;

use function assert;
Expand Down Expand Up @@ -108,6 +109,8 @@ protected function setUp(): void
$driverConnection = $this->createMock(Driver\Connection::class);
$driverConnection->method('prepare')
->willReturn($driverStatement);
$driverConnection->method('lastInsertId')
->willReturnOnConsecutiveCalls(1, 2, 3, 4, 5, 6);

$driver = $this->createMock(Driver::class);
$driver->method('getDatabasePlatform')
Expand Down Expand Up @@ -923,6 +926,25 @@ public function testRemovedEntityIsRemovedFromOneToManyCollection(): void
self::assertFalse($user->phonenumbers->isDirty());
self::assertEmpty($user->phonenumbers->getSnapshot());
}

public function testItThrowsWhenApplicationProvidedIdsCollide(): void
{
// We're using application-provided IDs and assign the same ID twice
// Note this is about colliding IDs in the identity map in memory.
// Duplicate database-level IDs would be spotted when the EM is flushed.

$phone1 = new CmsPhonenumber();
$phone1->phonenumber = '1234';
$this->_unitOfWork->persist($phone1);

$phone2 = new CmsPhonenumber();
$phone2->phonenumber = '1234';

$this->expectException(RuntimeException::class);
$this->expectExceptionMessageMatches('/another object .* was already present for the same ID/');

$this->_unitOfWork->persist($phone2);
}
}

/** @Entity */
Expand Down