diff --git a/CHANGELOG.md b/CHANGELOG.md index 735c5de2b5..ea7bf573a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,9 @@ a release. ### Changed - All: Removed the dollar sign from the generated cache ID for extension metadata to ensure only characters mandated by [PSR-6](https://www.php-fig.org/psr/psr-6/#definitions) are used, improving compatibility with caching implementations with strict character requirements (#2978) +### Fixed +- Tree: Fix TreeObjectHydrator compatibility with ORM 3 when parent property is defined before children property in entity + ## [3.22.0] - 2025-12-13 ### Added - Support for Symfony 8 diff --git a/src/Tree/Hydrator/ORM/TreeObjectHydrator.php b/src/Tree/Hydrator/ORM/TreeObjectHydrator.php index 42f2d6d849..94e819699a 100644 --- a/src/Tree/Hydrator/ORM/TreeObjectHydrator.php +++ b/src/Tree/Hydrator/ORM/TreeObjectHydrator.php @@ -250,6 +250,13 @@ protected function getChildrenField($entityClass) $associationMapping = $meta->getAssociationMapping($property->getName()); + // ORM 3 mapping objects implement ArrayAccess, so both ORM 2 arrays + // and ORM 3 objects go through the same path. Owning-side mappings (the parent + // ManyToOne) have no `mappedBy`, hence the isset() guard. + if (!isset($associationMapping['mappedBy'])) { + continue; + } + // Make sure the association is mapped by the parent property if ($associationMapping['mappedBy'] !== $this->parentField) { continue; diff --git a/tests/Gedmo/Tree/Fixture/RootCategoryReversed.php b/tests/Gedmo/Tree/Fixture/RootCategoryReversed.php new file mode 100644 index 0000000000..346e11e70b --- /dev/null +++ b/tests/Gedmo/Tree/Fixture/RootCategoryReversed.php @@ -0,0 +1,191 @@ + 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; + +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\Mapping as ORM; +use Gedmo\Mapping\Annotation as Gedmo; +use Gedmo\Tree\Entity\Repository\NestedTreeRepository; +use Gedmo\Tree\Node; + +/** + * @ORM\Entity(repositoryClass="Gedmo\Tree\Entity\Repository\NestedTreeRepository") + * + * @Gedmo\Tree(type="nested") + */ +#[ORM\Entity(repositoryClass: NestedTreeRepository::class)] +#[Gedmo\Tree(type: 'nested')] +class RootCategoryReversed implements Node +{ + /** + * @Gedmo\TreeParent + * + * @ORM\ManyToOne(targetEntity="RootCategoryReversed", inversedBy="children") + * @ORM\JoinColumns({ + * @ORM\JoinColumn(name="parent_id", referencedColumnName="id", onDelete="CASCADE") + * }) + */ + #[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')] + #[ORM\JoinColumn(name: 'parent_id', referencedColumnName: 'id', onDelete: 'CASCADE')] + #[Gedmo\TreeParent] + private ?RootCategoryReversed $parent = null; + + /** + * @var Collection + * + * @ORM\OneToMany(targetEntity="RootCategoryReversed", mappedBy="parent") + */ + #[ORM\OneToMany(targetEntity: self::class, mappedBy: 'parent')] + private $children; + + /** + * @var int|null + * + * @ORM\Id + * @ORM\GeneratedValue + * @ORM\Column(type="integer") + */ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: Types::INTEGER)] + private $id; + + /** + * @ORM\Column(name="title", type="string", length=64) + */ + #[ORM\Column(name: 'title', type: Types::STRING, length: 64)] + private ?string $title = null; + + /** + * @var int|null + * + * @Gedmo\TreeLeft + * + * @ORM\Column(name="lft", type="integer") + */ + #[ORM\Column(name: 'lft', type: Types::INTEGER)] + #[Gedmo\TreeLeft] + private $lft; + + /** + * @var int|null + * + * @Gedmo\TreeRight + * + * @ORM\Column(name="rgt", type="integer") + */ + #[ORM\Column(name: 'rgt', type: Types::INTEGER)] + #[Gedmo\TreeRight] + private $rgt; + + /** + * @var int|null + * + * @Gedmo\TreeRoot + * + * @ORM\Column(type="integer") + */ + #[ORM\Column(type: Types::INTEGER)] + #[Gedmo\TreeRoot] + private $root; + + /** + * @var int|null + * + * @Gedmo\TreeLevel(base=1) + * + * @ORM\Column(name="lvl", type="integer") + */ + #[ORM\Column(name: 'lvl', type: Types::INTEGER)] + #[Gedmo\TreeLevel(base: 1)] + private $level; + + private ?Node $sibling = null; + + public function __construct() + { + $this->children = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function setTitle(?string $title): void + { + $this->title = $title; + } + + public function getTitle(): ?string + { + return $this->title; + } + + public function setParent(?self $parent = null): void + { + $this->parent = $parent; + } + + public function getParent(): ?self + { + return $this->parent; + } + + public function getRoot(): ?int + { + return $this->root; + } + + public function getLeft(): ?int + { + return $this->lft; + } + + public function getRight(): ?int + { + return $this->rgt; + } + + public function getLevel(): ?int + { + return $this->level; + } + + /** + * @return Collection + */ + public function getChildren(): Collection + { + return $this->children; + } + + /** + * @param Collection $children + */ + public function setChildren(Collection $children): void + { + $this->children = $children; + } + + public function setSibling(Node $node): void + { + $this->sibling = $node; + } + + public function getSibling(): ?Node + { + return $this->sibling; + } +} diff --git a/tests/Gedmo/Tree/TreeObjectHydratorTest.php b/tests/Gedmo/Tree/TreeObjectHydratorTest.php index a4c06f1a3c..d374255c70 100644 --- a/tests/Gedmo/Tree/TreeObjectHydratorTest.php +++ b/tests/Gedmo/Tree/TreeObjectHydratorTest.php @@ -16,6 +16,7 @@ use Gedmo\Tests\Tool\BaseTestCaseORM; use Gedmo\Tests\Tree\Fixture\Category; use Gedmo\Tests\Tree\Fixture\RootCategory; +use Gedmo\Tests\Tree\Fixture\RootCategoryReversed; use Gedmo\Tree\Entity\Repository\NestedTreeRepository; use Gedmo\Tree\Hydrator\ORM\TreeObjectHydrator; use Gedmo\Tree\TreeListener; @@ -166,11 +167,52 @@ public function testMultipleRootNodesTreeHydration(): void static::assertCount(2, $this->queryLogger->queries); } + public function testFullTreeHydrationWithReversedFieldOrder(): void + { + $this->populateReversed(); + $this->em->clear(); + + $this->queryLogger->reset(); + + $repo = $this->em->getRepository(RootCategoryReversed::class); + + $result = $repo->createQueryBuilder('node') + ->orderBy('node.lft', 'ASC') + ->getQuery() + ->setHint(Query::HINT_INCLUDE_META_COLUMNS, true) + ->getResult('tree'); + + static::assertCount(1, $result); + + $food = $result[0]; + static::assertSame('Food', $food->getTitle()); + static::assertCount(2, $food->getChildren()); + + $fruits = $food->getChildren()->get(0); + static::assertSame('Fruits', $fruits->getTitle()); + static::assertCount(2, $fruits->getChildren()); + + $vegetables = $food->getChildren()->get(1); + static::assertSame('Vegetables', $vegetables->getTitle()); + static::assertCount(0, $vegetables->getChildren()); + + $oranges = $fruits->getChildren()->get(0); + static::assertSame('Oranges', $oranges->getTitle()); + static::assertCount(0, $oranges->getChildren()); + + $citrons = $fruits->getChildren()->get(1); + static::assertSame('Citrons', $citrons->getTitle()); + static::assertCount(0, $citrons->getChildren()); + + static::assertCount(1, $this->queryLogger->queries); + } + protected function getUsedEntityFixtures(): array { return [ Category::class, RootCategory::class, + RootCategoryReversed::class, ]; } @@ -210,4 +252,33 @@ private function populate(): void $this->em->flush(); } + + private function populateReversed(): void + { + $repo = $this->em->getRepository(RootCategoryReversed::class); + + $food = new RootCategoryReversed(); + $food->setTitle('Food'); + + $fruits = new RootCategoryReversed(); + $fruits->setTitle('Fruits'); + + $vegetables = new RootCategoryReversed(); + $vegetables->setTitle('Vegetables'); + + $oranges = new RootCategoryReversed(); + $oranges->setTitle('Oranges'); + + $citrons = new RootCategoryReversed(); + $citrons->setTitle('Citrons'); + + $repo + ->persistAsFirstChild($food) + ->persistAsLastChildOf($fruits, $food) + ->persistAsLastChildOf($vegetables, $food) + ->persistAsLastChildOf($oranges, $fruits) + ->persistAsLastChildOf($citrons, $fruits); + + $this->em->flush(); + } }