Skip to content

Commit 19d9244

Browse files
authored
Merge pull request #11575 from eltharin/named_arguments
Allow named Arguments to be passed to Dto
2 parents 10a5a3f + c223b8f commit 19d9244

File tree

13 files changed

+550
-76
lines changed

13 files changed

+550
-76
lines changed

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

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -591,23 +591,80 @@ You can also nest several DTO :
591591
// Bind values to the object properties.
592592
}
593593
}
594-
594+
595595
class AddressDTO
596596
{
597597
public function __construct(string $street, string $city, string $zip)
598598
{
599599
// Bind values to the object properties.
600600
}
601601
}
602-
602+
603603
.. code-block:: php
604604
605605
<?php
606606
$query = $em->createQuery('SELECT NEW CustomerDTO(c.name, e.email, NEW AddressDTO(a.street, a.city, a.zip)) FROM Customer c JOIN c.email e JOIN c.address a');
607607
$users = $query->getResult(); // array of CustomerDTO
608-
608+
609609
Note that you can only pass scalar expressions or other Data Transfer Objects to the constructor.
610610

611+
If you use your data transfer objects for multiple queries, and you would rather not have to
612+
specify arguments that precede the ones you are really interested in, you can use named arguments.
613+
614+
Consider the following DTO, which uses optional arguments:
615+
616+
.. code-block:: php
617+
618+
<?php
619+
620+
class CustomerDTO
621+
{
622+
public function __construct(
623+
public string|null $name = null,
624+
public string|null $email = null,
625+
public string|null $city = null,
626+
public mixed|null $value = null,
627+
public AddressDTO|null $address = null,
628+
) {
629+
}
630+
}
631+
632+
You can specify arbitrary arguments in an arbitrary order by using the named argument syntax, and the ORM will try to match argument names with the selected column names.
633+
The syntax relies on the NAMED keyword, like so:
634+
635+
.. code-block:: php
636+
637+
<?php
638+
$query = $em->createQuery('SELECT NEW NAMED CustomerDTO(a.city, c.name) FROM Customer c JOIN c.address a');
639+
$users = $query->getResult(); // array of CustomerDTO
640+
641+
// CustomerDTO => {name : 'SMITH', email: null, city: 'London', value: null}
642+
643+
ORM will also give precedence to column aliases over column names :
644+
645+
.. code-block:: php
646+
647+
<?php
648+
$query = $em->createQuery('SELECT NEW NAMED CustomerDTO(c.name, CONCAT(a.city, ' ' , a.zip) AS value) FROM Customer c JOIN c.address a');
649+
$users = $query->getResult(); // array of CustomerDTO
650+
651+
// CustomerDTO => {name : 'DOE', email: null, city: null, value: 'New York 10011'}
652+
653+
To define a custom name for a DTO constructor argument, you can either alias the column with the ``AS`` keyword.
654+
655+
The ``NAMED`` keyword must precede all DTO you want to instantiate :
656+
657+
.. code-block:: php
658+
659+
<?php
660+
$query = $em->createQuery('SELECT NEW NAMED CustomerDTO(c.name, NEW NAMED AddressDTO(a.street, a.city, a.zip) AS address) FROM Customer c JOIN c.address a');
661+
$users = $query->getResult(); // array of CustomerDTO
662+
663+
// CustomerDTO => {name : 'DOE', email: null, city: null, value: 'New York 10011'}
664+
665+
If two arguments have the same name, a ``DuplicateFieldException`` is thrown.
666+
If a field cannot be matched with a property name, a ``NoMatchingPropertyException`` is thrown. This typically happens when using functions without aliasing them.
667+
611668
Using INDEX BY
612669
~~~~~~~~~~~~~~
613670

@@ -1627,7 +1684,7 @@ Select Expressions
16271684
PartialObjectExpression ::= "PARTIAL" IdentificationVariable "." PartialFieldSet
16281685
PartialFieldSet ::= "{" SimpleStateField {"," SimpleStateField}* "}"
16291686
NewObjectExpression ::= "NEW" AbstractSchemaName "(" NewObjectArg {"," NewObjectArg}* ")"
1630-
NewObjectArg ::= ScalarExpression | "(" Subselect ")" | NewObjectExpression
1687+
NewObjectArg ::= (ScalarExpression | "(" Subselect ")" | NewObjectExpression) ["AS" AliasResultVariable]
16311688
16321689
Conditional Expressions
16331690
~~~~~~~~~~~~~~~~~~~~~~~
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Doctrine\ORM\Exception;
6+
7+
use LogicException;
8+
9+
use function sprintf;
10+
11+
class DuplicateFieldException extends LogicException implements ORMException
12+
{
13+
public static function create(string $argName, string $columnName): self
14+
{
15+
return new self(sprintf('Name "%s" for "%s" already in use.', $argName, $columnName));
16+
}
17+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Doctrine\ORM\Exception;
6+
7+
use LogicException;
8+
9+
use function sprintf;
10+
11+
class NoMatchingPropertyException extends LogicException implements ORMException
12+
{
13+
public static function create(string $property): self
14+
{
15+
return new self(sprintf('Column name "%s" does not match any property name. Consider aliasing it to the name of an existing property.', $property));
16+
}
17+
}

src/Internal/Hydration/AbstractHydrator.php

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ abstract protected function hydrateAllData(): mixed;
261261
*/
262262
protected function gatherRowData(array $data, array &$id, array &$nonemptyComponents): array
263263
{
264-
$rowData = ['data' => []];
264+
$rowData = ['data' => [], 'newObjects' => []];
265265

266266
foreach ($data as $key => $value) {
267267
$cacheKeyInfo = $this->hydrateColumnInfo($key);
@@ -282,10 +282,6 @@ protected function gatherRowData(array $data, array &$id, array &$nonemptyCompon
282282
$value = $this->buildEnum($value, $cacheKeyInfo['enumType']);
283283
}
284284

285-
if (! isset($rowData['newObjects'])) {
286-
$rowData['newObjects'] = [];
287-
}
288-
289285
$rowData['newObjects'][$objIndex]['class'] = $cacheKeyInfo['class'];
290286
$rowData['newObjects'][$objIndex]['args'][$argIndex] = $value;
291287
break;
@@ -341,28 +337,22 @@ protected function gatherRowData(array $data, array &$id, array &$nonemptyCompon
341337
}
342338

343339
foreach ($this->resultSetMapping()->nestedNewObjectArguments as $objIndex => ['ownerIndex' => $ownerIndex, 'argIndex' => $argIndex]) {
344-
if (! isset($rowData['newObjects'][$objIndex])) {
340+
if (! isset($rowData['newObjects'][$ownerIndex . ':' . $argIndex])) {
345341
continue;
346342
}
347343

348-
$newObject = $rowData['newObjects'][$objIndex];
349-
unset($rowData['newObjects'][$objIndex]);
344+
$newObject = $rowData['newObjects'][$ownerIndex . ':' . $argIndex];
345+
unset($rowData['newObjects'][$ownerIndex . ':' . $argIndex]);
350346

351-
$class = $newObject['class'];
352-
$args = $newObject['args'];
353-
$obj = $class->newInstanceArgs($args);
347+
$obj = $newObject['class']->newInstanceArgs($newObject['args']);
354348

355349
$rowData['newObjects'][$ownerIndex]['args'][$argIndex] = $obj;
356350
}
357351

358-
if (isset($rowData['newObjects'])) {
359-
foreach ($rowData['newObjects'] as $objIndex => $newObject) {
360-
$class = $newObject['class'];
361-
$args = $newObject['args'];
362-
$obj = $class->newInstanceArgs($args);
352+
foreach ($rowData['newObjects'] as $objIndex => $newObject) {
353+
$obj = $newObject['class']->newInstanceArgs($newObject['args']);
363354

364-
$rowData['newObjects'][$objIndex]['obj'] = $obj;
365-
}
355+
$rowData['newObjects'][$objIndex]['obj'] = $obj;
366356
}
367357

368358
return $rowData;

src/Query/Parser.php

Lines changed: 63 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
use Doctrine\Common\Lexer\Token;
88
use Doctrine\Deprecations\Deprecation;
99
use Doctrine\ORM\EntityManagerInterface;
10+
use Doctrine\ORM\Exception\DuplicateFieldException;
11+
use Doctrine\ORM\Exception\NoMatchingPropertyException;
1012
use Doctrine\ORM\Internal\Hydration\HydrationException;
1113
use Doctrine\ORM\Mapping\AssociationMapping;
1214
use Doctrine\ORM\Mapping\ClassMetadata;
@@ -17,6 +19,7 @@
1719
use ReflectionClass;
1820

1921
use function array_intersect;
22+
use function array_key_exists;
2023
use function array_search;
2124
use function assert;
2225
use function class_exists;
@@ -32,6 +35,7 @@
3235
use function strrpos;
3336
use function strtolower;
3437
use function substr;
38+
use function trim;
3539

3640
/**
3741
* An LL(*) recursive-descent parser for the context-free grammar of the Doctrine Query Language.
@@ -1758,20 +1762,26 @@ public function PartialObjectExpression(): AST\PartialObjectExpression
17581762
*/
17591763
public function NewObjectExpression(): AST\NewObjectExpression
17601764
{
1761-
$args = [];
1765+
$useNamedArguments = false;
1766+
$args = [];
1767+
$argFieldAlias = [];
17621768
$this->match(TokenType::T_NEW);
17631769

1770+
if ($this->lexer->isNextToken(TokenType::T_NAMED)) {
1771+
$this->match(TokenType::T_NAMED);
1772+
$useNamedArguments = true;
1773+
}
1774+
17641775
$className = $this->AbstractSchemaName(); // note that this is not yet validated
17651776
$token = $this->lexer->token;
17661777

17671778
$this->match(TokenType::T_OPEN_PARENTHESIS);
17681779

1769-
$args[] = $this->NewObjectArg();
1780+
$this->addArgument($args, $useNamedArguments);
17701781

17711782
while ($this->lexer->isNextToken(TokenType::T_COMMA)) {
17721783
$this->match(TokenType::T_COMMA);
1773-
1774-
$args[] = $this->NewObjectArg();
1784+
$this->addArgument($args, $useNamedArguments);
17751785
}
17761786

17771787
$this->match(TokenType::T_CLOSE_PARENTHESIS);
@@ -1788,29 +1798,71 @@ public function NewObjectExpression(): AST\NewObjectExpression
17881798
return $expression;
17891799
}
17901800

1801+
/** @param array<mixed> $args */
1802+
public function addArgument(array &$args, bool $useNamedArguments): void
1803+
{
1804+
$fieldAlias = null;
1805+
1806+
if ($useNamedArguments) {
1807+
$startToken = $this->lexer->lookahead?->position ?? 0;
1808+
1809+
$newArg = $this->NewObjectArg($fieldAlias);
1810+
1811+
$key = $fieldAlias ?? $newArg->field ?? null;
1812+
1813+
if ($key === null) {
1814+
throw NoMatchingPropertyException::create(trim(substr(
1815+
($this->query->getDQL() ?? ''),
1816+
$startToken,
1817+
($this->lexer->lookahead->position ?? 0) - $startToken,
1818+
)));
1819+
}
1820+
1821+
if (array_key_exists($key, $args)) {
1822+
throw DuplicateFieldException::create($key, trim(substr(
1823+
($this->query->getDQL() ?? ''),
1824+
$startToken,
1825+
($this->lexer->lookahead->position ?? 0) - $startToken,
1826+
)));
1827+
}
1828+
1829+
$args[$key] = $newArg;
1830+
} else {
1831+
$args[] = $this->NewObjectArg($fieldAlias);
1832+
}
1833+
}
1834+
17911835
/**
1792-
* NewObjectArg ::= ScalarExpression | "(" Subselect ")" | NewObjectExpression
1836+
* NewObjectArg ::= (ScalarExpression | "(" Subselect ")" | NewObjectExpression) ["AS" AliasResultVariable]
17931837
*/
1794-
public function NewObjectArg(): mixed
1838+
public function NewObjectArg(string|null &$fieldAlias = null): mixed
17951839
{
1840+
$fieldAlias = null;
1841+
17961842
assert($this->lexer->lookahead !== null);
17971843
$token = $this->lexer->lookahead;
17981844
$peek = $this->lexer->glimpse();
17991845

18001846
assert($peek !== null);
1847+
1848+
$expression = null;
1849+
18011850
if ($token->type === TokenType::T_OPEN_PARENTHESIS && $peek->type === TokenType::T_SELECT) {
18021851
$this->match(TokenType::T_OPEN_PARENTHESIS);
18031852
$expression = $this->Subselect();
18041853
$this->match(TokenType::T_CLOSE_PARENTHESIS);
1805-
1806-
return $expression;
1854+
} elseif ($token->type === TokenType::T_NEW) {
1855+
$expression = $this->NewObjectExpression();
1856+
} else {
1857+
$expression = $this->ScalarExpression();
18071858
}
18081859

1809-
if ($token->type === TokenType::T_NEW) {
1810-
return $this->NewObjectExpression();
1860+
if ($this->lexer->isNextToken(TokenType::T_AS)) {
1861+
$this->match(TokenType::T_AS);
1862+
$fieldAlias = $this->AliasIdentificationVariable();
18111863
}
18121864

1813-
return $this->ScalarExpression();
1865+
return $expression;
18141866
}
18151867

18161868
/**

src/Query/ResultSetMapping.php

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
namespace Doctrine\ORM\Query;
66

7-
use function array_merge;
87
use function count;
98

109
/**
@@ -549,25 +548,4 @@ public function addMetaResult(
549548

550549
return $this;
551550
}
552-
553-
public function addNewObjectAsArgument(string|int $alias, string|int $objOwner, int $objOwnerIdx): static
554-
{
555-
$owner = [
556-
'ownerIndex' => $objOwner,
557-
'argIndex' => $objOwnerIdx,
558-
];
559-
560-
if (! isset($this->nestedNewObjectArguments[$owner['ownerIndex']])) {
561-
$this->nestedNewObjectArguments[$alias] = $owner;
562-
563-
return $this;
564-
}
565-
566-
$this->nestedNewObjectArguments = array_merge(
567-
[$alias => $owner],
568-
$this->nestedNewObjectArguments,
569-
);
570-
571-
return $this;
572-
}
573551
}

src/Query/SqlWalker.php

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1520,6 +1520,7 @@ public function walkNewObject(AST\NewObjectExpression $newObjectExpression, stri
15201520
$this->newObjectStack[] = [$objIndex, $argIndex];
15211521
$sqlSelectExpressions[] = $e->dispatch($this);
15221522
array_pop($this->newObjectStack);
1523+
$this->rsm->nestedNewObjectArguments[$columnAlias] = ['ownerIndex' => $objIndex, 'argIndex' => $argIndex];
15231524
break;
15241525

15251526
case $e instanceof AST\Subselect:
@@ -1573,10 +1574,6 @@ public function walkNewObject(AST\NewObjectExpression $newObjectExpression, stri
15731574
'objIndex' => $objIndex,
15741575
'argIndex' => $argIndex,
15751576
];
1576-
1577-
if ($objOwner !== null && $objOwnerIdx !== null) {
1578-
$this->rsm->addNewObjectAsArgument($objIndex, $objOwner, $objOwnerIdx);
1579-
}
15801577
}
15811578

15821579
return implode(', ', $sqlSelectExpressions);

src/Query/TokenType.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,4 +89,5 @@ enum TokenType: int
8989
case T_WHEN = 254;
9090
case T_WHERE = 255;
9191
case T_WITH = 256;
92+
case T_NAMED = 257;
9293
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Doctrine\Tests\Models\CMS;
6+
7+
class CmsAddressDTONamedArgs
8+
{
9+
public function __construct(
10+
public string|null $country = null,
11+
public string|null $city = null,
12+
public string|null $zip = null,
13+
public CmsAddressDTO|string|null $address = null,
14+
) {
15+
}
16+
}

0 commit comments

Comments
 (0)