diff --git a/.doctrine-project.json b/.doctrine-project.json index c25d00e444..e1f84c7b85 100644 --- a/.doctrine-project.json +++ b/.doctrine-project.json @@ -12,21 +12,27 @@ "upcoming": true }, { - "name": "2.16", - "branchName": "2.16.x", - "slug": "2.16", + "name": "2.17", + "branchName": "2.17.x", + "slug": "2.17", "upcoming": true }, { - "name": "2.15", - "branchName": "2.15.x", - "slug": "2.15", + "name": "2.16", + "branchName": "2.16.x", + "slug": "2.16", "current": true, "aliases": [ "current", "stable" ] }, + { + "name": "2.15", + "branchName": "2.15.x", + "slug": "2.15", + "maintained": false + }, { "name": "2.14", "branchName": "2.14.x", diff --git a/UPGRADE.md b/UPGRADE.md index ef703efa09..326d17de07 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,5 +1,31 @@ # Upgrade to 2.16 +## Deprecated accepting duplicate IDs in the identity map + +For any given entity class and ID value, there should be only one object instance +representing the entity. + +In https://github.com/doctrine/orm/pull/10785, a check was added that will guard this +in the identity map. The most probable cause for violations of this rule are collisions +of application-provided IDs. + +In ORM 2.16.0, the check was added by throwing an exception. In ORM 2.16.1, this will be +changed to a deprecation notice. ORM 3.0 will make it an exception again. Use +`\Doctrine\ORM\Configuration::setRejectIdCollisionInIdentityMap()` if you want to opt-in +to the new mode. + +## Potential changes to the order in which `INSERT`s are executed + +In https://github.com/doctrine/orm/pull/10547, the commit order computation was improved +to fix a series of bugs where a correct (working) commit order was previously not found. +Also, the new computation may get away with fewer queries being executed: By inserting +referred-to entities first and using their ID values for foreign key fields in subsequent +`INSERT` statements, additional `UPDATE` statements that were previously necessary can be +avoided. + +When using database-provided, auto-incrementing IDs, this may lead to IDs being assigned +to entities in a different order than it was previously the case. + ## Deprecated `\Doctrine\ORM\Internal\CommitOrderCalculator` and related classes With changes made to the commit order computation, the internal classes diff --git a/composer.json b/composer.json index a897bbbf8f..14646162ad 100644 --- a/composer.json +++ b/composer.json @@ -42,14 +42,14 @@ "doctrine/annotations": "^1.13 || ^2", "doctrine/coding-standard": "^9.0.2 || ^12.0", "phpbench/phpbench": "^0.16.10 || ^1.0", - "phpstan/phpstan": "~1.4.10 || 1.10.25", + "phpstan/phpstan": "~1.4.10 || 1.10.28", "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6", "psr/log": "^1 || ^2 || ^3", "squizlabs/php_codesniffer": "3.7.2", "symfony/cache": "^4.4 || ^5.4 || ^6.0", "symfony/var-exporter": "^4.4 || ^5.4 || ^6.2", "symfony/yaml": "^3.4 || ^4.0 || ^5.0 || ^6.0", - "vimeo/psalm": "4.30.0 || 5.13.1" + "vimeo/psalm": "4.30.0 || 5.14.1" }, "conflict": { "doctrine/annotations": "<1.13 || >= 3.0" diff --git a/docs/en/reference/advanced-configuration.rst b/docs/en/reference/advanced-configuration.rst index fdd42aeb6c..6761ac1b97 100644 --- a/docs/en/reference/advanced-configuration.rst +++ b/docs/en/reference/advanced-configuration.rst @@ -29,7 +29,7 @@ steps of configuration. $config = new Configuration; $config->setMetadataCache($metadataCache); - $driverImpl = new AttributeDriver(['/path/to/lib/MyProject/Entities']); + $driverImpl = new AttributeDriver(['/path/to/lib/MyProject/Entities'], true); $config->setMetadataDriverImpl($driverImpl); $config->setQueryCache($queryCache); $config->setProxyDir('/path/to/myproject/lib/MyProject/Proxies'); @@ -134,7 +134,7 @@ The attribute driver can be injected in the ``Doctrine\ORM\Configuration``: setMetadataDriverImpl($driverImpl); The path information to the entities is required for the attribute diff --git a/docs/en/reference/working-with-objects.rst b/docs/en/reference/working-with-objects.rst index 32d53212e6..f10c0ab091 100644 --- a/docs/en/reference/working-with-objects.rst +++ b/docs/en/reference/working-with-objects.rst @@ -192,6 +192,11 @@ be properly synchronized with the database when database in the most efficient way and a single, short transaction, taking care of maintaining referential integrity. +.. note:: + + Do not make any assumptions in your code about the number of queries + it takes to flush changes, about the ordering of ``INSERT``, ``UPDATE`` + and ``DELETE`` queries or the order in which entities will be processed. Example: diff --git a/lib/Doctrine/ORM/AbstractQuery.php b/lib/Doctrine/ORM/AbstractQuery.php index 1ec2d5904e..f4ba885a28 100644 --- a/lib/Doctrine/ORM/AbstractQuery.php +++ b/lib/Doctrine/ORM/AbstractQuery.php @@ -1010,9 +1010,8 @@ public function getSingleResult($hydrationMode = null) * * Alias for getSingleResult(HYDRATE_SINGLE_SCALAR). * - * @return bool|float|int|string The scalar result. + * @return bool|float|int|string|null The scalar result. * - * @throws NoResultException If the query returned no result. * @throws NonUniqueResultException If the query result is not unique. */ public function getSingleScalarResult() diff --git a/lib/Doctrine/ORM/Configuration.php b/lib/Doctrine/ORM/Configuration.php index fcde0c190f..ca5063667c 100644 --- a/lib/Doctrine/ORM/Configuration.php +++ b/lib/Doctrine/ORM/Configuration.php @@ -1117,4 +1117,14 @@ public function setLazyGhostObjectEnabled(bool $flag): void $this->_attributes['isLazyGhostObjectEnabled'] = $flag; } + + public function setRejectIdCollisionInIdentityMap(bool $flag): void + { + $this->_attributes['rejectIdCollisionInIdentityMap'] = $flag; + } + + public function isRejectIdCollisionInIdentityMapEnabled(): bool + { + return $this->_attributes['rejectIdCollisionInIdentityMap'] ?? false; + } } diff --git a/lib/Doctrine/ORM/Exception/EntityIdentityCollisionException.php b/lib/Doctrine/ORM/Exception/EntityIdentityCollisionException.php new file mode 100644 index 0000000000..a70108c6f5 --- /dev/null +++ b/lib/Doctrine/ORM/Exception/EntityIdentityCollisionException.php @@ -0,0 +1,42 @@ +children() as $action) { + $children = $cascadeElement->children(); + assert($children !== null); + + foreach ($children as $action) { // According to the JPA specifications, XML uses "cascade-persist" // instead of "persist". Here, both variations // are supported because YAML, Annotation and Attribute use "persist" diff --git a/lib/Doctrine/ORM/Mapping/UnderscoreNamingStrategy.php b/lib/Doctrine/ORM/Mapping/UnderscoreNamingStrategy.php index c5b7ecc2c6..e1c7b13fcf 100644 --- a/lib/Doctrine/ORM/Mapping/UnderscoreNamingStrategy.php +++ b/lib/Doctrine/ORM/Mapping/UnderscoreNamingStrategy.php @@ -30,7 +30,10 @@ class UnderscoreNamingStrategy implements NamingStrategy /** @var int */ private $case; - /** @var string */ + /** + * @var string + * @psalm-var non-empty-string + */ private $pattern; /** diff --git a/lib/Doctrine/ORM/ORMSetup.php b/lib/Doctrine/ORM/ORMSetup.php index 4b66762861..7210209727 100644 --- a/lib/Doctrine/ORM/ORMSetup.php +++ b/lib/Doctrine/ORM/ORMSetup.php @@ -101,10 +101,11 @@ public static function createAttributeMetadataConfiguration( array $paths, bool $isDevMode = false, ?string $proxyDir = null, - ?CacheItemPoolInterface $cache = null + ?CacheItemPoolInterface $cache = null, + bool $reportFieldsWhereDeclared = false ): Configuration { $config = self::createConfiguration($isDevMode, $proxyDir, $cache); - $config->setMetadataDriverImpl(new AttributeDriver($paths)); + $config->setMetadataDriverImpl(new AttributeDriver($paths, $reportFieldsWhereDeclared)); return $config; } diff --git a/lib/Doctrine/ORM/Tools/EntityGenerator.php b/lib/Doctrine/ORM/Tools/EntityGenerator.php index 5bf086d52b..04be655188 100644 --- a/lib/Doctrine/ORM/Tools/EntityGenerator.php +++ b/lib/Doctrine/ORM/Tools/EntityGenerator.php @@ -1376,7 +1376,7 @@ protected function generateEntityStubMethod(ClassMetadataInfo $metadata, $type, $this->staticReflection[$metadata->name]['methods'][] = strtolower($methodName); $var = sprintf('%sMethodTemplate', $type); - $template = static::$$var; + $template = (string) static::$$var; $methodTypeHint = ''; $types = Type::getTypesMap(); @@ -1695,7 +1695,7 @@ protected function generateFieldMappingPropertyDocBlock(array $fieldMapping, Cla } if (isset($fieldMapping['options']['comment']) && $fieldMapping['options']['comment']) { - $options[] = '"comment"="' . str_replace('"', '""', $fieldMapping['options']['comment']) . '"'; + $options[] = '"comment"="' . str_replace('"', '""', (string) $fieldMapping['options']['comment']) . '"'; } if (isset($fieldMapping['options']['collation']) && $fieldMapping['options']['collation']) { diff --git a/lib/Doctrine/ORM/Tools/Pagination/LimitSubqueryOutputWalker.php b/lib/Doctrine/ORM/Tools/Pagination/LimitSubqueryOutputWalker.php index 02fae2564c..6bcd178d03 100644 --- a/lib/Doctrine/ORM/Tools/Pagination/LimitSubqueryOutputWalker.php +++ b/lib/Doctrine/ORM/Tools/Pagination/LimitSubqueryOutputWalker.php @@ -404,7 +404,7 @@ private function recreateInnerSql( /** * @return string[][] - * @psalm-return array{0: list, 1: list} + * @psalm-return array{0: list, 1: list} */ private function generateSqlAliasReplacements(): array { diff --git a/lib/Doctrine/ORM/UnitOfWork.php b/lib/Doctrine/ORM/UnitOfWork.php index 5bf8a6b608..4c343f2ec9 100644 --- a/lib/Doctrine/ORM/UnitOfWork.php +++ b/lib/Doctrine/ORM/UnitOfWork.php @@ -23,6 +23,7 @@ use Doctrine\ORM\Event\PrePersistEventArgs; use Doctrine\ORM\Event\PreRemoveEventArgs; use Doctrine\ORM\Event\PreUpdateEventArgs; +use Doctrine\ORM\Exception\EntityIdentityCollisionException; use Doctrine\ORM\Exception\ORMException; use Doctrine\ORM\Exception\UnexpectedAssociationValue; use Doctrine\ORM\Id\AssignedGenerator; @@ -692,6 +693,7 @@ public function computeChangeSet(ClassMetadata $class, $entity) if ($class->isCollectionValuedAssociation($name) && $value !== null) { if ($value instanceof PersistentCollection) { if ($value->getOwner() === $entity) { + $actualData[$name] = $value; continue; } @@ -1623,6 +1625,7 @@ public function isEntityScheduled($entity) * the entity in question is already managed. * * @throws ORMInvalidArgumentException + * @throws EntityIdentityCollisionException * * @ignore */ @@ -1634,27 +1637,38 @@ public function addToIdentityMap($entity) if (isset($this->identityMap[$className][$idHash])) { if ($this->identityMap[$className][$idHash] !== $entity) { - throw new RuntimeException(sprintf( + if ($this->em->getConfiguration()->isRejectIdCollisionInIdentityMapEnabled()) { + throw EntityIdentityCollisionException::create($this->identityMap[$className][$idHash], $entity, $idHash); + } + + Deprecation::trigger( + 'doctrine/orm', + 'https://github.com/doctrine/orm/pull/10785', <<<'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: +another object of class %s was already present for the same ID. This will trigger +an exception in ORM 3.0. + +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. +- 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. -Otherwise, it might be an ORM-internal inconsistency, please report it. +To opt-in to the new exception, call +\Doctrine\ORM\Configuration::setRejectIdCollisionInIdentityMap on the entity +manager's configuration. EXCEPTION , get_class($entity), $idHash, get_class($this->identityMap[$className][$idHash]) - )); + ); } return false; @@ -3014,6 +3028,7 @@ public function createEntity($className, array $data, &$hints = []) // We are negating the condition here. Other cases will assume it is valid! case $hints['fetchMode'][$class->name][$field] !== ClassMetadata::FETCH_EAGER: $newValue = $this->em->getProxyFactory()->getProxy($assoc['targetEntity'], $normalizedAssociatedId); + $this->registerManaged($newValue, $associatedId, []); break; // Deferred eager load only works for single identifier classes @@ -3022,6 +3037,7 @@ public function createEntity($className, array $data, &$hints = []) $this->eagerLoadingEntities[$targetClass->rootEntityName][$relatedIdHash] = current($normalizedAssociatedId); $newValue = $this->em->getProxyFactory()->getProxy($assoc['targetEntity'], $normalizedAssociatedId); + $this->registerManaged($newValue, $associatedId, []); break; default: @@ -3029,13 +3045,6 @@ public function createEntity($className, array $data, &$hints = []) $newValue = $this->em->find($assoc['targetEntity'], $normalizedAssociatedId); break; } - - if ($newValue === null) { - break; - } - - $this->registerManaged($newValue, $associatedId, []); - break; } $this->originalEntityData[$oid][$field] = $newValue; diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 2b887162e5..fd8707f080 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,5 +1,5 @@ - + IterableResult @@ -964,9 +964,6 @@ children()]]> children()]]> - - children()]]> - getName() === 'embeddable']]> getName() === 'entity']]> diff --git a/tests/Doctrine/Tests/Models/Issue9300/Issue9300Child.php b/tests/Doctrine/Tests/Models/Issue9300/Issue9300Child.php new file mode 100644 index 0000000000..0919d6d33b --- /dev/null +++ b/tests/Doctrine/Tests/Models/Issue9300/Issue9300Child.php @@ -0,0 +1,44 @@ + + * @ManyToMany(targetEntity="Issue9300Parent") + */ + public $parents; + + /** + * @var string + * @Column(type="string") + */ + public $name; + + public function __construct() + { + $this->parents = new ArrayCollection(); + } +} diff --git a/tests/Doctrine/Tests/Models/Issue9300/Issue9300Parent.php b/tests/Doctrine/Tests/Models/Issue9300/Issue9300Parent.php new file mode 100644 index 0000000000..f9b50b36c9 --- /dev/null +++ b/tests/Doctrine/Tests/Models/Issue9300/Issue9300Parent.php @@ -0,0 +1,30 @@ +_em->getConfiguration()->setRejectIdCollisionInIdentityMap(true); + $user = new CmsUser(); $user->name = 'test'; $user->username = 'test'; @@ -1345,7 +1347,7 @@ public function testItThrowsWhenReferenceUsesIdAssignedByDatabase(): void // 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->expectException(EntityIdentityCollisionException::class); $this->expectExceptionMessageMatches('/another object .* was already present for the same ID/'); // depending on ID generation strategy, the ID may be asssigned already here diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/GH10880Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH10880Test.php new file mode 100644 index 0000000000..dd299fdfdd --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH10880Test.php @@ -0,0 +1,119 @@ +setUpEntitySchema([ + GH10880BaseProcess::class, + GH10880Process::class, + GH10880ProcessOwner::class, + ]); + } + + public function testProcessShouldBeUpdated(): void + { + $process = new GH10880Process(); + $process->description = 'first value'; + + $owner = new GH10880ProcessOwner(); + $owner->process = $process; + + $this->_em->persist($process); + $this->_em->persist($owner); + $this->_em->flush(); + $this->_em->clear(); + + $ownerLoaded = $this->_em->getRepository(GH10880ProcessOwner::class)->find($owner->id); + $ownerLoaded->process->description = 'other description'; + + $queryLog = $this->getQueryLog(); + $queryLog->reset()->enable(); + $this->_em->flush(); + + $this->removeTransactionCommandsFromQueryLog(); + + self::assertCount(1, $queryLog->queries); + $query = reset($queryLog->queries); + self::assertSame('UPDATE GH10880BaseProcess SET description = ? WHERE id = ?', $query['sql']); + } + + private function removeTransactionCommandsFromQueryLog(): void + { + $log = $this->getQueryLog(); + + foreach ($log->queries as $key => $entry) { + if ($entry['sql'] === '"START TRANSACTION"' || $entry['sql'] === '"COMMIT"') { + unset($log->queries[$key]); + } + } + } +} + +/** + * @ORM\Entity + */ +class GH10880ProcessOwner +{ + /** + * @ORM\Id + * @ORM\GeneratedValue + * @ORM\Column(type="integer") + * + * @var int + */ + public $id; + + /** + * fetch=EAGER is important to reach the part of \Doctrine\ORM\UnitOfWork::createEntity() + * that is important for this regression test + * + * @ORM\ManyToOne(targetEntity="GH10880Process", fetch="EAGER") + * + * @var GH10880Process + */ + public $process; +} + +/** + * @ORM\Entity() + * @ORM\InheritanceType("SINGLE_TABLE") + * @ORM\DiscriminatorColumn(name="type", type="string") + * @ORM\DiscriminatorMap({"process" = "GH10880Process"}) + */ +abstract class GH10880BaseProcess +{ + /** + * @ORM\Id + * @ORM\GeneratedValue + * @ORM\Column(type="integer") + * + * @var int + */ + public $id; + + /** + * @ORM\Column(type="text") + * + * @var string + */ + public $description; +} + +/** + * @ORM\Entity + */ +class GH10880Process extends GH10880BaseProcess +{ +} diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/Issue9300Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/Issue9300Test.php new file mode 100644 index 0000000000..5a6309e433 --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/Issue9300Test.php @@ -0,0 +1,80 @@ +useModelSet('issue9300'); + + parent::setUp(); + } + + /** + * @group GH-9300 + */ + public function testPersistedCollectionIsPresentInOriginalDataAfterFlush(): void + { + $parent = new Issue9300Parent(); + $child = new Issue9300Child(); + $child->parents->add($parent); + + $parent->name = 'abc'; + $child->name = 'abc'; + + $this->_em->persist($parent); + $this->_em->persist($child); + $this->_em->flush(); + + $parent->name = 'abcd'; + $child->name = 'abcd'; + + $this->_em->flush(); + + self::assertArrayHasKey('parents', $this->_em->getUnitOfWork()->getOriginalEntityData($child)); + } + + /** + * @group GH-9300 + */ + public function testPersistingCollectionAfterFlushWorksAsExpected(): void + { + $parentOne = new Issue9300Parent(); + $parentTwo = new Issue9300Parent(); + $childOne = new Issue9300Child(); + + $parentOne->name = 'abc'; + $parentTwo->name = 'abc'; + $childOne->name = 'abc'; + $childOne->parents = new ArrayCollection([$parentOne]); + + $this->_em->persist($parentOne); + $this->_em->persist($parentTwo); + $this->_em->persist($childOne); + $this->_em->flush(); + + // Recalculate change-set -> new original data + $childOne->name = 'abcd'; + $this->_em->flush(); + + $childOne->parents = new ArrayCollection([$parentTwo]); + + $this->_em->flush(); + $this->_em->clear(); + + $childOneFresh = $this->_em->find(Issue9300Child::class, $childOne->id); + self::assertCount(1, $childOneFresh->parents); + self::assertEquals($parentTwo->id, $childOneFresh->parents[0]->id); + } +} diff --git a/tests/Doctrine/Tests/ORM/UnitOfWorkTest.php b/tests/Doctrine/Tests/ORM/UnitOfWorkTest.php index e47af9d57d..0569bfd06d 100644 --- a/tests/Doctrine/Tests/ORM/UnitOfWorkTest.php +++ b/tests/Doctrine/Tests/ORM/UnitOfWorkTest.php @@ -13,6 +13,7 @@ use Doctrine\Deprecations\PHPUnit\VerifyDeprecations; use Doctrine\ORM\EntityNotFoundException; use Doctrine\ORM\Events; +use Doctrine\ORM\Exception\EntityIdentityCollisionException; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Mapping\Column; use Doctrine\ORM\Mapping\Entity; @@ -41,7 +42,6 @@ use Doctrine\Tests\OrmTestCase; use Doctrine\Tests\PHPUnitCompatibility\MockBuilderCompatibilityTools; use PHPUnit\Framework\MockObject\MockObject; -use RuntimeException; use stdClass; use function assert; @@ -927,8 +927,28 @@ public function testRemovedEntityIsRemovedFromOneToManyCollection(): void self::assertEmpty($user->phonenumbers->getSnapshot()); } + public function testItTriggersADeprecationNoticeWhenApplicationProvidedIdsCollide(): 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->expectDeprecationWithIdentifier('https://github.com/doctrine/orm/pull/10785'); + + $this->_unitOfWork->persist($phone2); + } + public function testItThrowsWhenApplicationProvidedIdsCollide(): void { + $this->_emMock->getConfiguration()->setRejectIdCollisionInIdentityMap(true); + // 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. @@ -940,7 +960,7 @@ public function testItThrowsWhenApplicationProvidedIdsCollide(): void $phone2 = new CmsPhonenumber(); $phone2->phonenumber = '1234'; - $this->expectException(RuntimeException::class); + $this->expectException(EntityIdentityCollisionException::class); $this->expectExceptionMessageMatches('/another object .* was already present for the same ID/'); $this->_unitOfWork->persist($phone2); diff --git a/tests/Doctrine/Tests/OrmFunctionalTestCase.php b/tests/Doctrine/Tests/OrmFunctionalTestCase.php index 07bf94eb65..4a64136f18 100644 --- a/tests/Doctrine/Tests/OrmFunctionalTestCase.php +++ b/tests/Doctrine/Tests/OrmFunctionalTestCase.php @@ -338,6 +338,10 @@ abstract class OrmFunctionalTestCase extends OrmTestCase Models\Issue5989\Issue5989Employee::class, Models\Issue5989\Issue5989Manager::class, ], + 'issue9300' => [ + Models\Issue9300\Issue9300Child::class, + Models\Issue9300\Issue9300Parent::class, + ], ]; /** @param class-string ...$models */