mirror of
https://github.com/doctrine/orm.git
synced 2026-04-29 09:23:20 +02:00
No ForeignKeyConstraintViolationException exception when trying to remove an entity #7161
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Originally created by @jakubtobiasz on GitHub (Jun 3, 2023).
Bug Report
Summary
Since the following change

Doctrine stopped throwing
ForeignKeyConstraintViolationExceptionand (from what I noticed) it removes the entity from the database. What's more interesting, if we add the deleted lines, and leave the same code at the end of the method – it doesn't work. So putting thisifstatement somehow breaks the expected behavior.Current behavior
No
ForeignKeyConstraintViolationException🤷🏼♂️.How to reproduce
#10753
Expected behavior
ForeignKeyConstraintViolationExceptionshould be thrown.@mpdude commented on GitHub (Jun 3, 2023):
#10486 implemented that change
@mpdude commented on GitHub (Jun 3, 2023):
Looking at the code in #10753, why should it throw? Where is the foreign key?
@jakubtobiasz commented on GitHub (Jun 3, 2023):
Hi @mpdude,
this example is based on our Promotion-Order relation in Sylius. The foreign key is created under the hood.
Configuration is the same (in terms of relation between these two entities), and there are two foreign keys in the db.
@mpdude commented on GitHub (Jun 3, 2023):
Yes, that’s the foreign keys in the m2m association table.
But where is the foreign key restricting removal of the element from the many-to-many collection?
@jakubtobiasz commented on GitHub (Jun 3, 2023):
@mpdude considering on
2.15.1it's working I guess it's a default. Anyway, while debugging, I setRestricton delete and the same behavior occurred. I can update the reproducing PR if you'd like :).Somehow between
2.15.1and2.15.2the behavior changed without any configuration changes.P.S. Also, trying to delete via MySQL CLI resulted in the error, so there's a constraint preventing removing the record.
@mpdude commented on GitHub (Jun 3, 2023):
Ok, I think I begin to understand the situation.
You have an unidirectional
Order *-->* Promotionmany-to-many association.Already before
a9513692cb, when you directly remove aPromotionentity that is part of anOrder's collection, the collection will be updated as well:https://github.com/doctrine/orm/blob/76b8a215c237cb3d3ff540e005bb0c1276d066f6/lib/Doctrine/ORM/UnitOfWork.php#L960-L967
The intention is that the collection should no longer point to/contain an entity that no longer exists.
pre
a9513692cb, this collection update went unnoticed for the currentflush()operation. The UoW would not try to synchronize the collection change to the database.This is why you got the
ForeignKeyConstraintViolationException(for a database that supports it), since thePromotionwill be deleted, but it is still pointed to by the m2m association table. You probably rely on this, since you do not wantPromotions to be removed that are still being referred to fromOrders.The problem with that was reported as #10485: After the
flush()operation, we are left with a "dirty" collection. That is, a collection that knows one of its elements has been removed and that this still needs to be synchronized to the database. The next timeflush()is called, the UoW notices a collection entry is missing that was previously there. It tries toDELETEthe row from the m2m association table but... the entity it needs to qualify the table row (thePromotion) is no longer in the identity map.The
a9513692cbchange makes sure to include the collection update in the sameflush()operation as the removal of the entity. That way, we can first remove the association row (as the entity is still known), right before the entity itself is deleted and removed from the identity map.This is why you no longer get the exception: The ORM now removes the join table row before the
Promotionentity is removed; no FK violation anymore!Out of curiosity, I removed the lines shown above. The only tests that fail are those added in #10486. So, it seems that we did not have any tests at all covering this collection-updating code.
To make matters worse and the situation a bit more complicated, the tests from #10753 pass even with the
a9513692cbchange if youclear()the entity manager after the setup phase and load only thePromotionentity into memory.This time, the UoW does not know about the m2m collection on the
Order, since it is not in memory. And, I am not surprised that the UoW does not go out to find and load all the collections that possibly contain thePromotionwhen you remove thePromotion. The consequence is that, this time, the UoW will not update the collection (it's not in memory), so not try to synchronize that change, not delete the association row in the database and you get the foreign key reference violation you're expecting.Now, at that point, I don't know how to proceed. I think it is necessary to agree on how the ORM should behave, whether it should update the collections or not. I've checked the documentation on this, but found nothing really distinct.
I am not sure if that might mean the ORM would have to implement configuration or switches to support
CASCADEvs.RESTRICTbehaviour on collections? I don't know if that is already there?One thing I would assume is that, at least, the behaviour should be consistent regardless of whether a collection has been loaded into memory or not.
🤷🏻
@greg0ire commented on GitHub (Jun 3, 2023):
One thing is unclear to me about the current behavior. If there is a foreign key pointing to the entity being removed, has is it even possible to remove the referenced entity? I would expect the RDBMS to make that impossible.
@mpdude commented on GitHub (Jun 3, 2023):
Depending on the database level CASCADE/RESTRICT behavior, the RDBMS would abort or remove the n/m table row.
The point is that Doctrine removes the to-be-deleted entity from the collection and flushes that first, so the DB level foreign key never notices a problem.
@greg0ire commented on GitHub (Jun 3, 2023):
So right now, there is no exception, and nothing gets removed, or there is an exception, just not the expected one? @jakubtobiasz didn't mention an exception, so I guess it cascades right now?
@jakubtobiasz commented on GitHub (Jun 4, 2023):
Hello,
in terms of "whether anything is removed":
On

2.15.1; state of thesylius_promotion_order; after running a Behat scenario:On

2.15.2:The promotion is also deleted:

When I try to delete the promotion with using a SQL:

From what I noticed yesterday, no exception is thrown.
@greg0ire commented on GitHub (Jun 4, 2023):
Ok this means Doctrine removes the n-m table row before deleting the promotion row, whereas before, it forced users to be explicit about this.
Well if ensuring a consistent behavior is too complicated, let's say that the behavior is undefined: it may throw or may succeed, and if users want a reliable result, then they should explicitly delete the link between Order and Promotion. If you cannot find documentation stating what should happen, then it is undefined, and if you want you can contribute a documentation paragraph making it explicitly undefined, and stating how to be sure not to get an exception (here, I think it's by removing the promotion from the collection of promotions in the order object).
Maybe the best place to document that would be here: https://www.doctrine-project.org/projects/doctrine-orm/en/stable/reference/working-with-associations.html#removing-associations
It can always been changed afterwards if you somehow manage to get a consistent behavior for this (removing the target entity of an m2m unidirectional association, i.e. removing entity on the inverse side). Sounds fair?
I don't know either, maybe another maintainer will.
@mpdude commented on GitHub (Jun 4, 2023):
For the first time, I've noticed the following documentation section:
https://github.com/doctrine/orm/blob/76b8a215c237cb3d3ff540e005bb0c1276d066f6/docs/en/reference/working-with-objects.rst?plain=1#L289-L294
How do you interpret that?
@greg0ire commented on GitHub (Jun 4, 2023):
The first sentence makes it clear what is suppose to happen. According to it, there should never have been a
ForeignKeyConstraintViolationExceptionin the first place, just silent deletions. The other 2 sentence seem to say it's guaranteed to happen, but not necessarily thanks to aDELETE. A database-level cascade might take care of it. That's my interpretation anyway, what's yours?@mpdude commented on GitHub (Jun 4, 2023):
Well, yes, that's pretty unambiguous. But.
DELETEstatements. But, I cannot see in the code where is difference would be made.Order *-->* Promotionexample. Now, you have a "clean" entity manager, and all you do is to load onePromotionand remove it. How is the EM even supposed to find out about all the potential m2m join tables that might exist, in order to perform this cleanup? Remember, we don't have a "full" view of all classes that (might) exist, we only know about what we loaded so far and what associations those classes declare. In this example,Promotiondoes not declare anything (and does not have to!).@beberlei maybe you have thoughts on that? (sorry for pinging)
@mpdude commented on GitHub (Jun 4, 2023):
Maybe we can say that when an entity is removed, what happens entirely depends on the
onDeletebehaviour and whether the database supports it?@mpdude commented on GitHub (Jun 5, 2023):
I have found https://github.com/doctrine/orm/blob/2afe2dc8af41111fd8206a793768937e4b138221/lib/Doctrine/ORM/Persisters/Entity/BasicEntityPersister.php#L538 – that is where the
EntityPersistercleans up m2m join-table rows with aDELETEstatement when an entity is removed, unlessonDeleteCascadeis set. It works at the SQL level and can remove all join table rows referring to that particular entity in a single query.That code only works when the association is declared on the side of the entity being removed, either as the owning or inverse side. That is not the case for the
Promotionin the OP. Otherwise, the ORM cannot see/detect which other entities might possibly have a m2m association against the entity in question.\Doctrine\ORM\UnitOfWork::computeAssociationChangesis more concerned with keeping in-memory collections updated. Since #10486, it will flush those changes to the DB as well – and that happens before the EntityPersister.→ Need to check if that causes an amount of extra queries.
@mpdude commented on GitHub (Jun 5, 2023):
Without the change from #10486, the tests in #10753 pass... unless you make the association bidirectional.
Note that the database-level foreign keys are still at their default,
RESTRICT. IMHO, adding an inverse side like this should also not remove foreign key protections the application might rely on – by suddenly starting to clean up join table rows in the ORM/BasicEntityPersister, non?@greg0ire commented on GitHub (Jun 5, 2023):
It's not that weird IMO. If
Promotionis suddenly aware of the link it has with Order, it might make sense to anybody taking a look atPromotionthat removing it will result in the link withOrderbeing removed (because the link between the 2 is now obvious).@mpdude commented on GitHub (Jun 5, 2023):
#10486 had another side effect: We started to take care of the dirty collections (where to-be-removed entities have been removed), which happens before we reach
\Doctrine\ORM\Persisters\Entity\BasicEntityPersister::deleteJoinTableRecords. So, it takes one query per join table row (for every single collection entry), instead of being able to remove all join table rows in a single step per removed entity.I'll add query count expectations to
\Doctrine\Tests\ORM\Functional\ManyToManyBasicAssociationTestthat would have uncovered this.@mpdude commented on GitHub (Jun 7, 2023):
A possible fix including documentation updates is in #10763.