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 )
66in PostgreSQL.
77
88``` sql
@@ -14,7 +14,7 @@ customize the migration that introduces the `ltree` field by adding this line
1414at 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
2929namespace App\Entity;
3030
31- use App\EventListener\MyEntityListener;
32- use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Ltree;
33- use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\LtreeInterface;
3431use Doctrine\Common\Collections\ArrayCollection;
3532use Doctrine\Common\Collections\Collection;
3633use Doctrine\ORM\Mapping as ORM;
34+ use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Ltree;
35+ use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\LtreeInterface;
3736use Symfony\Bridge\Doctrine\Types\UuidType;
3837use 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' )]
4245class 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 ) .
137142Add a GiST index to an ` ltree ` column by manually adding its ` CREATE INDEX `
138143command to the migration:
139144
140145``` sql
141146-- Example GiST index for ltree with a custom signature length (must be a multiple of 4)
142147CREATE 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
147155to all its children.
148156This 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 )
152159to handle updating the ` path ` column of the updated entity children
153160when the ` path ` is present in the changed fields:
154161
155162``` php
156163<?php
157164
165+ declare(strict_types=1);
166+
158167namespace App\EventListener;
159168
160169use 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 ;
163172use 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