diff --git a/src/Cache/Persister/Entity/AbstractEntityPersister.php b/src/Cache/Persister/Entity/AbstractEntityPersister.php index 945ad5b348b..b878c36b261 100644 --- a/src/Cache/Persister/Entity/AbstractEntityPersister.php +++ b/src/Cache/Persister/Entity/AbstractEntityPersister.php @@ -371,7 +371,7 @@ public function loadAll( /** * {@inheritDoc} */ - public function loadById(array $identifier, object|null $entity = null): object|null + public function loadById(array $identifier, object|null $entity = null, bool $readOnly = false): object|null { $cacheKey = new EntityCacheKey($this->class->rootEntityName, $identifier); $cacheEntry = $this->region->get($cacheKey); @@ -391,7 +391,7 @@ public function loadById(array $identifier, object|null $entity = null): object| } } - $entity = $this->persister->loadById($identifier, $entity); + $entity = $this->persister->loadById($identifier, $entity, $readOnly); if ($entity === null) { return null; diff --git a/src/Decorator/EntityManagerDecorator.php b/src/Decorator/EntityManagerDecorator.php index 6f1b0419686..436acf25537 100644 --- a/src/Decorator/EntityManagerDecorator.php +++ b/src/Decorator/EntityManagerDecorator.php @@ -112,9 +112,9 @@ public function lock(object $entity, LockMode|int $lockMode, DateTimeInterface|i $this->wrapped->lock($entity, $lockMode, $lockVersion); } - public function find(string $className, mixed $id, LockMode|int|null $lockMode = null, int|null $lockVersion = null): object|null + public function find(string $className, mixed $id, LockMode|int|null $lockMode = null, int|null $lockVersion = null, bool $readOnly = false): object|null { - return $this->wrapped->find($className, $id, $lockMode, $lockVersion); + return $this->wrapped->find($className, $id, $lockMode, $lockVersion, $readOnly); } public function refresh(object $object, LockMode|int|null $lockMode = null): void diff --git a/src/EntityManager.php b/src/EntityManager.php index 2e160393417..0dd50db83dd 100644 --- a/src/EntityManager.php +++ b/src/EntityManager.php @@ -271,7 +271,7 @@ public function flush(): void /** * {@inheritDoc} */ - public function find($className, mixed $id, LockMode|int|null $lockMode = null, int|null $lockVersion = null): object|null + public function find($className, mixed $id, LockMode|int|null $lockMode = null, int|null $lockVersion = null, bool $readOnly = false): object|null { $class = $this->metadataFactory->getMetadataFor(ltrim($className, '\\')); @@ -343,6 +343,10 @@ public function find($className, mixed $id, LockMode|int|null $lockMode = null, break; } + if (! $readOnly && $unitOfWork->isReadOnly($entity)) { + $unitOfWork->unmarkReadOnly($entity); + } + return $entity; // Hit! } @@ -350,7 +354,7 @@ public function find($className, mixed $id, LockMode|int|null $lockMode = null, switch (true) { case $lockMode === LockMode::OPTIMISTIC: - $entity = $persister->load($sortedId); + $entity = $persister->load($sortedId, hints: [Query::HINT_READ_ONLY => $readOnly]); if ($entity !== null) { $unitOfWork->lock($entity, $lockMode, $lockVersion); @@ -360,10 +364,10 @@ public function find($className, mixed $id, LockMode|int|null $lockMode = null, case $lockMode === LockMode::PESSIMISTIC_READ: case $lockMode === LockMode::PESSIMISTIC_WRITE: - return $persister->load($sortedId, null, null, [], $lockMode); + return $persister->load($sortedId, null, null, [Query::HINT_READ_ONLY => $readOnly], $lockMode); default: - return $persister->loadById($sortedId); + return $persister->loadById($sortedId, readOnly: $readOnly); } } diff --git a/src/EntityManagerInterface.php b/src/EntityManagerInterface.php index 03dbdbbea19..06ef58fe670 100644 --- a/src/EntityManagerInterface.php +++ b/src/EntityManagerInterface.php @@ -130,7 +130,7 @@ public function createQueryBuilder(): QueryBuilder; * * @template T of object */ - public function find(string $className, mixed $id, LockMode|int|null $lockMode = null, int|null $lockVersion = null): object|null; + public function find(string $className, mixed $id, LockMode|int|null $lockMode = null, int|null $lockVersion = null, bool $readOnly = false): object|null; /** * Refreshes the persistent state of an object from the database, diff --git a/src/Persisters/Entity/BasicEntityPersister.php b/src/Persisters/Entity/BasicEntityPersister.php index 4670a62a9e8..9303c24373f 100644 --- a/src/Persisters/Entity/BasicEntityPersister.php +++ b/src/Persisters/Entity/BasicEntityPersister.php @@ -748,9 +748,9 @@ public function load( /** * {@inheritDoc} */ - public function loadById(array $identifier, object|null $entity = null): object|null + public function loadById(array $identifier, object|null $entity = null, bool $readOnly = false): object|null { - return $this->load($identifier, $entity); + return $this->load($identifier, $entity, hints: [Query::HINT_READ_ONLY => $readOnly]); } /** diff --git a/src/Persisters/Entity/EntityPersister.php b/src/Persisters/Entity/EntityPersister.php index 1c4da2f4c04..bdf305579b9 100644 --- a/src/Persisters/Entity/EntityPersister.php +++ b/src/Persisters/Entity/EntityPersister.php @@ -182,7 +182,7 @@ public function load( * * @todo Check parameters */ - public function loadById(array $identifier, object|null $entity = null): object|null; + public function loadById(array $identifier, object|null $entity = null, bool $readOnly = false): object|null; /** * Loads an entity of this persister's mapped class as part of a single-valued diff --git a/src/UnitOfWork.php b/src/UnitOfWork.php index 4c55b728775..2926a44e164 100644 --- a/src/UnitOfWork.php +++ b/src/UnitOfWork.php @@ -3080,9 +3080,6 @@ private static function objToStr(object $obj): string /** * Marks an entity as read-only so that it will not be considered for updates during UnitOfWork#commit(). * - * This operation cannot be undone as some parts of the UnitOfWork now keep gathering information - * on this object that might be necessary to perform a correct update. - * * @throws ORMInvalidArgumentException */ public function markReadOnly(object $object): void @@ -3094,6 +3091,21 @@ public function markReadOnly(object $object): void $this->readOnlyObjects[spl_object_id($object)] = true; } + /** + * Unmarks an entity as read-only so that it will be considered for updates during UnitOfWork#commit() + * even if it was previously fetched as read-only. + * + * @throws ORMInvalidArgumentException + */ + public function unmarkReadOnly(object $object): void + { + if (! $this->isInIdentityMap($object)) { + throw ORMInvalidArgumentException::readOnlyRequiresManagedEntity($object); + } + + unset($this->readOnlyObjects[spl_object_id($object)]); + } + /** * Is this entity read only? * diff --git a/tests/Performance/Mock/NonLoadingPersister.php b/tests/Performance/Mock/NonLoadingPersister.php index bf487978c9f..d0c51d497f9 100644 --- a/tests/Performance/Mock/NonLoadingPersister.php +++ b/tests/Performance/Mock/NonLoadingPersister.php @@ -18,7 +18,7 @@ public function __construct( $this->class = $class; } - public function loadById(array $identifier, object|null $entity = null): object|null + public function loadById(array $identifier, object|null $entity = null, bool $readOnly = false): object|null { return $entity ?? new ($this->class->name)(); } diff --git a/tests/Performance/Mock/NonProxyLoadingEntityManager.php b/tests/Performance/Mock/NonProxyLoadingEntityManager.php index bff330ab9be..b38ec760703 100644 --- a/tests/Performance/Mock/NonProxyLoadingEntityManager.php +++ b/tests/Performance/Mock/NonProxyLoadingEntityManager.php @@ -163,9 +163,9 @@ public function hasFilters(): bool return $this->realEntityManager->hasFilters(); } - public function find(string $className, mixed $id, LockMode|int|null $lockMode = null, int|null $lockVersion = null): object|null + public function find(string $className, mixed $id, LockMode|int|null $lockMode = null, int|null $lockVersion = null, bool $readOnly = false): object|null { - return $this->realEntityManager->find($className, $id, $lockMode, $lockVersion); + return $this->realEntityManager->find($className, $id, $lockMode, $lockVersion, $readOnly); } public function persist(object $object): void diff --git a/tests/Tests/ORM/Functional/ReadOnlyTest.php b/tests/Tests/ORM/Functional/ReadOnlyTest.php index 7a27a8b2605..ba2aba705c1 100644 --- a/tests/Tests/ORM/Functional/ReadOnlyTest.php +++ b/tests/Tests/ORM/Functional/ReadOnlyTest.php @@ -37,7 +37,7 @@ public function testReadOnlyEntityNeverChangeTracked(): void $this->_em->flush(); $this->_em->clear(); - $dbReadOnly = $this->_em->find(ReadOnlyEntity::class, $readOnly->id); + $dbReadOnly = $this->getWithoutReadOnlyHint($readOnly->id); self::assertEquals('Test1', $dbReadOnly->name); self::assertEquals(1234, $dbReadOnly->numericValue); } @@ -77,13 +77,7 @@ public function testReadOnlyQueryHint(): void $this->_em->flush(); $this->_em->clear(); - $dql = 'SELECT u FROM ' . ReadOnlyEntity::class . ' u WHERE u.id = ?1'; - - $query = $this->_em->createQuery($dql); - $query->setParameter(1, $user->id); - $query->setHint(Query::HINT_READ_ONLY, true); - - $user = $query->getSingleResult(); + $user = $this->getWithReadOnlyHint($user->id, readOnly: true); self::assertTrue($this->_em->getUnitOfWork()->isReadOnly($user)); } @@ -97,11 +91,7 @@ public function testNotReadOnlyQueryHint(): void $this->_em->flush(); $this->_em->clear(); - $query = $this->_em->createQuery('SELECT u FROM ' . ReadOnlyEntity::class . ' u WHERE u.id = ?1'); - $query->setParameter(1, $user->id); - $query->setHint(Query::HINT_READ_ONLY, false); - - $user = $query->getSingleResult(); + $user = $this->getWithReadOnlyHint($user->id, readOnly: false); self::assertFalse($this->_em->getUnitOfWork()->isReadOnly($user)); } @@ -117,13 +107,7 @@ public function testNotReadOnlyIfObjectWasProxyBefore(): void $user = $this->_em->getReference(ReadOnlyEntity::class, $user->id); - $dql = 'SELECT u FROM ' . ReadOnlyEntity::class . ' u WHERE u.id = ?1'; - - $query = $this->_em->createQuery($dql); - $query->setParameter(1, $user->id); - $query->setHint(Query::HINT_READ_ONLY, true); - - $user = $query->getSingleResult(); + $user = $this->getWithReadOnlyHint($user->id, readOnly: true); self::assertFalse($this->_em->getUnitOfWork()->isReadOnly($user)); } @@ -137,18 +121,43 @@ public function testNotReadOnlyIfObjectWasKnownBefore(): void $this->_em->flush(); $this->_em->clear(); - $userIntoIdentityMap = $this->_em->find(ReadOnlyEntity::class, $user->id); + $userIntoIdentityMap = $this->getWithoutReadOnlyHint($user->id); - $dql = 'SELECT u FROM ' . ReadOnlyEntity::class . ' u WHERE u.id = ?1'; + $user = $this->getWithReadOnlyHint($user->id, readOnly: true); - $query = $this->_em->createQuery($dql); - $query->setParameter(1, $user->id); - $query->setHint(Query::HINT_READ_ONLY, true); + self::assertFalse($this->_em->getUnitOfWork()->isReadOnly($user)); + } + + public function testWriteableEvenIfObjectWasKnownAsReadOnlyBefore(): void + { + $user = new ReadOnlyEntity('Théo', 1234); - $user = $query->getSingleResult(); + $this->_em->persist($user); + + $this->_em->flush(); + $this->_em->clear(); + + $userIntoIdentityMap = $this->getWithReadOnlyHint($user->id, readOnly: true); + $user = $this->getWithoutReadOnlyHint($user->id); self::assertFalse($this->_em->getUnitOfWork()->isReadOnly($user)); } + + private function getWithoutReadOnlyHint(int $id): ReadOnlyEntity + { + return $this->_em->find(ReadOnlyEntity::class, $id); + } + + private function getWithReadOnlyHint(int $id, bool $readOnly): ReadOnlyEntity + { + $dql = 'SELECT u FROM ' . ReadOnlyEntity::class . ' u WHERE u.id = ?1'; + + $query = $this->_em->createQuery($dql); + $query->setParameter(1, $id); + $query->setHint(Query::HINT_READ_ONLY, $readOnly); + + return $query->getSingleResult(); + } } #[Entity(readOnly: true)]