Repository: doctrine/orm (php)

Issue:
Order of referenced child deletions not correct when referencing to itself
### Bug Report

|    Q        |   A
|------------ | ------
| BC Break    | no
| Version     | 2.14.0

#### Summary

The order in which the UoW wants to delete children is somehow missing a dependency when there is an association to the same class even if you specified `cascade: ['remove']`.
Given you have

- Parent -> has an association to children with `cascade: ['remove']`
- Child 1
- Child 2 -> has an association to Child 1 with `cascade: ['remove']`.

Now you delete the parent.
Doctrine tries to remove Child 1 first instead of Child 2 causing a foreign constraint violation: 

> Uncaught PDOException: SQLSTATE[23000]: Integrity constraint violation: 1451 Cannot delete or update a parent row: a foreign key constraint fails (`test`.`child_entities`, CONSTRAINT `FK_DAEB194156A273CC` FOREIGN KEY (`origin_id`) REFERENCES `child_entities` (`id`))


#### How to reproduce

I've created a test setup because I'm not familiar with the internals of ORM at all. So here we go:

1. Clone https://github.com/Toflar/doctrine-orm-bug-reproducer
2. Run `composer install`
3. Adjust dummy mysql connection example in `bootstrap.php`.
4. Run `bin/doctrine orm:schema-tool:drop --force && bin/doctrine orm:schema-tool:create`
5. Run `php test.php`.

#### Expected behavior

It should not throw an exception. Doctrine should remove Child 2 first.



Pull request:
Include `ON DELETE CASCADE` associations in the delete order computation
In order to resolve #10348, some changes were included in #10547 to improve the computed _delete_ order for entities. 

One assumption was that foreign key references with `ON DELETE SET NULL` or `... CASCADE` need not need to be taken into consideration when planning the deletion order, since the RDBMS would unset or cascade-delete such associations by itself when necessary. Only associations that do _not_ use RDBMS-level cascade handling would be sequenced, to make sure the referring entity is deleted before the referred-to one.

This assumption is wrong for `ON DELETE CASCADE`. The following examples give reasons why we need to also consider such associations, and in addition, we need to be able to deal with cycles formed by them.

In the following diagrams, `odc` means `ON DELETE CASCADE`, and `ref` is a regular foreign key with no extra `ON DELETE` semantics.

```mermaid
graph LR;
C-->|ref| B;
B-->|odc| A;
```

In this example, C must be removed before B and A. If we ignore the B->A dependency in the delete order computation, the result may not to be correct. ACB is not a working solution.

```mermaid
graph LR;
A-->|odc| B;
B-->|odc| A;
C-->|ref| B;
```

This is the situation in #10912. We have to deal with a cycle in the graph. C must be removed before A as well as B. If we ignore the B->A dependency (e.g. because we set it to "optional" to get away with the cycle), we might end up with an incorrect order ACB.

```mermaid
graph LR;
A-->|odc| B;
B-->|odc| A;
A-->|ref| C;
C-->|ref| B;
```

This example has no possible remove order. But, if we treat `odc` edges as optional, A -> C -> B would wrongly be deemed suitable.

```mermaid
graph LR;
A-->|ref| B;
B-->|odc| C;
C-->|odc| B;
D-->|ref| C;
```

Here, we must first remove A and D in any order; then, B and C in any order. If we treat one of the `odc` edges as optional, we might find the invalid solutions ABDC or DCAB.

#### Solution implemented in this PR

First, build a graph with a node for every to-be-removed entity, and edges for `ON DELETE CASCADE` associations between those entities. Then, use [Tarjan's algorithm](https://en.wikipedia.org/wiki/Tarjan%27s_strongly_connected_components_algorithm) to find strongly connected components (SCCs) in this graph. The significance of SCCs is that whenever we remove one of the entities in a SCC from the database (no matter which one), the DBMS will immediately remove _all_ the other entities of that group as well.

For every SCC, pick one (arbitrary) entity from the group to represent all entities of that group. 

Then, build a second graph. Again we have nodes for all entities that are to be removed. This time, we insert edges for all regular (foreign key) associations and those with `ON DELETE CASCADE`. `ON DELETE SET NULL` can be left out. The edges are not added between the entities themselves, but between the entities representing the respective SCCs.

Also, for all non-trivial SCCs (those containing more than a single entity), add dependency edges to indicate that all entities of the SCC shall be processed _after_ the entity representing the group. This is to make sure we do not remove a SCC inadvertedly by removing one of its entities too early.

Run a topological sort on the second graph to get the actual delete order. Cycles in this second graph are a problem, there is no delete order.

Fixes #10912.

Patch:
diff --git a/lib/Doctrine/ORM/Internal/StronglyConnectedComponents.php b/lib/Doctrine/ORM/Internal/StronglyConnectedComponents.php
new file mode 100644
index 000000000..0e184c30f
--- /dev/null
+++ b/lib/Doctrine/ORM/Internal/StronglyConnectedComponents.php
@@ -0,0 +1,170 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Doctrine\ORM\Internal;
+
+use InvalidArgumentException;
+
+use function array_keys;
+use function array_pop;
+use function array_push;
+use function min;
+use function spl_object_id;
+
+/**
+ * StronglyConnectedComponents implements Tarjan's algorithm to find strongly connected
+ * components (SCC) in a directed graph. This algorithm has a linear running time based on
+ * nodes (V) and edges between the nodes (E), resulting in a computational complexity
+ * of O(V + E).
+ *
+ * See https://en.wikipedia.org/wiki/Tarjan%27s_strongly_connected_components_algorithm
+ * for an explanation and the meaning of the DFS and lowlink numbers.
+ *
+ * @internal
+ */
+final class StronglyConnectedComponents
+{
+    private const NOT_VISITED = 1;
+    private const IN_PROGRESS = 2;
+    private const VISITED     = 3;
+
+    /**
+     * Array of all nodes, indexed by object ids.
+     *
+     * @var array<int, object>
+     */
+    private $nodes = [];
+
+    /**
+     * DFS state for the different nodes, indexed by node object id and using one of
+     * this class' constants as value.
+     *
+     * @var array<int, self::*>
+     */
+    private $states = [];
+
+    /**
+     * Edges between the nodes. The first-level key is the object id of the outgoing
+     * node; the second array maps the destination node by object id as key.
+     *
+     * @var array<int, array<int, bool>>
+     */
+    private $edges = [];
+
+    /**
+     * DFS numbers, by object ID
+     *
+     * @var array<int, int>
+     */
+    private $dfs = [];
+
+    /**
+     * lowlink numbers, by object ID
+     *
+     * @var array<int, int>
+     */
+    private $lowlink = [];
+
+    /** @var int */
+    private $maxdfs = 0;
+
+    /**
+     * Nodes representing the SCC another node is in, indexed by lookup-node object ID
+     *
+     * @var array<int, object>
+     */
+    private $representingNodes = [];
+
+    /**
+     * Stack with OIDs of nodes visited in the current state of the DFS
+     *
+     * @var list<int>
+     */
+    private $stack = [];
+
+    /** @param object $node */
+    public function addNode($node): void
+    {
+        $id                = spl_object_id($node);
+        $this->nodes[$id]  = $node;
+        $this->states[$id] = self::NOT_VISITED;
+        $this->edges[$id]  = [];
+    }
+
+    /** @param object $node */
+    public function hasNode($node): bool
+    {
+        return isset($this->nodes[spl_object_id($node)]);
+    }
+
+    /**
+     * Adds a new edge between two nodes to the graph
+     *
+     * @param object $from
+     * @param object $to
+     */
+    public function addEdge($from, $to): void
+    {
+        $fromId = spl_object_id($from);
+        $toId   = spl_object_id($to);
+
+        $this->edges[$fromId][$toId] = true;
+    }
+
+    public function findStronglyConnectedComponents(): void
+    {
+        foreach (array_keys($this->nodes) as $oid) {
+            if ($this->states[$oid] === self::NOT_VISITED) {
+                $this->tarjan($oid);
+            }
+        }
+    }
+
+    private function tarjan(int $oid): void
+    {
+        $this->dfs[$oid]    = $this->lowlink[$oid] = $this->maxdfs++;
+        $this->states[$oid] = self::IN_PROGRESS;
+        array_push($this->stack, $oid);
+
+        foreach ($this->edges[$oid] as $adjacentId => $ignored) {
+            if ($this->states[$adjacentId] === self::NOT_VISITED) {
+                $this->tarjan($adjacentId);
+                $this->lowlink[$oid] = min($this->lowlink[$oid], $this->lowlink[$adjacentId]);
+            } elseif ($this->states[$adjacentId] === self::IN_PROGRESS) {
+                $this->lowlink[$oid] = min($this->lowlink[$oid], $this->dfs[$adjacentId]);
+            }
+        }
+
+        $lowlink = $this->lowlink[$oid];
+        if ($lowlink === $this->dfs[$oid]) {
+            $representingNode = null;
+            do {
+                $unwindOid = array_pop($this->stack);
+
+                if (! $representingNode) {
+                    $representingNode = $this->nodes[$unwindOid];
+                }
+
+                $this->representingNodes[$unwindOid] = $representingNode;
+                $this->states[$unwindOid]            = self::VISITED;
+            } while ($unwindOid !== $oid);
+        }
+    }
+
+    /**
+     * @param object $node
+     *
+     * @return object
+     */
+    public function getNodeRepresentingStronglyConnectedComponent($node)
+    {
+        $oid = spl_object_id($node);
+
+        if (! isset($this->representingNodes[$oid])) {
+            throw new InvalidArgumentException('unknown node');
+        }
+
+        return $this->representingNodes[$oid];
+    }
+}
diff --git a/lib/Doctrine/ORM/UnitOfWork.php b/lib/Doctrine/ORM/UnitOfWork.php
index b3285a884..00698e56c 100644
--- a/lib/Doctrine/ORM/UnitOfWork.php
+++ b/lib/Doctrine/ORM/UnitOfWork.php
@@ -28,6 +28,7 @@ use Doctrine\ORM\Exception\ORMException;
 use Doctrine\ORM\Exception\UnexpectedAssociationValue;
 use Doctrine\ORM\Id\AssignedGenerator;
 use Doctrine\ORM\Internal\HydrationCompleteHandler;
+use Doctrine\ORM\Internal\StronglyConnectedComponents;
 use Doctrine\ORM\Internal\TopologicalSort;
 use Doctrine\ORM\Mapping\ClassMetadata;
 use Doctrine\ORM\Mapping\MappingException;
@@ -1391,14 +1392,19 @@ class UnitOfWork implements PropertyChangedListener
     /** @return list<object> */
     private function computeDeleteExecutionOrder(): array
     {
-        $sort = new TopologicalSort();
+        $stronglyConnectedComponents = new StronglyConnectedComponents();
+        $sort                        = new TopologicalSort();
 
-        // First make sure we have all the nodes
         foreach ($this->entityDeletions as $entity) {
+            $stronglyConnectedComponents->addNode($entity);
             $sort->addNode($entity);
         }
 
-        // Now add edges
+        // First, consider only "on delete cascade" associations between entities
+        // and find strongly connected groups. Once we delete any one of the entities
+        // in such a group, _all_ of the other entities will be removed as well. So,
+        // we need to treat those groups like a single entity when performing delete
+        // order topological sorting.
         foreach ($this->entityDeletions as $entity) {
             $class = $this->em->getClassMetadata(get_class($entity));
 
@@ -1410,16 +1416,65 @@ class UnitOfWork implements PropertyChangedListener
                     continue;
                 }
 
-                // For associations that implement a database-level cascade/set null operation,
+                assert(isset($assoc['joinColumns']));
+                $joinColumns = reset($assoc['joinColumns']);
+                if (! isset($joinColumns['onDelete'])) {
+                    continue;
+                }
+
+                $onDeleteOption = strtolower($joinColumns['onDelete']);
+                if ($onDeleteOption !== 'cascade') {
+                    continue;
+                }
+
+                $targetEntity = $class->getFieldValue($entity, $assoc['fieldName']);
+
+                // If the association does not refer to another entity or that entity
+                // is not to be deleted, there is no ordering problem and we can
+                // skip this particular association.
+                if ($targetEntity === null || ! $stronglyConnectedComponents->hasNode($targetEntity)) {
+                    continue;
+                }
+
+                $stronglyConnectedComponents->addEdge($entity, $targetEntity);
+            }
+        }
+
+        $stronglyConnectedComponents->findStronglyConnectedComponents();
+
+        // Now do the actual topological sorting to find the delete order.
+        foreach ($this->entityDeletions as $entity) {
+            $class = $this->em->getClassMetadata(get_class($entity));
+
+            // Get the entities representing the SCC
+            $entityComponent = $stronglyConnectedComponents->getNodeRepresentingStronglyConnectedComponent($entity);
+
+            // When $entity is part of a non-trivial strongly connected component group
+            // (a group containing not only those entities alone), make sure we process it _after_ the
+            // entity representing the group.
+            // The dependency direction implies that "$entity depends on $entityComponent
+            // being deleted first". The topological sort will output the depended-upon nodes first.
+            if ($entityComponent !== $entity) {
+                $sort->addEdge($entity, $entityComponent, false);
+            }
+
+            foreach ($class->associationMappings as $assoc) {
+                // We only need to consider the owning sides of to-one associations,
+                // since many-to-many associations can always be (and have already been)
+                // deleted in a preceding step.
+                if (! ($assoc['isOwningSide'] && $assoc['type'] & ClassMetadata::TO_ONE)) {
+                    continue;
+                }
+
+                // For associations that implement a database-level set null operation,
                 // we do not have to follow a particular order: If the referred-to entity is
-                // deleted first, the DBMS will either delete the current $entity right away
-                // (CASCADE) or temporarily set the foreign key to NULL (SET NULL).
-                // Either way, we can skip it in the computation.
+                // deleted first, the DBMS will temporarily set the foreign key to NULL (SET NULL).
+                // So, we can skip it in the computation.
                 assert(isset($assoc['joinColumns']));
                 $joinColumns = reset($assoc['joinColumns']);
                 if (isset($joinColumns['onDelete'])) {
                     $onDeleteOption = strtolower($joinColumns['onDelete']);
-                    if ($onDeleteOption === 'cascade' || $onDeleteOption === 'set null') {
+                    if ($onDeleteOption === 'set null') {
                         continue;
                     }
                 }
@@ -1433,10 +1488,17 @@ class UnitOfWork implements PropertyChangedListener
                     continue;
                 }
 
-                // Add dependency. The dependency direction implies that "$targetEntity depends on $entity
+                // Get the entities representing the SCC
+                $targetEntityComponent = $stronglyConnectedComponents->getNodeRepresentingStronglyConnectedComponent($targetEntity);
+
+                // When we have a dependency between two different groups of strongly connected nodes,
+                // add it to the computation.
+                // The dependency direction implies that "$targetEntityComponent depends on $entityComponent
                 // being deleted first". The topological sort will output the depended-upon nodes first,
                 // so we can work through the result in the returned order.
-                $sort->addEdge($targetEntity, $entity, false);
+                if ($targetEntityComponent !== $entityComponent) {
+                    $sort->addEdge($targetEntityComponent, $entityComponent, false);
+                }
             }
         }
 
@@ -3106,9 +3168,10 @@ EXCEPTION
                     $reflField->setValue($entity, $pColl);
 
                     if ($hints['fetchMode'][$class->name][$field] === ClassMetadata::FETCH_EAGER) {
-                        if ($assoc['type'] === ClassMetadata::ONE_TO_MANY) {
+                        $isIteration = isset($hints[Query::HINT_INTERNAL_ITERATION]) && $hints[Query::HINT_INTERNAL_ITERATION];
+                        if (! $isIteration && $assoc['type'] === ClassMetadata::ONE_TO_MANY) {
                             $this->scheduleCollectionForBatchLoading($pColl, $class);
-                        } elseif ($assoc['type'] === ClassMetadata::MANY_TO_MANY) {
+                        } elseif (($isIteration && $assoc['type'] === ClassMetadata::ONE_TO_MANY) || $assoc['type'] === ClassMetadata::MANY_TO_MANY) {
                             $this->loadCollection($pColl);
                             $pColl->takeSnapshot();
                         }


Test patch:
diff --git a/tests/Doctrine/Tests/ORM/Functional/EagerFetchCollectionTest.php b/tests/Doctrine/Tests/ORM/Functional/EagerFetchCollectionTest.php
index 90cd1793a..cd76ee8b0 100644
--- a/tests/Doctrine/Tests/ORM/Functional/EagerFetchCollectionTest.php
+++ b/tests/Doctrine/Tests/ORM/Functional/EagerFetchCollectionTest.php
@@ -84,6 +84,18 @@ class EagerFetchCollectionTest extends OrmFunctionalTestCase
         $query->getResult();
     }
 
+    public function testEagerFetchWithIterable(): void
+    {
+        $this->createOwnerWithChildren(2);
+        $this->_em->flush();
+        $this->_em->clear();
+
+        $iterable = $this->_em->getRepository(EagerFetchOwner::class)->createQueryBuilder('o')->getQuery()->toIterable();
+        $owner    = $iterable->current();
+
+        $this->assertCount(2, $owner->children);
+    }
+
     protected function createOwnerWithChildren(int $children): EagerFetchOwner
     {
         $owner = new EagerFetchOwner();
diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/GH10912Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH10912Test.php
new file mode 100644
index 000000000..b81f23f51
--- /dev/null
+++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH10912Test.php
@@ -0,0 +1,165 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Doctrine\Tests\ORM\Functional\Ticket;
+
+use Doctrine\Common\Collections\ArrayCollection;
+use Doctrine\Common\Collections\Collection;
+use Doctrine\ORM\Mapping as ORM;
+use Doctrine\Tests\OrmFunctionalTestCase;
+
+use function array_filter;
+use function array_values;
+use function strpos;
+
+class GH10912Test extends OrmFunctionalTestCase
+{
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->setUpEntitySchema([
+            GH10912User::class,
+            GH10912Profile::class,
+            GH10912Room::class,
+        ]);
+    }
+
+    public function testIssue(): void
+    {
+        $user    = new GH10912User();
+        $profile = new GH10912Profile();
+        $room    = new GH10912Room();
+
+        $user->rooms->add($room);
+        $user->profile = $profile;
+        $profile->user = $user;
+        $room->user    = $user;
+
+        $this->_em->persist($room);
+        $this->_em->persist($user);
+        $this->_em->persist($profile);
+        $this->_em->flush();
+
+        /*
+         * This issue is about finding a special deletion order:
+         * $user and $profile cross-reference each other with ON DELETE CASCADE.
+         * So, whichever one gets deleted first, the DBMS will immediately dispose
+         * of the other one as well.
+         *
+         * $user -> $room is the unproblematic (irrelevant) inverse side of
+         * a OneToMany association.
+         *
+         * $room -> $user is a not-nullable, no DBMS-level-cascade, owning side
+         * of ManyToOne. We *must* remove the $room _before_ the $user can be
+         * deleted. And remember, $user deletion happens either when we DELETE the
+         * user (direct deletion), or when we delete the $profile (ON DELETE CASCADE
+         * propagates to the user).
+         *
+         * In the original bug report, the ordering of fields in the entities was
+         * relevant, in combination with a cascade=persist configuration.
+         *
+         * But, for the sake of clarity, let's put these features away and create
+         * the problematic sequence in UnitOfWork::$entityDeletions directly:
+         */
+        $this->_em->remove($profile);
+        $this->_em->remove($user);
+        $this->_em->remove($room);
+
+        $queryLog = $this->getQueryLog();
+        $queryLog->reset()->enable();
+
+        $this->_em->flush();
+
+        $queries = array_values(array_filter($queryLog->queries, static function (array $entry): bool {
+            return strpos($entry['sql'], 'DELETE') === 0;
+        }));
+
+        self::assertCount(3, $queries);
+
+        // we do not care about the order of $user vs. $profile, so do not check them.
+        self::assertSame('DELETE FROM GH10912Room WHERE id = ?', $queries[0]['sql'], '$room deletion is the first query');
+
+        // The EntityManager is aware that all three entities have been deleted (sanity check)
+        $im = $this->_em->getUnitOfWork()->getIdentityMap();
+        self::assertEmpty($im[GH10912Profile::class]);
+        self::assertEmpty($im[GH10912User::class]);
+        self::assertEmpty($im[GH10912Room::class]);
+    }
+}
+
+/** @ORM\Entity */
+class GH10912User
+{
+    /**
+     * @ORM\Id
+     * @ORM\Column(type="integer")
+     * @ORM\GeneratedValue
+     *
+     * @var int
+     */
+    public $id;
+
+    /**
+     * @ORM\OneToMany(targetEntity=GH10912Room::class, mappedBy="user")
+     *
+     * @var Collection<int, GH10912Room>
+     */
+    public $rooms;
+
+    /**
+     * @ORM\OneToOne(targetEntity=GH10912Profile::class)
+     * @ORM\JoinColumn(onDelete="cascade")
+     *
+     * @var GH10912Profile
+     */
+    public $profile;
+
+    public function __construct()
+    {
+        $this->rooms = new ArrayCollection();
+    }
+}
+
+/** @ORM\Entity */
+class GH10912Profile
+{
+    /**
+     * @ORM\Id
+     * @ORM\Column(type="integer")
+     * @ORM\GeneratedValue
+     *
+     * @var int
+     */
+    public $id;
+
+    /**
+     * @ORM\OneToOne(targetEntity=GH10912User::class)
+     * @ORM\JoinColumn(onDelete="cascade")
+     *
+     * @var GH10912User
+     */
+    public $user;
+}
+
+/** @ORM\Entity */
+class GH10912Room
+{
+    /**
+     * @ORM\Id
+     * @ORM\Column(type="integer")
+     * @ORM\GeneratedValue
+     *
+     * @var int
+     */
+    public $id;
+
+    /**
+     * @ORM\ManyToOne(targetEntity=GH10912User::class, inversedBy="rooms")
+     * @ORM\JoinColumn(nullable=false)
+     *
+     * @var GH10912User
+     */
+    public $user;
+}
diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/GH10913Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH10913Test.php
new file mode 100644
index 000000000..c75f7d9da
--- /dev/null
+++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH10913Test.php
@@ -0,0 +1,167 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Doctrine\Tests\ORM\Functional\Ticket;
+
+use Doctrine\ORM\Internal\TopologicalSort\CycleDetectedException;
+use Doctrine\ORM\Mapping as ORM;
+use Doctrine\Tests\OrmFunctionalTestCase;
+
+use function array_filter;
+use function array_values;
+use function strpos;
+
+class GH10913Test extends OrmFunctionalTestCase
+{
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->setUpEntitySchema([
+            GH10913Entity::class,
+        ]);
+    }
+
+    public function testExample1(): void
+    {
+        [$a, $b, $c] = $this->createEntities(3);
+
+        $c->ref = $b;
+        $b->odc = $a;
+
+        $this->_em->persist($a);
+        $this->_em->persist($b);
+        $this->_em->persist($c);
+        $this->_em->flush();
+
+        $this->_em->remove($a);
+        $this->_em->remove($b);
+        $this->_em->remove($c);
+
+        $this->flushAndAssertNumberOfDeleteQueries(3);
+    }
+
+    public function testExample2(): void
+    {
+        [$a, $b, $c] = $this->createEntities(3);
+
+        $a->odc = $b;
+        $b->odc = $a;
+        $c->ref = $b;
+
+        $this->_em->persist($a);
+        $this->_em->persist($b);
+        $this->_em->persist($c);
+        $this->_em->flush();
+
+        $this->_em->remove($a);
+        $this->_em->remove($b);
+        $this->_em->remove($c);
+
+        $this->flushAndAssertNumberOfDeleteQueries(3);
+    }
+
+    public function testExample3(): void
+    {
+        [$a, $b, $c] = $this->createEntities(3);
+
+        $a->odc = $b;
+        $a->ref = $c;
+        $c->ref = $b;
+        $b->odc = $a;
+
+        $this->_em->persist($a);
+        $this->_em->persist($b);
+        $this->_em->persist($c);
+        $this->_em->flush();
+
+        $this->_em->remove($a);
+        $this->_em->remove($b);
+        $this->_em->remove($c);
+
+        self::expectException(CycleDetectedException::class);
+
+        $this->_em->flush();
+    }
+
+    public function testExample4(): void
+    {
+        [$a, $b, $c, $d] = $this->createEntities(4);
+
+        $a->ref = $b;
+        $b->odc = $c;
+        $c->odc = $b;
+        $d->ref = $c;
+
+        $this->_em->persist($a);
+        $this->_em->persist($b);
+        $this->_em->persist($c);
+        $this->_em->persist($d);
+        $this->_em->flush();
+
+        $this->_em->remove($b);
+        $this->_em->remove($c);
+        $this->_em->remove($d);
+        $this->_em->remove($a);
+
+        $this->flushAndAssertNumberOfDeleteQueries(4);
+    }
+
+    private function flushAndAssertNumberOfDeleteQueries(int $expectedCount): void
+    {
+        $queryLog = $this->getQueryLog();
+        $queryLog->reset()->enable();
+
+        $this->_em->flush();
+
+        $queries = array_values(array_filter($queryLog->queries, static function (array $entry): bool {
+            return strpos($entry['sql'], 'DELETE') === 0;
+        }));
+
+        self::assertCount($expectedCount, $queries);
+    }
+
+    /**
+     * @return list<GH10913Entity>
+     */
+    private function createEntities(int $count = 1): array
+    {
+        $result = [];
+
+        for ($i = 0; $i < $count; $i++) {
+            $result[] = new GH10913Entity();
+        }
+
+        return $result;
+    }
+}
+
+/** @ORM\Entity */
+class GH10913Entity
+{
+    /**
+     * @ORM\Id
+     * @ORM\Column(type="integer")
+     * @ORM\GeneratedValue
+     *
+     * @var int
+     */
+    public $id;
+
+    /**
+     * @ORM\ManyToOne(targetEntity=GH10913Entity::class)
+     * @ORM\JoinColumn(nullable=true, onDelete="CASCADE")
+     *
+     * @var GH10913Entity
+     */
+    public $odc;
+
+    /**
+     * @ORM\ManyToOne(targetEntity=GH10913Entity::class)
+     * @ORM\JoinColumn(nullable=true)
+     *
+     * @var GH10913Entity
+     */
+    public $ref;
+}
diff --git a/tests/Doctrine/Tests/ORM/Internal/StronglyConnectedComponentsTest.php b/tests/Doctrine/Tests/ORM/Internal/StronglyConnectedComponentsTest.php
new file mode 100644
index 000000000..a2adee9dc
--- /dev/null
+++ b/tests/Doctrine/Tests/ORM/Internal/StronglyConnectedComponentsTest.php
@@ -0,0 +1,113 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Doctrine\Tests\ORM\Internal;
+
+use Doctrine\ORM\Internal\StronglyConnectedComponents;
+use Doctrine\Tests\OrmTestCase;
+
+class StronglyConnectedComponentsTest extends OrmTestCase
+{
+    /** @var array<string, Node> */
+    private $nodes = [];
+
+    /** @var StronglyConnectedComponents */
+    private $stronglyConnectedComponents;
+
+    protected function setUp(): void
+    {
+        $this->stronglyConnectedComponents = new StronglyConnectedComponents();
+    }
+
+    public function testFindStronglyConnectedComponents(): void
+    {
+        // A -> B <-> C -> D <-> E
+        $this->addNodes('A', 'B', 'C', 'D', 'E');
+
+        $this->addEdge('A', 'B');
+        $this->addEdge('B', 'C');
+        $this->addEdge('C', 'B');
+        $this->addEdge('C', 'D');
+        $this->addEdge('D', 'E');
+        $this->addEdge('E', 'D');
+
+        $this->stronglyConnectedComponents->findStronglyConnectedComponents();
+
+        $this->assertNodesAreInSameComponent('B', 'C');
+        $this->assertNodesAreInSameComponent('D', 'E');
+        $this->assertNodesAreNotInSameComponent('A', 'B');
+        $this->assertNodesAreNotInSameComponent('A', 'D');
+    }
+
+    public function testFindStronglyConnectedComponents2(): void
+    {
+        // A -> B -> C -> D -> B
+        $this->addNodes('A', 'B', 'C', 'D');
+
+        $this->addEdge('A', 'B');
+        $this->addEdge('B', 'C');
+        $this->addEdge('C', 'D');
+        $this->addEdge('D', 'B');
+
+        $this->stronglyConnectedComponents->findStronglyConnectedComponents();
+
+        $this->assertNodesAreInSameComponent('B', 'C');
+        $this->assertNodesAreInSameComponent('C', 'D');
+        $this->assertNodesAreNotInSameComponent('A', 'B');
+    }
+
+    public function testFindStronglyConnectedComponents3(): void
+    {
+        //           v---------.
+        // A -> B -> C -> D -> E
+        //      ^--------´
+
+        $this->addNodes('A', 'B', 'C', 'D', 'E');
+
+        $this->addEdge('A', 'B');
+        $this->addEdge('B', 'C');
+        $this->addEdge('C', 'D');
+        $this->addEdge('D', 'E');
+        $this->addEdge('E', 'C');
+        $this->addEdge('D', 'B');
+
+        $this->stronglyConnectedComponents->findStronglyConnectedComponents();
+
+        $this->assertNodesAreInSameComponent('B', 'C');
+        $this->assertNodesAreInSameComponent('C', 'D');
+        $this->assertNodesAreInSameComponent('D', 'E');
+        $this->assertNodesAreInSameComponent('E', 'B');
+        $this->assertNodesAreNotInSameComponent('A', 'B');
+    }
+
+    private function addNodes(string ...$names): void
+    {
+        foreach ($names as $name) {
+            $node               = new Node($name);
+            $this->nodes[$name] = $node;
+            $this->stronglyConnectedComponents->addNode($node);
+        }
+    }
+
+    private function addEdge(string $from, string $to, bool $optional = false): void
+    {
+        $this->stronglyConnectedComponents->addEdge($this->nodes[$from], $this->nodes[$to], $optional);
+    }
+
+    private function assertNodesAreInSameComponent(string $first, string $second): void
+    {
+        self::assertSame(
+            $this->stronglyConnectedComponents->getNodeRepresentingStronglyConnectedComponent($this->nodes[$first]),
+            $this->stronglyConnectedComponents->getNodeRepresentingStronglyConnectedComponent($this->nodes[$second])
+        );
+    }
+
+    private function assertNodesAreNotInSameComponent(string $first, string $second): void
+    {
+        self::assertNotSame(
+            $this->stronglyConnectedComponents->getNodeRepresentingStronglyConnectedComponent($this->nodes[$first]),
+            $this->stronglyConnectedComponents->getNodeRepresentingStronglyConnectedComponent($this->nodes[$second])
+        );
+    }
+}