Doctrine cascade remove does not work when there are two different relations related to same entity

Viewed 104

I have a question about a strange behavior I noticed regarding how cascade remove works in Doctrine. In summary the situation is that executing $em->remove() on a parent object of N children, the cascade remove does not work and does not remove the children when there is another one-to-one relationship pointing to the same entity of the N children .

Code of src/Entity/Parent.php:

<?php

namespace App\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM;
use App\Entity\Child;

#[ORM\Entity]
class Parent
{

    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: 'integer')]
    private $id;

    #[ORM\OneToOne(targetEntity: Child::class, cascade: ['persist', 'remove'])]
    private $currentChild;

    #[ORM\OneToMany(targetEntity: Child::class, mappedBy: 'parent', cascade: ['persist', 'remove'], orphanRemoval: true)]
    private $children;

    public function __construct()
    {
        $this->children = new ArrayCollection();
    }

    public function getId() : ?int
    {
        return $this->id;
    }

    public function setCurrentChild(?Child $child) : self
    {
        $this->currentChild = $child;
        return $this;
    }

    public function getCurrentChild() : ?Child
    {
        return $this->currentChild;
    }

    public function getChildren() : array
    {
        return $this->children->toArray();
    }

    public function addChild(Child $child) : self
    {
        if (!$this->children->contains($child)) {
            $this->children[] = $child;
            $child->setParent($this);
        }

        return $this;
    }

    public function removeChild(Child $child) : self
    {
        if ($this->children->removeElement($child)) {
            if ($child->getParent() === $this) {
                $child->setParent(null);
            }
        }

        return $this;
    }

    public function clearChildren() : self
    {
        foreach ($this->children as $child) {
            $this->removeChild($child);
        }

        return $this;
    }

}

Code of src/Entity/Child.php:

<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use App\Entity\Parent;

#[ORM\Entity]
class Child
{

    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: 'integer')]
    private $id;

    #[ORM\ManyToOne(targetEntity: Parent::class, inversedBy: 'children')]
    private $parent;

    public function getId() : ?int
    {
        return $this->id;
    }

    public function getParent() : ?Parent
    {
        return $this->parent;
    }

    public function setParent(?Parent $parent) : self
    {
        $this->parent = $parent;
        return $this;
    }

}

Code of src/Controller/DefaultController.php:

<?php

namespace App\Controller;

use DateTime;
use DateInterval;
use Exception;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\DBAL\Logging\DebugStack;
use App\Entity\Father;
use App\Entity\Child;

#[Route('/')]
class DefaultController
{

    private $debugStack;

    public function __construct
    (
        private EntityManagerInterface $em,
    )
    {
        $conn = $this->em->getConnection();
        $stack = new DebugStack();
        $conn->getConfiguration()->setSQLLogger($stack);
        $this->debugStack = $stack;
    }

    #[Route('/create')]
    public function createAction(Request $request) : Response
    {
        $father = new Father();
        $child1 = new Child();
        $child2 = new Child();
        $parent->addChild($child1);
        $parent->addChild($child2);
        $parent->setCurrentChild($child1);
        $this->em->persist($father);
        $this->em->flush();
        $this->debugQueries();
        return new Response();
    }

    #[Route('/remove')]
    public function removeAction(Request $request) : Response
    {
        try {
            $father = $this->getFather(1);
            $this->em->remove($father);
            $this->em->flush();
            $this->debugQueries();
        } catch (Exception $e) {
            $this->debugQueries();
        }
        return new Response();
    }

    private function getFather(int $id) : ?Father
    {
        $qb = $this->em->createQueryBuilder();
        $qb
            ->addSelect('father')
            ->from(Father::class, 'father')
            ->andWhere($qb->expr()->eq('father.id', ':id'))
            ->setParameter('id', $id)
        ;
        $query = $qb->getQuery();
        $father = $query->getOneOrNullResult();
        return $father;
    }

    private function debugQueries() : void
    {
        echo '<pre>';
        print_r(array_map(function($data) {
            return [
                'sql' => $data['sql'],
                'params' => $data['params'],
            ];
        }, $this->debugStack->queries));
        echo '</pre>';
    }

}

When I run /create:

Array (
    [1] => Array (
        [sql] => "START TRANSACTION"
        [params] => 
    )
    [2] => Array (
        [sql] => INSERT INTO father (current_child_id) VALUES (?)
        [params] => Array (
            [1] => 
        )
    )
    [3] => Array (
        [sql] => INSERT INTO child (father_id) VALUES (?)
        [params] => Array (
            [1] => 1
        )
    )
    [4] => Array (
        [sql] => INSERT INTO child (father_id) VALUES (?)
        [params] => Array (
            [1] => 1
        )
    )
    [5] => Array (
        [sql] => UPDATE father SET current_child_id = ? WHERE id = ?
        [params] => Array (
            [0] => 1
            [1] => 1
        )
    )
    [6] => Array (
        [sql] => "COMMIT"
        [params] => 
    )
)

Then, when I run /remove:

Array (
    [1] => Array (
        [sql] => SELECT f0_.id AS id_0, f0_.current_child_id AS current_child_id_1 FROM father f0_ WHERE f0_.id = ?
        [params] => Array (
            [0] => 1
        )
    )
    [2] => Array (
        [sql] => SELECT t0.id AS id_1, t0.father_id AS father_id_2 FROM child t0 WHERE t0.father_id = ?
        [params] => Array (
            [0] => 1
        )
    )
    [3] => Array (
        [sql] => "START TRANSACTION"
        [params] => 
    )
    [4] => Array (
        [sql] => DELETE FROM father WHERE id = ?
        [params] => Array (
            [0] => 1
        )
    )
    /* 
       CASCADE REMOVE NOT WORKING, I EXPECTED 2 DELETE HERE...
    */
    [5] => Array (
        [sql] => "ROLLBACK"
        [params] => 
    )
)

As we can see, the cascade remove didn't work, triggering an error and a rollback. After that, I removed the $currentChild relation, and the exit of the /remove, after a new /create, was:

Array
(
    [1] => Array (
        [sql] => SELECT f0_.id AS id_0 FROM father f0_ WHERE f0_.id = ?
        [params] => Array (
            [0] => 1
        )
    )
    [2] => Array (
        [sql] => SELECT t0.id AS id_1, t0.father_id AS father_id_2 FROM child t0 WHERE t0.father_id = ?
        [params] => Array (
            [0] => 1
        )
    )
    [3] => Array (
        [sql] => "START TRANSACTION"
        [params] => 
    )
    [4] => Array (
        [sql] => DELETE FROM child WHERE id = ? /* CASCADE REMOVE WORKING!!! */
        [params] => Array(
            [0] => 1
        )
    )
    [5] => Array(
        [sql] => DELETE FROM child WHERE id = ? /* CASCADE REMOVE WORKING!!!! */
        [params] => Array(
            [0] => 2
        )
    )
    [6] => Array (
        [sql] => DELETE FROM father WHERE id = ?
        [params] => Array(
            [0] => 1
        )
    )
    [7] => Array (
        [sql] => "COMMIT"
        [params] => 
    )
)

The question is: why does Doctrine not respect cascade remove when there is a $currentChild relationship? In my application, for each OneToMany relationship that deals with an array, I need a OneToOne relationship that stores the current record out of the many stored. But when I put the OneToOne relationship together OneToMany, the cascade remove no longer works as expected. Any explanation for this or suggestion of how to solve it another way?

Used versions:

  • PHP: 8.1.5 NTS
  • Symfony: 6.0.8
  • Doctrine Bundle: 2.6
  • Doctrine ORM: 2.12
  • MariaDB 10.4.21

Thank you all!

0 Answers
Related