Skip to content

Commit ec68aa7

Browse files
committed
feat: add support for ltree type.
- add `Ltree` value object implementing `LtreeInterface`. - add `Ltree` doctrine type. - add corresponding unit and integration tests. Signed-off-by: Pierre-Yves Landuré <pierre-yves@landure.fr>
1 parent e01a298 commit ec68aa7

File tree

9 files changed

+584
-0
lines changed

9 files changed

+584
-0
lines changed
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace MartinGeorgiev\Doctrine\DBAL\Types\Exceptions;
6+
7+
use Doctrine\DBAL\Types\ConversionException;
8+
9+
class InvalidLtreeForDatabaseException extends ConversionException
10+
{
11+
private static function create(string $message, mixed $value): self
12+
{
13+
return new self(\sprintf($message, \var_export($value, true)));
14+
}
15+
16+
public static function forInvalidType(mixed $value): self
17+
{
18+
return self::create('Database value must be a Ltree string representation, %s given', $value);
19+
}
20+
21+
public static function forInvalidFormat(mixed $value): self
22+
{
23+
return self::create('Invalid Ltree format in database: %s', $value);
24+
}
25+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace MartinGeorgiev\Doctrine\DBAL\Types\Exceptions;
6+
7+
use Doctrine\DBAL\Types\ConversionException;
8+
9+
class InvalidLtreeForPHPException extends ConversionException
10+
{
11+
private static function create(string $message, mixed $value): self
12+
{
13+
return new self(\sprintf($message, \var_export($value, true)));
14+
}
15+
16+
public static function forInvalidType(mixed $value): self
17+
{
18+
return self::create('Value must be a LtreeInterface, %s given', \gettype($value));
19+
}
20+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace MartinGeorgiev\Doctrine\DBAL\Types;
6+
7+
use Doctrine\DBAL\ParameterType;
8+
use Doctrine\DBAL\Platforms\AbstractPlatform;
9+
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
10+
use MartinGeorgiev\Doctrine\DBAL\Types\Exceptions\InvalidLtreeForDatabaseException;
11+
use MartinGeorgiev\Doctrine\DBAL\Types\Exceptions\InvalidLtreeForPHPException;
12+
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Ltree as LtreeValueObject;
13+
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\LtreeInterface;
14+
15+
final class Ltree extends BaseType
16+
{
17+
protected const TYPE_NAME = 'ltree';
18+
19+
#[\Override]
20+
public function getSQLDeclaration(array $column, AbstractPlatform $platform): string
21+
{
22+
$this->assertPostgreSQLPlatform($platform);
23+
24+
return 'ltree';
25+
}
26+
27+
#[\Override]
28+
public function getBindingType(): ParameterType
29+
{
30+
return ParameterType::STRING;
31+
}
32+
33+
#[\Override]
34+
public function getMappedDatabaseTypes(AbstractPlatform $platform): array
35+
{
36+
return [
37+
'ltree',
38+
];
39+
}
40+
41+
/**
42+
* {@inheritDoc}
43+
*
44+
* @SuppressWarnings("PHPMD.StaticAccess")
45+
*/
46+
#[\Override]
47+
public function convertToPHPValue($value, AbstractPlatform $platform): ?LtreeValueObject
48+
{
49+
$this->assertPostgreSQLPlatform($platform);
50+
51+
if (null === $value) {
52+
return null;
53+
}
54+
55+
if (\is_string($value)) {
56+
try {
57+
return LtreeValueObject::fromString($value);
58+
} catch (\InvalidArgumentException) {
59+
throw InvalidLtreeForDatabaseException::forInvalidFormat($value);
60+
}
61+
}
62+
63+
throw InvalidLtreeForDatabaseException::forInvalidType($value);
64+
}
65+
66+
#[\Override]
67+
public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string
68+
{
69+
$this->assertPostgreSQLPlatform($platform);
70+
71+
if ($value instanceof LtreeInterface) {
72+
return (string) $value;
73+
}
74+
75+
if (null === $value) {
76+
return null;
77+
}
78+
79+
throw InvalidLtreeForPHPException::forInvalidType($value);
80+
}
81+
82+
private function assertPostgreSQLPlatform(AbstractPlatform $platform): void
83+
{
84+
if (!$platform instanceof PostgreSQLPlatform) {
85+
throw new \InvalidArgumentException('LtreeType can only be used with PostgreSQL platform.');
86+
}
87+
}
88+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace MartinGeorgiev\Doctrine\DBAL\Types\ValueObject;
6+
7+
/**
8+
* @phpstan-consistent-constructor
9+
*/
10+
class Ltree implements LtreeInterface
11+
{
12+
/**
13+
* @param list<non-empty-string> $branch
14+
*
15+
* @throws \InvalidArgumentException if the branch contains empty strings or is not a list
16+
*/
17+
public function __construct(
18+
private readonly array $branch,
19+
) {
20+
self::assertListOfNonEmptyStrings($branch);
21+
}
22+
23+
#[\Override]
24+
public function __toString(): string
25+
{
26+
return \implode('.', $this->branch);
27+
}
28+
29+
#[\Override]
30+
public static function fromString(string $ltree): static
31+
{
32+
if ('' === $ltree) {
33+
return new static([]);
34+
}
35+
36+
$branch = \explode('.', $ltree);
37+
38+
return new static($branch); // @phpstan-ignore-line argument.type
39+
}
40+
41+
#[\Override]
42+
public function createLeaf(string $leaf): static
43+
{
44+
if ('' === $leaf) {
45+
throw new \InvalidArgumentException('Leaf cannot be empty.');
46+
}
47+
48+
if (\str_contains($leaf, '.')) {
49+
throw new \InvalidArgumentException('Leaf cannot contain dot.');
50+
}
51+
52+
$newBranch = [...$this->branch, $leaf];
53+
54+
return new static($newBranch);
55+
}
56+
57+
#[\Override]
58+
public function getBranch(): array
59+
{
60+
return $this->branch;
61+
}
62+
63+
#[\Override]
64+
public function equals(LtreeInterface $ltree): bool
65+
{
66+
return $this->branch === $ltree->getBranch();
67+
}
68+
69+
#[\Override]
70+
public function isAncestorOf(LtreeInterface $ltree): bool
71+
{
72+
return [] === $this->branch || \str_starts_with((string) $ltree, \sprintf('%s.', (string) $this));
73+
}
74+
75+
#[\Override]
76+
public function isDescendantOf(LtreeInterface $ltree): bool
77+
{
78+
return \str_starts_with((string) $this, \sprintf('%s.', (string) $ltree));
79+
}
80+
81+
#[\Override]
82+
public function isRoot(): bool
83+
{
84+
return 1 >= \count($this->branch);
85+
}
86+
87+
#[\Override]
88+
public function getParent(): static
89+
{
90+
if ([] === $this->branch) {
91+
throw new \LogicException('Empty ltree has no parent.');
92+
}
93+
94+
$parentBranch = \array_slice($this->branch, 0, -1);
95+
self::assertListOfNonEmptyStrings($parentBranch);
96+
97+
return new static($parentBranch);
98+
}
99+
100+
/**
101+
* @param mixed[] $value
102+
*
103+
* @throws \InvalidArgumentException if the value is not a list of non-empty strings
104+
*
105+
* @phpstan-assert list<non-empty-string> $value
106+
*/
107+
protected static function assertListOfNonEmptyStrings(array $value): void
108+
{
109+
if (!\array_is_list($value)) {
110+
throw new \InvalidArgumentException('Branch must be a list of non-empty strings.');
111+
}
112+
113+
\array_map(
114+
self::assertNonEmptyString(...),
115+
$value,
116+
);
117+
}
118+
119+
/**
120+
* @throws \InvalidArgumentException if the value is not a non-empty string
121+
*
122+
* @phpstan-assert non-empty-string $value
123+
*/
124+
protected static function assertNonEmptyString(mixed $value): void
125+
{
126+
if (!\is_string($value) || '' === $value) {
127+
throw new \InvalidArgumentException('Value must be a non-empty string.');
128+
}
129+
}
130+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace MartinGeorgiev\Doctrine\DBAL\Types\ValueObject;
6+
7+
interface LtreeInterface extends \Stringable
8+
{
9+
/**
10+
* @param list<non-empty-string> $branch
11+
*
12+
* @throws \InvalidArgumentException if the branch is empty
13+
*/
14+
public function __construct(array $branch);
15+
16+
/**
17+
* @throws \InvalidArgumentException if the ltree is empty
18+
*/
19+
public static function fromString(string $ltree): static;
20+
21+
/**
22+
* @param non-empty-string $leaf
23+
*
24+
* @throws \InvalidArgumentException if the leaf is empty or contains dot
25+
*/
26+
public function createLeaf(string $leaf): static;
27+
28+
/**
29+
* @return list<non-empty-string>
30+
*/
31+
public function getBranch(): array;
32+
33+
public function equals(LtreeInterface $ltree): bool;
34+
35+
public function isAncestorOf(LtreeInterface $ltree): bool;
36+
37+
public function isDescendantOf(LtreeInterface $ltree): bool;
38+
39+
public function isRoot(): bool;
40+
41+
/**
42+
* @tthrows \LogicException if the ltree is root
43+
*/
44+
public function getParent(): static;
45+
}

tests/Integration/MartinGeorgiev/Doctrine/DBAL/Types/DBALTypesTest.php

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\DateRange as DateRangeValueObject;
99
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Int4Range as Int4RangeValueObject;
1010
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Int8Range as Int8RangeValueObject;
11+
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Ltree as LtreeValueObject;
1112
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\NumericRange as NumRangeValueObject;
1213
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Point as PointValueObject;
1314
use MartinGeorgiev\Doctrine\DBAL\Types\ValueObject\Range as RangeValueObject;
@@ -17,6 +18,14 @@
1718

1819
class DBALTypesTest extends TestCase
1920
{
21+
#[\Override]
22+
protected function setUp(): void
23+
{
24+
parent::setUp();
25+
26+
$this->createLtreeExtension();
27+
}
28+
2029
#[DataProvider('provideScalarTypeTestCases')]
2130
public function test_scalar_type(string $typeName, string $columnType, mixed $testValue): void
2231
{
@@ -122,6 +131,25 @@ public static function providePointTypeTestCases(): array
122131
];
123132
}
124133

134+
#[DataProvider('provideLtreeTypeTestCases')]
135+
public function test_ltree_type(string $typeName, string $columnType, mixed $testValue): void
136+
{
137+
$this->runTypeTest($typeName, $columnType, $testValue);
138+
}
139+
140+
/**
141+
* @return array<string, array{string, string, ?LtreeValueObject}>
142+
*/
143+
public static function provideLtreeTypeTestCases(): array
144+
{
145+
return [
146+
'ltree simple string' => ['ltree', 'LTREE', new LtreeValueObject(['foo', 'bar', 'baz'])],
147+
'ltree simple numeric' => ['ltree', 'LTREE', new LtreeValueObject(['1', '2', '3'])],
148+
'ltree single numeric' => ['ltree', 'LTREE', new LtreeValueObject(['1'])],
149+
'ltree empty' => ['ltree', 'LTREE', new LtreeValueObject([])],
150+
];
151+
}
152+
125153
/**
126154
* @param DateRangeValueObject|Int4RangeValueObject|Int8RangeValueObject|NumRangeValueObject|TsRangeValueObject|TstzRangeValueObject $rangeValueObject
127155
*/
@@ -195,6 +223,7 @@ private function assertDatabaseRoundtripEquals(mixed $expected, mixed $actual, s
195223
match (true) {
196224
$expected instanceof PointValueObject => $this->assertPointEquals($expected, $actual, $typeName),
197225
$expected instanceof RangeValueObject => $this->assertRangeEquals($expected, $actual, $typeName),
226+
$expected instanceof LtreeValueObject => $this->assertLtreeEquals($expected, $actual, $typeName),
198227
\is_array($expected) => $this->assertEquals($expected, $actual, 'Failed asserting that array values are equal for type '.$typeName),
199228
default => $this->assertSame($expected, $actual, 'Failed asserting that values are identical for type '.$typeName)
200229
};
@@ -216,4 +245,16 @@ private function assertRangeEquals(RangeValueObject $rangeValueObject, mixed $ac
216245
$this->assertEquals($rangeValueObject->__toString(), $actual->__toString(), 'Failed asserting that range string representations are equal for type '.$typeName);
217246
$this->assertEquals($rangeValueObject->isEmpty(), $actual->isEmpty(), 'Failed asserting that range empty states are equal for type '.$typeName);
218247
}
248+
249+
private function assertLtreeEquals(LtreeValueObject $ltreeValueObject, mixed $actual, string $typeName): void
250+
{
251+
$this->assertInstanceOf(LtreeValueObject::class, $actual, 'Failed asserting that value is a Ltree object for type '.$typeName);
252+
$this->assertTrue($ltreeValueObject->equals($actual), 'Failed asserting that values are equal for type '.$typeName);
253+
}
254+
255+
private function createLtreeExtension(): void
256+
{
257+
$sql = 'CREATE EXTENSION IF NOT EXISTS ltree';
258+
$this->connection->executeStatement($sql);
259+
}
219260
}

0 commit comments

Comments
 (0)