Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
5d477bf
feat: add support for `ltree` type.
landure Aug 8, 2025
e87e243
fix: improve type validation in LtreeTypeTest
landure Aug 24, 2025
b9d1c46
fix: remove LtreeInterface interface
landure Aug 25, 2025
2c08702
docs: fix fragile links and remove unique index on path
landure Aug 25, 2025
2aed8a3
fix: rename tests to follow coding style
landure Aug 25, 2025
94ea198
fix: remove equals() method
landure Aug 25, 2025
69cac09
refactor: rename runTypeTest calls to runDbalBindingRoundTrip
landure Aug 25, 2025
0dd55d4
feat: implement InvalidLtreeException for Ltree value object
landure Aug 25, 2025
7d51c35
fix: fix Ltree type getSqlDeclaration() method to its parent in BaseT…
landure Aug 25, 2025
ff0187d
fix: fix Ltree exception text
landure Aug 26, 2025
dd8a0ee
fix: move extension activation logic to TestCase
landure Aug 26, 2025
c872af5
fix: remove checks for PostgreSQL platform
landure Aug 26, 2025
be99580
fix: remove redundant comments
landure Aug 26, 2025
4d924c6
fix: reword gist and git index mention according to coderabbit recomm…
landure Aug 26, 2025
be43569
fix: fix minor copy paste residue
landure Aug 26, 2025
745a434
fix: remove PHPMD annotation, and rephrase onFlush listener presentation
landure Aug 26, 2025
c104ce7
fix: add missing docblock with since version and author annotations
landure Sep 5, 2025
c271601
feat: add forImpossibleLtree() method.
landure Sep 5, 2025
5b77b63
fix: fix getParent() method to throw InvalidLtreeException
landure Sep 5, 2025
37a4a3f
fix: narrow catch clauses in convert methods and add docblock
landure Sep 5, 2025
8f5c647
fix: add missing #[Test] attributes
landure Sep 5, 2025
91e4e75
fix: replace static assertion for coding style respect
landure Sep 5, 2025
8cc4dc7
fix: rename `throws_exception_when_getting_empty_ltree_parent` method
landure Sep 5, 2025
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ This package provides comprehensive Doctrine support for PostgreSQL features:
- **Range Types**
- Date and time ranges (`daterange`, `tsrange`, `tstzrange`)
- Numeric ranges (`numrange`, `int4range`, `int8range`)
- **Hierarchical Types**
- [ltree](https://www.postgresql.org/docs/16/ltree.html) (`ltree`)

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

See our [ltree type usage guide](docs/LTREE-TYPE.md) for an example of how to use the `ltree` type.

## 🧪 Testing

### Unit Tests
Expand Down
2 changes: 2 additions & 0 deletions docs/AVAILABLE-TYPES.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,5 @@
| geometry[] | _geometry | `MartinGeorgiev\Doctrine\DBAL\Types\GeometryArray` |
| point | point | `MartinGeorgiev\Doctrine\DBAL\Types\Point` |
| point[] | _point | `MartinGeorgiev\Doctrine\DBAL\Types\PointArray` |
|---|---|---|
| ltree | ltree | `MartinGeorgiev\Doctrine\DBAL\Types\Ltree` |
10 changes: 10 additions & 0 deletions docs/INTEGRATING-WITH-DOCTRINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ Type::addType('int8range', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\Int8Range");
Type::addType('numrange', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\NumRange");
Type::addType('tsrange', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\TsRange");
Type::addType('tstzrange', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\TstzRange");

// Hierarchical types
Type::addType('ltree', "MartinGeorgiev\\Doctrine\\DBAL\\Types\\Ltree");
```


Expand Down Expand Up @@ -281,6 +284,9 @@ $platform->registerDoctrineTypeMapping('int8range', 'int8range');
$platform->registerDoctrineTypeMapping('numrange', 'numrange');
$platform->registerDoctrineTypeMapping('tsrange', 'tsrange');
$platform->registerDoctrineTypeMapping('tstzrange', 'tstzrange');

// Hierarchical mappings
$platform->registerDoctrineTypeMapping('ltree','ltree');
```

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

use Doctrine\ORM\Mapping as ORM;
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\DateRange;
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Ltree;
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\NumericRange;
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Point;
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\WktSpatialData;
Expand Down Expand Up @@ -319,5 +326,8 @@ class MyEntity

#[ORM\Column(type: 'inet')]
private string $ipAddress;

#[ORM\Column(type: 'ltree')]
private Ltree $pathFromRoot;
}
```
18 changes: 18 additions & 0 deletions docs/INTEGRATING-WITH-LARAVEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ return [
'numrange' => 'numrange',
'tsrange' => 'tsrange',
'tstzrange' => 'tstzrange',

// Hierarchical type mappings
'ltree' => 'ltree'
],
],
],
Expand Down Expand Up @@ -123,6 +126,9 @@ return [
'numrange' => MartinGeorgiev\Doctrine\DBAL\Types\NumRange::class,
'tsrange' => MartinGeorgiev\Doctrine\DBAL\Types\TsRange::class,
'tstzrange' => MartinGeorgiev\Doctrine\DBAL\Types\TstzRange::class,

// Hierarchical types
'ltree' => MartinGeorgiev\Doctrine\DBAL\Types\Ltree::class,
],

// ... other configuration
Expand Down Expand Up @@ -339,6 +345,7 @@ class PostgreSQLTypesSubscriber implements EventSubscriber
$this->registerNetworkTypes();
$this->registerSpatialTypes();
$this->registerRangeTypes();
$this->registerHierarchicalTypes();
}

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

private function registerHierarchicalTypes(): void
{
$this->addTypeIfNotExists('ltree', \MartinGeorgiev\Doctrine\DBAL\Types\Ltree::class);
}

private function addTypeIfNotExists(string $name, string $className): void
{
if (!Type::hasType($name)) {
Expand Down Expand Up @@ -430,6 +442,7 @@ namespace App\Entities;

use Doctrine\ORM\Mapping as ORM;
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\DateRange;
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Ltree;
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\NumericRange;
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Point;
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\WktSpatialData;
Expand Down Expand Up @@ -463,6 +476,9 @@ class Product

#[ORM\Column(type: 'inet')]
private string $originServerIp;

#[ORM\Column(type: 'ltree')]
private Ltree $pathFromRoot;
}
```

Expand Down Expand Up @@ -503,6 +519,7 @@ class PostgreSQLDoctrineServiceProvider extends ServiceProvider
'text[]' => \MartinGeorgiev\Doctrine\DBAL\Types\TextArray::class,
'point' => \MartinGeorgiev\Doctrine\DBAL\Types\Point::class,
'numrange' => \MartinGeorgiev\Doctrine\DBAL\Types\NumRange::class,
'ltree' => \MartinGeorgiev\Doctrine\DBAL\Types\Ltree::class,
// Add other types as needed...
];

Expand All @@ -524,6 +541,7 @@ class PostgreSQLDoctrineServiceProvider extends ServiceProvider
'point' => 'point',
'_point' => 'point[]',
'numrange' => 'numrange',
'ltree' => 'ltree',
// Add other mappings as needed...
];

Expand Down
10 changes: 10 additions & 0 deletions docs/INTEGRATING-WITH-SYMFONY.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ doctrine:
numrange: MartinGeorgiev\Doctrine\DBAL\Types\NumRange
tsrange: MartinGeorgiev\Doctrine\DBAL\Types\TsRange
tstzrange: MartinGeorgiev\Doctrine\DBAL\Types\TstzRange

# Hierarchical types
ltree: MartinGeorgiev\Doctrine\DBAL\Types\Ltree
```


Expand Down Expand Up @@ -111,6 +114,9 @@ doctrine:
numrange: numrange
tsrange: tsrange
tstzrange: tstzrange

# Hierarchical type mappings
ltree: ltree
```


Expand Down Expand Up @@ -296,6 +302,7 @@ namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\DateRange;
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Ltree;
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\NumericRange;
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Point;
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\WktSpatialData;
Expand Down Expand Up @@ -326,6 +333,9 @@ class Product

#[ORM\Column(type: 'inet')]
private string $originServerIp;

#[ORM\Column(type: 'ltree')]
private Ltree $pathFromRoot;
}
```

Expand Down
221 changes: 221 additions & 0 deletions docs/LTREE-TYPE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
# ltree type usage

## Requirements

The `ltree` data type requires enabling the [`ltree` extension](https://www.postgresql.org/docs/16/ltree.html)
in PostgreSQL.

```sql
CREATE EXTENSION IF NOT EXISTS ltree;
```

For [Symfony](https://symfony.com/),
customize the migration that introduces the `ltree` field by adding this line
at the beginning of the `up()` method:

```php
$this->addSql('CREATE EXTENSION IF NOT EXISTS ltree');
```

## Usage

An example implementation (for a Symfony project) is:

```php
<?php

declare(strict_types=1);

namespace App\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Ltree;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;

/**
* Manually edit `my_entity_path_gist_idx` in migration to use GIST.
* Declaring the index using Doctrine attributes prevents its removal during migrations.
*/
#[ORM\Entity()]
#[ORM\Index(columns: ['path'], name: 'my_entity_path_gist_idx')]
class MyEntity implements \Stringable
{
#[ORM\Column(type: UuidType::NAME)]
#[ORM\GeneratedValue(strategy: 'NONE')]
#[ORM\Id()]
private Uuid $id;

#[ORM\Column(type: 'ltree')]
private Ltree $path;

/**
* @var Collection<array-key,MyEntity> $children
*/
#[ORM\OneToMany(targetEntity: MyEntity::class, mappedBy: 'parent')]
private Collection $children;

public function __construct(
#[ORM\Column(unique: true, length: 128)]
private string $name,

#[ORM\ManyToOne(targetEntity: MyEntity::class, inversedBy: 'children')]
private ?MyEntity $parent = null,
) {
$this->id = Uuid::v7();
$this->children = new ArrayCollection();

$this->path = Ltree::fromString($this->id->toBase58());
if ($parent instanceof MyEntity) {
// Initialize the path using the parent.
$this->setParent($parent);
}
}

#[\Override]
public function __toString(): string
{
return $this->name;
}

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

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

public function getName(): string
{
return $this->name;
}

public function getPath(): Ltree
{
return $this->path;
}

/**
* @return Collection<array-key,MyEntity>
*/
public function getChildren(): Collection
{
return $this->children;
}

public function setName(string $name): void
{
$this->name = $name;
}

public function setParent(MyEntity $parent): void
{
if ($parent->getId()->equals($this->id)) {
throw new \InvalidArgumentException("Parent MyEntity can't be self");
}

// Prevent cycles: the parent can't be a descendant of the current node.
if ($parent->getPath()->isDescendantOf($this->getPath())) {
throw new \InvalidArgumentException("Parent MyEntity can't be a descendant of the current MyEntity");
}

$this->parent = $parent;

// Use withLeaf() to create a new Ltree instance
// with the parent's path and the current entity's ID.
$this->path = $parent->getPath()->withLeaf($this->id->toBase58());
}
}
```

🗃️ Doctrine's schema tool can't define PostgreSQL [GiST](https://www.postgresql.org/docs/16/gist.html)
or [GIN](https://www.postgresql.org/docs/16/gin.html) indexes with the required ltree operator classes.
Create the index via a manual `CREATE INDEX` statement in your migration:

```sql
-- Example GiST index for ltree with a custom signature length (must be a multiple of 4)
CREATE INDEX my_entity_path_gist_idx
ON my_entity USING GIST (path gist_ltree_ops(siglen = 100));
-- Alternative: GIN index for ltree
CREATE INDEX my_entity_path_gin_idx
ON my_entity USING GIN (path gin_ltree_ops);
```

⚠️ **Important**: Changing an entity's parent requires cascading the change
to all its children.
This is not handled automatically by Doctrine.
Implement an [onFlush](https://www.doctrine-project.org/projects/doctrine-orm/en/3.3/reference/events.html#reference-events-on-flush)
[Doctrine entity listener](https://symfony.com/doc/7.3/doctrine/events.html#doctrine-lifecycle-listeners)
to handle updating the `path` column of the updated entity's children
when `path` is present in the change set:

```php
<?php

declare(strict_types=1);

namespace App\EventListener;

use App\Entity\MyEntity;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\UnitOfWork;

#[AsDoctrineListener(event: Events::onFlush, priority: 500, connection: 'default')]
final readonly class MyEntityOnFlushListener
{
public function onFlush(OnFlushEventArgs $eventArgs): void
{
$entityManager = $eventArgs->getObjectManager();
$unitOfWork = $entityManager->getUnitOfWork();
$entityMetadata = $entityManager->getClassMetadata(MyEntity::class);

foreach ($unitOfWork->getScheduledEntityUpdates() as $entity) {
$this->processEntity($entity, $entityMetadata, $unitOfWork);
}
}

/**
* @param ClassMetadata<MyEntity> $entityMetadata
*/
private function processEntity(object $entity, ClassMetadata $entityMetadata, UnitOfWork $unitOfWork): void
{
if (!$entity instanceof MyEntity) {
return;
}

$changeset = $unitOfWork->getEntityChangeSet($entity);

// check if $entity->path has changed
// If the path stays the same, no need to update children
if (!isset($changeset['path'])) {
return;
}

$this->updateChildrenPaths($entity, $entityMetadata, $unitOfWork);
}

/**
* @param ClassMetadata<MyEntity> $entityMetadata
*/
private function updateChildrenPaths(MyEntity $entity, ClassMetadata $entityMetadata, UnitOfWork $unitOfWork): void
{
foreach ($entity->getChildren() as $child) {
// call the setParent method on the child, which recomputes its Ltree path.
$child->setParent($entity);

$unitOfWork->recomputeSingleEntityChangeSet($entityMetadata, $child);

// cascade the update to the child's children
$this->updateChildrenPaths($child, $entityMetadata, $unitOfWork);
}
}
}
```
Loading
Loading