Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ a release.
---

## [Unreleased]
### Fixed
- Tree: Fixed inserting multiple root nodes in one flush operation with the nested set strategy in certain circumstances (#2582)

## [3.20.0] - 2025-04-04
### Fixed
Expand Down
5 changes: 5 additions & 0 deletions src/Tree/Strategy/ORM/Nested.php
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,11 @@ public function updateNode(EntityManagerInterface $em, $node, $parent, $position
$wrapped->setPropertyValue($config['right'], $right);
}
$newRoot = $parentRoot;

if (!isset($this->treeEdges[$meta->getName()])) {
$this->treeEdges[$meta->getName()] = $this->max($em, $config['useObjectClass'], $newRoot) + 1;
}
$this->treeEdges[$meta->getName()] += 2;
} elseif (!isset($config['root'])
|| ($meta->isSingleValuedAssociation($config['root']) && null !== $parent && ($newRoot = $meta->getFieldValue($node, $config['root'])))) {
if (!isset($this->treeEdges[$meta->getName()])) {
Expand Down
132 changes: 132 additions & 0 deletions tests/Gedmo/Tree/Fixture/Issue2582/OU.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
<?php

/*
* This file is part of the Doctrine Behavioral Extensions package.
* (c) Gediminas Morkevicius <[email protected]> http://www.gediminasm.org
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Gedmo\Tests\Tree\Fixture\Issue2582;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Gedmo\Mapping\Annotation as Gedmo;

/**
* @Gedmo\Tree(type="nested")
*
* @ORM\Table(
* name="ous",
* indexes={
* @ORM\Index(name="idx_tree", fields={"left", "right"})
* }
* )
* @ORM\Entity()
*/
#[ORM\Table(name: 'ous')]
#[ORM\Index(name: 'idx_tree', columns: ['lft', 'rgt'])]
#[ORM\Entity]
#[Gedmo\Tree(type: 'nested')]
class OU
{
/**
* @ORM\Column(name="id", type="guid")
* @ORM\Id()
*/
#[ORM\Column('id', 'guid')]
#[ORM\Id]
private string $id;

/**
* @Gedmo\TreeParent()
*
* @ORM\ManyToOne(targetEntity="\Gedmo\Tests\Tree\Fixture\Issue2582\OU", inversedBy="children")
* @ORM\JoinColumn(name="parent", referencedColumnName="id", nullable=true, onDelete="CASCADE")
*/
#[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')]
#[ORM\JoinColumn(name: 'parent', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
#[Gedmo\TreeParent]
private ?self $parent = null;

/**
* @Gedmo\TreeLeft()
*
* @ORM\Column(name="lft", type="integer", options={"unsigned"=true})
*/
#[ORM\Column(name: 'lft', type: 'integer', options: ['unsigned' => true])]
#[Gedmo\TreeLeft]
private int $left = 1;

/**
* @Gedmo\TreeLevel()
*
* @ORM\Column(name="lvl", type="integer", options={"unsigned"=true})
*/
#[ORM\Column(name: 'lvl', type: 'integer', options: ['unsigned' => true])]
#[Gedmo\TreeLevel]
private int $level = 0;

/**
* @Gedmo\TreeRight()
*
* @ORM\Column(name="rgt", type="integer", options={"unsigned"=true})
*/
#[ORM\Column(name: 'rgt', type: 'integer', options: ['unsigned' => true])]
#[Gedmo\TreeRight]
private int $right = 2;

/**
* @ORM\OneToMany(targetEntity="\Gedmo\Tests\Tree\Fixture\Issue2582\OU", mappedBy="parent")
* @ORM\OrderBy({"left" = "ASC"})
*
* @var Collection<int, self>
*/
#[ORM\OneToMany(targetEntity: self::class, mappedBy: 'parent')]
#[ORM\OrderBy(['left' => 'ASC'])]
private Collection $children;

public function __construct(string $id, ?self $parent = null)
{
$this->id = $id;
$this->children = new ArrayCollection();
$this->parent = $parent;
if ($parent) {
$parent->children->add($this);
}
}

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

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

public function getLeft(): int
{
return $this->left;
}

public function getLevel(): int
{
return $this->level;
}

public function getRight(): int
{
return $this->right;
}

/**
* @return Collection<int, self>
*/
public function getChildren(): Collection
{
return $this->children;
}
}
157 changes: 157 additions & 0 deletions tests/Gedmo/Tree/Issue/Issue2582Test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
<?php

/*
* This file is part of the Doctrine Behavioral Extensions package.
* (c) Gediminas Morkevicius <[email protected]> http://www.gediminasm.org
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Gedmo\Tests\Tree\Issue;

use Doctrine\Common\EventManager;
use Gedmo\Tests\Tool\BaseTestCaseORM;
use Gedmo\Tests\Tree\Fixture\Issue2582\OU;
use Gedmo\Tree\TreeListener;

final class Issue2582Test extends BaseTestCaseORM
{
private TreeListener $listener;

protected function setUp(): void
{
parent::setUp();

$this->listener = new TreeListener();

$evm = new EventManager();
$evm->addEventSubscriber($this->listener);

$this->getDefaultMockSqliteEntityManager($evm);
}

public function testInsertTwoRootsInOneFlush(): void
{
$ou1 = new OU('00000000-0000-0000-0000-000000000001', null);
$ou11 = new OU('00000000-0000-0000-0000-000000000011', $ou1);
$ou2 = new OU('00000000-0000-0000-0000-000000000002', null);
$ou21 = new OU('00000000-0000-0000-0000-000000000021', $ou2);

$this->em->persist($ou1);
$this->em->persist($ou11);
$this->em->persist($ou2);
$this->em->persist($ou21);
$this->em->flush();

$this->em->clear();

$expected = [
['00000000-0000-0000-0000-000000000001', null, 1, 0, 4],
['00000000-0000-0000-0000-000000000011', '00000000-0000-0000-0000-000000000001', 2, 1, 3],
['00000000-0000-0000-0000-000000000002', null, 5, 0, 8],
['00000000-0000-0000-0000-000000000021', '00000000-0000-0000-0000-000000000002', 6, 1, 7],
];
foreach ($this->fetchAllOUs() as $i => $a) {
static::assertSame(
$expected[$i],
[
$a->getId(),
$a->getParent() ? $a->getParent()->getId() : null,
$a->getLeft(),
$a->getLevel(),
$a->getRight(),
],
);
}
}

public function testInsertTwoRootsInOneFlushRootsFirst(): void
{
$ou1 = new OU('00000000-0000-0000-0000-000000000001', null);
$ou11 = new OU('00000000-0000-0000-0000-000000000011', $ou1);
$ou2 = new OU('00000000-0000-0000-0000-000000000002', null);
$ou21 = new OU('00000000-0000-0000-0000-000000000021', $ou2);

$this->em->persist($ou1);
$this->em->persist($ou2);
$this->em->persist($ou11);
$this->em->persist($ou21);
$this->em->flush();

$this->em->clear();

$expected = [
['00000000-0000-0000-0000-000000000001', null, 1, 0, 4],
['00000000-0000-0000-0000-000000000011', '00000000-0000-0000-0000-000000000001', 2, 1, 3],
['00000000-0000-0000-0000-000000000002', null, 5, 0, 8],
['00000000-0000-0000-0000-000000000021', '00000000-0000-0000-0000-000000000002', 6, 1, 7],
];
foreach ($this->fetchAllOUs() as $i => $a) {
static::assertSame(
$expected[$i],
[
$a->getId(),
$a->getParent() ? $a->getParent()->getId() : null,
$a->getLeft(),
$a->getLevel(),
$a->getRight(),
],
);
}
}

public function testInsertTwoRootsInTwoFlushes(): void
{
$ou1 = new OU('00000000-0000-0000-0000-000000000001', null);
$ou11 = new OU('00000000-0000-0000-0000-000000000011', $ou1);
$ou2 = new OU('00000000-0000-0000-0000-000000000002', null);
$ou21 = new OU('00000000-0000-0000-0000-000000000021', $ou2);

$this->em->persist($ou1);
$this->em->persist($ou11);
$this->em->flush();
$this->em->persist($ou2);
$this->em->persist($ou21);
$this->em->flush();

$this->em->clear();

$expected = [
['00000000-0000-0000-0000-000000000001', null, 1, 0, 4],
['00000000-0000-0000-0000-000000000011', '00000000-0000-0000-0000-000000000001', 2, 1, 3],
['00000000-0000-0000-0000-000000000002', null, 5, 0, 8],
['00000000-0000-0000-0000-000000000021', '00000000-0000-0000-0000-000000000002', 6, 1, 7],
];
foreach ($this->fetchAllOUs() as $i => $a) {
static::assertSame(
$expected[$i],
[
$a->getId(),
$a->getParent() ? $a->getParent()->getId() : null,
$a->getLeft(),
$a->getLevel(),
$a->getRight(),
],
);
}
}

protected function getUsedEntityFixtures(): array
{
return [OU::class];
}

/**
* @return list<OU>
*/
private function fetchAllOUs(): array
{
$categoryRepo = $this->em->getRepository(OU::class);

return $categoryRepo
->createQueryBuilder('ou')
->orderBy('ou.left', 'ASC')
->getQuery()
->getResult();
}
}