diff --git a/src/Internal/Hydration/AbstractHydrator.php b/src/Internal/Hydration/AbstractHydrator.php index d8bffe4ad3..0388ed4429 100644 --- a/src/Internal/Hydration/AbstractHydrator.php +++ b/src/Internal/Hydration/AbstractHydrator.php @@ -253,7 +253,8 @@ abstract protected function hydrateAllData(): mixed; * data: array, * newObjects?: array, * scalars?: array * } @@ -281,8 +282,9 @@ protected function gatherRowData(array $data, array &$id, array &$nonemptyCompon $value = $this->buildEnum($value, $cacheKeyInfo['enumType']); } - $rowData['newObjects'][$objIndex]['class'] = $cacheKeyInfo['class']; - $rowData['newObjects'][$objIndex]['args'][$argIndex] = $value; + $rowData['newObjects'][$objIndex]['class'] = $cacheKeyInfo['class']; + $rowData['newObjects'][$objIndex]['args'][$argIndex] = $value; + $rowData['newObjects'][$objIndex]['argNames'][$argIndex] = $key; break; case isset($cacheKeyInfo['isScalar']): diff --git a/src/Internal/Hydration/ObjectHydrator.php b/src/Internal/Hydration/ObjectHydrator.php index d0fc101f21..bff80f7664 100644 --- a/src/Internal/Hydration/ObjectHydrator.php +++ b/src/Internal/Hydration/ObjectHydrator.php @@ -10,6 +10,7 @@ use Doctrine\ORM\PersistentCollection; use Doctrine\ORM\Query; use Doctrine\ORM\UnitOfWork; +use Doctrine\ORM\WithNamedArguments; use function array_fill_keys; use function array_keys; @@ -556,9 +557,20 @@ protected function hydrateRowData(array $row, array &$result): void $scalarCount = (isset($rowData['scalars']) ? count($rowData['scalars']) : 0); foreach ($rowData['newObjects'] as $objIndex => $newObject) { - $class = $newObject['class']; - $args = $newObject['args']; - $obj = $class->newInstanceArgs($args); + $class = $newObject['class']; + $args = $newObject['args']; + $argNames = $newObject['argNames']; + + if ($class->implementsInterface(WithNamedArguments::class)) { + $newArgs = []; + foreach ($args as $key => $val) { + $newArgs[$this->resultSetMapping()->newObjectMappings[$argNames[$key]]['argName']] = $val; + } + + $args = $newArgs; + } + + $obj = $class->newInstanceArgs($args); if ($scalarCount === 0 && count($rowData['newObjects']) === 1) { $result[$resultKey] = $obj; diff --git a/src/Query/AST/NewObjectExpression.php b/src/Query/AST/NewObjectExpression.php index 7383c48723..dca8759416 100644 --- a/src/Query/AST/NewObjectExpression.php +++ b/src/Query/AST/NewObjectExpression.php @@ -13,8 +13,11 @@ */ class NewObjectExpression extends Node { - /** @param mixed[] $args */ - public function __construct(public string $className, public array $args) + /** + * @param array $args + * @param array $argNames args key for named Arguments DTO + **/ + public function __construct(public string $className, public array $args, public array $argNames) { } diff --git a/src/Query/Parser.php b/src/Query/Parser.php index e948f2c6b0..b12c3cdd7e 100644 --- a/src/Query/Parser.php +++ b/src/Query/Parser.php @@ -1626,7 +1626,9 @@ public function JoinAssociationDeclaration(): AST\JoinAssociationDeclaration */ public function NewObjectExpression(): AST\NewObjectExpression { - $args = []; + $args = []; + $argNames = []; + $this->match(TokenType::T_NEW); $className = $this->AbstractSchemaName(); // note that this is not yet validated @@ -1634,17 +1636,20 @@ public function NewObjectExpression(): AST\NewObjectExpression $this->match(TokenType::T_OPEN_PARENTHESIS); - $args[] = $this->NewObjectArg(); + $argName = null; + $args[] = $this->NewObjectArg($argName); + $argNames[] = $argName; while ($this->lexer->isNextToken(TokenType::T_COMMA)) { $this->match(TokenType::T_COMMA); - $args[] = $this->NewObjectArg(); + $args[] = $this->NewObjectArg($argName); + $argNames[] = $argName; } $this->match(TokenType::T_CLOSE_PARENTHESIS); - $expression = new AST\NewObjectExpression($className, $args); + $expression = new AST\NewObjectExpression($className, $args, $argNames); // Defer NewObjectExpression validation $this->deferredNewObjectExpressions[] = [ @@ -1659,22 +1664,32 @@ public function NewObjectExpression(): AST\NewObjectExpression /** * NewObjectArg ::= ScalarExpression | "(" Subselect ")" */ - public function NewObjectArg(): mixed + public function NewObjectArg(string|null &$argName = null): mixed { + $argName = null; assert($this->lexer->lookahead !== null); $token = $this->lexer->lookahead; $peek = $this->lexer->glimpse(); assert($peek !== null); + + $expression = null; + if ($token->type === TokenType::T_OPEN_PARENTHESIS && $peek->type === TokenType::T_SELECT) { $this->match(TokenType::T_OPEN_PARENTHESIS); $expression = $this->Subselect(); $this->match(TokenType::T_CLOSE_PARENTHESIS); + } else { + $expression = $this->ScalarExpression(); + } - return $expression; + if ($this->lexer->isNextToken(TokenType::T_AS)) { + $this->match(TokenType::T_AS); + $aliasIdentificationVariable = $this->AliasIdentificationVariable(); + $argName = $aliasIdentificationVariable; } - return $this->ScalarExpression(); + return $expression; } /** diff --git a/src/Query/SqlWalker.php b/src/Query/SqlWalker.php index c6f98c12d5..08b324b51b 100644 --- a/src/Query/SqlWalker.php +++ b/src/Query/SqlWalker.php @@ -1467,6 +1467,7 @@ public function walkNewObject(AST\NewObjectExpression $newObjectExpression, stri $resultAlias = $this->scalarResultCounter++; $columnAlias = $this->getSQLColumnAlias('sclr'); $fieldType = 'string'; + $argName = $newObjectExpression->argNames[$argIndex]; switch (true) { case $e instanceof AST\NewObjectExpression: @@ -1485,6 +1486,7 @@ public function walkNewObject(AST\NewObjectExpression $newObjectExpression, stri $fieldMapping = $class->fieldMappings[$fieldName]; $fieldType = $fieldMapping->type; $col = trim($e->dispatch($this)); + $argName ??= $newObjectExpression->args[$argIndex]->field; $type = Type::getType($fieldType); $col = $type->convertToPHPValueSQL($col, $this->platform); @@ -1523,6 +1525,7 @@ public function walkNewObject(AST\NewObjectExpression $newObjectExpression, stri 'className' => $newObjectExpression->className, 'objIndex' => $objIndex, 'argIndex' => $argIndex, + 'argName' => $argName, ]; } diff --git a/src/WithNamedArguments.php b/src/WithNamedArguments.php new file mode 100644 index 0000000000..04942f3251 --- /dev/null +++ b/src/WithNamedArguments.php @@ -0,0 +1,12 @@ +name = $args['name'] ?? null; + $this->email = $args['email'] ?? null; + $this->phonenumbers = $args['phonenumbers'] ?? null; + $this->address = $args['address'] ?? null; + } +} diff --git a/tests/Tests/ORM/Functional/NewOperatorTest.php b/tests/Tests/ORM/Functional/NewOperatorTest.php index 7f89a938e8..baae0f4371 100644 --- a/tests/Tests/ORM/Functional/NewOperatorTest.php +++ b/tests/Tests/ORM/Functional/NewOperatorTest.php @@ -12,6 +12,8 @@ use Doctrine\Tests\Models\CMS\CmsPhonenumber; use Doctrine\Tests\Models\CMS\CmsUser; use Doctrine\Tests\Models\CMS\CmsUserDTO; +use Doctrine\Tests\Models\CMS\CmsUserDTONamedArgs; +use Doctrine\Tests\Models\CMS\CmsUserDTOVariadicArg; use Doctrine\Tests\OrmFunctionalTestCase; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; @@ -1013,6 +1015,106 @@ 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(); } + + public function testNamedArguments(): void + { + $dql = ' + SELECT + new CmsUserDTONamedArgs( + e.email, + u.name, + CONCAT(a.country, \' \', a.city, \' \', a.zip) AS address + ) as user, + u.status, + u.username as cmsUserUsername + FROM + Doctrine\Tests\Models\CMS\CmsUser u + JOIN + u.email e + JOIN + u.address a + ORDER BY + u.name'; + + $query = $this->getEntityManager()->createQuery($dql); + $result = $query->getResult(); + + self::assertCount(3, $result); + + self::assertInstanceOf(CmsUserDTONamedArgs::class, $result[0]['user']); + self::assertInstanceOf(CmsUserDTONamedArgs::class, $result[1]['user']); + self::assertInstanceOf(CmsUserDTONamedArgs::class, $result[2]['user']); + + self::assertSame($this->fixtures[0]->name, $result[0]['user']->name); + self::assertSame($this->fixtures[1]->name, $result[1]['user']->name); + self::assertSame($this->fixtures[2]->name, $result[2]['user']->name); + + self::assertSame($this->fixtures[0]->email->email, $result[0]['user']->email); + self::assertSame($this->fixtures[1]->email->email, $result[1]['user']->email); + self::assertSame($this->fixtures[2]->email->email, $result[2]['user']->email); + + self::assertSame($this->fixtures[0]->address->country . ' ' . $this->fixtures[0]->address->city . ' ' . $this->fixtures[0]->address->zip, $result[0]['user']->address); + self::assertSame($this->fixtures[1]->address->country . ' ' . $this->fixtures[1]->address->city . ' ' . $this->fixtures[1]->address->zip, $result[1]['user']->address); + self::assertSame($this->fixtures[2]->address->country . ' ' . $this->fixtures[2]->address->city . ' ' . $this->fixtures[2]->address->zip, $result[2]['user']->address); + + self::assertSame($this->fixtures[0]->status, $result[0]['status']); + self::assertSame($this->fixtures[1]->status, $result[1]['status']); + self::assertSame($this->fixtures[2]->status, $result[2]['status']); + + self::assertSame($this->fixtures[0]->username, $result[0]['cmsUserUsername']); + self::assertSame($this->fixtures[1]->username, $result[1]['cmsUserUsername']); + self::assertSame($this->fixtures[2]->username, $result[2]['cmsUserUsername']); + } + + public function testVariadicArgument(): void + { + $dql = ' + SELECT + new CmsUserDTOVariadicArg( + CONCAT(a.country, \' \', a.city, \' \', a.zip) AS address, + e.email, + u.name + ) as user, + u.status, + u.username as cmsUserUsername + FROM + Doctrine\Tests\Models\CMS\CmsUser u + JOIN + u.email e + JOIN + u.address a + ORDER BY + u.name'; + + $query = $this->getEntityManager()->createQuery($dql); + $result = $query->getResult(); + + self::assertCount(3, $result); + + self::assertInstanceOf(CmsUserDTOVariadicArg::class, $result[0]['user']); + self::assertInstanceOf(CmsUserDTOVariadicArg::class, $result[1]['user']); + self::assertInstanceOf(CmsUserDTOVariadicArg::class, $result[2]['user']); + + self::assertSame($this->fixtures[0]->name, $result[0]['user']->name); + self::assertSame($this->fixtures[1]->name, $result[1]['user']->name); + self::assertSame($this->fixtures[2]->name, $result[2]['user']->name); + + self::assertSame($this->fixtures[0]->email->email, $result[0]['user']->email); + self::assertSame($this->fixtures[1]->email->email, $result[1]['user']->email); + self::assertSame($this->fixtures[2]->email->email, $result[2]['user']->email); + + self::assertSame($this->fixtures[0]->address->country . ' ' . $this->fixtures[0]->address->city . ' ' . $this->fixtures[0]->address->zip, $result[0]['user']->address); + self::assertSame($this->fixtures[1]->address->country . ' ' . $this->fixtures[1]->address->city . ' ' . $this->fixtures[1]->address->zip, $result[1]['user']->address); + self::assertSame($this->fixtures[2]->address->country . ' ' . $this->fixtures[2]->address->city . ' ' . $this->fixtures[2]->address->zip, $result[2]['user']->address); + + self::assertSame($this->fixtures[0]->status, $result[0]['status']); + self::assertSame($this->fixtures[1]->status, $result[1]['status']); + self::assertSame($this->fixtures[2]->status, $result[2]['status']); + + self::assertSame($this->fixtures[0]->username, $result[0]['cmsUserUsername']); + self::assertSame($this->fixtures[1]->username, $result[1]['cmsUserUsername']); + self::assertSame($this->fixtures[2]->username, $result[2]['cmsUserUsername']); + } } class ClassWithTooMuchArgs