diff --git a/lib/Doctrine/ORM/UnitOfWork.php b/lib/Doctrine/ORM/UnitOfWork.php index 94331d9ba82..f329cc0fb31 100644 --- a/lib/Doctrine/ORM/UnitOfWork.php +++ b/lib/Doctrine/ORM/UnitOfWork.php @@ -28,6 +28,7 @@ use Doctrine\ORM\Id\AssignedGenerator; use Doctrine\ORM\Internal\CommitOrderCalculator; use Doctrine\ORM\Internal\HydrationCompleteHandler; +use Doctrine\ORM\Internal\TopologicalSort; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Mapping\MappingException; use Doctrine\ORM\Mapping\Reflection\ReflectionPropertiesGetter; @@ -408,9 +409,6 @@ public function commit($entity = null) $this->dispatchOnFlushEvent(); - // Now we need a commit order to maintain referential integrity - $commitOrder = $this->getCommitOrder(); - $conn = $this->em->getConnection(); $conn->beginTransaction(); @@ -431,7 +429,7 @@ public function commit($entity = null) // into account (new entities referring to other new entities), since all other types (entities // with updates or scheduled deletions) are currently not a problem, since they are already // in the database. - $this->executeInserts($this->computeInsertExecutionOrder($commitOrder)); + $this->executeInserts($this->computeInsertExecutionOrder()); } if ($this->entityUpdates) { @@ -456,7 +454,7 @@ public function commit($entity = null) // Entity deletions come last. Their order only needs to take care of other deletions // (first delete entities depending upon others, before deleting depended-upon entities). if ($this->entityDeletions) { - $this->executeDeletions($this->computeDeleteExecutionOrder($commitOrder)); + $this->executeDeletions($this->computeDeleteExecutionOrder()); } // Commit failed silently @@ -1265,36 +1263,61 @@ private function executeDeletions(array $entities): void } } - /** - * @param list $commitOrder - * - * @return list - */ - private function computeInsertExecutionOrder(array $commitOrder): array + /** @return list */ + private function computeInsertExecutionOrder(): array { - $result = []; - foreach ($commitOrder as $class) { - $className = $class->name; - foreach ($this->entityInsertions as $entity) { - if ($this->em->getClassMetadata(get_class($entity))->name !== $className) { + $sort = new TopologicalSort(); + + // First make sure we have all the nodes + foreach ($this->entityInsertions as $entity) { + $sort->addNode($entity); + } + + // Now add edges + foreach ($this->entityInsertions as $entity) { + $class = $this->em->getClassMetadata(get_class($entity)); + + foreach ($class->associationMappings as $assoc) { + // We only need to consider the owning sides of to-one associations, + // since many-to-many associations are persisted at a later step and + // have no insertion order problems (all entities already in the database + // at that time). + if (! ($assoc['isOwningSide'] && $assoc['type'] & ClassMetadata::TO_ONE)) { continue; } - $result[] = $entity; + $targetEntity = $class->getFieldValue($entity, $assoc['fieldName']); + + // If there is no entity that we need to refer to, or it is already in the + // database (i. e. does not have to be inserted), no need to consider it. + if ($targetEntity === null || ! $sort->hasNode($targetEntity)) { + continue; + } + + // According to https://www.doctrine-project.org/projects/doctrine-orm/en/2.14/reference/annotations-reference.html#annref_joincolumn, + // the default for "nullable" is true. Unfortunately, it seems this default is not applied at the metadata driver, factory or other + // level, but in fact we may have an undefined 'nullable' key here, so we must assume that default here as well. + // + // Same in \Doctrine\ORM\Tools\EntityGenerator::isAssociationIsNullable or \Doctrine\ORM\Persisters\Entity\BasicEntityPersister::getJoinSQLForJoinColumns, + // to give two examples. + assert(isset($assoc['joinColumns'])); + $joinColumns = reset($assoc['joinColumns']); + $isNullable = ! isset($joinColumns['nullable']) || $joinColumns['nullable']; + + // Add dependency. The dependency direction implies that "$targetEntity has to go before $entity", + // so we can work through the topo sort result from left to right (with all edges pointing right). + $sort->addEdge($targetEntity, $entity, $isNullable); } } - return $result; + return $sort->sort(); } - /** - * @param list $commitOrder - * - * @return list - */ - private function computeDeleteExecutionOrder(array $commitOrder): array + /** @return list */ + private function computeDeleteExecutionOrder(): array { - $result = []; + $commitOrder = $this->getCommitOrder(); + $result = []; foreach (array_reverse($commitOrder) as $class) { $className = $class->name; foreach ($this->entityDeletions as $entity) { diff --git a/tests/Doctrine/Tests/ORM/Functional/OneToOneSelfReferentialAssociationTest.php b/tests/Doctrine/Tests/ORM/Functional/OneToOneSelfReferentialAssociationTest.php index 6c211aa603c..9cefa18ab7c 100644 --- a/tests/Doctrine/Tests/ORM/Functional/OneToOneSelfReferentialAssociationTest.php +++ b/tests/Doctrine/Tests/ORM/Functional/OneToOneSelfReferentialAssociationTest.php @@ -74,9 +74,11 @@ public function testFind(): void public function testEagerLoadsAssociation(): void { - $this->createFixture(); + $customerId = $this->createFixture(); + + $query = $this->_em->createQuery('select c, m from Doctrine\Tests\Models\ECommerce\ECommerceCustomer c left join c.mentor m where c.id = :id'); + $query->setParameter('id', $customerId); - $query = $this->_em->createQuery('select c, m from Doctrine\Tests\Models\ECommerce\ECommerceCustomer c left join c.mentor m order by c.id asc'); $result = $query->getResult(); $customer = $result[0]; $this->assertLoadingOfAssociation($customer); diff --git a/tests/Doctrine/Tests/OrmFunctionalTestCase.php b/tests/Doctrine/Tests/OrmFunctionalTestCase.php index c2054179d6f..b024f09f553 100644 --- a/tests/Doctrine/Tests/OrmFunctionalTestCase.php +++ b/tests/Doctrine/Tests/OrmFunctionalTestCase.php @@ -421,6 +421,7 @@ protected function tearDown(): void $conn->executeStatement('DELETE FROM ecommerce_products_categories'); $conn->executeStatement('DELETE FROM ecommerce_products_related'); $conn->executeStatement('DELETE FROM ecommerce_carts'); + $conn->executeStatement('DELETE FROM ecommerce_customers WHERE mentor_id IS NOT NULL'); $conn->executeStatement('DELETE FROM ecommerce_customers'); $conn->executeStatement('DELETE FROM ecommerce_features'); $conn->executeStatement('DELETE FROM ecommerce_products');