Reputation: 254916
For a Many-to-Many relations (say Groups and Users) the rows from the joint table are automatically removed as soon as either of the entities is deleted, like the cascade remove attribute was set for the relation. So basically I want to delete it IF ONLY IT IS EMPTY. So the solution must guarantee no relations were dropped (exactly like FK constraint guarantees it).
Is it possible to not do that by default and throw an exception on foreign key constraint violation?
PS: checking before deletion is not a solution since it's a race condition prone.
PPS: mapping definitions are trivial, for the sake of completeness I post them here (even though they don't bring anything useful)
PPPS: onDelete: cascade
is not a solution either: it creates the corresponding ON DELETE CASCADE
on the database level.
PPPPS: ON DELETE RESTRICT
CANNOT BE USED since doctrine will remove all the references from the joint table.
In roles:
manyToMany:
users:
targetEntity: UserAccount
mappedBy: roles
In users:
manyToMany:
roles:
targetEntity: Role
joinTable:
name: user_role
joinColumns:
user_id:
referencedColumnName: id
inverseJoinColumns:
role_id:
referencedColumnName: id
Upvotes: 8
Views: 3424
Reputation: 11
After bumping into the same issue and not finding any solution I spent couple hours trying to find the reason why this is happening. I'm posting my findings here for anyone else stumbling across this issue.
Role
is the owning side of the relationshiponDelete: 'restrict'
to JoinColumns. (If you're using for example symfony maker this will result in a migration with the correct ON DELETE RESTRICT)onDelete: 'cascade'
to InverseJoinColumns. (This will prevent the data from being deleted)Here is are example class implementations using Attributes:
#[ORM\Entity]
class User
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private $id;
#[ORM\ManyToMany(targetEntity: Role::class, mappedBy: 'users')]
private $roles;
}
#[ORM\Entity]
class Role
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private $id;
#[ORM\ManyToMany(targetEntity: User::class, inversedBy: 'roles')]
#[ORM\JoinColumn(onDelete: 'restrict')]
#[ORM\InverseJoinColumn(onDelete: 'cascade')]
private $users;
}
Now I am not sure if this is how it should work, but the following findings led me to this solution:
Where it goes wrong is in the method
Doctrine\ORM\Persisters\Entity\BasicEntityPersister::deleteJoinTableRecords()
Specifically the condition
if (isset($mapping['isOnDeleteCascade'])) {
continue;
}
Basically, unless this mapping is set doctrine will automatically add a query that to delete all existing entries in the relation table.
Now, where this mapping key is actually set is in the method
Doctrine\ORM\Mapping\ClassMetadataInfo::_validateAndCompleteManyToManyMapping()
This is the only place I found where isOnDeleteCascade
is added to the mapping. This method checks both the JoinColumn and InverseJoinColumn and if either has an onDelete='cascade'
the key is set to true. Specifically:
if (isset($joinColumn['onDelete']) && strtolower($joinColumn['onDelete']) === 'cascade') {
$mapping['isOnDeleteCascade'] = true;
}
Unfortunately I couldn't find much information about the issue in the documentation, or if this is the intended way to deal with this issue. From what I can see, it is impossible to have a RESTRICT condition on both foreign keys the way this is currently implemented in Doctrine. But it works when only one side needs to restrict the other.
Upvotes: 1
Reputation: 1529
I came to the same problem a few hours ago:
I have a simple ManyToMany
association between User
and Group
entities.
I don't want Doctrine to delete a Group
entity if there are some related users
, while I want Doctrine to delete a User
even if it in some Group.
To accomplish this, in the Group
entity I have this lines of code:
/**
* Group
*
* @ORM\Entity()
* @ORM\HasLifecycleCallbacks()
*
*/
class Group
{
...
/**
* @var \Doctrine\Common\Collections\ArrayCollection
*
* @ORM\ManyToMany(targetEntity="User", mappedBy="groups")
*
*/
private $users;
...
/**
* @ORM\PreRemove()
*/
public function preRemove() {
if ($this->users->count()>0) {
throw new \AppBundle\Utils\Exception\FKConstraintViolationException();
}
}
}
This is the FKConstraintViolationException class
namespace AppBundle\Utils\Exception;
class FKConstraintViolationException extends \Doctrine\DBAL\Exception\ForeignKeyConstraintViolationException {
public function __construct() {
$message='Self generated exception';
$driverException=new \Doctrine\DBAL\Driver\OCI8\OCI8Exception('Self generated exception.'); //or whatever driver you use if you have another db
parent::__construct($message, $driverException);
}
}
and in the controller I put the remove()
in a try/catch
block
try {
$em->remove($group);
$em->flush();
$this->addFlash('success', 'deleted_successfully');
} catch (\Doctrine\DBAL\Exception\ForeignKeyConstraintViolationException $e) {
$this->addFlash('error', 'cannotdelete_related_entities');
}
Here is the documentation http://docs.doctrine-project.org/projects/doctrine-orm/en/latest/reference/events.html#lifecycle-events
Maybe it could be best implemented creating a listener on the preFlush
or onFlush
event because you could actually call remove()
on an object without flushing and you would obtain the exception.
Upvotes: 1
Reputation: 4491
This answer can be considered as a workaround. The many-to-many
association can be replaced by one-to-many/many-to-one
associations between the 3 participating classes, because Doctrine has no cascade deleting with one-to-many
by default.
Upvotes: 3