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!