Skip to content

Commit 0e61da2

Browse files
committed
[Tree] Fix TreeObjectHydrator compatibility with ORM 3
1 parent eef8a13 commit 0e61da2

4 files changed

Lines changed: 277 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ a release.
2121
### Changed
2222
- 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)
2323

24+
### Fixed
25+
- Tree: Fix TreeObjectHydrator compatibility with ORM 3 when parent property is defined before children property in entity
26+
2427
## [3.22.0] - 2025-12-13
2528
### Added
2629
- Support for Symfony 8

src/Tree/Hydrator/ORM/TreeObjectHydrator.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use Doctrine\Common\Collections\ArrayCollection;
1313
use Doctrine\ORM\EntityManagerInterface;
1414
use Doctrine\ORM\Internal\Hydration\ObjectHydrator;
15+
use Doctrine\ORM\Mapping\OneToManyAssociationMapping;
1516
use Doctrine\ORM\PersistentCollection;
1617
use Gedmo\Exception\InvalidMappingException;
1718
use Gedmo\Tool\ORM\Hydration\EntityManagerRetriever;
@@ -250,6 +251,18 @@ protected function getChildrenField($entityClass)
250251

251252
$associationMapping = $meta->getAssociationMapping($property->getName());
252253

254+
if ($associationMapping instanceof OneToManyAssociationMapping) {
255+
if ($associationMapping->mappedBy !== $this->parentField) {
256+
continue;
257+
}
258+
259+
return $associationMapping->fieldName;
260+
}
261+
262+
if (!isset($associationMapping['mappedBy'])) {
263+
continue;
264+
}
265+
253266
// Make sure the association is mapped by the parent property
254267
if ($associationMapping['mappedBy'] !== $this->parentField) {
255268
continue;
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the Doctrine Behavioral Extensions package.
7+
* (c) Gediminas Morkevicius <gediminas.morkevicius@gmail.com> http://www.gediminasm.org
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Gedmo\Tests\Tree\Fixture;
13+
14+
use Doctrine\Common\Collections\ArrayCollection;
15+
use Doctrine\Common\Collections\Collection;
16+
use Doctrine\DBAL\Types\Types;
17+
use Doctrine\ORM\Mapping as ORM;
18+
use Gedmo\Mapping\Annotation as Gedmo;
19+
use Gedmo\Tree\Entity\Repository\NestedTreeRepository;
20+
use Gedmo\Tree\Node;
21+
22+
/**
23+
* @ORM\Entity(repositoryClass="Gedmo\Tree\Entity\Repository\NestedTreeRepository")
24+
*
25+
* @Gedmo\Tree(type="nested")
26+
*/
27+
#[ORM\Entity(repositoryClass: NestedTreeRepository::class)]
28+
#[Gedmo\Tree(type: 'nested')]
29+
class RootCategoryReversed implements Node
30+
{
31+
/**
32+
* @var Collection<int, self>
33+
*
34+
* @ORM\OneToMany(targetEntity="RootCategoryReversed", mappedBy="parent")
35+
*/
36+
#[ORM\OneToMany(targetEntity: self::class, mappedBy: 'parent')]
37+
protected $children;
38+
/**
39+
* @Gedmo\TreeParent
40+
*
41+
* @ORM\ManyToOne(targetEntity="RootCategoryReversed", inversedBy="children")
42+
* @ORM\JoinColumns({
43+
* @ORM\JoinColumn(name="parent_id", referencedColumnName="id", onDelete="CASCADE")
44+
* })
45+
*/
46+
#[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')]
47+
#[ORM\JoinColumn(name: 'parent_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
48+
#[Gedmo\TreeParent]
49+
private ?RootCategoryReversed $parent = null;
50+
51+
/**
52+
* @var int|null
53+
*
54+
* @ORM\Id
55+
* @ORM\GeneratedValue
56+
* @ORM\Column(type="integer")
57+
*/
58+
#[ORM\Id]
59+
#[ORM\GeneratedValue]
60+
#[ORM\Column(type: Types::INTEGER)]
61+
private $id;
62+
63+
/**
64+
* @ORM\Column(name="title", type="string", length=64)
65+
*/
66+
#[ORM\Column(name: 'title', type: Types::STRING, length: 64)]
67+
private ?string $title = null;
68+
69+
/**
70+
* @var int|null
71+
*
72+
* @Gedmo\TreeLeft
73+
*
74+
* @ORM\Column(name="lft", type="integer")
75+
*/
76+
#[ORM\Column(name: 'lft', type: Types::INTEGER)]
77+
#[Gedmo\TreeLeft]
78+
private $lft;
79+
80+
/**
81+
* @var int|null
82+
*
83+
* @Gedmo\TreeRight
84+
*
85+
* @ORM\Column(name="rgt", type="integer")
86+
*/
87+
#[ORM\Column(name: 'rgt', type: Types::INTEGER)]
88+
#[Gedmo\TreeRight]
89+
private $rgt;
90+
91+
/**
92+
* @var int|null
93+
*
94+
* @Gedmo\TreeRoot
95+
*
96+
* @ORM\Column(type="integer")
97+
*/
98+
#[ORM\Column(type: Types::INTEGER)]
99+
#[Gedmo\TreeRoot]
100+
private $root;
101+
102+
/**
103+
* @var int|null
104+
*
105+
* @Gedmo\TreeLevel(base=1)
106+
*
107+
* @ORM\Column(name="lvl", type="integer")
108+
*/
109+
#[ORM\Column(name: 'lvl', type: Types::INTEGER)]
110+
#[Gedmo\TreeLevel(base: 1)]
111+
private $level;
112+
113+
private ?Node $sibling = null;
114+
115+
public function __construct()
116+
{
117+
$this->children = new ArrayCollection();
118+
}
119+
120+
public function getId(): ?int
121+
{
122+
return $this->id;
123+
}
124+
125+
public function setTitle(?string $title): void
126+
{
127+
$this->title = $title;
128+
}
129+
130+
public function getTitle(): ?string
131+
{
132+
return $this->title;
133+
}
134+
135+
public function setParent(?self $parent = null): void
136+
{
137+
$this->parent = $parent;
138+
}
139+
140+
public function getParent(): ?self
141+
{
142+
return $this->parent;
143+
}
144+
145+
public function getRoot(): ?int
146+
{
147+
return $this->root;
148+
}
149+
150+
public function getLeft(): ?int
151+
{
152+
return $this->lft;
153+
}
154+
155+
public function getRight(): ?int
156+
{
157+
return $this->rgt;
158+
}
159+
160+
public function getLevel(): ?int
161+
{
162+
return $this->level;
163+
}
164+
165+
/**
166+
* @return Collection<int, self>
167+
*/
168+
public function getChildren(): Collection
169+
{
170+
return $this->children;
171+
}
172+
173+
/**
174+
* @param Collection<int, self> $children
175+
*/
176+
public function setChildren(Collection $children): void
177+
{
178+
$this->children = $children;
179+
}
180+
181+
public function setSibling(Node $node): void
182+
{
183+
$this->sibling = $node;
184+
}
185+
186+
public function getSibling(): ?Node
187+
{
188+
return $this->sibling;
189+
}
190+
}

tests/Gedmo/Tree/TreeObjectHydratorTest.php

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use Gedmo\Tests\Tool\BaseTestCaseORM;
1717
use Gedmo\Tests\Tree\Fixture\Category;
1818
use Gedmo\Tests\Tree\Fixture\RootCategory;
19+
use Gedmo\Tests\Tree\Fixture\RootCategoryReversed;
1920
use Gedmo\Tree\Entity\Repository\NestedTreeRepository;
2021
use Gedmo\Tree\Hydrator\ORM\TreeObjectHydrator;
2122
use Gedmo\Tree\TreeListener;
@@ -166,11 +167,52 @@ public function testMultipleRootNodesTreeHydration(): void
166167
static::assertCount(2, $this->queryLogger->queries);
167168
}
168169

170+
public function testFullTreeHydrationWithReversedFieldOrder(): void
171+
{
172+
$this->populateReversed();
173+
$this->em->clear();
174+
175+
$this->queryLogger->reset();
176+
177+
$repo = $this->em->getRepository(RootCategoryReversed::class);
178+
179+
$result = $repo->createQueryBuilder('node')
180+
->orderBy('node.lft', 'ASC')
181+
->getQuery()
182+
->setHint(Query::HINT_INCLUDE_META_COLUMNS, true)
183+
->getResult('tree');
184+
185+
static::assertCount(1, $result);
186+
187+
$food = $result[0];
188+
static::assertSame('Food', $food->getTitle());
189+
static::assertCount(2, $food->getChildren());
190+
191+
$fruits = $food->getChildren()->get(0);
192+
static::assertSame('Fruits', $fruits->getTitle());
193+
static::assertCount(2, $fruits->getChildren());
194+
195+
$vegetables = $food->getChildren()->get(1);
196+
static::assertSame('Vegetables', $vegetables->getTitle());
197+
static::assertCount(0, $vegetables->getChildren());
198+
199+
$oranges = $fruits->getChildren()->get(0);
200+
static::assertSame('Oranges', $oranges->getTitle());
201+
static::assertCount(0, $oranges->getChildren());
202+
203+
$citrons = $fruits->getChildren()->get(1);
204+
static::assertSame('Citrons', $citrons->getTitle());
205+
static::assertCount(0, $citrons->getChildren());
206+
207+
static::assertCount(1, $this->queryLogger->queries);
208+
}
209+
169210
protected function getUsedEntityFixtures(): array
170211
{
171212
return [
172213
Category::class,
173214
RootCategory::class,
215+
RootCategoryReversed::class,
174216
];
175217
}
176218

@@ -210,4 +252,33 @@ private function populate(): void
210252

211253
$this->em->flush();
212254
}
255+
256+
private function populateReversed(): void
257+
{
258+
$repo = $this->em->getRepository(RootCategoryReversed::class);
259+
260+
$food = new RootCategoryReversed();
261+
$food->setTitle('Food');
262+
263+
$fruits = new RootCategoryReversed();
264+
$fruits->setTitle('Fruits');
265+
266+
$vegetables = new RootCategoryReversed();
267+
$vegetables->setTitle('Vegetables');
268+
269+
$oranges = new RootCategoryReversed();
270+
$oranges->setTitle('Oranges');
271+
272+
$citrons = new RootCategoryReversed();
273+
$citrons->setTitle('Citrons');
274+
275+
$repo
276+
->persistAsFirstChild($food)
277+
->persistAsLastChildOf($fruits, $food)
278+
->persistAsLastChildOf($vegetables, $food)
279+
->persistAsLastChildOf($oranges, $fruits)
280+
->persistAsLastChildOf($citrons, $fruits);
281+
282+
$this->em->flush();
283+
}
213284
}

0 commit comments

Comments
 (0)