From 6a02ad2ca9171a7132754e954e5e05de2b8ddc0f Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Sat, 29 Mar 2025 02:59:57 +0000 Subject: [PATCH] feat: add support for `xmlagg()` --- docs/AVAILABLE-FUNCTIONS-AND-OPERATORS.md | 1 + docs/INTEGRATING-WITH-DOCTRINE.md | 10 +++- docs/INTEGRATING-WITH-LARAVEL.md | 15 +++-- docs/INTEGRATING-WITH-SYMFONY.md | 15 +++-- .../ORM/Query/AST/Functions/XmlAgg.php | 56 +++++++++++++++++++ .../ORM/Query/AST/Functions/XmlAggTest.php | 38 +++++++++++++ 6 files changed, 121 insertions(+), 14 deletions(-) create mode 100644 src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/XmlAgg.php create mode 100644 tests/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/XmlAggTest.php diff --git a/docs/AVAILABLE-FUNCTIONS-AND-OPERATORS.md b/docs/AVAILABLE-FUNCTIONS-AND-OPERATORS.md index 946d25ac..02b37214 100644 --- a/docs/AVAILABLE-FUNCTIONS-AND-OPERATORS.md +++ b/docs/AVAILABLE-FUNCTIONS-AND-OPERATORS.md @@ -100,6 +100,7 @@ | tstzrange | TSTZRANGE | `MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Tstzrange` | | unaccent | UNACCENT | `MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Unaccent` | | unnest | UNNEST | `MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Unnest` | +| xmlagg | XML_AGG | `MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\XmlAgg` | # Bonus helpers diff --git a/docs/INTEGRATING-WITH-DOCTRINE.md b/docs/INTEGRATING-WITH-DOCTRINE.md index d9a6611a..12121c1b 100644 --- a/docs/INTEGRATING-WITH-DOCTRINE.md +++ b/docs/INTEGRATING-WITH-DOCTRINE.md @@ -32,8 +32,15 @@ use Doctrine\ORM\Configuration; $configuration = new Configuration(); -# Register json functions +# Register aggregation functions +$configuration->addCustomStringFunction('ARRAY_AGG', MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ArrayAgg::class); $configuration->addCustomStringFunction('JSON_AGG', MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonAgg::class); +$configuration->addCustomStringFunction('JSON_OBJECT_AGG', MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonObjectAgg::class); +$configuration->addCustomStringFunction('JSONB_AGG', MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbAgg::class); +$configuration->addCustomStringFunction('JSONB_OBJECT_AGG', MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbObjectAgg::class); +$configuration->addCustomStringFunction('STRING_AGG', MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\StringAgg::class); +$configuration->addCustomStringFunction('XML_AGG', MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\XmlAgg::class); +# Register json functions $configuration->addCustomStringFunction('JSON_ARRAY_LENGTH', MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonArrayLength::class); $configuration->addCustomStringFunction('JSON_BUILD_OBJECT', MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonBuildObject::class); $configuration->addCustomStringFunction('JSON_EACH', MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonEach::class); @@ -44,7 +51,6 @@ $configuration->addCustomStringFunction('JSON_GET_FIELD_AS_TEXT', MartinGeorgiev $configuration->addCustomStringFunction('JSON_GET_FIELD_AS_INTEGER', MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonGetFieldAsInteger::class); $configuration->addCustomStringFunction('JSON_GET_OBJECT', MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonGetObject::class); $configuration->addCustomStringFunction('JSON_GET_OBJECT_AS_TEXT', MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonGetObjectAsText::class); -$configuration->addCustomStringFunction('JSON_OBJECT_AGG', MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonObjectAgg::class); $configuration->addCustomStringFunction('JSON_OBJECT_KEYS', MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonObjectKeys::class); $configuration->addCustomStringFunction('JSON_QUERY', MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonQuery::class); $configuration->addCustomStringFunction('JSON_SCALAR', MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonScalar::class); diff --git a/docs/INTEGRATING-WITH-LARAVEL.md b/docs/INTEGRATING-WITH-LARAVEL.md index e8192c78..c561de4b 100644 --- a/docs/INTEGRATING-WITH-LARAVEL.md +++ b/docs/INTEGRATING-WITH-LARAVEL.md @@ -87,7 +87,6 @@ return [ # array and string specific functions 'IN_ARRAY' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\InArray::class, 'ARRAY' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Arr::class, - 'ARRAY_AGG' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ArrayAgg::class, 'ARRAY_APPEND' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ArrayAppend::class, 'ARRAY_CARDINALITY' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ArrayCardinality::class, 'ARRAY_CAT' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ArrayCat::class, @@ -101,12 +100,10 @@ return [ 'ARRAY_TO_STRING' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ArrayToString::class, 'SPLIT_PART' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\SplitPart::class, 'STARTS_WITH' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\StartsWith::class, - 'STRING_AGG' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\StringAgg::class, 'STRING_TO_ARRAY' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\StringToArray::class, 'UNNEST' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Unnest::class, # json specific functions - 'JSON_AGG' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonAgg::class, 'JSON_ARRAY_LENGTH' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonArrayLength::class, 'JSON_BUILD_OBJECT' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonBuildObject::class, 'JSON_EACH' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonEach::class, @@ -117,7 +114,6 @@ return [ 'JSON_GET_FIELD_AS_INTEGER' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonGetFieldAsInteger::class, 'JSON_GET_OBJECT' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonGetObject::class, 'JSON_GET_OBJECT_AS_TEXT' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonGetObjectAsText::class, - 'JSON_OBJECT_AGG' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonObjectAgg::class, 'JSON_OBJECT_KEYS' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonObjectKeys::class, 'JSON_QUERY' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonQuery::class, 'JSON_SCALAR' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonScalar::class, @@ -128,7 +124,6 @@ return [ 'ROW_TO_JSON' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\RowToJson::class, # jsonb specific functions - 'JSONB_AGG' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbAgg::class, 'JSONB_ARRAY_ELEMENTS' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbArrayElements::class, 'JSONB_ARRAY_ELEMENTS_TEXT' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbArrayElementsText::class, 'JSONB_ARRAY_LENGTH' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbArrayLength::class, @@ -137,7 +132,6 @@ return [ 'JSONB_EACH_TEXT' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbEachText::class, 'JSONB_EXISTS' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbExists::class, 'JSONB_INSERT' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbInsert::class, - 'JSONB_OBJECT_AGG' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbObjectAgg::class, 'JSONB_OBJECT_KEYS' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbObjectKeys::class, 'JSONB_PRETTY' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbPretty::class, 'JSONB_SET' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbSet::class, @@ -177,6 +171,15 @@ return [ 'FLAGGED_REGEXP_MATCH' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\FlaggedRegexpMatch::class, 'REGEXP_MATCH' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\RegexpMatch::class, 'STRCONCAT' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\StrConcat::class, // the `||` operator + + # aggregation functions + 'ARRAY_AGG' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ArrayAgg::class, + 'JSON_AGG' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonAgg::class, + 'JSON_OBJECT_AGG' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonObjectAgg::class, + 'JSONB_AGG' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbAgg::class, + 'JSONB_OBJECT_AGG' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbObjectAgg::class, + 'STRING_AGG' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\StringAgg::class, + 'XML_AGG' => MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\XmlAgg::class, ], ... diff --git a/docs/INTEGRATING-WITH-SYMFONY.md b/docs/INTEGRATING-WITH-SYMFONY.md index 20016ce3..fb514a23 100644 --- a/docs/INTEGRATING-WITH-SYMFONY.md +++ b/docs/INTEGRATING-WITH-SYMFONY.md @@ -80,7 +80,6 @@ doctrine: # array and string specific functions IN_ARRAY: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\InArray ARRAY: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Arr - ARRAY_AGG: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ArrayAgg ARRAY_APPEND: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ArrayAppend ARRAY_CARDINALITY: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ArrayCardinality ARRAY_CAT: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ArrayCat @@ -94,12 +93,10 @@ doctrine: ARRAY_TO_STRING: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ArrayToString SPLIT_PART: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\SplitPart STARTS_WITH: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\StartsWith - STRING_AGG: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\StringAgg STRING_TO_ARRAY: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\StringToArray UNNEST: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Unnest # json specific functions - JSON_AGG: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonAgg JSON_ARRAY_LENGTH: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonArrayLength JSON_BUILD_OBJECT: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonBuildObject JSON_EACH: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonEach @@ -110,7 +107,6 @@ doctrine: JSON_GET_FIELD_AS_TEXT: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonGetFieldAsText JSON_GET_OBJECT: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonGetObject JSON_GET_OBJECT_AS_TEXT: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonGetObjectAsText - JSON_OBJECT_AGG: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonObjectAgg JSON_OBJECT_KEYS: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonObjectKeys JSON_QUERY: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonQuery JSON_SCALAR: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonScalar @@ -122,7 +118,6 @@ doctrine: ROW_TO_JSON: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\RowToJson # jsonb specific functions - JSONB_AGG: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbAgg JSONB_ARRAY_ELEMENTS: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbArrayElements JSONB_ARRAY_ELEMENTS_TEXT: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbArrayElementsText JSONB_ARRAY_LENGTH: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbArrayLength @@ -131,7 +126,6 @@ doctrine: JSONB_EACH_TEXT: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbEachText JSONB_EXISTS: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbExists JSONB_INSERT: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbInsert - JSONB_OBJECT_AGG: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbObjectAgg JSONB_OBJECT_KEYS: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbObjectKeys JSONB_PRETTY: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbPretty JSONB_SET: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbSet @@ -171,4 +165,13 @@ doctrine: FLAGGED_REGEXP_MATCH: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\FlaggedRegexpMatch REGEXP_MATCH: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\RegexpMatch STRCONCAT: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\StrConcat + + # aggregation functions + ARRAY_AGG: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\ArrayAgg + JSON_AGG: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonAgg + JSON_OBJECT_AGG: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonObjectAgg + JSONB_AGG: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbAgg + JSONB_OBJECT_AGG: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\JsonbObjectAgg + STRING_AGG: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\StringAgg + XML_AGG: MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\XmlAgg ``` diff --git a/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/XmlAgg.php b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/XmlAgg.php new file mode 100644 index 00000000..3598979f --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/XmlAgg.php @@ -0,0 +1,56 @@ + + */ +class XmlAgg extends BaseFunction +{ + use OrderableTrait; + + protected function customizeFunction(): void + { + $this->setFunctionPrototype('xmlagg(%s%s)'); + $this->addNodeMapping('StringPrimary'); + } + + public function parse(Parser $parser): void + { + $shouldUseLexer = DoctrineOrm::isPre219(); + + $this->customizeFunction(); + + $parser->match($shouldUseLexer ? Lexer::T_IDENTIFIER : TokenType::T_IDENTIFIER); + $parser->match($shouldUseLexer ? Lexer::T_OPEN_PARENTHESIS : TokenType::T_OPEN_PARENTHESIS); + + $this->expression = $parser->StringPrimary(); + $this->parseOrderByClause($parser); + + $parser->match($shouldUseLexer ? Lexer::T_CLOSE_PARENTHESIS : TokenType::T_CLOSE_PARENTHESIS); + } + + public function getSql(SqlWalker $sqlWalker): string + { + $dispatched = [ + $this->expression->dispatch($sqlWalker), + $this->getOptionalOrderByClause($sqlWalker), + ]; + + return \vsprintf($this->functionPrototype, $dispatched); + } +} diff --git a/tests/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/XmlAggTest.php b/tests/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/XmlAggTest.php new file mode 100644 index 00000000..5f7cfbb4 --- /dev/null +++ b/tests/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/XmlAggTest.php @@ -0,0 +1,38 @@ + XmlAgg::class, + ]; + } + + protected function getExpectedSqlStatements(): array + { + return [ + 'basic usage' => 'SELECT xmlagg(c0_.text1) AS sclr_0 FROM ContainsTexts c0_', + 'with concatenation' => 'SELECT xmlagg(c0_.text1 || c0_.text2) AS sclr_0 FROM ContainsTexts c0_', + 'with ORDER BY' => 'SELECT xmlagg(c0_.text1 ORDER BY c0_.text1 ASC) AS sclr_0 FROM ContainsTexts c0_', + 'with ORDER BY DESC' => 'SELECT xmlagg(c0_.text1 ORDER BY c0_.text1 DESC) AS sclr_0 FROM ContainsTexts c0_', + ]; + } + + protected function getDqlStatements(): array + { + return [ + 'basic usage' => \sprintf('SELECT XML_AGG(e.text1) FROM %s e', ContainsTexts::class), + 'with concatenation' => \sprintf('SELECT XML_AGG(CONCAT(e.text1, e.text2)) FROM %s e', ContainsTexts::class), + 'with ORDER BY' => \sprintf('SELECT XML_AGG(e.text1 ORDER BY e.text1) FROM %s e', ContainsTexts::class), + 'with ORDER BY DESC' => \sprintf('SELECT XML_AGG(e.text1 ORDER BY e.text1 DESC) FROM %s e', ContainsTexts::class), + ]; + } +}