zerkms
zerkms

Reputation: 254916

How to disable cascade deletion for many-to-many relations?

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

Answers (3)

DMinimus
DMinimus

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.

In short

  • Make sure Role is the owning side of the relationship
  • Add onDelete: 'restrict' to JoinColumns. (If you're using for example symfony maker this will result in a migration with the correct ON DELETE RESTRICT)
  • Add 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;
}

In more detail

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

ste
ste

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

Serge Kvashnin
Serge Kvashnin

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.

one-to-many/many-to-one

Upvotes: 3

Related Questions