From 11021dc96fda40c4dc1ac0b1923241e8e18416c0 Mon Sep 17 00:00:00 2001 From: Zuruuh Date: Tue, 6 Feb 2024 21:47:08 +0100 Subject: [PATCH 1/2] Rebase and squash on branch 3.1 --- .../reference/dql-doctrine-query-language.rst | 12 +- src/Internal/Hydration/ObjectHydrator.php | 3 +- src/Query/AST/NamedScalarExpression.php | 21 ++ src/Query/Parser.php | 27 ++- src/Query/SqlWalker.php | 2 +- .../Tests/ORM/Functional/NewOperatorTest.php | 183 ++++++++++++++++++ 6 files changed, 241 insertions(+), 7 deletions(-) create mode 100644 src/Query/AST/NamedScalarExpression.php diff --git a/docs/en/reference/dql-doctrine-query-language.rst b/docs/en/reference/dql-doctrine-query-language.rst index 80d41f17002..13dcb49f98b 100644 --- a/docs/en/reference/dql-doctrine-query-language.rst +++ b/docs/en/reference/dql-doctrine-query-language.rst @@ -562,6 +562,16 @@ And then use the ``NEW`` DQL keyword : Note that you can only pass scalar expressions to the constructor. +The ``NEW`` operator also supports named arguments: + +.. code-block:: php + + createQuery('SELECT NEW CustomerDTO(email: e.email, name: c.name, address: a.city) FROM Customer c JOIN c.email e JOIN c.address a'); + $users = $query->getResult(); // array of CustomerDTO + +Note that you must not pass ordered arguments after named ones. + Using INDEX BY ~~~~~~~~~~~~~~ @@ -1650,7 +1660,7 @@ Select Expressions SelectExpression ::= (IdentificationVariable | ScalarExpression | AggregateExpression | FunctionDeclaration | "(" Subselect ")" | CaseExpression | NewObjectExpression) [["AS"] ["HIDDEN"] AliasResultVariable] SimpleSelectExpression ::= (StateFieldPathExpression | IdentificationVariable | FunctionDeclaration | AggregateExpression | "(" Subselect ")" | ScalarExpression) [["AS"] AliasResultVariable] NewObjectExpression ::= "NEW" AbstractSchemaName "(" NewObjectArg {"," NewObjectArg}* ")" - NewObjectArg ::= ScalarExpression | "(" Subselect ")" + NewObjectArg ::= ScalarExpression | NamedScalarExpression | "(" Subselect ")" Conditional Expressions ~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/Internal/Hydration/ObjectHydrator.php b/src/Internal/Hydration/ObjectHydrator.php index c83c1e4701e..596d4cc627d 100644 --- a/src/Internal/Hydration/ObjectHydrator.php +++ b/src/Internal/Hydration/ObjectHydrator.php @@ -554,7 +554,8 @@ protected function hydrateRowData(array $row, array &$result): void foreach ($rowData['newObjects'] as $objIndex => $newObject) { $class = $newObject['class']; $args = $newObject['args']; - $obj = $class->newInstanceArgs($args); + + $obj = $class->newInstanceArgs($args); if ($scalarCount === 0 && count($rowData['newObjects']) === 1) { $result[$resultKey] = $obj; diff --git a/src/Query/AST/NamedScalarExpression.php b/src/Query/AST/NamedScalarExpression.php new file mode 100644 index 00000000000..2854e3ba357 --- /dev/null +++ b/src/Query/AST/NamedScalarExpression.php @@ -0,0 +1,21 @@ +innerExpression->dispatch($walker); + } +} diff --git a/src/Query/Parser.php b/src/Query/Parser.php index ade4bf347fe..657962e2c75 100644 --- a/src/Query/Parser.php +++ b/src/Query/Parser.php @@ -1634,12 +1634,20 @@ public function NewObjectExpression(): AST\NewObjectExpression $this->match(TokenType::T_OPEN_PARENTHESIS); - $args[] = $this->NewObjectArg(); + $arg = $this->NewObjectArg(); + $namedArgAlreadyParsed = $arg instanceof AST\NamedScalarExpression; + $args = [$arg]; while ($this->lexer->isNextToken(TokenType::T_COMMA)) { $this->match(TokenType::T_COMMA); + if ($this->lexer->isNextToken(TokenType::T_CLOSE_PARENTHESIS)) { + // Comma above is a trailing comma, ignore it + break; + } - $args[] = $this->NewObjectArg(); + $arg = $this->NewObjectArg($namedArgAlreadyParsed); + $namedArgAlreadyParsed = $namedArgAlreadyParsed || $arg instanceof AST\NamedScalarExpression; + $args[] = $arg; } $this->match(TokenType::T_CLOSE_PARENTHESIS); @@ -1657,15 +1665,26 @@ public function NewObjectExpression(): AST\NewObjectExpression } /** - * NewObjectArg ::= ScalarExpression | "(" Subselect ")" + * NewObjectArg ::= ScalarExpression | NamedScalarExpression | "(" Subselect ")" */ - public function NewObjectArg(): mixed + public function NewObjectArg(bool $namedArgAlreadyParsed = false): mixed { assert($this->lexer->lookahead !== null); $token = $this->lexer->lookahead; $peek = $this->lexer->glimpse(); assert($peek !== null); + if ($token->type === TokenType::T_IDENTIFIER && $peek->type === TokenType::T_INPUT_PARAMETER) { + $this->match(TokenType::T_IDENTIFIER); + $this->match(TokenType::T_INPUT_PARAMETER); + + return new AST\NamedScalarExpression($this->ScalarExpression(), $token->value); + } + + if ($namedArgAlreadyParsed) { + throw QueryException::syntaxError('Cannot specify ordered arguments after named ones.'); + } + if ($token->type === TokenType::T_OPEN_PARENTHESIS && $peek->type === TokenType::T_SELECT) { $this->match(TokenType::T_OPEN_PARENTHESIS); $expression = $this->Subselect(); diff --git a/src/Query/SqlWalker.php b/src/Query/SqlWalker.php index 00b180fd4c9..4e0ba82b898 100644 --- a/src/Query/SqlWalker.php +++ b/src/Query/SqlWalker.php @@ -1503,7 +1503,7 @@ public function walkNewObject(AST\NewObjectExpression $newObjectExpression, stri $this->rsm->newObjectMappings[$columnAlias] = [ 'className' => $newObjectExpression->className, 'objIndex' => $objIndex, - 'argIndex' => $argIndex, + 'argIndex' => $e instanceof AST\NamedScalarExpression ? $e->name : $argIndex, ]; } diff --git a/tests/Tests/ORM/Functional/NewOperatorTest.php b/tests/Tests/ORM/Functional/NewOperatorTest.php index 7f89a938e88..b642c7d43fa 100644 --- a/tests/Tests/ORM/Functional/NewOperatorTest.php +++ b/tests/Tests/ORM/Functional/NewOperatorTest.php @@ -1013,6 +1013,189 @@ public function testClassCantBeInstantiatedException(): void $dql = 'SELECT new Doctrine\Tests\ORM\Functional\ClassWithPrivateConstructor(u.name) FROM Doctrine\Tests\Models\CMS\CmsUser u'; $this->_em->createQuery($dql)->getResult(); } + + /** @return array */ + public static function provideQueriesWithNamedArguments(): array + { + return [ + 'Only named arguments in order' => [ + 'SELECT + new Doctrine\Tests\Models\CMS\CmsUserDTO( + name: u.name, + email: e.email, + address: a.city, + ) + FROM + Doctrine\Tests\Models\CMS\CmsUser u + JOIN + u.email e + JOIN + u.address a + ORDER BY + u.name', + ], + 'Only named arguments not in order' => [ + 'SELECT + new Doctrine\Tests\Models\CMS\CmsUserDTO( + email: e.email, + name: u.name, + address: a.city, + ) + FROM + Doctrine\Tests\Models\CMS\CmsUser u + JOIN + u.email e + JOIN + u.address a + ORDER BY + u.name', + ], + 'Both named and ordered arguments' => [ + 'SELECT + new Doctrine\Tests\Models\CMS\CmsUserDTO( + u.name, + address: a.city, + email: e.email, + ) + FROM + Doctrine\Tests\Models\CMS\CmsUser u + JOIN + u.email e + JOIN + u.address a + ORDER BY + u.name', + ], + 'Both named and ordered arguments without trailing comma' => [ + 'SELECT + new Doctrine\Tests\Models\CMS\CmsUserDTO( + u.name, + address: a.city, + email: e.email + ) + FROM + Doctrine\Tests\Models\CMS\CmsUser u + JOIN + u.email e + JOIN + u.address a + ORDER BY + u.name', + ], + ]; + } + + #[DataProvider('provideQueriesWithNamedArguments')] + public function testQueryWithNamedArguments(string $query): void + { + $query = $this->_em->createQuery($query); + $result = $query->getResult(); + + self::assertCount(3, $result); + + self::assertInstanceOf(CmsUserDTO::class, $result[0]); + self::assertInstanceOf(CmsUserDTO::class, $result[1]); + self::assertInstanceOf(CmsUserDTO::class, $result[2]); + + self::assertEquals($this->fixtures[0]->name, $result[0]->name); + self::assertEquals($this->fixtures[1]->name, $result[1]->name); + self::assertEquals($this->fixtures[2]->name, $result[2]->name); + + self::assertEquals($this->fixtures[0]->email->email, $result[0]->email); + self::assertEquals($this->fixtures[1]->email->email, $result[1]->email); + self::assertEquals($this->fixtures[2]->email->email, $result[2]->email); + + self::assertEquals($this->fixtures[0]->address->city, $result[0]->address); + self::assertEquals($this->fixtures[1]->address->city, $result[1]->address); + self::assertEquals($this->fixtures[2]->address->city, $result[2]->address); + + self::assertNull($result[0]->phonenumbers); + self::assertNull($result[1]->phonenumbers); + self::assertNull($result[2]->phonenumbers); + } + + public function testQueryWithOrderedArgumentAfterNamedArgument(): void + { + $dql = ' + SELECT + new Doctrine\Tests\Models\CMS\CmsUserDTO( + address: a.city, + email: e.email, + u.name, + ) + FROM + Doctrine\Tests\Models\CMS\CmsUser u + JOIN + u.email e + JOIN + u.address a + ORDER BY + u.name'; + + $query = $this->_em->createQuery($dql); + $this->expectException(QueryException::class); + $this->expectExceptionMessage('[Syntax Error] Cannot specify ordered arguments after named ones.'); + + $query->getResult(); + } + + public function testQueryWithNamedArgumentsWithoutOptionalParameters(): void + { + $dql = ' + SELECT + new Doctrine\Tests\Models\CMS\CmsUserDTO( + address: a.city, + email: e.email, + ) + FROM + Doctrine\Tests\Models\CMS\CmsUser u + JOIN + u.email e + JOIN + u.address a + ORDER BY + u.name'; + + $query = $this->_em->createQuery($dql); + $result = $query->getResult(); + + self::assertInstanceOf(CmsUserDTO::class, $result[0]); + self::assertInstanceOf(CmsUserDTO::class, $result[1]); + self::assertInstanceOf(CmsUserDTO::class, $result[2]); + + self::assertNull($result[0]->name); + self::assertNull($result[1]->name); + self::assertNull($result[2]->name); + + self::assertEquals($this->fixtures[0]->email->email, $result[0]->email); + self::assertEquals($this->fixtures[1]->email->email, $result[1]->email); + self::assertEquals($this->fixtures[2]->email->email, $result[2]->email); + + self::assertEquals($this->fixtures[0]->address->city, $result[0]->address); + self::assertEquals($this->fixtures[1]->address->city, $result[1]->address); + self::assertEquals($this->fixtures[2]->address->city, $result[2]->address); + + self::assertNull($result[0]->phonenumbers); + self::assertNull($result[1]->phonenumbers); + self::assertNull($result[2]->phonenumbers); + } + + public function testQueryWithNamedArgumentsMissingRequiredArguments(): void + { + $dql = ' + SELECT + new ' . ClassWithTooMuchArgs::class . '( + bar: u.name, + ) + FROM + Doctrine\Tests\Models\CMS\CmsUser u + '; + + $query = $this->_em->createQuery($dql); + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Number of arguments does not match with "Doctrine\Tests\ORM\Functional\ClassWithTooMuchArgs" constructor declaration.'); + $result = $query->getResult(); + } } class ClassWithTooMuchArgs From 7280fc220ee88a2df580f5618a7f2bc6a0ef31db Mon Sep 17 00:00:00 2001 From: Zuruuh Date: Fri, 23 Feb 2024 13:10:58 +0100 Subject: [PATCH 2/2] remove whitespace --- src/Internal/Hydration/ObjectHydrator.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Internal/Hydration/ObjectHydrator.php b/src/Internal/Hydration/ObjectHydrator.php index 596d4cc627d..c83c1e4701e 100644 --- a/src/Internal/Hydration/ObjectHydrator.php +++ b/src/Internal/Hydration/ObjectHydrator.php @@ -554,8 +554,7 @@ protected function hydrateRowData(array $row, array &$result): void foreach ($rowData['newObjects'] as $objIndex => $newObject) { $class = $newObject['class']; $args = $newObject['args']; - - $obj = $class->newInstanceArgs($args); + $obj = $class->newInstanceArgs($args); if ($scalarCount === 0 && count($rowData['newObjects']) === 1) { $result[$resultKey] = $obj;