diff --git a/CHANGELOG.md b/CHANGELOG.md index e6fd39c05c..6bdc3fcd21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/Tree/Strategy/ORM/Nested.php b/src/Tree/Strategy/ORM/Nested.php index becef5a70e..a6fb33cb24 100644 --- a/src/Tree/Strategy/ORM/Nested.php +++ b/src/Tree/Strategy/ORM/Nested.php @@ -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()])) { diff --git a/tests/Gedmo/Tree/Fixture/Issue2582/OU.php b/tests/Gedmo/Tree/Fixture/Issue2582/OU.php new file mode 100644 index 0000000000..0c4efc152a --- /dev/null +++ b/tests/Gedmo/Tree/Fixture/Issue2582/OU.php @@ -0,0 +1,132 @@ + 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 + */ + #[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 + */ + public function getChildren(): Collection + { + return $this->children; + } +} diff --git a/tests/Gedmo/Tree/Issue/Issue2582Test.php b/tests/Gedmo/Tree/Issue/Issue2582Test.php new file mode 100644 index 0000000000..fe481adf86 --- /dev/null +++ b/tests/Gedmo/Tree/Issue/Issue2582Test.php @@ -0,0 +1,157 @@ + 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 + */ + private function fetchAllOUs(): array + { + $categoryRepo = $this->em->getRepository(OU::class); + + return $categoryRepo + ->createQueryBuilder('ou') + ->orderBy('ou.left', 'ASC') + ->getQuery() + ->getResult(); + } +}