-
Notifications
You must be signed in to change notification settings - Fork 2.5k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
…10566) When computing the commit order for entity removals, we have to look out for `@ORM\JoinColumn(onDelete="SET NULL")` to find places where cyclic associations can be broken. #### Background The UoW computes a "commit order" to find the sequence in which tables shall be processed when inserting entities into the database or performing delete operations. For the insert case, the ORM is able to schedule _extra updates_ that will be performed after all entities have been inserted. Associations which are configured as `@ORM\JoinColumn(nullable=true, ...)` can be left as `NULL` in the database when performing the initial `INSERT` statements, and will be updated once all new entities have been written to the database. This can be used to break cyclic associations between entity instances. For removals, the ORM does not currently implement up-front `UPDATE` statements to `NULL` out associations before `DELETE` statements are executed. That means when associations form a cycle, users have to configure `@ORM\JoinColumn(onDelete="SET NULL", ...)` on one of the associations involved. This transfers responsibility to the DBMS to break the cycle at that place. _But_, we still have to perform the delete statements in an order that makes this happen early enough. This may be a _different_ order than the one required for the insert case. We can find it _only_ by looking at the `onDelete` behaviour. We must ignore the `nullable` property, which is irrelevant, since we do not even try to `NULL` anything. #### Example Assume three entity classes `A`, `B`, `C`. There are unidirectional one-to-one associations `A -> B`, `B -> C`, `C -> A`. All those associations are `nullable= true`. Three entities `$a`, `$b`, `$c` are created from these respective classes and associations are set up. All operations `cascade` at the ORM level. So we can test what happens when we start the operations at the three individual entities, but in the end, they will always involve all three of them. _Any_ insert order will work, so the improvements necessary to solve #10531 or #10532 are not needed here. Since all associations are between different tables, the current table-level computation is good enough. For the removal case, only the `A -> B` association has `onDelete="SET NULL"`. So, the only possible execution order is `$b`, `$c`, `$a`. We have to find that regardless of where we start the cascade operation. The DBMS will set the `A -> B` association on `$a` to `NULL` when we remove `$b`. We can then remove `$c` since it is no longer being referred to, then `$a`. #### Related cases These cases ask for the ORM to perform the extra update before the delete by itself, without DBMS-level support: * #5665 * #10548
- Loading branch information
Showing
2 changed files
with
230 additions
and
26 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
177 changes: 177 additions & 0 deletions
177
tests/Doctrine/Tests/ORM/Functional/Ticket/GH10566Test.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,177 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace Doctrine\Tests\ORM\Functional\Ticket; | ||
|
||
use Doctrine\ORM\Mapping as ORM; | ||
use Doctrine\Tests\OrmFunctionalTestCase; | ||
use Generator; | ||
|
||
use function is_a; | ||
|
||
class GH10566Test extends OrmFunctionalTestCase | ||
{ | ||
protected function setUp(): void | ||
{ | ||
parent::setUp(); | ||
|
||
$this->createSchemaForModels( | ||
GH10566A::class, | ||
GH10566B::class, | ||
GH10566C::class | ||
); | ||
} | ||
|
||
/** | ||
* @dataProvider provideEntityClasses | ||
*/ | ||
public function testInsertion(string $startEntityClass): void | ||
{ | ||
$a = new GH10566A(); | ||
$b = new GH10566B(); | ||
$c = new GH10566C(); | ||
|
||
$a->other = $b; | ||
$b->other = $c; | ||
$c->other = $a; | ||
|
||
foreach ([$a, $b, $c] as $candidate) { | ||
if (is_a($candidate, $startEntityClass)) { | ||
$this->_em->persist($candidate); | ||
} | ||
} | ||
|
||
// Since all associations are nullable, the ORM has no problem finding an insert order, | ||
// it can always schedule "deferred updates" to fill missing foreign key values. | ||
$this->_em->flush(); | ||
|
||
self::assertNotNull($a->id); | ||
self::assertNotNull($b->id); | ||
self::assertNotNull($c->id); | ||
} | ||
|
||
/** | ||
* @dataProvider provideEntityClasses | ||
*/ | ||
public function testRemoval(string $startEntityClass): void | ||
{ | ||
$a = new GH10566A(); | ||
$b = new GH10566B(); | ||
$c = new GH10566C(); | ||
|
||
$a->other = $b; | ||
$b->other = $c; | ||
$c->other = $a; | ||
|
||
$this->_em->persist($a); | ||
$this->_em->flush(); | ||
|
||
$aId = $a->id; | ||
$bId = $b->id; | ||
$cId = $c->id; | ||
|
||
// In the removal case, the ORM currently does not schedule "extra updates" | ||
// to break association cycles before entities are removed. So, we must not | ||
// look at "nullable" for associations to find a delete commit order. | ||
// | ||
// To make it work, the user needs to have a database-level "ON DELETE SET NULL" | ||
// on an association. That's where the cycle can be broken. Commit order computation | ||
// for the removal case needs to look at this property. | ||
// | ||
// In this example, only A -> B can be used to break the cycle. So, regardless which | ||
// entity we start with, the ORM-level cascade will always remove all three entities, | ||
// and the order of database deletes always has to be (can only be) from B, then C, then A. | ||
|
||
foreach ([$a, $b, $c] as $candidate) { | ||
if (is_a($candidate, $startEntityClass)) { | ||
$this->_em->remove($candidate); | ||
} | ||
} | ||
|
||
$this->_em->flush(); | ||
|
||
self::assertFalse($this->_em->getConnection()->fetchOne('SELECT id FROM gh10566_a WHERE id = ?', [$aId])); | ||
self::assertFalse($this->_em->getConnection()->fetchOne('SELECT id FROM gh10566_b WHERE id = ?', [$bId])); | ||
self::assertFalse($this->_em->getConnection()->fetchOne('SELECT id FROM gh10566_c WHERE id = ?', [$cId])); | ||
} | ||
|
||
public function provideEntityClasses(): Generator | ||
{ | ||
yield [GH10566A::class]; | ||
yield [GH10566B::class]; | ||
yield [GH10566C::class]; | ||
} | ||
} | ||
|
||
/** | ||
* @ORM\Entity | ||
* @ORM\Table(name="gh10566_a") | ||
*/ | ||
class GH10566A | ||
{ | ||
/** | ||
* @ORM\Id | ||
* @ORM\Column(type="integer") | ||
* @ORM\GeneratedValue() | ||
* | ||
* @var int | ||
*/ | ||
public $id; | ||
|
||
/** | ||
* @ORM\OneToOne(targetEntity="GH10566B", cascade={"all"}) | ||
* @ORM\JoinColumn(nullable=true, onDelete="SET NULL") | ||
* | ||
* @var GH10566B | ||
*/ | ||
public $other; | ||
} | ||
|
||
/** | ||
* @ORM\Entity | ||
* @ORM\Table(name="gh10566_b") | ||
*/ | ||
class GH10566B | ||
{ | ||
/** | ||
* @ORM\Id | ||
* @ORM\Column(type="integer") | ||
* @ORM\GeneratedValue() | ||
* | ||
* @var int | ||
*/ | ||
public $id; | ||
|
||
/** | ||
* @ORM\OneToOne(targetEntity="GH10566C", cascade={"all"}) | ||
* @ORM\JoinColumn(nullable=true) | ||
* | ||
* @var GH10566C | ||
*/ | ||
public $other; | ||
} | ||
|
||
/** | ||
* @ORM\Entity | ||
* @ORM\Table(name="gh10566_c") | ||
*/ | ||
class GH10566C | ||
{ | ||
/** | ||
* @ORM\Id | ||
* @ORM\Column(type="integer") | ||
* @ORM\GeneratedValue() | ||
* | ||
* @var int | ||
*/ | ||
public $id; | ||
|
||
/** | ||
* @ORM\OneToOne(targetEntity="GH10566A", cascade={"all"}) | ||
* @ORM\JoinColumn(nullable=true) | ||
* | ||
* @var GH10566A | ||
*/ | ||
public $other; | ||
} |