-
Notifications
You must be signed in to change notification settings - Fork 2.5k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
(Try to) add a reproducer for a delete ordering issue #10912
Conversation
In doctrine#10864 (comment) and subsequent comments, @Dallas62 reported that since the upgrade to 2.16.1, they have an issue with the order of `DELETE` statements. The order of `DELETES` leads to a foreign key constraint violation. Since the report does not exactly match the original issue topic in doctrine#10864, I am creating this one here so we can track it individually. In order to reproduce, a demo Symfony application was provided in https://github.com/Dallas62/doctrine-orm-reproducer-deletion-issue, but obviously we cannot include that in the ORM test suite. I have picked the two entities I deemed relevant, but as of now, this is not sufficient to reproduce the bug.
af73761
to
9f84c51
Compare
@Dallas62 I think what you're seeing is different from #10864. So let's continue the discussion here. I have tried to pick the relevant classes from your repo and strip it down to the bare minimum needed to run as a test. But, as you can see, this test currently passes. So, there must be more to it... Can you experiment with the test as it can be found in https://github.com/mpdude/doctrine2/tree/delete-foreign-key-issue, and feel free to open PRs against it? We need a failing test to proceed, and it has to be a test that we can include and run in the ORM test suite. Thanks! |
public $id; | ||
|
||
/** | ||
* @ORM\OneToMany(targetEntity=GH10912Room::class, mappedBy="user", cascade={"remove"}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If I remove cascade: remove
, I'll get the error
Doctrine\DBAL\Exception\ForeignKeyConstraintViolationException : An exception occurred while executing a query: SQLSTATE[23000]: Integrity constraint violation: 1451 Cannot delete or update a parent row: a foreign key constraint fails (
doctrine_tests
.GH10912Room
, CONSTRAINTFK_7FCB83EEA76ED395
FOREIGN KEY (user_id
) REFERENCESGH10912User
(id
))
This is because we have a GH10912Room
pointing to the user that is to be removed, the association is not nullable and we have no database-level ON DELETE
setting.
TIL that cascade
seemingly also works on the inverse side of an association (@derrabus )
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Adding orphanRemoval
, as can be seen in
does not make difference.
Thanks @mpdude for your time, I will take a look later this day. I found during making the reproducer, it only happen when the |
I have added the @Dallas62 looking forward to your suggestions! |
Ok, so I have continue digging on this subject.
Next:
|
Not sure whether SQLite and/or in_memory implements referential integrity. Without that, the issue won't surface. |
I simplified the reproducer:
|
@mpdude After more digging into this issue, I finally got the cause of the reproducibility ! 🎉 The So:
Good news, working with pdo_sqlite in_memory. |
@@ -80,13 +80,6 @@ class GH10912User
*/
public $id;
- /**
- * @ORM\OneToMany(targetEntity=GH10912Room::class, mappedBy="user", cascade={"remove"})
- *
- * @var Collection<int, GH10912Room>
- */
- public $rooms;
-
/**
* @ORM\OneToOne(targetEntity=GH10912Profile::class, cascade={"remove"})
* @ORM\JoinColumn(onDelete="cascade")
@@ -95,6 +88,13 @@ class GH10912User
*/
public $profile;
+ /**
+ * @ORM\OneToMany(targetEntity=GH10912Room::class, mappedBy="user", cascade={"remove"})
+ *
+ * @var Collection<int, GH10912Room>
+ */
+ public $rooms;
+
public function __construct()
{
$this->rooms = new ArrayCollection();
|
X-ref #10566; maybe there’s more conditions we need to consider? |
(description to be written) Fixes doctrine#10912.
Thanks, I will take a look asap. I will also extends your tests based on some particular cases I have in mind. |
I tried with the given repository (at commit Dallas62/doctrine-orm-reproducer-deletion-issue@6e14b75) For me it seems ok, let some more experimented contributors review changes and give feedbacks. Just in case: <?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 GH10912Case2Test extends OrmFunctionalTestCase
{
protected function setUp(): void
{
parent::setUp();
$this->setUpEntitySchema([
GH10912User::class,
GH10912Profile::class,
GH10912Room::class,
GH10912AnotherDependency::class
]);
}
public function testIssue(): void
{
$user = new GH10912User();
$profile = new GH10912Profile();
$room = new GH10912Room();
$dependency = new GH10912AnotherDependency();
$user->rooms->add($room);
$user->profile = $profile;
$profile->user = $user;
$room->user = $user;
$dependency->profile = $profile;
$profile->dependencies->add($dependency);
$this->_em->persist($room);
$this->_em->persist($user);
$this->_em->persist($profile);
$this->_em->persist($dependency);
$this->_em->flush();
$this->_em->clear();
$userReloaded = $this->_em->find(GH10912User::class, $user->id);
$queryLog = $this->getQueryLog();
$queryLog->reset()->enable();
$this->_em->remove($userReloaded);
$this->_em->flush();
$queries = array_values(array_filter($queryLog->queries, static function ($entry) {
return strpos($entry['sql'], 'DELETE') === 0;
}));
self::assertCount(4, $queries);
self::assertSame('DELETE FROM GH10912AnotherDependency WHERE id = ?', $queries[0]['sql']);
self::assertSame('DELETE FROM GH10912Room WHERE id = ?', $queries[1]['sql']);
self::assertSame('DELETE FROM GH10912User WHERE id = ?', $queries[2]['sql']);
self::assertSame('DELETE FROM GH10912Profile WHERE id = ?', $queries[3]['sql']);
// The EntityManager is aware that all three entities have been deleted
$im = $this->_em->getUnitOfWork()->getIdentityMap();
self::assertEmpty($im[GH10912Profile::class]);
self::assertEmpty($im[GH10912User::class]);
self::assertEmpty($im[GH10912Room::class]);
self::assertEmpty($im[GH10912AnotherDependency::class]);
}
}
/** @ORM\Entity */
class GH10912User
{
/**
* @ORM\Id
* @ORM\Column(type="integer")
* @ORM\GeneratedValue
*
* @var int
*/
public $id;
/**
* @ORM\OneToOne(targetEntity=GH10912Profile::class, cascade={"remove"})
* @ORM\JoinColumn(onDelete="cascade")
*
* @var GH10912Profile
*/
public $profile;
/**
* @ORM\OneToMany(targetEntity=GH10912Room::class, mappedBy="user", cascade={"remove"})
*
* @var Collection<int, GH10912Room>
*/
public $rooms;
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\OneToMany(targetEntity=GH10912AnotherDependency::class, mappedBy="profile", cascade={"remove"})
*
* @var Collection<int, GH10912AnotherDependency>
*/
public $dependencies;
public function __construct()
{
$this->dependencies = new ArrayCollection();
}
}
/** @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;
}
/** @ORM\Entity */
class GH10912AnotherDependency
{
/**
* @ORM\Id
* @ORM\Column(type="integer")
* @ORM\GeneratedValue
*
* @var int
*/
public $id;
/**
* @ORM\ManyToOne(targetEntity=GH10912Profile::class, inversedBy="dependencies")
* @ORM\JoinColumn(nullable=false)
*
* @var GH10912Profile
*/
public $profile;
} |
@mpdude
, in 2.16, and it used to work fine in 2.15 |
@asmafarhat2006 Might not be helpful, but we got the same problem in our codebase and I dug arround and found out why. Of course, there is multiple ways to trigger this same error. class MyEntity {
# ManyToOne
public OtherFooEntity $foo;
}
# This will give you problem in 2.16 and not in earlier version (we came from 2.14):
$myEntity->foo = null;
$em->remove($myEntity->foo);
$em->remove($myEntity);
$em->flush(); // Crash
// Doctrine thinks its OK to remove OtherFooEntity before MyEntity
// because it looks at the current state and not the original and current state in db.
// Was easy for us to refactor this, as it didnt make much sense to set foo = null |
Thanks for looking into this
But my issue is slightly different .the child entity does exist but doesnt
have the annotations representing the relation in the entity class.
It used to work fine without adding the relation on the inverse side in
2.15 but it crashes in 2.16
…On Thu, 12 Oct 2023, 22:33 Einar, ***@***.***> wrote:
@asmafarhat2006 <https://github.com/asmafarhat2006> Might not be helpful,
but we got the same problem in our codebase and I dug arround and found out
why.
class MyEntity {
# ManyToOne
public OtherFooEntity $foo;
}
# This will give you problem in 2.16 and not in earlier version (we came from 2.14):$myEntity->foo = null;$em->remove($myEntity->foo);$em->remove($myEntity);
$em->flush(); // Crash
// Doctrine thinks its OK to remove OtherFooEntity before MyEntity// because it looks at the current state and not the original and current state in db.
// Was easy for us to refactor this, as it didnt make much sense to set foo = null
—
Reply to this email directly, view it on GitHub
<#10912 (comment)>, or
unsubscribe
<https://github.com/notifications/unsubscribe-auth/ABQ5MNFRIKW3GPOD35L7T73X7BO2HAVCNFSM6AAAAAA3TCP3SWVHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMYTONRQGM4TGMRVGU>
.
You are receiving this because you were mentioned.Message ID:
***@***.***>
|
…ion (#10913) 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.
The fix from #10913 will be included in the next 2.17 bugfix release. |
In #10864 (comment) and subsequent comments, @Dallas62 reported that since the upgrade to 2.16.1, they have an issue with the order of
DELETE
statements. The order ofDELETES
leads to a foreign key constraint violation.Since the report does not exactly match the original issue topic in #10864, I am creating this one here so we can track it individually.
In order to reproduce, a demo Symfony application was provided in https://github.com/Dallas62/doctrine-orm-reproducer-deletion-issue, but obviously we cannot include that in the ORM test suite.
I have picked the two entities I deemed relevant, but as of now, this is not sufficient to reproduce the bug.