diff --git a/docs/AVAILABLE-FUNCTIONS-AND-OPERATORS.md b/docs/AVAILABLE-FUNCTIONS-AND-OPERATORS.md index f0cdb2f2..e2aa387d 100644 --- a/docs/AVAILABLE-FUNCTIONS-AND-OPERATORS.md +++ b/docs/AVAILABLE-FUNCTIONS-AND-OPERATORS.md @@ -49,6 +49,11 @@ Complete documentation for mathematical operations and utility functions. - **[Mathematical and Utility Functions](MATHEMATICAL-FUNCTIONS.md)** - Includes: Mathematical functions, type conversion functions, formatting functions, utility functions +### **🌳 Ltree Functions** +Complete documentation for PostgreSQL ltree (label tree) operations and hierarchical data processing. +- **[Ltree Functions](LTREE-TYPE.md)** +- Includes: Path manipulation functions, ancestor/descendant operations, type conversion functions + ## 🚀 Quick Reference ### Most Commonly Used Functions @@ -83,6 +88,13 @@ Complete documentation for mathematical operations and utility functions. - `ROUND` - Round numeric values - `RANDOM` - Generate random numbers +**Ltree Operations:** ([Complete documentation](LTREE-TYPE.md)) +- `SUBLTREE` - Extract subpath from ltree +- `SUBPATH` - Extract subpath with offset and length +- `NLEVEL` - Get number of labels in path +- `INDEX` - Find position of ltree in another ltree +- `LCA` - Find longest common ancestor + ## 📋 Summary of Available Function Categories ### **Array & JSON Functions** @@ -111,6 +123,11 @@ Complete documentation for mathematical operations and utility functions. - **Aggregation**: Array and JSON aggregation functions - **Utility Functions**: Random numbers, rounding, type casting +### **Ltree Functions** +- **Path Operations**: Extract subpaths, manipulate hierarchical paths +- **Ancestor Operations**: Find common ancestors, calculate path levels +- **Type Conversion**: Convert between ltree and text types + ### **Operators** - **Array Operators**: Contains, overlaps, element testing - **Spatial Operators**: Bounding box and distance operations @@ -125,6 +142,7 @@ Complete documentation for mathematical operations and utility functions. 4. **JSON functions** support both JSON and JSONB data types → [Array and JSON Functions](ARRAY-AND-JSON-FUNCTIONS.md) 5. **Range functions** provide efficient storage and querying for value ranges → [Range Types](RANGE-TYPES.md) 6. **Mathematical functions** work with numeric types and return appropriate precision → [Mathematical Functions](MATHEMATICAL-FUNCTIONS.md) +7. **Ltree functions** provide efficient hierarchical data operations and path manipulation → [Ltree Functions](LTREE-TYPE.md) --- diff --git a/docs/LTREE-TYPE.md b/docs/LTREE-TYPE.md index 756ed8aa..fb414176 100644 --- a/docs/LTREE-TYPE.md +++ b/docs/LTREE-TYPE.md @@ -219,3 +219,158 @@ final readonly class MyEntityOnFlushListener } } ``` + +## Ltree Functions + +This library provides DQL functions for all PostgreSQL ltree operations. These functions allow you to work with ltree data directly in your Doctrine queries. + +### Path Manipulation Functions + +#### `SUBLTREE(ltree, start, end)` +Extracts a subpath from an ltree from position `start` to position `end-1` (counting from 0). + +```php +// DQL +$dql = "SELECT SUBLTREE(e.path, 1, 2) FROM Entity e"; +// SQL: subltree(e.path, 1, 2) +// Example: subltree('Top.Child1.Child2', 1, 2) → 'Child1' +``` + +#### `SUBPATH(ltree, offset, len)` +Extracts a subpath starting at position `offset` with length `len`. Supports negative values. + +```php +// DQL +$dql = "SELECT SUBPATH(e.path, 0, 2) FROM Entity e"; +// SQL: subpath(e.path, 0, 2) +// Example: subpath('Top.Child1.Child2', 0, 2) → 'Top.Child1' + +// With negative offset +$dql = "SELECT SUBPATH(e.path, -2) FROM Entity e"; +// SQL: subpath(e.path, -2) +// Example: subpath('Top.Child1.Child2', -2) → 'Child1.Child2' +``` + +#### `SUBPATH(ltree, offset)` +Extracts a subpath starting at position `offset` extending to the end of the path. + +```php +// DQL +$dql = "SELECT SUBPATH(e.path, 1) FROM Entity e"; +// SQL: subpath(e.path, 1) +// Example: subpath('Top.Child1.Child2', 1) → 'Child1.Child2' +``` + +### Path Information Functions + +#### `NLEVEL(ltree)` +Returns the number of labels in the path. + +```php +// DQL +$dql = "SELECT NLEVEL(e.path) FROM Entity e"; +// SQL: nlevel(e.path) +// Example: nlevel('Top.Child1.Child2') → 3 +``` + +#### `INDEX(a, b)` +Returns the position of the first occurrence of `b` in `a`, or -1 if not found. + +```php +// DQL +$dql = "SELECT INDEX(e.path, 'Child1') FROM Entity e"; +// SQL: index(e.path, 'Child1') +// Example: index('Top.Child1.Child2', 'Child1') → 1 +``` + +#### `INDEX(a, b, offset)` +Returns the position of the first occurrence of `b` in `a` starting at position `offset`. + +```php +// DQL +$dql = "SELECT INDEX(e.path, 'Child1', 1) FROM Entity e"; +// SQL: index(e.path, 'Child1', 1) +// Example: index('Top.Child1.Child2', 'Child1', 1) → 1 +``` + +### Ancestor Functions + +#### `LCA(ltree1, ltree2, ...)` +Computes the longest common ancestor of multiple paths (up to 8 arguments supported). + +```php +// DQL +$dql = "SELECT LCA(e.path1, e.path2, e.path3) FROM Entity e"; +// SQL: lca(e.path1, e.path2, e.path3) +// Example: lca('Top.Child1.Child2', 'Top.Child1', 'Top.Child2.Child3') → 'Top' +``` + +### Type Conversion Functions + +#### `TEXT2LTREE(text)` +Casts text to ltree. + +```php +// DQL +$dql = "SELECT TEXT2LTREE('Top.Child1.Child2') FROM Entity e"; +// SQL: text2ltree('Top.Child1.Child2') +// Example: text2ltree('Top.Child1.Child2') → 'Top.Child1.Child2'::ltree +``` + +#### `LTREE2TEXT(ltree)` +Casts ltree to text. + +```php +// DQL +$dql = "SELECT LTREE2TEXT(e.path) FROM Entity e"; +// SQL: ltree2text(e.path) +// Example: ltree2text('Top.Child1.Child2'::ltree) → 'Top.Child1.Child2' +``` + +### Usage Examples + +#### Finding Ancestors and Descendants + +```php +// Find all descendants of a specific path +$dql = "SELECT e FROM Entity e WHERE e.path <@ TEXT2LTREE('Top.Child1')"; + +// Find all ancestors of a specific path +$dql = "SELECT e FROM Entity e WHERE TEXT2LTREE('Top.Child1') <@ e.path"; + +// Find the longest common ancestor of multiple entities +$dql = "SELECT LCA(e1.path, e2.path) FROM Entity e1, Entity e2 WHERE e1.id = 1 AND e2.id = 2"; +``` + +#### Path Analysis + +```php +// Get the depth of a path +$dql = "SELECT NLEVEL(e.path) FROM Entity e"; + +// Extract the parent path (everything except the last label) +$dql = "SELECT SUBPATH(e.path, 0, NLEVEL(e.path) - 1) FROM Entity e"; + +// Extract the root label +$dql = "SELECT SUBPATH(e.path, 0, 1) FROM Entity e"; +``` + +#### Path Manipulation + +```php +// Find entities at a specific level +$dql = "SELECT e FROM Entity e WHERE NLEVEL(e.path) = 2"; + +// Find entities with a specific parent +$dql = "SELECT e FROM Entity e WHERE SUBPATH(e.path, 0, NLEVEL(e.path) - 1) = 'Top.Child1'"; + +// Find entities that contain a specific label +$dql = "SELECT e FROM Entity e WHERE INDEX(e.path, 'Child1') >= 0"; +``` + +### Performance Considerations + +- Use GiST or GIN indexes on ltree columns for optimal performance +- The `@>` and `<@` operators work automatically with ltree types +- Consider using `SUBPATH` with negative offsets for efficient parent path extraction +- `LCA` function is efficient for finding common ancestors in hierarchical data diff --git a/fixtures/MartinGeorgiev/Doctrine/Entity/ContainsLtrees.php b/fixtures/MartinGeorgiev/Doctrine/Entity/ContainsLtrees.php new file mode 100644 index 00000000..b6238993 --- /dev/null +++ b/fixtures/MartinGeorgiev/Doctrine/Entity/ContainsLtrees.php @@ -0,0 +1,20 @@ + + * + * @example Using it in DQL: "SELECT INDEX(e.path, 'Child1') FROM Entity e" + * Returns integer, position of first occurrence or -1 if not found. + */ +class Index extends BaseVariadicFunction +{ + protected function getNodeMappingPattern(): array + { + return [ + 'StringPrimary,StringPrimary,SimpleArithmeticExpression', + 'StringPrimary,StringPrimary', + ]; + } + + protected function getFunctionName(): string + { + return 'index'; + } + + protected function getMinArgumentCount(): int + { + return 2; + } + + protected function getMaxArgumentCount(): int + { + return 3; + } +} diff --git a/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/Lca.php b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/Lca.php new file mode 100644 index 00000000..841521da --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/Lca.php @@ -0,0 +1,43 @@ + + * + * @example Using it in DQL: "SELECT LCA(e.path1, e.path2) FROM Entity e" + * Returns ltree, longest common ancestor of paths. + */ +class Lca extends BaseVariadicFunction +{ + protected function getNodeMappingPattern(): array + { + return ['StringPrimary']; + } + + protected function getFunctionName(): string + { + return 'lca'; + } + + protected function getMinArgumentCount(): int + { + return 2; + } + + protected function getMaxArgumentCount(): int + { + return 8; + } +} diff --git a/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/Ltree2text.php b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/Ltree2text.php new file mode 100644 index 00000000..135d8e81 --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/Ltree2text.php @@ -0,0 +1,29 @@ + + * + * @example Using it in DQL: "SELECT LTREE2TEXT(e.path) FROM Entity e" + * Returns text, converted from ltree. + */ +class Ltree2text extends BaseFunction +{ + protected function customizeFunction(): void + { + $this->setFunctionPrototype('ltree2text(%s)'); + $this->addNodeMapping('StringPrimary'); + } +} diff --git a/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/Nlevel.php b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/Nlevel.php new file mode 100644 index 00000000..9a8602fb --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/Nlevel.php @@ -0,0 +1,29 @@ + + * + * @example Using it in DQL: "SELECT NLEVEL(e.path) FROM Entity e" + * Returns integer, number of labels in path. + */ +class Nlevel extends BaseFunction +{ + protected function customizeFunction(): void + { + $this->setFunctionPrototype('nlevel(%s)'); + $this->addNodeMapping('StringPrimary'); + } +} diff --git a/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/Subltree.php b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/Subltree.php new file mode 100644 index 00000000..c576e077 --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/Subltree.php @@ -0,0 +1,31 @@ + + * + * @example Using it in DQL: "SELECT SUBLTREE(e.path, 1, 2) FROM Entity e" + * Returns ltree, subpath from position start to position end-1. + */ +class Subltree extends BaseFunction +{ + protected function customizeFunction(): void + { + $this->setFunctionPrototype('subltree(%s, %s, %s)'); + $this->addNodeMapping('StringPrimary'); + $this->addNodeMapping('SimpleArithmeticExpression'); + $this->addNodeMapping('SimpleArithmeticExpression'); + } +} diff --git a/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/Subpath.php b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/Subpath.php new file mode 100644 index 00000000..5af61e8b --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/Subpath.php @@ -0,0 +1,48 @@ + + * + * @example Using it in DQL: "SELECT SUBPATH(e.path, 0, 2) FROM Entity e" + * Returns ltree, subpath starting at position offset, with length len. + */ +class Subpath extends BaseVariadicFunction +{ + protected function getNodeMappingPattern(): array + { + return [ + 'StringPrimary,SimpleArithmeticExpression,SimpleArithmeticExpression', + 'StringPrimary,SimpleArithmeticExpression', + ]; + } + + protected function getFunctionName(): string + { + return 'subpath'; + } + + protected function getMinArgumentCount(): int + { + return 2; + } + + protected function getMaxArgumentCount(): int + { + return 3; + } +} diff --git a/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/Text2ltree.php b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/Text2ltree.php new file mode 100644 index 00000000..23b2be53 --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/Text2ltree.php @@ -0,0 +1,29 @@ + + * + * @example Using it in DQL: "SELECT TEXT2LTREE('Top.Child1.Child2') FROM Entity e" + * Returns ltree, converted from text. + */ +class Text2ltree extends BaseFunction +{ + protected function customizeFunction(): void + { + $this->setFunctionPrototype('text2ltree(%s)'); + $this->addNodeMapping('StringPrimary'); + } +} diff --git a/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/WebsearchToTsquery.php b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/WebsearchToTsquery.php index df6eecc8..f72d2714 100644 --- a/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/WebsearchToTsquery.php +++ b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/WebsearchToTsquery.php @@ -7,7 +7,7 @@ /** * Implementation of PostgreSQL WEBSEARCH_TO_TSQUERY(). * - * @see https://www.postgresql.org/docs/current/textsearch-controls.html + * @see https://www.postgresql.org/docs/17/textsearch-controls.html * @since 3.5 * * @author Jan Klan diff --git a/tests/Integration/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/IndexTest.php b/tests/Integration/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/IndexTest.php new file mode 100644 index 00000000..c513ceec --- /dev/null +++ b/tests/Integration/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/IndexTest.php @@ -0,0 +1,50 @@ + Index::class, + ]; + } + + #[Test] + public function can_find_position_of_ltree_in_another_ltree(): void + { + $dql = 'SELECT INDEX(l.ltree1, l.ltree2) as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsLtrees l WHERE l.id = 1'; + $result = $this->executeDqlQuery($dql); + $this->assertSame(0, $result[0]['result']); + } + + #[Test] + public function returns_negative_one_when_not_found(): void + { + $dql = 'SELECT INDEX(l.ltree2, l.ltree3) as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsLtrees l WHERE l.id = 1'; + $result = $this->executeDqlQuery($dql); + $this->assertSame(-1, $result[0]['result']); + } + + #[Test] + public function finds_position_with_offset(): void + { + $dql = "SELECT INDEX(l.ltree1, 'Child1', 1) as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsLtrees l WHERE l.id = 1"; + $result = $this->executeDqlQuery($dql); + $this->assertSame(1, $result[0]['result']); + } + + #[Test] + public function finds_position_with_negative_offset(): void + { + $dql = "SELECT INDEX(l.ltree1, 'Child1', -2) as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsLtrees l WHERE l.id = 1"; + $result = $this->executeDqlQuery($dql); + $this->assertSame(1, $result[0]['result']); + } +} diff --git a/tests/Integration/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/LcaTest.php b/tests/Integration/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/LcaTest.php new file mode 100644 index 00000000..b7042c2f --- /dev/null +++ b/tests/Integration/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/LcaTest.php @@ -0,0 +1,50 @@ + Lca::class, + ]; + } + + #[Test] + public function can_compute_longest_common_ancestor_of_two_paths(): void + { + $dql = 'SELECT LCA(l.ltree1, l.ltree2) as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsLtrees l WHERE l.id = 1'; + $result = $this->executeDqlQuery($dql); + $this->assertSame('Top', $result[0]['result']); + } + + #[Test] + public function can_compute_longest_common_ancestor_of_three_paths(): void + { + $dql = 'SELECT LCA(l.ltree1, l.ltree2, l.ltree3, \'1.2.3.456\') as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsLtrees l WHERE l.id = 4'; + $result = $this->executeDqlQuery($dql); + $this->assertSame('1.2.3', $result[0]['result']); + } + + #[Test] + public function can_compute_longest_common_ancestor_to_be_empty_string_when_one_of_the_paths_has_only_a_root_with_no_leafs(): void + { + $dql = 'SELECT LCA(l.ltree1, l.ltree2) as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsLtrees l WHERE l.id = 3'; + $result = $this->executeDqlQuery($dql); + $this->assertSame('', $result[0]['result']); + } + + #[Test] + public function can_compute_longest_common_ancestor_with_string_literals(): void + { + $dql = "SELECT LCA('Top.Child1.Child2', 'Top.Child1', 'Top.Child2.Child3') as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsLtrees l WHERE l.id = 1"; + $result = $this->executeDqlQuery($dql); + $this->assertSame('Top', $result[0]['result']); + } +} diff --git a/tests/Integration/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/Ltree2textTest.php b/tests/Integration/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/Ltree2textTest.php new file mode 100644 index 00000000..2f0c77e7 --- /dev/null +++ b/tests/Integration/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/Ltree2textTest.php @@ -0,0 +1,26 @@ + Ltree2text::class, + ]; + } + + #[Test] + public function can_cast_ltree_to_text(): void + { + $dql = 'SELECT LTREE2TEXT(l.ltree1) as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsLtrees l WHERE l.id = 1'; + $result = $this->executeDqlQuery($dql); + $this->assertSame('Top.Child1.Child2', $result[0]['result']); + } +} diff --git a/tests/Integration/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/LtreeTestCase.php b/tests/Integration/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/LtreeTestCase.php new file mode 100644 index 00000000..956be313 --- /dev/null +++ b/tests/Integration/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/LtreeTestCase.php @@ -0,0 +1,49 @@ +createTestTableForLtreeFixture(); + $this->insertTestDataForLtreeFixture(); + } + + protected function createTestTableForLtreeFixture(): void + { + $tableName = 'containsltrees'; + + $this->createTestSchema(); + $this->dropTestTableIfItExists($tableName); + + $fullTableName = \sprintf('%s.%s', self::DATABASE_SCHEMA, $tableName); + $sql = \sprintf(' + CREATE TABLE %s ( + id SERIAL PRIMARY KEY, + ltree1 LTREE, + ltree2 LTREE, + ltree3 LTREE + ) + ', $fullTableName); + + $this->connection->executeStatement($sql); + } + + protected function insertTestDataForLtreeFixture(): void + { + $sql = \sprintf(' + INSERT INTO %s.containsltrees (ltree1, ltree2, ltree3) VALUES + (\'Top.Child1.Child2\', \'Top.Child1\', \'Top.Child2.Child3\'), + (\'A.B.C.D\', \'A.B\', \'A.B.C\'), + (\'Root\', \'Root.Leaf\', \'Root.Branch\'), + (\'1.2.3.4.5.6\', \'1.2.3.4.5\', \'1.2.3.12.15.71\') + ', self::DATABASE_SCHEMA); + $this->connection->executeStatement($sql); + } +} diff --git a/tests/Integration/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/NlevelTest.php b/tests/Integration/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/NlevelTest.php new file mode 100644 index 00000000..030ed2f0 --- /dev/null +++ b/tests/Integration/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/NlevelTest.php @@ -0,0 +1,34 @@ + Nlevel::class, + ]; + } + + #[Test] + public function returns_number_of_labels_in_path(): void + { + $dql = 'SELECT NLEVEL(l.ltree1) as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsLtrees l WHERE l.id = 1'; + $result = $this->executeDqlQuery($dql); + $this->assertSame(3, $result[0]['result']); + } + + #[Test] + public function returns_number_of_labels_for_single_node(): void + { + $dql = 'SELECT NLEVEL(l.ltree1) as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsLtrees l WHERE l.id = 3'; + $result = $this->executeDqlQuery($dql); + $this->assertSame(1, $result[0]['result']); + } +} diff --git a/tests/Integration/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/SubltreeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/SubltreeTest.php new file mode 100644 index 00000000..19600370 --- /dev/null +++ b/tests/Integration/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/SubltreeTest.php @@ -0,0 +1,34 @@ + Subltree::class, + ]; + } + + #[Test] + public function can_extract_subpath_from_an_arbitrary_position(): void + { + $dql = 'SELECT SUBLTREE(l.ltree1, 1, 2) as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsLtrees l WHERE l.id = 1'; + $result = $this->executeDqlQuery($dql); + $this->assertSame('Child1', $result[0]['result']); + } + + #[Test] + public function can_extract_subpath_from_the_beginning(): void + { + $dql = 'SELECT SUBLTREE(l.ltree1, 0, 2) as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsLtrees l WHERE l.id = 1'; + $result = $this->executeDqlQuery($dql); + $this->assertSame('Top.Child1', $result[0]['result']); + } +} diff --git a/tests/Integration/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/SubpathTest.php b/tests/Integration/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/SubpathTest.php new file mode 100644 index 00000000..d5fcdcd4 --- /dev/null +++ b/tests/Integration/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/SubpathTest.php @@ -0,0 +1,50 @@ + Subpath::class, + ]; + } + + #[Test] + public function can_extract_with_offset_and_length(): void + { + $dql = 'SELECT SUBPATH(l.ltree1, 0, 2) as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsLtrees l WHERE l.id = 1'; + $result = $this->executeDqlQuery($dql); + $this->assertSame('Top.Child1', $result[0]['result']); + } + + #[Test] + public function can_extract_with_offset_only(): void + { + $dql = 'SELECT SUBPATH(l.ltree1, 1) as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsLtrees l WHERE l.id = 1'; + $result = $this->executeDqlQuery($dql); + $this->assertSame('Child1.Child2', $result[0]['result']); + } + + #[Test] + public function can_extract_with_negative_offset(): void + { + $dql = 'SELECT SUBPATH(l.ltree1, -1) as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsLtrees l WHERE l.id = 1'; + $result = $this->executeDqlQuery($dql); + $this->assertSame('Child2', $result[0]['result']); + } + + #[Test] + public function can_extract_with_negative_length(): void + { + $dql = 'SELECT SUBPATH(l.ltree1, 0, -1) as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsLtrees l WHERE l.id = 1'; + $result = $this->executeDqlQuery($dql); + $this->assertSame('Top.Child1', $result[0]['result']); + } +} diff --git a/tests/Integration/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/Text2ltreeTest.php b/tests/Integration/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/Text2ltreeTest.php new file mode 100644 index 00000000..c5783791 --- /dev/null +++ b/tests/Integration/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/Text2ltreeTest.php @@ -0,0 +1,42 @@ + Text2ltree::class, + ]; + } + + #[Test] + public function can_cast_text_to_ltree(): void + { + $dql = "SELECT TEXT2LTREE('Top.Child1.Child2') as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsLtrees l WHERE l.id = 1"; + $result = $this->executeDqlQuery($dql); + $this->assertSame('Top.Child1.Child2', $result[0]['result']); + } + + #[Test] + public function can_cast_single_node_text_to_ltree(): void + { + $dql = "SELECT TEXT2LTREE('Root') as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsLtrees l WHERE l.id = 1"; + $result = $this->executeDqlQuery($dql); + $this->assertSame('Root', $result[0]['result']); + } + + #[Test] + public function can_cast_empty_text_to_ltree(): void + { + $dql = "SELECT TEXT2LTREE('') as result FROM Fixtures\\MartinGeorgiev\\Doctrine\\Entity\\ContainsLtrees l WHERE l.id = 1"; + $result = $this->executeDqlQuery($dql); + $this->assertSame('', $result[0]['result']); + } +} diff --git a/tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/IndexTest.php b/tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/IndexTest.php new file mode 100644 index 00000000..804c8eb8 --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/IndexTest.php @@ -0,0 +1,54 @@ + Index::class, + ]; + } + + protected function getExpectedSqlStatements(): array + { + return [ + 'finds position of ltree in another ltree' => 'SELECT index(c0_.text1, c0_.text2) AS sclr_0 FROM ContainsTexts c0_', + 'finds position with offset' => 'SELECT index(c0_.text1, c0_.text2, -4) AS sclr_0 FROM ContainsTexts c0_', + ]; + } + + protected function getDqlStatements(): array + { + return [ + 'finds position of ltree in another ltree' => \sprintf('SELECT INDEX(e.text1, e.text2) FROM %s e', ContainsTexts::class), + 'finds position with offset' => \sprintf('SELECT INDEX(e.text1, e.text2, -4) FROM %s e', ContainsTexts::class), + ]; + } + + public function test_throws_exception_when_argument_count_is_too_low(): void + { + $this->expectException(InvalidArgumentForVariadicFunctionException::class); + $this->expectExceptionMessage('index() requires at least 2 arguments'); + + $dql = \sprintf('SELECT INDEX(e.text1) FROM %s e', ContainsTexts::class); + $this->buildEntityManager()->createQuery($dql)->getSQL(); + } + + public function test_throws_exception_when_argument_count_is_too_high(): void + { + $this->expectException(InvalidArgumentForVariadicFunctionException::class); + $this->expectExceptionMessage('index() requires between 2 and 3 arguments'); + + $dql = \sprintf('SELECT INDEX(e.text1, e.text2, 0, 1) FROM %s e', ContainsTexts::class); + $this->buildEntityManager()->createQuery($dql)->getSQL(); + } +} diff --git a/tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/LcaTest.php b/tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/LcaTest.php new file mode 100644 index 00000000..112c1385 --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/LcaTest.php @@ -0,0 +1,57 @@ + Lca::class, + ]; + } + + protected function getExpectedSqlStatements(): array + { + return [ + 'computes longest common ancestor of two paths' => 'SELECT lca(c0_.text1, c0_.text2) AS sclr_0 FROM ContainsTexts c0_', + 'computes longest common ancestor of three paths' => "SELECT lca(c0_.text1, c0_.text2, 'Top.Child1') AS sclr_0 FROM ContainsTexts c0_", + ]; + } + + protected function getDqlStatements(): array + { + return [ + 'computes longest common ancestor of two paths' => \sprintf('SELECT LCA(e.text1, e.text2) FROM %s e', ContainsTexts::class), + 'computes longest common ancestor of three paths' => \sprintf("SELECT LCA(e.text1, e.text2, 'Top.Child1') FROM %s e", ContainsTexts::class), + ]; + } + + #[Test] + public function throws_exception_when_argument_count_is_too_low(): void + { + $this->expectException(InvalidArgumentForVariadicFunctionException::class); + $this->expectExceptionMessage('lca() requires at least 2 arguments'); + + $dql = \sprintf('SELECT LCA(e.text1) FROM %s e', ContainsTexts::class); + $this->buildEntityManager()->createQuery($dql)->getSQL(); + } + + #[Test] + public function throws_exception_when_argument_count_is_too_high(): void + { + $this->expectException(InvalidArgumentForVariadicFunctionException::class); + $this->expectExceptionMessage('lca() requires between 2 and 8 arguments'); + + $dql = \sprintf('SELECT LCA(e.text1, e.text2, e.text3, e.text4, e.text5, e.text6, e.text7, e.text8, e.text9) FROM %s e', ContainsTexts::class); + $this->buildEntityManager()->createQuery($dql)->getSQL(); + } +} diff --git a/tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/Ltree2textTest.php b/tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/Ltree2textTest.php new file mode 100644 index 00000000..79b48ca2 --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/Ltree2textTest.php @@ -0,0 +1,33 @@ + Ltree2text::class, + ]; + } + + protected function getExpectedSqlStatements(): array + { + return [ + 'casts ltree to text' => 'SELECT ltree2text(c0_.text1) AS sclr_0 FROM ContainsTexts c0_', + ]; + } + + protected function getDqlStatements(): array + { + return [ + 'casts ltree to text' => \sprintf('SELECT LTREE2TEXT(e.text1) FROM %s e', ContainsTexts::class), + ]; + } +} diff --git a/tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/NlevelTest.php b/tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/NlevelTest.php new file mode 100644 index 00000000..6dd0bcbf --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/NlevelTest.php @@ -0,0 +1,33 @@ + Nlevel::class, + ]; + } + + protected function getExpectedSqlStatements(): array + { + return [ + 'returns number of labels in path' => 'SELECT nlevel(c0_.text1) AS sclr_0 FROM ContainsTexts c0_', + ]; + } + + protected function getDqlStatements(): array + { + return [ + 'returns number of labels in path' => \sprintf('SELECT NLEVEL(e.text1) FROM %s e', ContainsTexts::class), + ]; + } +} diff --git a/tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/SubltreeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/SubltreeTest.php new file mode 100644 index 00000000..dda019e3 --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/SubltreeTest.php @@ -0,0 +1,33 @@ + Subltree::class, + ]; + } + + protected function getExpectedSqlStatements(): array + { + return [ + 'extracts subpath from ltree' => 'SELECT subltree(c0_.text1, 1, 2) AS sclr_0 FROM ContainsTexts c0_', + ]; + } + + protected function getDqlStatements(): array + { + return [ + 'extracts subpath from ltree' => \sprintf('SELECT SUBLTREE(e.text1, 1, 2) FROM %s e', ContainsTexts::class), + ]; + } +} diff --git a/tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/SubpathTest.php b/tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/SubpathTest.php new file mode 100644 index 00000000..62b4816b --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/SubpathTest.php @@ -0,0 +1,57 @@ + Subpath::class, + ]; + } + + protected function getExpectedSqlStatements(): array + { + return [ + 'extracts subpath with offset and length' => 'SELECT subpath(c0_.text1, 0, 2) AS sclr_0 FROM ContainsTexts c0_', + 'extracts subpath with offset only' => 'SELECT subpath(c0_.text1, 1) AS sclr_0 FROM ContainsTexts c0_', + ]; + } + + protected function getDqlStatements(): array + { + return [ + 'extracts subpath with offset and length' => \sprintf('SELECT SUBPATH(e.text1, 0, 2) FROM %s e', ContainsTexts::class), + 'extracts subpath with offset only' => \sprintf('SELECT SUBPATH(e.text1, 1) FROM %s e', ContainsTexts::class), + ]; + } + + #[Test] + public function throws_exception_when_argument_count_is_too_low(): void + { + $this->expectException(InvalidArgumentForVariadicFunctionException::class); + $this->expectExceptionMessage('subpath() requires at least 2 arguments'); + + $dql = \sprintf('SELECT SUBPATH(e.text1) FROM %s e', ContainsTexts::class); + $this->buildEntityManager()->createQuery($dql)->getSQL(); + } + + #[Test] + public function throws_exception_when_argument_count_is_too_high(): void + { + $this->expectException(InvalidArgumentForVariadicFunctionException::class); + $this->expectExceptionMessage('subpath() requires between 2 and 3 arguments'); + + $dql = \sprintf('SELECT SUBPATH(e.text1, 0, 2, 3) FROM %s e', ContainsTexts::class); + $this->buildEntityManager()->createQuery($dql)->getSQL(); + } +} diff --git a/tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/Text2ltreeTest.php b/tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/Text2ltreeTest.php new file mode 100644 index 00000000..04e518ea --- /dev/null +++ b/tests/Unit/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Ltree/Text2ltreeTest.php @@ -0,0 +1,33 @@ + Text2ltree::class, + ]; + } + + protected function getExpectedSqlStatements(): array + { + return [ + 'casts text to ltree' => "SELECT text2ltree('Top.Child1.Child2') AS sclr_0 FROM ContainsTexts c0_", + ]; + } + + protected function getDqlStatements(): array + { + return [ + 'casts text to ltree' => \sprintf("SELECT TEXT2LTREE('Top.Child1.Child2') FROM %s e", ContainsTexts::class), + ]; + } +}