Skip to content

Commit 2bbae68

Browse files
committed
docs: fix Ltree documentation to include Doctrine onFlush lifecycle event listener
1 parent d85ea6e commit 2bbae68

File tree

1 file changed

+71
-28
lines changed

1 file changed

+71
-28
lines changed

docs/LTREE-TYPE.md

Lines changed: 71 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## Requirements
44

5-
The `ltree` data type requires enabling the [`ltree` module](https://www.postgresql.org/docs/current/ltree.html)
5+
The `ltree` data type requires enabling the [`ltree` extension](https://www.postgresql.org/docs/current/ltree.html)
66
in PostgreSQL.
77

88
```sql
@@ -14,7 +14,7 @@ customize the migration that introduces the `ltree` field by adding this line
1414
at the beginning of the `up()` method:
1515

1616
```php
17-
$this->addSql('CREATE EXTENSION IF NOT EXISTS ltree WITH SCHEMA public');
17+
$this->addSql('CREATE EXTENSION IF NOT EXISTS ltree');
1818
```
1919

2020
## Usage
@@ -28,17 +28,20 @@ declare(strict_types=1);
2828

2929
namespace App\Entity;
3030

31-
use App\EventListener\MyEntityListener;
32-
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Ltree;
33-
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\LtreeInterface;
3431
use Doctrine\Common\Collections\ArrayCollection;
3532
use Doctrine\Common\Collections\Collection;
3633
use Doctrine\ORM\Mapping as ORM;
34+
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Ltree;
35+
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\LtreeInterface;
3736
use Symfony\Bridge\Doctrine\Types\UuidType;
3837
use Symfony\Component\Uid\Uuid;
3938

39+
/**
40+
* Manually edit `my_entity_path_gist_idx` in migration to use GIST.
41+
* Declaring the index using Doctrine attributes prevents its removal during migrations.
42+
*/
4043
#[ORM\Entity()]
41-
#[ORM\EntityListeners([MyEntityListener::class])]
44+
#[ORM\Index(columns: ['path'], name: 'my_entity_path_gist_idx')]
4245
class MyEntity implements \Stringable
4346
{
4447
#[ORM\Column(type: UuidType::NAME)]
@@ -63,7 +66,7 @@ class MyEntity implements \Stringable
6366
private string $name,
6467

6568
#[ORM\ManyToOne(targetEntity: MyEntity::class, inversedBy: 'children')]
66-
private ?MyEntity $parent,
69+
private ?MyEntity $parent = null,
6770
) {
6871
$this->id = Uuid::v7();
6972
$this->children = new ArrayCollection();
@@ -120,8 +123,9 @@ class MyEntity implements \Stringable
120123
throw new \InvalidArgumentException("Parent MyEntity can't be self");
121124
}
122125

123-
if (in_array($this->getId()->toBase58(), $parent->getPath()->getPathFromRoot(), true)) {
124-
throw new \InvalidArgumentException("Parent MyEntity can't be a child of the current MyEntity");
126+
// Prevent cycles: the parent can't be a descendant of the current node.
127+
if ($parent->getPath()->isDescendantOf($this->getPath())) {
128+
throw new \InvalidArgumentException("Parent MyEntity can't be a descendant of the current MyEntity");
125129
}
126130

127131
$this->parent = $parent;
@@ -133,50 +137,89 @@ class MyEntity implements \Stringable
133137
}
134138
```
135139

136-
🗃️ Doctrine can't create [PostgreSQL GiST indexes](https://www.postgresql.org/docs/current/gist.html).
140+
🗃️ Doctrine can't create PostgreSQL [GiST indexes](https://www.postgresql.org/docs/current/gist.html)
141+
or [GIN indexes](https://www.postgresql.org/docs/current/gin.html).
137142
Add a GiST index to an `ltree` column by manually adding its `CREATE INDEX`
138143
command to the migration:
139144

140145
```sql
141146
-- Example GiST index for ltree with a custom signature length (must be a multiple of 4)
142147
CREATE INDEX my_entity_path_gist_idx
143148
ON my_entity USING GIST (path gist_ltree_ops(siglen = 100));
149+
-- Alternative: GIN index for ltree
150+
CREATE INDEX my_entity_path_gin_idx
151+
ON my_entity USING GIN (path gin_ltree_ops);
144152
```
145153

146154
⚠️ **Important**: Changing an entity's parent requires cascading the change
147155
to all its children.
148156
This is not handled automatically by Doctrine.
149-
Implement a [preUpdate](https://www.doctrine-project.org/projects/doctrine-orm/en/3.3/reference/events.html#reference-events-pre-update)
150-
[Doctrine Entity Listener](https://www.doctrine-project.org/projects/doctrine-orm/en/3.3/reference/events.html#entity-listeners-class)
151-
([Doctrine Entity Listener @ Symfony](https://symfony.com/doc/current/doctrine/events.html#doctrine-entity-listeners))
157+
Implement a [onFlush](https://www.doctrine-project.org/projects/doctrine-orm/en/3.3/reference/events.html#reference-events-on-flush)
158+
[Doctrine Entity Listener](https://symfony.com/doc/current/doctrine/events.html#doctrine-lifecycle-listeners)
152159
to handle updating the `path` column of the updated entity children
153160
when the `path` is present in the changed fields:
154161

155162
```php
156163
<?php
157164

165+
declare(strict_types=1);
166+
158167
namespace App\EventListener;
159168

160169
use App\Entity\MyEntity;
161-
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
162-
use Doctrine\ORM\Event\PreUpdateEventArgs;
170+
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
171+
use Doctrine\ORM\Event\OnFlushEventArgs;
163172
use Doctrine\ORM\Events;
173+
use Doctrine\ORM\Mapping\ClassMetadata;
174+
use Doctrine\ORM\UnitOfWork;
164175

165-
#[AsEntityListener(event: Events::preUpdate, method: 'preUpdate', entity: MyEntity::class)]
166-
final readonly class MyEntityListener
176+
#[AsDoctrineListener(event: Events::onFlush, priority: 500, connection: 'default')]
177+
final readonly class MyEntityOnFlushListener
167178
{
168-
public function preUpdate(MyEntity $entity, PreUpdateEventArgs $eventArgs): void
179+
public function onFlush(OnFlushEventArgs $eventArgs): void
180+
{
181+
$entityManager = $eventArgs->getObjectManager();
182+
$unitOfWork = $entityManager->getUnitOfWork();
183+
$entityMetadata = $entityManager->getClassMetadata(MyEntity::class);
184+
185+
foreach ($unitOfWork->getScheduledEntityUpdates() as $entity) {
186+
$this->processEntity($entity, $entityMetadata, $unitOfWork);
187+
}
188+
}
189+
190+
/**
191+
* @param ClassMetadata<MyEntity> $entityMetadata
192+
*/
193+
private function processEntity(object $entity, ClassMetadata $entityMetadata, UnitOfWork $unitOfWork): void
169194
{
170-
if ($eventArgs->hasChangedField('path')) {
171-
$em = $eventArgs->getObjectManager();
172-
$uow = $em->getUnitOfWork();
173-
$meta = $em->getClassMetadata(MyEntity::class);
174-
175-
foreach($entity->getChildren() as $child) {
176-
$child->setParent($entity);
177-
// Ensure Doctrine picks up the modification
178-
$uow->recomputeSingleEntityChangeSet($meta, $child);
179-
}
195+
if (!$entity instanceof MyEntity) {
196+
return;
197+
}
198+
199+
$changeset = $unitOfWork->getEntityChangeSet($entity);
200+
201+
// check if $entity->path has changed
202+
// If the path stays the same, no need to update children
203+
if (!isset($changeset['path'])) {
204+
return;
205+
}
206+
207+
$this->updateChildrenPaths($entity, $entityMetadata, $unitOfWork);
208+
}
209+
210+
/**
211+
* @param ClassMetadata<MyEntity> $entityMetadata
212+
*/
213+
private function updateChildrenPaths(MyEntity $entity, ClassMetadata $entityMetadata, UnitOfWork $unitOfWork): void
214+
{
215+
foreach ($entity->getChildren() as $child) {
216+
// call the setParent method on the child, which recomputes its Ltree path.
217+
$child->setParent($entity);
218+
219+
$unitOfWork->recomputeSingleEntityChangeSet($entityMetadata, $child);
220+
221+
// cascade the update to the child's children
222+
$this->updateChildrenPaths($child, $entityMetadata, $unitOfWork);
180223
}
181224
}
182225
}

0 commit comments

Comments
 (0)