Skip to content

Commit c683c30

Browse files
committed
Rebase and squash on branch 1.18
1 parent 40fbbf4 commit c683c30

File tree

6 files changed

+275
-7
lines changed

6 files changed

+275
-7
lines changed

docs/en/reference/dql-doctrine-query-language.rst

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,16 @@ And then use the ``NEW`` DQL keyword :
589589
590590
Note that you can only pass scalar expressions to the constructor.
591591

592+
The ``NEW`` operator also supports named arguments:
593+
594+
.. code-block:: php
595+
596+
<?php
597+
$query = $em->createQuery('SELECT NEW CustomerDTO(email: e.email, name: c.name, address: a.city) FROM Customer c JOIN c.email e JOIN c.address a');
598+
$users = $query->getResult(); // array of CustomerDTO
599+
600+
Note that you must not pass ordered arguments after named ones.
601+
592602
Using INDEX BY
593603
~~~~~~~~~~~~~~
594604

@@ -1688,7 +1698,7 @@ Select Expressions
16881698
PartialObjectExpression ::= "PARTIAL" IdentificationVariable "." PartialFieldSet
16891699
PartialFieldSet ::= "{" SimpleStateField {"," SimpleStateField}* "}"
16901700
NewObjectExpression ::= "NEW" AbstractSchemaName "(" NewObjectArg {"," NewObjectArg}* ")"
1691-
NewObjectArg ::= ScalarExpression | "(" Subselect ")"
1701+
NewObjectArg ::= ScalarExpression | NamedScalarExpression | "(" Subselect ")"
16921702
16931703
Conditional Expressions
16941704
~~~~~~~~~~~~~~~~~~~~~~~

src/Internal/Hydration/ObjectHydrator.php

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use Doctrine\ORM\UnitOfWork;
1313

1414
use function array_fill_keys;
15+
use function array_key_exists;
1516
use function array_keys;
1617
use function array_map;
1718
use function count;
@@ -20,6 +21,8 @@
2021
use function ltrim;
2122
use function spl_object_id;
2223

24+
use const PHP_VERSION_ID;
25+
2326
/**
2427
* The ObjectHydrator constructs an object graph out of an SQL result set.
2528
*
@@ -565,7 +568,28 @@ protected function hydrateRowData(array $row, array &$result)
565568
foreach ($rowData['newObjects'] as $objIndex => $newObject) {
566569
$class = $newObject['class'];
567570
$args = $newObject['args'];
568-
$obj = $class->newInstanceArgs($args);
571+
572+
if (PHP_VERSION_ID >= 80000) {
573+
$obj = $class->newInstanceArgs($args);
574+
} else {
575+
$constructor = $class->getConstructor();
576+
$orderedArgs = [];
577+
578+
$constructorArguments = $constructor->getParameters();
579+
$constructorArgumentsCount = count($constructorArguments);
580+
581+
foreach ($constructorArguments as $argument) {
582+
if (array_key_exists($argument->getName(), $args)) {
583+
$orderedArgs[$argument->getPosition()] = $args[$argument->getName()];
584+
} elseif (array_key_exists($argument->getPosition(), $args)) {
585+
$orderedArgs[$argument->getPosition()] = $args[$argument->getPosition()];
586+
} else {
587+
$orderedArgs[$argument->getPosition()] = $argument->getDefaultValue();
588+
}
589+
}
590+
591+
$obj = $class->newInstanceArgs($orderedArgs);
592+
}
569593

570594
if ($scalarCount === 0 && count($rowData['newObjects']) === 1) {
571595
$result[$resultKey] = $obj;
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Doctrine\ORM\Query\AST;
6+
7+
class NamedScalarExpression extends Node
8+
{
9+
/** @var Node */
10+
public $innerExpression;
11+
12+
/** @var string|null */
13+
public $name;
14+
15+
public function __construct(Node $scalarExpression, ?string $name = null)
16+
{
17+
$this->innerExpression = $scalarExpression;
18+
$this->name = $name;
19+
}
20+
21+
/**
22+
* {@inheritDoc}
23+
*/
24+
public function dispatch($walker)
25+
{
26+
return $this->innerExpression->dispatch($walker);
27+
}
28+
}

src/Query/Parser.php

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use function class_exists;
2020
use function count;
2121
use function explode;
22+
use function func_get_args;
2223
use function implode;
2324
use function in_array;
2425
use function interface_exists;
@@ -1915,12 +1916,20 @@ public function NewObjectExpression()
19151916

19161917
$this->match(Lexer::T_OPEN_PARENTHESIS);
19171918

1918-
$args[] = $this->NewObjectArg();
1919+
$arg = $this->NewObjectArg();
1920+
$namedArgAlreadyParsed = $arg instanceof AST\NamedScalarExpression;
1921+
$args = [$arg];
19191922

19201923
while ($this->lexer->isNextToken(Lexer::T_COMMA)) {
19211924
$this->match(Lexer::T_COMMA);
1925+
if ($this->lexer->isNextToken(Lexer::T_CLOSE_PARENTHESIS)) {
1926+
// Comma above is a trailing comma, ignore it
1927+
break;
1928+
}
19221929

1923-
$args[] = $this->NewObjectArg();
1930+
$arg = $this->NewObjectArg($namedArgAlreadyParsed);
1931+
$namedArgAlreadyParsed = $namedArgAlreadyParsed || $arg instanceof AST\NamedScalarExpression;
1932+
$args[] = $arg;
19241933
}
19251934

19261935
$this->match(Lexer::T_CLOSE_PARENTHESIS);
@@ -1938,17 +1947,30 @@ public function NewObjectExpression()
19381947
}
19391948

19401949
/**
1941-
* NewObjectArg ::= ScalarExpression | "(" Subselect ")"
1950+
* NewObjectArg ::= ScalarExpression | NamedScalarExpression | "(" Subselect ")"
19421951
*
19431952
* @return mixed
19441953
*/
1945-
public function NewObjectArg()
1954+
public function NewObjectArg(/* bool $namedArgAlreadyParsed = false */)
19461955
{
1956+
$namedArgAlreadyParsed = func_get_args()[0] ?? false;
1957+
19471958
assert($this->lexer->lookahead !== null);
19481959
$token = $this->lexer->lookahead;
19491960
$peek = $this->lexer->glimpse();
19501961

19511962
assert($peek !== null);
1963+
if ($token->type === Lexer::T_IDENTIFIER && $peek->type === Lexer::T_INPUT_PARAMETER) {
1964+
$this->match(Lexer::T_IDENTIFIER);
1965+
$this->match(Lexer::T_INPUT_PARAMETER);
1966+
1967+
return new AST\NamedScalarExpression($this->ScalarExpression(), $token->value);
1968+
}
1969+
1970+
if ($namedArgAlreadyParsed) {
1971+
throw QueryException::syntaxError('Cannot specify ordered arguments after named ones.');
1972+
}
1973+
19521974
if ($token->type === Lexer::T_OPEN_PARENTHESIS && $peek->type === Lexer::T_SELECT) {
19531975
$this->match(Lexer::T_OPEN_PARENTHESIS);
19541976
$expression = $this->Subselect();

src/Query/SqlWalker.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1787,7 +1787,7 @@ public function walkNewObject($newObjectExpression, $newObjectResultAlias = null
17871787
$this->rsm->newObjectMappings[$columnAlias] = [
17881788
'className' => $newObjectExpression->className,
17891789
'objIndex' => $objIndex,
1790-
'argIndex' => $argIndex,
1790+
'argIndex' => $e instanceof AST\NamedScalarExpression ? $e->name : $argIndex,
17911791
];
17921792
}
17931793

tests/Tests/ORM/Functional/NewOperatorTest.php

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use Doctrine\Common\Persistence\PersistentObject;
88
use Doctrine\ORM\Query;
9+
use Doctrine\ORM\QueryException;
910
use Doctrine\Tests\Models\CMS\CmsAddress;
1011
use Doctrine\Tests\Models\CMS\CmsAddressDTO;
1112
use Doctrine\Tests\Models\CMS\CmsEmail;
@@ -1074,6 +1075,189 @@ public function testClassCantBeInstantiatedException(): void
10741075
$dql = 'SELECT new Doctrine\Tests\ORM\Functional\ClassWithPrivateConstructor(u.name) FROM Doctrine\Tests\Models\CMS\CmsUser u';
10751076
$this->_em->createQuery($dql)->getResult();
10761077
}
1078+
1079+
/** @return array<string, array{string}> */
1080+
public static function provideQueriesWithNamedArguments(): array
1081+
{
1082+
return [
1083+
'Only named arguments in order' => [
1084+
'SELECT
1085+
new Doctrine\Tests\Models\CMS\CmsUserDTO(
1086+
name: u.name,
1087+
email: e.email,
1088+
address: a.city,
1089+
)
1090+
FROM
1091+
Doctrine\Tests\Models\CMS\CmsUser u
1092+
JOIN
1093+
u.email e
1094+
JOIN
1095+
u.address a
1096+
ORDER BY
1097+
u.name',
1098+
],
1099+
'Only named arguments not in order' => [
1100+
'SELECT
1101+
new Doctrine\Tests\Models\CMS\CmsUserDTO(
1102+
email: e.email,
1103+
name: u.name,
1104+
address: a.city,
1105+
)
1106+
FROM
1107+
Doctrine\Tests\Models\CMS\CmsUser u
1108+
JOIN
1109+
u.email e
1110+
JOIN
1111+
u.address a
1112+
ORDER BY
1113+
u.name',
1114+
],
1115+
'Both named and ordered arguments' => [
1116+
'SELECT
1117+
new Doctrine\Tests\Models\CMS\CmsUserDTO(
1118+
u.name,
1119+
address: a.city,
1120+
email: e.email,
1121+
)
1122+
FROM
1123+
Doctrine\Tests\Models\CMS\CmsUser u
1124+
JOIN
1125+
u.email e
1126+
JOIN
1127+
u.address a
1128+
ORDER BY
1129+
u.name',
1130+
],
1131+
'Both named and ordered arguments without trailing comma' => [
1132+
'SELECT
1133+
new Doctrine\Tests\Models\CMS\CmsUserDTO(
1134+
u.name,
1135+
address: a.city,
1136+
email: e.email
1137+
)
1138+
FROM
1139+
Doctrine\Tests\Models\CMS\CmsUser u
1140+
JOIN
1141+
u.email e
1142+
JOIN
1143+
u.address a
1144+
ORDER BY
1145+
u.name',
1146+
],
1147+
];
1148+
}
1149+
1150+
/** @dataProvider provideQueriesWithNamedArguments */
1151+
public function testQueryWithNamedArguments(string $query): void
1152+
{
1153+
$query = $this->_em->createQuery($query);
1154+
$result = $query->getResult();
1155+
1156+
self::assertCount(3, $result);
1157+
1158+
self::assertInstanceOf(CmsUserDTO::class, $result[0]);
1159+
self::assertInstanceOf(CmsUserDTO::class, $result[1]);
1160+
self::assertInstanceOf(CmsUserDTO::class, $result[2]);
1161+
1162+
self::assertEquals($this->fixtures[0]->name, $result[0]->name);
1163+
self::assertEquals($this->fixtures[1]->name, $result[1]->name);
1164+
self::assertEquals($this->fixtures[2]->name, $result[2]->name);
1165+
1166+
self::assertEquals($this->fixtures[0]->email->email, $result[0]->email);
1167+
self::assertEquals($this->fixtures[1]->email->email, $result[1]->email);
1168+
self::assertEquals($this->fixtures[2]->email->email, $result[2]->email);
1169+
1170+
self::assertEquals($this->fixtures[0]->address->city, $result[0]->address);
1171+
self::assertEquals($this->fixtures[1]->address->city, $result[1]->address);
1172+
self::assertEquals($this->fixtures[2]->address->city, $result[2]->address);
1173+
1174+
self::assertNull($result[0]->phonenumbers);
1175+
self::assertNull($result[1]->phonenumbers);
1176+
self::assertNull($result[2]->phonenumbers);
1177+
}
1178+
1179+
public function testQueryWithOrderedArgumentAfterNamedArgument(): void
1180+
{
1181+
$dql = '
1182+
SELECT
1183+
new Doctrine\Tests\Models\CMS\CmsUserDTO(
1184+
address: a.city,
1185+
email: e.email,
1186+
u.name,
1187+
)
1188+
FROM
1189+
Doctrine\Tests\Models\CMS\CmsUser u
1190+
JOIN
1191+
u.email e
1192+
JOIN
1193+
u.address a
1194+
ORDER BY
1195+
u.name';
1196+
1197+
$query = $this->_em->createQuery($dql);
1198+
$this->expectException(QueryException::class);
1199+
$this->expectExceptionMessage('[Syntax Error] Cannot specify ordered arguments after named ones.');
1200+
1201+
$query->getResult();
1202+
}
1203+
1204+
public function testQueryWithNamedArgumentsWithoutOptionalParameters(): void
1205+
{
1206+
$dql = '
1207+
SELECT
1208+
new Doctrine\Tests\Models\CMS\CmsUserDTO(
1209+
address: a.city,
1210+
email: e.email,
1211+
)
1212+
FROM
1213+
Doctrine\Tests\Models\CMS\CmsUser u
1214+
JOIN
1215+
u.email e
1216+
JOIN
1217+
u.address a
1218+
ORDER BY
1219+
u.name';
1220+
1221+
$query = $this->_em->createQuery($dql);
1222+
$result = $query->getResult();
1223+
1224+
self::assertInstanceOf(CmsUserDTO::class, $result[0]);
1225+
self::assertInstanceOf(CmsUserDTO::class, $result[1]);
1226+
self::assertInstanceOf(CmsUserDTO::class, $result[2]);
1227+
1228+
self::assertNull($result[0]->name);
1229+
self::assertNull($result[1]->name);
1230+
self::assertNull($result[2]->name);
1231+
1232+
self::assertEquals($this->fixtures[0]->email->email, $result[0]->email);
1233+
self::assertEquals($this->fixtures[1]->email->email, $result[1]->email);
1234+
self::assertEquals($this->fixtures[2]->email->email, $result[2]->email);
1235+
1236+
self::assertEquals($this->fixtures[0]->address->city, $result[0]->address);
1237+
self::assertEquals($this->fixtures[1]->address->city, $result[1]->address);
1238+
self::assertEquals($this->fixtures[2]->address->city, $result[2]->address);
1239+
1240+
self::assertNull($result[0]->phonenumbers);
1241+
self::assertNull($result[1]->phonenumbers);
1242+
self::assertNull($result[2]->phonenumbers);
1243+
}
1244+
1245+
public function testQueryWithNamedArgumentsMissingRequiredArguments(): void
1246+
{
1247+
$dql = '
1248+
SELECT
1249+
new ' . ClassWithTooMuchArgs::class . '(
1250+
bar: u.name,
1251+
)
1252+
FROM
1253+
Doctrine\Tests\Models\CMS\CmsUser u
1254+
';
1255+
1256+
$query = $this->_em->createQuery($dql);
1257+
$this->expectException(QueryException::class);
1258+
$this->expectExceptionMessage('Number of arguments does not match with "Doctrine\Tests\ORM\Functional\ClassWithTooMuchArgs" constructor declaration.');
1259+
$result = $query->getResult();
1260+
}
10771261
}
10781262

10791263
class ClassWithTooMuchArgs

0 commit comments

Comments
 (0)