Skip to content

Commit 5a6563d

Browse files
authored
feat(#410): add support for LTREE type (#411)
Signed-off-by: Pierre-Yves Landuré <pierre-yves@landure.fr>
1 parent f011b54 commit 5a6563d

File tree

15 files changed

+1391
-0
lines changed

15 files changed

+1391
-0
lines changed

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ This package provides comprehensive Doctrine support for PostgreSQL features:
6060
- **Range Types**
6161
- Date and time ranges (`daterange`, `tsrange`, `tstzrange`)
6262
- Numeric ranges (`numrange`, `int4range`, `int8range`)
63+
- **Hierarchical Types**
64+
- [ltree](https://www.postgresql.org/docs/16/ltree.html) (`ltree`)
6365

6466
### PostgreSQL Operators
6567
- **Array Operations**
@@ -128,6 +130,8 @@ composer require martin-georgiev/postgresql-for-doctrine
128130
## 💡 Usage Examples
129131
See our [Common Use Cases and Examples](docs/USE-CASES-AND-EXAMPLES.md) for detailed code samples.
130132

133+
See our [ltree type usage guide](docs/LTREE-TYPE.md) for an example of how to use the `ltree` type.
134+
131135
## 🧪 Testing
132136

133137
### Unit Tests

docs/AVAILABLE-TYPES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,5 @@
3333
| geometry[] | _geometry | `MartinGeorgiev\Doctrine\DBAL\Types\GeometryArray` |
3434
| point | point | `MartinGeorgiev\Doctrine\DBAL\Types\Point` |
3535
| point[] | _point | `MartinGeorgiev\Doctrine\DBAL\Types\PointArray` |
36+
|---|---|---|
37+
| ltree | ltree | `MartinGeorgiev\Doctrine\DBAL\Types\Ltree` |

docs/INTEGRATING-WITH-DOCTRINE.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ Type::addType('int8range', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\Int8Range");
4747
Type::addType('numrange', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\NumRange");
4848
Type::addType('tsrange', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\TsRange");
4949
Type::addType('tstzrange', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\TstzRange");
50+
51+
// Hierarchical types
52+
Type::addType('ltree', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\Ltree");
5053
```
5154

5255

@@ -281,6 +284,9 @@ $platform->registerDoctrineTypeMapping('int8range', 'int8range');
281284
$platform->registerDoctrineTypeMapping('numrange', 'numrange');
282285
$platform->registerDoctrineTypeMapping('tsrange', 'tsrange');
283286
$platform->registerDoctrineTypeMapping('tstzrange', 'tstzrange');
287+
288+
// Hierarchical mappings
289+
$platform->registerDoctrineTypeMapping('ltree','ltree');
284290
```
285291

286292
### Usage in Entities
@@ -292,6 +298,7 @@ Once types are registered, you can use them in your Doctrine entities:
292298

293299
use Doctrine\ORM\Mapping as ORM;
294300
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\DateRange;
301+
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Ltree;
295302
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\NumericRange;
296303
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Point;
297304
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\WktSpatialData;
@@ -319,5 +326,8 @@ class MyEntity
319326

320327
#[ORM\Column(type: 'inet')]
321328
private string $ipAddress;
329+
330+
#[ORM\Column(type: 'ltree')]
331+
private Ltree $pathFromRoot;
322332
}
323333
```

docs/INTEGRATING-WITH-LARAVEL.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ return [
8282
'numrange' => 'numrange',
8383
'tsrange' => 'tsrange',
8484
'tstzrange' => 'tstzrange',
85+
86+
// Hierarchical type mappings
87+
'ltree' => 'ltree'
8588
],
8689
],
8790
],
@@ -123,6 +126,9 @@ return [
123126
'numrange' => MartinGeorgiev\Doctrine\DBAL\Types\NumRange::class,
124127
'tsrange' => MartinGeorgiev\Doctrine\DBAL\Types\TsRange::class,
125128
'tstzrange' => MartinGeorgiev\Doctrine\DBAL\Types\TstzRange::class,
129+
130+
// Hierarchical types
131+
'ltree' => MartinGeorgiev\Doctrine\DBAL\Types\Ltree::class,
126132
],
127133

128134
// ... other configuration
@@ -339,6 +345,7 @@ class PostgreSQLTypesSubscriber implements EventSubscriber
339345
$this->registerNetworkTypes();
340346
$this->registerSpatialTypes();
341347
$this->registerRangeTypes();
348+
$this->registerHierarchicalTypes();
342349
}
343350

344351
private function registerArrayTypes(): void
@@ -388,6 +395,11 @@ class PostgreSQLTypesSubscriber implements EventSubscriber
388395
$this->addTypeIfNotExists('tstzrange', \MartinGeorgiev\Doctrine\DBAL\Types\TstzRange::class);
389396
}
390397

398+
private function registerHierarchicalTypes(): void
399+
{
400+
$this->addTypeIfNotExists('ltree', \MartinGeorgiev\Doctrine\DBAL\Types\Ltree::class);
401+
}
402+
391403
private function addTypeIfNotExists(string $name, string $className): void
392404
{
393405
if (!Type::hasType($name)) {
@@ -430,6 +442,7 @@ namespace App\Entities;
430442

431443
use Doctrine\ORM\Mapping as ORM;
432444
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\DateRange;
445+
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Ltree;
433446
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\NumericRange;
434447
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Point;
435448
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\WktSpatialData;
@@ -463,6 +476,9 @@ class Product
463476

464477
#[ORM\Column(type: 'inet')]
465478
private string $originServerIp;
479+
480+
#[ORM\Column(type: 'ltree')]
481+
private Ltree $pathFromRoot;
466482
}
467483
```
468484

@@ -503,6 +519,7 @@ class PostgreSQLDoctrineServiceProvider extends ServiceProvider
503519
'text[]' => \MartinGeorgiev\Doctrine\DBAL\Types\TextArray::class,
504520
'point' => \MartinGeorgiev\Doctrine\DBAL\Types\Point::class,
505521
'numrange' => \MartinGeorgiev\Doctrine\DBAL\Types\NumRange::class,
522+
'ltree' => \MartinGeorgiev\Doctrine\DBAL\Types\Ltree::class,
506523
// Add other types as needed...
507524
];
508525

@@ -524,6 +541,7 @@ class PostgreSQLDoctrineServiceProvider extends ServiceProvider
524541
'point' => 'point',
525542
'_point' => 'point[]',
526543
'numrange' => 'numrange',
544+
'ltree' => 'ltree',
527545
// Add other mappings as needed...
528546
];
529547

docs/INTEGRATING-WITH-SYMFONY.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ doctrine:
4747
numrange: MartinGeorgiev\Doctrine\DBAL\Types\NumRange
4848
tsrange: MartinGeorgiev\Doctrine\DBAL\Types\TsRange
4949
tstzrange: MartinGeorgiev\Doctrine\DBAL\Types\TstzRange
50+
51+
# Hierarchical types
52+
ltree: MartinGeorgiev\Doctrine\DBAL\Types\Ltree
5053
```
5154
5255
@@ -111,6 +114,9 @@ doctrine:
111114
numrange: numrange
112115
tsrange: tsrange
113116
tstzrange: tstzrange
117+
118+
# Hierarchical type mappings
119+
ltree: ltree
114120
```
115121

116122

@@ -296,6 +302,7 @@ namespace App\Entity;
296302
297303
use Doctrine\ORM\Mapping as ORM;
298304
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\DateRange;
305+
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Ltree;
299306
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\NumericRange;
300307
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Point;
301308
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\WktSpatialData;
@@ -326,6 +333,9 @@ class Product
326333
327334
#[ORM\Column(type: 'inet')]
328335
private string $originServerIp;
336+
337+
#[ORM\Column(type: 'ltree')]
338+
private Ltree $pathFromRoot;
329339
}
330340
```
331341

docs/LTREE-TYPE.md

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
# ltree type usage
2+
3+
## Requirements
4+
5+
The `ltree` data type requires enabling the [`ltree` extension](https://www.postgresql.org/docs/16/ltree.html)
6+
in PostgreSQL.
7+
8+
```sql
9+
CREATE EXTENSION IF NOT EXISTS ltree;
10+
```
11+
12+
For [Symfony](https://symfony.com/),
13+
customize the migration that introduces the `ltree` field by adding this line
14+
at the beginning of the `up()` method:
15+
16+
```php
17+
$this->addSql('CREATE EXTENSION IF NOT EXISTS ltree');
18+
```
19+
20+
## Usage
21+
22+
An example implementation (for a Symfony project) is:
23+
24+
```php
25+
<?php
26+
27+
declare(strict_types=1);
28+
29+
namespace App\Entity;
30+
31+
use Doctrine\Common\Collections\ArrayCollection;
32+
use Doctrine\Common\Collections\Collection;
33+
use Doctrine\ORM\Mapping as ORM;
34+
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Ltree;
35+
use Symfony\Bridge\Doctrine\Types\UuidType;
36+
use Symfony\Component\Uid\Uuid;
37+
38+
/**
39+
* Manually edit `my_entity_path_gist_idx` in migration to use GIST.
40+
* Declaring the index using Doctrine attributes prevents its removal during migrations.
41+
*/
42+
#[ORM\Entity()]
43+
#[ORM\Index(columns: ['path'], name: 'my_entity_path_gist_idx')]
44+
class MyEntity implements \Stringable
45+
{
46+
#[ORM\Column(type: UuidType::NAME)]
47+
#[ORM\GeneratedValue(strategy: 'NONE')]
48+
#[ORM\Id()]
49+
private Uuid $id;
50+
51+
#[ORM\Column(type: 'ltree')]
52+
private Ltree $path;
53+
54+
/**
55+
* @var Collection<array-key,MyEntity> $children
56+
*/
57+
#[ORM\OneToMany(targetEntity: MyEntity::class, mappedBy: 'parent')]
58+
private Collection $children;
59+
60+
public function __construct(
61+
#[ORM\Column(unique: true, length: 128)]
62+
private string $name,
63+
64+
#[ORM\ManyToOne(targetEntity: MyEntity::class, inversedBy: 'children')]
65+
private ?MyEntity $parent = null,
66+
) {
67+
$this->id = Uuid::v7();
68+
$this->children = new ArrayCollection();
69+
70+
$this->path = Ltree::fromString($this->id->toBase58());
71+
if ($parent instanceof MyEntity) {
72+
// Initialize the path using the parent.
73+
$this->setParent($parent);
74+
}
75+
}
76+
77+
#[\Override]
78+
public function __toString(): string
79+
{
80+
return $this->name;
81+
}
82+
83+
public function getId(): Uuid
84+
{
85+
return $this->id;
86+
}
87+
88+
public function getParent(): ?MyEntity
89+
{
90+
return $this->parent;
91+
}
92+
93+
public function getName(): string
94+
{
95+
return $this->name;
96+
}
97+
98+
public function getPath(): Ltree
99+
{
100+
return $this->path;
101+
}
102+
103+
/**
104+
* @return Collection<array-key,MyEntity>
105+
*/
106+
public function getChildren(): Collection
107+
{
108+
return $this->children;
109+
}
110+
111+
public function setName(string $name): void
112+
{
113+
$this->name = $name;
114+
}
115+
116+
public function setParent(MyEntity $parent): void
117+
{
118+
if ($parent->getId()->equals($this->id)) {
119+
throw new \InvalidArgumentException("Parent MyEntity can't be self");
120+
}
121+
122+
// Prevent cycles: the parent can't be a descendant of the current node.
123+
if ($parent->getPath()->isDescendantOf($this->getPath())) {
124+
throw new \InvalidArgumentException("Parent MyEntity can't be a descendant of the current MyEntity");
125+
}
126+
127+
$this->parent = $parent;
128+
129+
// Use withLeaf() to create a new Ltree instance
130+
// with the parent's path and the current entity's ID.
131+
$this->path = $parent->getPath()->withLeaf($this->id->toBase58());
132+
}
133+
}
134+
```
135+
136+
🗃️ Doctrine's schema tool can't define PostgreSQL [GiST](https://www.postgresql.org/docs/16/gist.html)
137+
or [GIN](https://www.postgresql.org/docs/16/gin.html) indexes with the required ltree operator classes.
138+
Create the index via a manual `CREATE INDEX` statement in your migration:
139+
140+
```sql
141+
-- Example GiST index for ltree with a custom signature length (must be a multiple of 4)
142+
CREATE INDEX my_entity_path_gist_idx
143+
ON my_entity USING GIST (path gist_ltree_ops(siglen = 100));
144+
-- Alternative: GIN index for ltree
145+
CREATE INDEX my_entity_path_gin_idx
146+
ON my_entity USING GIN (path gin_ltree_ops);
147+
```
148+
149+
⚠️ **Important**: Changing an entity's parent requires cascading the change
150+
to all its children.
151+
This is not handled automatically by Doctrine.
152+
Implement an [onFlush](https://www.doctrine-project.org/projects/doctrine-orm/en/3.3/reference/events.html#reference-events-on-flush)
153+
[Doctrine entity listener](https://symfony.com/doc/7.3/doctrine/events.html#doctrine-lifecycle-listeners)
154+
to handle updating the `path` column of the updated entity's children
155+
when `path` is present in the change set:
156+
157+
```php
158+
<?php
159+
160+
declare(strict_types=1);
161+
162+
namespace App\EventListener;
163+
164+
use App\Entity\MyEntity;
165+
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
166+
use Doctrine\ORM\Event\OnFlushEventArgs;
167+
use Doctrine\ORM\Events;
168+
use Doctrine\ORM\Mapping\ClassMetadata;
169+
use Doctrine\ORM\UnitOfWork;
170+
171+
#[AsDoctrineListener(event: Events::onFlush, priority: 500, connection: 'default')]
172+
final readonly class MyEntityOnFlushListener
173+
{
174+
public function onFlush(OnFlushEventArgs $eventArgs): void
175+
{
176+
$entityManager = $eventArgs->getObjectManager();
177+
$unitOfWork = $entityManager->getUnitOfWork();
178+
$entityMetadata = $entityManager->getClassMetadata(MyEntity::class);
179+
180+
foreach ($unitOfWork->getScheduledEntityUpdates() as $entity) {
181+
$this->processEntity($entity, $entityMetadata, $unitOfWork);
182+
}
183+
}
184+
185+
/**
186+
* @param ClassMetadata<MyEntity> $entityMetadata
187+
*/
188+
private function processEntity(object $entity, ClassMetadata $entityMetadata, UnitOfWork $unitOfWork): void
189+
{
190+
if (!$entity instanceof MyEntity) {
191+
return;
192+
}
193+
194+
$changeset = $unitOfWork->getEntityChangeSet($entity);
195+
196+
// check if $entity->path has changed
197+
// If the path stays the same, no need to update children
198+
if (!isset($changeset['path'])) {
199+
return;
200+
}
201+
202+
$this->updateChildrenPaths($entity, $entityMetadata, $unitOfWork);
203+
}
204+
205+
/**
206+
* @param ClassMetadata<MyEntity> $entityMetadata
207+
*/
208+
private function updateChildrenPaths(MyEntity $entity, ClassMetadata $entityMetadata, UnitOfWork $unitOfWork): void
209+
{
210+
foreach ($entity->getChildren() as $child) {
211+
// call the setParent method on the child, which recomputes its Ltree path.
212+
$child->setParent($entity);
213+
214+
$unitOfWork->recomputeSingleEntityChangeSet($entityMetadata, $child);
215+
216+
// cascade the update to the child's children
217+
$this->updateChildrenPaths($child, $entityMetadata, $unitOfWork);
218+
}
219+
}
220+
}
221+
```

0 commit comments

Comments
 (0)