diff --git a/README.md b/README.md index d970dab9..b7a20953 100644 --- a/README.md +++ b/README.md @@ -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** @@ -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 diff --git a/docs/AVAILABLE-TYPES.md b/docs/AVAILABLE-TYPES.md index ad58bc7e..730f185f 100644 --- a/docs/AVAILABLE-TYPES.md +++ b/docs/AVAILABLE-TYPES.md @@ -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` | diff --git a/docs/INTEGRATING-WITH-DOCTRINE.md b/docs/INTEGRATING-WITH-DOCTRINE.md index 96768f1a..cdcf3aff 100644 --- a/docs/INTEGRATING-WITH-DOCTRINE.md +++ b/docs/INTEGRATING-WITH-DOCTRINE.md @@ -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"); ``` @@ -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 @@ -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; @@ -319,5 +326,8 @@ class MyEntity #[ORM\Column(type: 'inet')] private string $ipAddress; + + #[ORM\Column(type: 'ltree')] + private Ltree $pathFromRoot; } ``` diff --git a/docs/INTEGRATING-WITH-LARAVEL.md b/docs/INTEGRATING-WITH-LARAVEL.md index 83be55b9..97b2c304 100644 --- a/docs/INTEGRATING-WITH-LARAVEL.md +++ b/docs/INTEGRATING-WITH-LARAVEL.md @@ -82,6 +82,9 @@ return [ 'numrange' => 'numrange', 'tsrange' => 'tsrange', 'tstzrange' => 'tstzrange', + + // Hierarchical type mappings + 'ltree' => 'ltree' ], ], ], @@ -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 @@ -339,6 +345,7 @@ class PostgreSQLTypesSubscriber implements EventSubscriber $this->registerNetworkTypes(); $this->registerSpatialTypes(); $this->registerRangeTypes(); + $this->registerHierarchicalTypes(); } private function registerArrayTypes(): void @@ -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)) { @@ -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; @@ -463,6 +476,9 @@ class Product #[ORM\Column(type: 'inet')] private string $originServerIp; + + #[ORM\Column(type: 'ltree')] + private Ltree $pathFromRoot; } ``` @@ -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... ]; @@ -524,6 +541,7 @@ class PostgreSQLDoctrineServiceProvider extends ServiceProvider 'point' => 'point', '_point' => 'point[]', 'numrange' => 'numrange', + 'ltree' => 'ltree', // Add other mappings as needed... ]; diff --git a/docs/INTEGRATING-WITH-SYMFONY.md b/docs/INTEGRATING-WITH-SYMFONY.md index a5e15794..67fa387f 100644 --- a/docs/INTEGRATING-WITH-SYMFONY.md +++ b/docs/INTEGRATING-WITH-SYMFONY.md @@ -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 ``` @@ -111,6 +114,9 @@ doctrine: numrange: numrange tsrange: tsrange tstzrange: tstzrange + + # Hierarchical type mappings + ltree: ltree ``` @@ -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; @@ -326,6 +333,9 @@ class Product #[ORM\Column(type: 'inet')] private string $originServerIp; + + #[ORM\Column(type: 'ltree')] + private Ltree $pathFromRoot; } ``` diff --git a/docs/LTREE-TYPE.md b/docs/LTREE-TYPE.md new file mode 100644 index 00000000..756ed8aa --- /dev/null +++ b/docs/LTREE-TYPE.md @@ -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 + $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 + */ + 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 +getObjectManager(); + $unitOfWork = $entityManager->getUnitOfWork(); + $entityMetadata = $entityManager->getClassMetadata(MyEntity::class); + + foreach ($unitOfWork->getScheduledEntityUpdates() as $entity) { + $this->processEntity($entity, $entityMetadata, $unitOfWork); + } + } + + /** + * @param ClassMetadata $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 $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); + } + } +} +``` diff --git a/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidLtreeForDatabaseException.php b/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidLtreeForDatabaseException.php new file mode 100644 index 00000000..4bdd36e5 --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/DBAL/Types/Exceptions/InvalidLtreeForDatabaseException.php @@ -0,0 +1,30 @@ + $pathFromRoot A list with one element represents the root. The list may be empty. + * + * @throws InvalidLtreeException if the pathFromRoot is not a valid ltree path + * (contains labels which are empty or contains one or more dots) + */ + public function __construct( + private readonly array $pathFromRoot, + ) { + self::assertListOfValidLtreeNodes($pathFromRoot); + } + + #[\Override] + public function __toString(): string + { + return \implode('.', $this->pathFromRoot); + } + + /** + * @throws InvalidLtreeException if $ltree contains invalid/empty labels (e.g., consecutive dots) + */ + public static function fromString(string $ltree): static + { + if ('' === $ltree) { + return new static([]); + } + + $pathFromRoot = \explode('.', $ltree); + + return new static($pathFromRoot); + } + + /** + * @return list + */ + #[\Override] + public function jsonSerialize(): array + { + return $this->getPathFromRoot(); + } + + /** + * @return list + */ + public function getPathFromRoot(): array + { + return $this->pathFromRoot; + } + + /** + * @throws InvalidLtreeException if the ltree is empty + */ + public function getParent(): static + { + if ($this->isEmpty()) { + throw InvalidLtreeException::forImpossibleLtree('Empty ltree has no parent.'); + } + + $parentPathFromRoot = \array_slice($this->pathFromRoot, 0, -1); + + return new static($parentPathFromRoot); + } + + public function isEmpty(): bool + { + return [] === $this->pathFromRoot; + } + + /** + * Checks if the ltree has only one node. + */ + public function isRoot(): bool + { + return 1 === \count($this->pathFromRoot); + } + + public function isAncestorOf(Ltree $ltree): bool + { + if ($this->equals($ltree) || $ltree->isEmpty()) { + return false; + } + + if ($this->isEmpty()) { + return true; + } + + $prefix = \sprintf('%s.', (string) $this); + + return \str_starts_with((string) $ltree, $prefix); + } + + public function isDescendantOf(Ltree $ltree): bool + { + if ($this->equals($ltree) || $this->isEmpty()) { + return false; + } + + if ($ltree->isEmpty()) { + return true; + } + + $prefix = \sprintf('%s.', (string) $ltree); + + return \str_starts_with((string) $this, $prefix); + } + + public function isParentOf(Ltree $ltree): bool + { + if ($ltree->isEmpty()) { + return false; + } + + return $this->equals($ltree->getParent()); + } + + public function isChildOf(Ltree $ltree): bool + { + if ($this->equals($ltree) || $this->isEmpty()) { + return false; + } + + return $ltree->equals($this->getParent()); + } + + public function isSiblingOf(Ltree $ltree): bool + { + if ($this->isEmpty() || $ltree->isEmpty() || $this->equals($ltree)) { + return false; + } + + return $this->getParent()->equals($ltree->getParent()); + } + + /** + * Creates a new Ltree instance with the given leaf added to the end of the path. + * + * @param non-empty-string $leaf + * + * @throws InvalidLtreeException if the leaf format is invalid (empty string, contains dots, ...) + */ + public function withLeaf(string $leaf): static + { + self::assertValidLtreeNode($leaf); + + $newBranch = [...$this->pathFromRoot, $leaf]; + + return new static($newBranch); + } + + /** + * @param mixed[] $value + * + * @throws InvalidLtreeException if the value is not a list of non-empty strings + * + * @phpstan-assert list $value + */ + protected static function assertListOfValidLtreeNodes(array $value): void + { + if (!\array_is_list($value)) { + throw InvalidLtreeException::forInvalidPathFromRootFormat($value, 'list of non-empty strings'); + } + + foreach ($value as $node) { + self::assertValidLtreeNode($node); + } + } + + /** + * @throws InvalidLtreeException if the value is not a non-empty string + * + * @phpstan-assert non-empty-string $value + */ + protected static function assertValidLtreeNode(mixed $value): void + { + if (!\is_string($value) || '' === $value) { + throw InvalidLtreeException::forInvalidNodeFormat($value, 'non-empty string'); + } + + if (\str_contains($value, '.')) { + throw InvalidLtreeException::forInvalidNodeFormat($value, 'string without dot'); + } + } + + private function equals(Ltree $ltree): bool + { + return $this->pathFromRoot === $ltree->getPathFromRoot(); + } +} diff --git a/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/LtreeTypeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/LtreeTypeTest.php new file mode 100644 index 00000000..01751e85 --- /dev/null +++ b/tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/LtreeTypeTest.php @@ -0,0 +1,85 @@ +getTypeName(); + $columnType = $this->getPostgresTypeName(); + + $this->runDbalBindingRoundTrip($typeName, $columnType, null); + } + + #[Test] + public function can_handle_string_values(): void + { + $typeName = $this->getTypeName(); + $columnType = $this->getPostgresTypeName(); + + $this->runDbalBindingRoundTrip($typeName, $columnType, 'root.child.grand-child'); + } + + /** + * Override to handle Ltree value object comparison. + */ + protected function assertTypeValueEquals(mixed $expected, mixed $actual, string $typeName): void + { + if (!$actual instanceof LtreeValueObject) { + throw new \InvalidArgumentException('LtreeTypeTest expects actual value to be a Ltree object'); + } + + if (!$expected instanceof LtreeValueObject && !\is_string($expected)) { + throw new \InvalidArgumentException('LtreeTypeTest expects expected value to be a Ltree object or string'); + } + + $this->assertLtreeEquals($expected, $actual, $typeName); + } + + #[DataProvider('provideValidTransformations')] + #[Test] + public function can_handle_ltree_values(LtreeValueObject $ltreeValueObject): void + { + $typeName = $this->getTypeName(); + $columnType = $this->getPostgresTypeName(); + + $this->runDbalBindingRoundTrip($typeName, $columnType, $ltreeValueObject); + } + + /** + * @return array + */ + public static function provideValidTransformations(): array + { + return [ + 'ltree simple string' => [new LtreeValueObject(['foo', 'bar', 'baz'])], + 'ltree simple numeric' => [new LtreeValueObject(['1', '2', '3'])], + 'ltree single numeric' => [new LtreeValueObject(['1'])], + 'ltree empty' => [new LtreeValueObject([])], + ]; + } + + private function assertLtreeEquals(LtreeValueObject|string $ltreeValueObject, mixed $actual, string $typeName): void + { + $this->assertInstanceOf(LtreeValueObject::class, $actual, 'Failed asserting that value is a Ltree object for type '.$typeName); + $this->assertSame((string) $ltreeValueObject, (string) $actual, 'Failed asserting that ltree string representations are identical for type '.$typeName); + } +} diff --git a/tests/Integration/MartinGeorgiev/TestCase.php b/tests/Integration/MartinGeorgiev/TestCase.php index b112c5be..b751aead 100644 --- a/tests/Integration/MartinGeorgiev/TestCase.php +++ b/tests/Integration/MartinGeorgiev/TestCase.php @@ -30,6 +30,7 @@ use MartinGeorgiev\Doctrine\DBAL\Types\IntegerArray; use MartinGeorgiev\Doctrine\DBAL\Types\Jsonb; use MartinGeorgiev\Doctrine\DBAL\Types\JsonbArray; +use MartinGeorgiev\Doctrine\DBAL\Types\Ltree; use MartinGeorgiev\Doctrine\DBAL\Types\Macaddr; use MartinGeorgiev\Doctrine\DBAL\Types\MacaddrArray; use MartinGeorgiev\Doctrine\DBAL\Types\NumRange; @@ -161,6 +162,17 @@ protected function createTestSchema(): void $this->connection->executeStatement(\sprintf('DROP SCHEMA IF EXISTS %s CASCADE', self::DATABASE_SCHEMA)); $this->connection->executeStatement(\sprintf('CREATE SCHEMA %s', self::DATABASE_SCHEMA)); + // Ensure Ltree is available for hierarchy tree types + // Ensure Ltree is available in the test schema + try { + // Ensure PostGIS is installed and, if possible, placed in the test schema + $this->connection->executeStatement('CREATE EXTENSION IF NOT EXISTS ltree'); + // Move the extension objects into the test schema to resolve types without relying on public + $this->connection->executeStatement(\sprintf('ALTER EXTENSION ltree SET SCHEMA %s', self::DATABASE_SCHEMA)); + } catch (\Throwable) { + // Fallback: if moving the extension is not possible, keep public in the search_path below + } + // Ensure PostGIS is available for geometry/geography types // Ensure PostGIS is available in the test schema and as default search_path try { @@ -198,6 +210,7 @@ protected function registerCustomTypes(): void 'integer[]' => IntegerArray::class, 'jsonb' => Jsonb::class, 'jsonb[]' => JsonbArray::class, + 'ltree' => Ltree::class, 'macaddr' => Macaddr::class, 'macaddr[]' => MacaddrArray::class, 'numrange' => NumRange::class, diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/LtreeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/LtreeTest.php new file mode 100644 index 00000000..7c0c027b --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/LtreeTest.php @@ -0,0 +1,129 @@ +platform = $this->createMock(PostgreSQLPlatform::class); + + $this->fixture = new Ltree(); + } + + #[Test] + public function has_name(): void + { + $this->assertSame('ltree', $this->fixture->getName()); + } + + #[Test] + public function can_convert_string_to_database_value(): void + { + $value = 'alpha.beta.gamma'; + $databaseValue = $this->fixture->convertToDatabaseValue($value, $this->platform); + + $this->assertSame($value, $databaseValue); + } + + #[DataProvider('provideValidTransformations')] + #[Test] + public function can_transform_from_php_value(?LtreeValueObject $ltreeValueObject, ?string $postgresValue): void + { + $this->assertSame($postgresValue, $this->fixture->convertToDatabaseValue($ltreeValueObject, $this->platform)); + } + + #[DataProvider('provideValidTransformations')] + #[Test] + public function can_transform_to_php_value(?LtreeValueObject $ltreeValueObject, ?string $postgresValue): void + { + $this->assertEquals($ltreeValueObject, $this->fixture->convertToPHPValue($postgresValue, $this->platform)); + } + + /** + * @return array + */ + public static function provideValidTransformations(): array + { + return [ + 'null' => [ + 'ltreeValueObject' => null, + 'postgresValue' => null, + ], + 'valid empty ltree' => [ + 'ltreeValueObject' => new LtreeValueObject([]), + 'postgresValue' => '', + ], + 'valid numeric ltree' => [ + 'ltreeValueObject' => new LtreeValueObject(['1', '2', '3']), + 'postgresValue' => '1.2.3', + ], + 'valid string ltree' => [ + 'ltreeValueObject' => new LtreeValueObject(['alpha', 'beta', 'gamma']), + 'postgresValue' => 'alpha.beta.gamma', + ], + ]; + } + + #[DataProvider('provideInvalidDatabaseValueInputs')] + #[Test] + public function throws_exception_for_invalid_database_value_inputs(mixed $phpValue): void + { + $this->expectException(InvalidLtreeForPHPException::class); + $this->fixture->convertToDatabaseValue($phpValue, $this->platform); + } + + /** + * @return array + */ + public static function provideInvalidDatabaseValueInputs(): array + { + return [ + 'invalid string' => ['invalid..ltree'], + 'integer input' => [123], + 'array input' => [['not', 'ltree']], + 'boolean input' => [true], + 'object input' => [new \stdClass()], + ]; + } + + #[DataProvider('provideInvalidPHPValueInputs')] + #[Test] + public function throws_exception_for_invalid_php_value_inputs(mixed $phpValue): void + { + $this->expectException(InvalidLtreeForDatabaseException::class); + $this->fixture->convertToPHPValue($phpValue, $this->platform); + } + + /** + * @return array + */ + public static function provideInvalidPHPValueInputs(): array + { + return [ + 'starting by dot' => ['.root'], + 'ending by dot' => ['root.'], + 'empty dots' => ['a..b'], + 'not a string' => [123], + 'array input' => [['not', 'ltree']], + 'boolean input' => [false], + 'object input' => [new \stdClass()], + ]; + } +} diff --git a/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/LtreeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/LtreeTest.php new file mode 100644 index 00000000..2931fae4 --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/DBAL/Types/ValueObject/LtreeTest.php @@ -0,0 +1,526 @@ +assertSame('a.b.c', (string) $ltree); + } + + /** + * @param mixed[] $value + */ + #[DataProvider('provideInvalidPathFromRoot')] + #[Test] + public function throws_exception_for_invalid_path_from_root(array $value): void + { + $this->expectException(InvalidLtreeException::class); + new Ltree($value); // @phpstan-ignore argument.type + } + + /** + * @return iterable> + */ + public static function provideInvalidPathFromRoot(): iterable + { + yield 'not a list' => [[0 => 'a', 2 => 'b', 3 => 'c']]; + yield 'empty string in path' => [['a', '', 'c']]; + yield 'list with object' => [['a', new \stdClass(), 'c']]; + yield 'list with null' => [['a', null, 'c']]; + yield 'list with dotted string' => [['a', 'b.c', 'ds']]; + } + + #[DataProvider('provideInvalidStringRepresentation')] + #[Test] + public function throws_exception_for_invalid_string_representation(string $value): void + { + $this->expectException(InvalidLtreeException::class); + Ltree::fromString($value); + } + + /** + * @return iterable + */ + public static function provideInvalidStringRepresentation(): iterable + { + yield 'string starting with dot' => ['.b']; + yield 'string ending with dot' => ['a.']; + } + + /** + * @param list $expected + */ + #[DataProvider('provideValidRepresentation')] + #[Test] + public function can_create_from_string(string $value, array $expected): void + { + $ltree = Ltree::fromString($value); + $this->assertSame($expected, $ltree->getPathFromRoot()); + $this->assertSame($value, (string) $ltree); + } + + /** + * @param list $value + */ + #[DataProvider('provideValidRepresentation')] + #[Test] + public function can_convert_to_string(string $expected, array $value): void + { + $ltree = new Ltree($value); + $this->assertSame($expected, (string) $ltree); + } + + /** + * @param list $expected + */ + #[DataProvider('provideValidRepresentation')] + #[Test] + public function can_serialize_to_json(string $value, array $expected): void + { + $ltreeFromString = Ltree::fromString($value); + $this->assertSame($expected, $ltreeFromString->jsonSerialize()); + + $ltree = new Ltree($expected); + $this->assertSame($expected, $ltree->jsonSerialize()); + } + + /** + * @return iterable}> + */ + public static function provideValidRepresentation(): iterable + { + yield 'empty string' => ['', []]; + yield 'single node' => ['a', ['a']]; + yield 'multiple nodes' => ['a.b.c', ['a', 'b', 'c']]; + yield 'with numbers' => ['1.2.3', ['1', '2', '3']]; + yield 'with special characters' => ['a.b.c-d_e', ['a', 'b', 'c-d_e']]; + } + + #[Test] + public function can_encode_to_json_array(): void + { + $ltree = new Ltree(['a', 'b', 'c']); + $json = \json_encode($ltree, \JSON_THROW_ON_ERROR); + $this->assertSame('["a","b","c"]', $json); + } + + #[DataProvider('provideParentRelationship')] + #[Test] + public function can_get_parent(Ltree $child, Ltree $parent): void + { + $ltree = $child->getParent(); + $this->assertSame((string) $parent, (string) $ltree); + } + + #[DataProvider('provideParentRelationship')] + #[Test] + public function respect_immutability_when_getting_parent(Ltree $child, Ltree $parent): void + { + $childAsString = (string) $child; + $ltree = $child->getParent(); + $this->assertNotSame($child, $ltree, 'getParent() should return a new instance'); + $this->assertSame($childAsString, (string) $child, 'getParent() should not mutate the original instance'); + } + + /** + * @return iterable + */ + public static function provideParentRelationship(): iterable + { + yield 'root' => [ + 'child' => new Ltree(['a']), + 'parent' => new Ltree([]), + ]; + + yield 'child with nodes' => [ + 'child' => new Ltree(['a', 'b', 'c']), + 'parent' => new Ltree(['a', 'b']), + ]; + } + + #[Test] + public function throws_exception_when_ltree_is_empty_and_therefore_has_no_parent(): void + { + $this->expectException(InvalidLtreeException::class); + (new Ltree([]))->getParent(); + } + + #[Test] + public function can_verify_empty_status(): void + { + $ltree = new Ltree([]); + $this->assertTrue($ltree->isEmpty()); + + $ltreeWithNodes = new Ltree(['a', 'b']); + $this->assertFalse($ltreeWithNodes->isEmpty()); + } + + #[Test] + public function can_verify_root_status(): void + { + $emptyRoot = new Ltree([]); + $root = new Ltree(['a']); + $notRoot = new Ltree(['a', 'b']); + $this->assertFalse($emptyRoot->isRoot()); + $this->assertTrue($root->isRoot()); + $this->assertFalse($notRoot->isRoot()); + } + + /** + * @param array{ + * isAncestorOf: bool, + * isDescendantOf: bool, + * isParentOf: bool, + * isChildOf: bool, + * isSiblingOf: bool, + * } $expected + */ + #[DataProvider('provideFamilyRelationshipWithExpectedResults')] + #[Test] + public function can_verify_relationship( + Ltree $left, + Ltree $right, + array $expected, + ): void { + foreach ($expected as $method => $value) { + $this->assertSame( + $value, + $left->{$method}($right), + \sprintf('Failed %s check', $method), + ); + } + } + + /** + * @return iterable + */ + public static function provideFamilyRelationshipWithExpectedResults(): iterable + { + $empty = new Ltree([]); + $root = new Ltree(['a']); + $child = new Ltree(['a', 'b']); + $secondChild = new Ltree(['a', 'e']); + $grandChild = new Ltree(['a', 'b', 'c']); + $secondGrandChild = new Ltree(['a', 'b', 'd']); + $thirdGrandChild = new Ltree(['a', 'e', 'f']); + $unrelated = new Ltree(['x', 'y']); + + yield 'empty is ? of empty' => [ + 'left' => $empty, + 'right' => $empty, + 'expected' => [ + 'isAncestorOf' => false, + 'isDescendantOf' => false, + 'isParentOf' => false, + 'isChildOf' => false, + 'isSiblingOf' => false, + ], + ]; + + yield 'empty is ? of root' => [ + 'left' => $empty, + 'right' => $root, + 'expected' => [ + 'isAncestorOf' => true, + 'isDescendantOf' => false, + 'isParentOf' => true, + 'isChildOf' => false, + 'isSiblingOf' => false, + ], + ]; + + yield 'root is ? of empty' => [ + 'left' => $root, + 'right' => $empty, + 'expected' => [ + 'isAncestorOf' => false, + 'isDescendantOf' => true, + 'isParentOf' => false, + 'isChildOf' => true, + 'isSiblingOf' => false, + ], + ]; + + yield 'root is ? of root' => [ + 'left' => $root, + 'right' => $root, + 'expected' => [ + 'isAncestorOf' => false, + 'isDescendantOf' => false, + 'isParentOf' => false, + 'isChildOf' => false, + 'isSiblingOf' => false, + ], + ]; + + yield 'child is ? of empty' => [ + 'left' => $child, + 'right' => $empty, + 'expected' => [ + 'isAncestorOf' => false, + 'isDescendantOf' => true, + 'isParentOf' => false, + 'isChildOf' => false, + 'isSiblingOf' => false, + ], + ]; + + yield 'root is ? of child' => [ + 'left' => $root, + 'right' => $child, + 'expected' => [ + 'isAncestorOf' => true, + 'isDescendantOf' => false, + 'isParentOf' => true, + 'isChildOf' => false, + 'isSiblingOf' => false, + ], + ]; + + yield 'child is ? of root' => [ + 'left' => $child, + 'right' => $root, + 'expected' => [ + 'isAncestorOf' => false, + 'isDescendantOf' => true, + 'isParentOf' => false, + 'isChildOf' => true, + 'isSiblingOf' => false, + ], + ]; + + yield 'child is ? of child' => [ + 'left' => $child, + 'right' => $child, + 'expected' => [ + 'isAncestorOf' => false, + 'isDescendantOf' => false, + 'isParentOf' => false, + 'isChildOf' => false, + 'isSiblingOf' => false, + ], + ]; + + yield 'child is ? of grandChild' => [ + 'left' => $child, + 'right' => $grandChild, + 'expected' => [ + 'isAncestorOf' => true, + 'isDescendantOf' => false, + 'isParentOf' => true, + 'isChildOf' => false, + 'isSiblingOf' => false, + ], + ]; + + yield 'grandChild is ? of child' => [ + 'left' => $grandChild, + 'right' => $child, + 'expected' => [ + 'isAncestorOf' => false, + 'isDescendantOf' => true, + 'isParentOf' => false, + 'isChildOf' => true, + 'isSiblingOf' => false, + ], + ]; + + yield 'child is ? of unrelated' => [ + 'left' => $child, + 'right' => $unrelated, + 'expected' => [ + 'isAncestorOf' => false, + 'isDescendantOf' => false, + 'isParentOf' => false, + 'isChildOf' => false, + 'isSiblingOf' => false, + ], + ]; + + yield 'unrelated is ? of child' => [ + 'left' => $unrelated, + 'right' => $child, + 'expected' => [ + 'isAncestorOf' => false, + 'isDescendantOf' => false, + 'isParentOf' => false, + 'isChildOf' => false, + 'isSiblingOf' => false, + ], + ]; + + yield 'child is ? of secondChild' => [ + 'left' => $child, + 'right' => $secondChild, + 'expected' => [ + 'isAncestorOf' => false, + 'isDescendantOf' => false, + 'isParentOf' => false, + 'isChildOf' => false, + 'isSiblingOf' => true, + ], + ]; + + yield 'secondChild is ? of child' => [ + 'left' => $secondChild, + 'right' => $child, + 'expected' => [ + 'isAncestorOf' => false, + 'isDescendantOf' => false, + 'isParentOf' => false, + 'isChildOf' => false, + 'isSiblingOf' => true, + ], + ]; + + yield 'grandChild is ? of secondGrandChild' => [ + 'left' => $grandChild, + 'right' => $secondGrandChild, + 'expected' => [ + 'isAncestorOf' => false, + 'isDescendantOf' => false, + 'isParentOf' => false, + 'isChildOf' => false, + 'isSiblingOf' => true, + ], + ]; + + yield 'secondGrandChild is ? of grandChild' => [ + 'left' => $secondGrandChild, + 'right' => $grandChild, + 'expected' => [ + 'isAncestorOf' => false, + 'isDescendantOf' => false, + 'isParentOf' => false, + 'isChildOf' => false, + 'isSiblingOf' => true, + ], + ]; + + yield 'grandChild is ? of thirdGrandChild' => [ + 'left' => $grandChild, + 'right' => $thirdGrandChild, + 'expected' => [ + 'isAncestorOf' => false, + 'isDescendantOf' => false, + 'isParentOf' => false, + 'isChildOf' => false, + 'isSiblingOf' => false, + ], + ]; + + yield 'thirdGrandChild is ? of grandChild' => [ + 'left' => $thirdGrandChild, + 'right' => $grandChild, + 'expected' => [ + 'isAncestorOf' => false, + 'isDescendantOf' => false, + 'isParentOf' => false, + 'isChildOf' => false, + 'isSiblingOf' => false, + ], + ]; + + yield 'secondChild is ? of secondGrandChild' => [ + 'left' => $secondChild, + 'right' => $secondGrandChild, + 'expected' => [ + 'isAncestorOf' => false, + 'isDescendantOf' => false, + 'isParentOf' => false, + 'isChildOf' => false, + 'isSiblingOf' => false, + ], + ]; + + yield 'secondChild is ? of thirdGrandChild' => [ + 'left' => $secondChild, + 'right' => $thirdGrandChild, + 'expected' => [ + 'isAncestorOf' => true, + 'isDescendantOf' => false, + 'isParentOf' => true, + 'isChildOf' => false, + 'isSiblingOf' => false, + ], + ]; + } + + /** + * @param non-empty-string $leaf + */ + #[DataProvider('provideValidLeaf')] + #[Test] + public function can_create_leaf(Ltree $parent, string $leaf, Ltree $expected): void + { + $ltree = $parent->withLeaf($leaf); + $this->assertSame((string) $expected, (string) $ltree); + } + + /** + * @param non-empty-string $leaf + */ + #[DataProvider('provideValidLeaf')] + #[Test] + public function respects_immutability_when_creating_leaf(Ltree $parent, string $leaf, Ltree $expected): void + { + $parentAsString = (string) $parent; + $ltree = $parent->withLeaf($leaf); + $this->assertNotSame($parent, $ltree, 'withLeaf() should return a new instance'); + $this->assertSame($parentAsString, (string) $parent, 'withLeaf() should not mutate the original instance'); + } + + /** + * @return iterable + */ + public static function provideValidLeaf(): iterable + { + yield 'add leaf to empty' => [new Ltree([]), 'a', new Ltree(['a'])]; + + yield 'add leaf to root' => [new Ltree(['a']), 'b', new Ltree(['a', 'b'])]; + + yield 'add leaf to child' => [new Ltree(['a', 'b']), 'c', new Ltree(['a', 'b', 'c'])]; + } + + #[DataProvider('provideInvalidLeaf')] + #[Test] + public function throws_exception_for_invalid_leaf(string $leaf): void + { + $ltree = new Ltree(['a', 'b']); + $this->expectException(InvalidLtreeException::class); + $ltree->withLeaf($leaf); // @phpstan-ignore argument.type + } + + /** + * @return iterable + */ + public static function provideInvalidLeaf(): iterable + { + yield 'with empty leaf' => ['']; + yield 'with leaf with dot' => ['a.b']; + yield 'with leaf starting by dot' => ['.b']; + yield 'with leaf ending by dot' => ['a.']; + } +}