From f15012aab63d4d431f99c4ac72e8f57ce9d57251 Mon Sep 17 00:00:00 2001 From: Martin Georgiev Date: Fri, 28 Mar 2025 19:19:10 +0000 Subject: [PATCH] feat: add support for `DISTINCT` clause to `array_agg()` --- .../ORM/Query/AST/Functions/ArrayAgg.php | 28 +++++++++-- .../ORM/Query/AST/Functions/StringAgg.php | 26 ++++++---- .../Functions/Traits/DistinctableTrait.php | 31 ++++++++++++ .../OrderableTrait.php} | 19 ++----- .../ORM/Query/AST/Functions/ArrayAggTest.php | 37 +++++++------- .../ORM/Query/AST/Functions/StringAggTest.php | 49 ++++++++----------- 6 files changed, 117 insertions(+), 73 deletions(-) create mode 100644 src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Traits/DistinctableTrait.php rename src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/{BaseOrderableFunction.php => Traits/OrderableTrait.php} (57%) diff --git a/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/ArrayAgg.php b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/ArrayAgg.php index e73c773a..34cb73f5 100644 --- a/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/ArrayAgg.php +++ b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/ArrayAgg.php @@ -4,8 +4,13 @@ namespace MartinGeorgiev\Doctrine\ORM\Query\AST\Functions; +use Doctrine\ORM\Query\Lexer; use Doctrine\ORM\Query\Parser; use Doctrine\ORM\Query\SqlWalker; +use Doctrine\ORM\Query\TokenType; +use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Traits\DistinctableTrait; +use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Traits\OrderableTrait; +use MartinGeorgiev\Utils\DoctrineOrm; /** * Implementation of PostgreSQL ARRAY_AGG(). @@ -15,21 +20,38 @@ * * @author Martin Georgiev */ -class ArrayAgg extends BaseOrderableFunction +class ArrayAgg extends BaseFunction { + use OrderableTrait; + use DistinctableTrait; + protected function customizeFunction(): void { - $this->setFunctionPrototype('array_agg(%s%s)'); + $this->setFunctionPrototype('array_agg(%s%s%s)'); + $this->addNodeMapping('StringPrimary'); } - protected function parseFunction(Parser $parser): void + 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->parseDistinctClause($parser); $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->getOptionalDistinctClause(), $this->expression->dispatch($sqlWalker), $this->getOptionalOrderByClause($sqlWalker), ]; diff --git a/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/StringAgg.php b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/StringAgg.php index 40572c7d..22c0530d 100644 --- a/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/StringAgg.php +++ b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/StringAgg.php @@ -9,6 +9,8 @@ use Doctrine\ORM\Query\Parser; use Doctrine\ORM\Query\SqlWalker; use Doctrine\ORM\Query\TokenType; +use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Traits\DistinctableTrait; +use MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Traits\OrderableTrait; use MartinGeorgiev\Utils\DoctrineOrm; /** @@ -19,38 +21,44 @@ * * @author Martin Georgiev */ -class StringAgg extends BaseOrderableFunction +class StringAgg extends BaseFunction { - private bool $isDistinct = false; + use OrderableTrait; + use DistinctableTrait; private Node $delimiter; protected function customizeFunction(): void { $this->setFunctionPrototype('string_agg(%s%s, %s%s)'); + $this->addNodeMapping('StringPrimary'); + $this->addNodeMapping('StringPrimary'); } - protected function parseFunction(Parser $parser): void + public function parse(Parser $parser): void { $shouldUseLexer = DoctrineOrm::isPre219(); - $lexer = $parser->getLexer(); - if ($lexer->isNextToken($shouldUseLexer ? Lexer::T_DISTINCT : TokenType::T_DISTINCT)) { - $parser->match($shouldUseLexer ? Lexer::T_DISTINCT : TokenType::T_DISTINCT); - $this->isDistinct = true; - } + $this->customizeFunction(); + $parser->match($shouldUseLexer ? Lexer::T_IDENTIFIER : TokenType::T_IDENTIFIER); + $parser->match($shouldUseLexer ? Lexer::T_OPEN_PARENTHESIS : TokenType::T_OPEN_PARENTHESIS); + + $this->parseDistinctClause($parser); $this->expression = $parser->StringPrimary(); $parser->match($shouldUseLexer ? Lexer::T_COMMA : TokenType::T_COMMA); $this->delimiter = $parser->StringPrimary(); + $this->parseOrderByClause($parser); + + $parser->match($shouldUseLexer ? Lexer::T_CLOSE_PARENTHESIS : TokenType::T_CLOSE_PARENTHESIS); } public function getSql(SqlWalker $sqlWalker): string { $dispatched = [ - $this->isDistinct ? 'DISTINCT ' : '', + $this->getOptionalDistinctClause(), $this->expression->dispatch($sqlWalker), $this->delimiter->dispatch($sqlWalker), $this->getOptionalOrderByClause($sqlWalker), diff --git a/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Traits/DistinctableTrait.php b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Traits/DistinctableTrait.php new file mode 100644 index 00000000..c8d4176e --- /dev/null +++ b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Traits/DistinctableTrait.php @@ -0,0 +1,31 @@ +getLexer(); + + if ($lexer->isNextToken($shouldUseLexer ? Lexer::T_DISTINCT : TokenType::T_DISTINCT)) { + $parser->match($shouldUseLexer ? Lexer::T_DISTINCT : TokenType::T_DISTINCT); + $this->isDistinct = true; + } + } + + protected function getOptionalDistinctClause(): string + { + return $this->isDistinct ? 'DISTINCT ' : ''; + } +} diff --git a/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/BaseOrderableFunction.php b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Traits/OrderableTrait.php similarity index 57% rename from src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/BaseOrderableFunction.php rename to src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Traits/OrderableTrait.php index 08579c66..796e282a 100644 --- a/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/BaseOrderableFunction.php +++ b/src/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/Traits/OrderableTrait.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace MartinGeorgiev\Doctrine\ORM\Query\AST\Functions; +namespace MartinGeorgiev\Doctrine\ORM\Query\AST\Functions\Traits; use Doctrine\ORM\Query\AST\Node; use Doctrine\ORM\Query\AST\OrderByClause; @@ -12,33 +12,22 @@ use Doctrine\ORM\Query\TokenType; use MartinGeorgiev\Utils\DoctrineOrm; -abstract class BaseOrderableFunction extends BaseFunction +trait OrderableTrait { protected Node $expression; protected ?OrderByClause $orderByClause = null; - public function parse(Parser $parser): void + protected function parseOrderByClause(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->parseFunction($parser); - $lexer = $parser->getLexer(); + if ($lexer->isNextToken($shouldUseLexer ? Lexer::T_ORDER : TokenType::T_ORDER)) { $this->orderByClause = $parser->OrderByClause(); } - - $parser->match($shouldUseLexer ? Lexer::T_CLOSE_PARENTHESIS : TokenType::T_CLOSE_PARENTHESIS); } - abstract protected function parseFunction(Parser $parser): void; - protected function getOptionalOrderByClause(SqlWalker $sqlWalker): string { return $this->orderByClause instanceof OrderByClause ? $this->orderByClause->dispatch($sqlWalker) : ''; diff --git a/tests/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/ArrayAggTest.php b/tests/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/ArrayAggTest.php index 4480ce86..14f4e8dc 100644 --- a/tests/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/ArrayAggTest.php +++ b/tests/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/ArrayAggTest.php @@ -19,29 +19,32 @@ protected function getStringFunctions(): array protected function getExpectedSqlStatements(): array { return [ - // Basic usage - 'SELECT array_agg(c0_.text1) AS sclr_0 FROM ContainsTexts c0_', - // With concatenation - 'SELECT array_agg(c0_.text1 || c0_.text2) AS sclr_0 FROM ContainsTexts c0_', - // With ORDER BY - 'SELECT array_agg(c0_.text1 ORDER BY c0_.text1 ASC) AS sclr_0 FROM ContainsTexts c0_', - 'SELECT array_agg(c0_.text1 ORDER BY c0_.text1 DESC) AS sclr_0 FROM ContainsTexts c0_', - // With concatenation and ORDER BY - 'SELECT array_agg(c0_.text1 || c0_.text2 ORDER BY c0_.text1 ASC) AS sclr_0 FROM ContainsTexts c0_', - // With multiple ORDER BY columns - 'SELECT array_agg(c0_.text1 ORDER BY c0_.text1 ASC, c0_.text2 DESC) AS sclr_0 FROM ContainsTexts c0_', + 'basic usage' => 'SELECT array_agg(c0_.text1) AS sclr_0 FROM ContainsTexts c0_', + 'with concatenation' => 'SELECT array_agg(c0_.text1 || c0_.text2) AS sclr_0 FROM ContainsTexts c0_', + 'with DISTINCT' => 'SELECT array_agg(DISTINCT c0_.text1) AS sclr_0 FROM ContainsTexts c0_', + 'with DISTINCT and concatenation' => 'SELECT array_agg(DISTINCT c0_.text1 || c0_.text2) AS sclr_0 FROM ContainsTexts c0_', + 'with ORDER BY' => 'SELECT array_agg(c0_.text1 ORDER BY c0_.text1 ASC) AS sclr_0 FROM ContainsTexts c0_', + 'with ORDER BY DESC' => 'SELECT array_agg(c0_.text1 ORDER BY c0_.text1 DESC) AS sclr_0 FROM ContainsTexts c0_', + 'with DISTINCT and ORDER BY' => 'SELECT array_agg(DISTINCT c0_.text1 ORDER BY c0_.text1 ASC) AS sclr_0 FROM ContainsTexts c0_', + 'with concatenation and ORDER BY' => 'SELECT array_agg(c0_.text1 || c0_.text2 ORDER BY c0_.text1 ASC) AS sclr_0 FROM ContainsTexts c0_', + 'with DISTINCT, concatenation and ORDER BY' => 'SELECT array_agg(DISTINCT c0_.text1 || c0_.text2 ORDER BY c0_.text1 ASC) AS sclr_0 FROM ContainsTexts c0_', + 'with multiple ORDER BY columns' => 'SELECT array_agg(c0_.text1 ORDER BY c0_.text1 ASC, c0_.text2 DESC) AS sclr_0 FROM ContainsTexts c0_', ]; } protected function getDqlStatements(): array { return [ - \sprintf('SELECT ARRAY_AGG(e.text1) FROM %s e', ContainsTexts::class), - \sprintf('SELECT ARRAY_AGG(CONCAT(e.text1, e.text2)) FROM %s e', ContainsTexts::class), - \sprintf('SELECT ARRAY_AGG(e.text1 ORDER BY e.text1) FROM %s e', ContainsTexts::class), - \sprintf('SELECT ARRAY_AGG(e.text1 ORDER BY e.text1 DESC) FROM %s e', ContainsTexts::class), - \sprintf('SELECT ARRAY_AGG(CONCAT(e.text1, e.text2) ORDER BY e.text1) FROM %s e', ContainsTexts::class), - \sprintf('SELECT ARRAY_AGG(e.text1 ORDER BY e.text1 ASC, e.text2 DESC) FROM %s e', ContainsTexts::class), + 'basic usage' => \sprintf('SELECT ARRAY_AGG(e.text1) FROM %s e', ContainsTexts::class), + 'with concatenation' => \sprintf('SELECT ARRAY_AGG(CONCAT(e.text1, e.text2)) FROM %s e', ContainsTexts::class), + 'with DISTINCT' => \sprintf('SELECT ARRAY_AGG(DISTINCT e.text1) FROM %s e', ContainsTexts::class), + 'with DISTINCT and concatenation' => \sprintf('SELECT ARRAY_AGG(DISTINCT CONCAT(e.text1, e.text2)) FROM %s e', ContainsTexts::class), + 'with ORDER BY' => \sprintf('SELECT ARRAY_AGG(e.text1 ORDER BY e.text1) FROM %s e', ContainsTexts::class), + 'with ORDER BY DESC' => \sprintf('SELECT ARRAY_AGG(e.text1 ORDER BY e.text1 DESC) FROM %s e', ContainsTexts::class), + 'with DISTINCT and ORDER BY' => \sprintf('SELECT ARRAY_AGG(DISTINCT e.text1 ORDER BY e.text1) FROM %s e', ContainsTexts::class), + 'with concatenation and ORDER BY' => \sprintf('SELECT ARRAY_AGG(CONCAT(e.text1, e.text2) ORDER BY e.text1) FROM %s e', ContainsTexts::class), + 'with DISTINCT, concatenation and ORDER BY' => \sprintf('SELECT ARRAY_AGG(DISTINCT CONCAT(e.text1, e.text2) ORDER BY e.text1) FROM %s e', ContainsTexts::class), + 'with multiple ORDER BY columns' => \sprintf('SELECT ARRAY_AGG(e.text1 ORDER BY e.text1 ASC, e.text2 DESC) FROM %s e', ContainsTexts::class), ]; } } diff --git a/tests/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/StringAggTest.php b/tests/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/StringAggTest.php index ee9c9166..746dd3df 100644 --- a/tests/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/StringAggTest.php +++ b/tests/MartinGeorgiev/Doctrine/ORM/Query/AST/Functions/StringAggTest.php @@ -19,41 +19,32 @@ protected function getStringFunctions(): array protected function getExpectedSqlStatements(): array { return [ - // Basic usage - "SELECT string_agg(c0_.text1, ',') AS sclr_0 FROM ContainsTexts c0_", - // With concatenation - "SELECT string_agg(c0_.text1 || c0_.text2, ',') AS sclr_0 FROM ContainsTexts c0_", - // With DISTINCT - "SELECT string_agg(DISTINCT c0_.text1, ',') AS sclr_0 FROM ContainsTexts c0_", - // With DISTINCT and concatenation - "SELECT string_agg(DISTINCT c0_.text1 || c0_.text2, ',') AS sclr_0 FROM ContainsTexts c0_", - // With ORDER BY - "SELECT string_agg(c0_.text1, ',' ORDER BY c0_.text1 ASC) AS sclr_0 FROM ContainsTexts c0_", - "SELECT string_agg(c0_.text1, ',' ORDER BY c0_.text1 DESC) AS sclr_0 FROM ContainsTexts c0_", - // With DISTINCT and ORDER BY - "SELECT string_agg(DISTINCT c0_.text1, ',' ORDER BY c0_.text1 ASC) AS sclr_0 FROM ContainsTexts c0_", - // With concatenation, DISTINCT and ORDER BY - "SELECT string_agg(DISTINCT c0_.text1 || c0_.text2, ',' ORDER BY c0_.text1 ASC) AS sclr_0 FROM ContainsTexts c0_", - // With multiple ORDER BY columns - "SELECT string_agg(c0_.text1, ',' ORDER BY c0_.text1 ASC, c0_.text2 DESC) AS sclr_0 FROM ContainsTexts c0_", - // With different delimiter - "SELECT string_agg(c0_.text1, ' | ') AS sclr_0 FROM ContainsTexts c0_", + 'basic usage' => "SELECT string_agg(c0_.text1, ',') AS sclr_0 FROM ContainsTexts c0_", + 'with concatenation' => "SELECT string_agg(c0_.text1 || c0_.text2, ',') AS sclr_0 FROM ContainsTexts c0_", + 'with DISTINCT' => "SELECT string_agg(DISTINCT c0_.text1, ',') AS sclr_0 FROM ContainsTexts c0_", + 'with DISTINCT and concatenation' => "SELECT string_agg(DISTINCT c0_.text1 || c0_.text2, ',') AS sclr_0 FROM ContainsTexts c0_", + 'with ORDER BY' => "SELECT string_agg(c0_.text1, ',' ORDER BY c0_.text1 ASC) AS sclr_0 FROM ContainsTexts c0_", + 'with ORDER BY DESC' => "SELECT string_agg(c0_.text1, ',' ORDER BY c0_.text1 DESC) AS sclr_0 FROM ContainsTexts c0_", + 'with DISTINCT and ORDER BY' => "SELECT string_agg(DISTINCT c0_.text1, ',' ORDER BY c0_.text1 ASC) AS sclr_0 FROM ContainsTexts c0_", + 'with concatenation, DISTINCT and ORDER BY' => "SELECT string_agg(DISTINCT c0_.text1 || c0_.text2, ',' ORDER BY c0_.text1 ASC) AS sclr_0 FROM ContainsTexts c0_", + 'with multiple ORDER BY columns' => "SELECT string_agg(c0_.text1, ',' ORDER BY c0_.text1 ASC, c0_.text2 DESC) AS sclr_0 FROM ContainsTexts c0_", + 'with different delimiter' => "SELECT string_agg(c0_.text1, ' | ') AS sclr_0 FROM ContainsTexts c0_", ]; } protected function getDqlStatements(): array { return [ - \sprintf("SELECT STRING_AGG(e.text1, ',') FROM %s e", ContainsTexts::class), - \sprintf("SELECT STRING_AGG(CONCAT(e.text1, e.text2), ',') FROM %s e", ContainsTexts::class), - \sprintf("SELECT STRING_AGG(DISTINCT e.text1, ',') FROM %s e", ContainsTexts::class), - \sprintf("SELECT STRING_AGG(DISTINCT CONCAT(e.text1, e.text2), ',') FROM %s e", ContainsTexts::class), - \sprintf("SELECT STRING_AGG(e.text1, ',' ORDER BY e.text1) FROM %s e", ContainsTexts::class), - \sprintf("SELECT STRING_AGG(e.text1, ',' ORDER BY e.text1 DESC) FROM %s e", ContainsTexts::class), - \sprintf("SELECT STRING_AGG(DISTINCT e.text1, ',' ORDER BY e.text1) FROM %s e", ContainsTexts::class), - \sprintf("SELECT STRING_AGG(DISTINCT CONCAT(e.text1, e.text2), ',' ORDER BY e.text1) FROM %s e", ContainsTexts::class), - \sprintf("SELECT STRING_AGG(e.text1, ',' ORDER BY e.text1 ASC, e.text2 DESC) FROM %s e", ContainsTexts::class), - \sprintf("SELECT STRING_AGG(e.text1, ' | ') FROM %s e", ContainsTexts::class), + 'basic usage' => \sprintf("SELECT STRING_AGG(e.text1, ',') FROM %s e", ContainsTexts::class), + 'with concatenation' => \sprintf("SELECT STRING_AGG(CONCAT(e.text1, e.text2), ',') FROM %s e", ContainsTexts::class), + 'with DISTINCT' => \sprintf("SELECT STRING_AGG(DISTINCT e.text1, ',') FROM %s e", ContainsTexts::class), + 'with DISTINCT and concatenation' => \sprintf("SELECT STRING_AGG(DISTINCT CONCAT(e.text1, e.text2), ',') FROM %s e", ContainsTexts::class), + 'with ORDER BY' => \sprintf("SELECT STRING_AGG(e.text1, ',' ORDER BY e.text1) FROM %s e", ContainsTexts::class), + 'with ORDER BY DESC' => \sprintf("SELECT STRING_AGG(e.text1, ',' ORDER BY e.text1 DESC) FROM %s e", ContainsTexts::class), + 'with DISTINCT and ORDER BY' => \sprintf("SELECT STRING_AGG(DISTINCT e.text1, ',' ORDER BY e.text1) FROM %s e", ContainsTexts::class), + 'with concatenation, DISTINCT and ORDER BY' => \sprintf("SELECT STRING_AGG(DISTINCT CONCAT(e.text1, e.text2), ',' ORDER BY e.text1) FROM %s e", ContainsTexts::class), + 'with multiple ORDER BY columns' => \sprintf("SELECT STRING_AGG(e.text1, ',' ORDER BY e.text1 ASC, e.text2 DESC) FROM %s e", ContainsTexts::class), + 'with different delimiter' => \sprintf("SELECT STRING_AGG(e.text1, ' | ') FROM %s e", ContainsTexts::class), ]; } }