Skip to content

Commit 4a1cd17

Browse files
committed
Allow named Arguments to be passed to Dto
Allow to change argument order or use variadic argument in dto constructor using new named keyword
1 parent 5724e62 commit 4a1cd17

14 files changed

+612
-74
lines changed

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

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -591,23 +591,85 @@ 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 but not necessarily with the same parameters,
612+
you can use named arguments or variadic arguments, add ``named`` keyword in your DQL querie before your Dto :
613+
614+
.. code-block:: php
615+
616+
<?php
617+
618+
class CustomerDTO
619+
{
620+
public function __construct(
621+
public string|null $name = null,
622+
public string|null $email = null,
623+
public string|null $city = null,
624+
public mixed|null $value = null,
625+
public AddressDTO|null $address = null,
626+
) {
627+
}
628+
}
629+
630+
And then you can select the columns you want in the order you want, and the ORM will try to match argument names with the selected columns names :
631+
632+
.. code-block:: php
633+
634+
<?php
635+
$query = $em->createQuery('SELECT NEW NAMED CustomerDTO(a.city, c.name) FROM Customer c JOIN c.address a');
636+
$users = $query->getResult(); // array of CustomerDTO
637+
638+
// CustomerDTO => {name : 'SMITH', email: null, city: 'London', value: null}
639+
640+
ORM will also look column aliases before columns names :
641+
642+
.. code-block:: php
643+
644+
<?php
645+
$query = $em->createQuery('SELECT NEW NAMED CustomerDTO(c.name, CONCAT(a.city, ' ' , a.zip) AS value) FROM Customer c JOIN c.address a');
646+
$users = $query->getResult(); // array of CustomerDTO
647+
648+
// CustomerDTO => {name : 'DOE', email: null, city: null, value: 'New York 10011'}
649+
650+
To define a custom name for a DTO constructor argument, you can either alias the column with the ``AS`` keyword, or with PHP's named arguments syntax.
651+
652+
.. code-block:: php
653+
654+
<?php
655+
$query = $em->createQuery('SELECT NEW NAMED CustomerDTO(name: c.name, value: CONCAT(a.city, ' ' , a.zip)) FROM Customer c JOIN c.address a');
656+
$users = $query->getResult(); // array of CustomerDTO
657+
658+
// CustomerDTO => {name : 'DOE', email: null, city: null, value: 'New York 10011'}
659+
660+
The ``NAMED`` keyword must precede all DTO you want to instantiate :
661+
662+
.. code-block:: php
663+
664+
<?php
665+
$query = $em->createQuery('SELECT NEW NAMED CustomerDTO(name: c.name, address: NEW NAMED AddressDTO(a.street, a.city, a.zip)) FROM Customer c JOIN c.address a');
666+
$users = $query->getResult(); // array of CustomerDTO
667+
668+
// CustomerDTO => {name : 'DOE', email: null, city: null, value: 'New York 10011'}
669+
670+
If two arguments have the same name, a ``DuplicateFieldException`` is thrown.
671+
If a field cannot be matched with a property name, a ``NoMatchingPropertyException`` is thrown. This typically happens when using functions without aliasing them.
672+
611673
Using INDEX BY
612674
~~~~~~~~~~~~~~
613675

psalm-baseline.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -961,6 +961,7 @@
961961
<code><![CDATA[$lookaheadType->value]]></code>
962962
<code><![CDATA[$lookaheadType->value]]></code>
963963
<code><![CDATA[$this->lexer->glimpse()->type]]></code>
964+
<code><![CDATA[$token->type]]></code>
964965
<code><![CDATA[$token->value]]></code>
965966
<code><![CDATA[$token->value]]></code>
966967
</PossiblyNullPropertyFetch>
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 $arg): self
14+
{
15+
return new self(sprintf('Identicals names are found for : %s', $arg));
16+
}
17+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Doctrine\ORM\Exception;
6+
7+
use LogicException;
8+
9+
class NoMatchingPropertyException extends LogicException implements ORMException
10+
{
11+
public static function create(): self
12+
{
13+
return new self('Column Name has no property name or alias.');
14+
}
15+
}

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: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
use Doctrine\Common\Lexer\Token;
88
use Doctrine\ORM\EntityManagerInterface;
9+
use Doctrine\ORM\Exception\NoMatchingPropertyException;
10+
use Doctrine\ORM\Exception\DuplicateFieldException;
911
use Doctrine\ORM\Internal\Hydration\HydrationException;
1012
use Doctrine\ORM\Mapping\AssociationMapping;
1113
use Doctrine\ORM\Mapping\ClassMetadata;
@@ -15,6 +17,7 @@
1517
use ReflectionClass;
1618

1719
use function array_intersect;
20+
use function array_key_exists;
1821
use function array_search;
1922
use function assert;
2023
use function class_exists;
@@ -1734,20 +1737,26 @@ public function PartialObjectExpression(): AST\PartialObjectExpression
17341737
*/
17351738
public function NewObjectExpression(): AST\NewObjectExpression
17361739
{
1737-
$args = [];
1740+
$useNamedArguments = false;
1741+
$args = [];
1742+
$argFieldAlias = [];
17381743
$this->match(TokenType::T_NEW);
17391744

1745+
if ($this->lexer->isNextToken(TokenType::T_NAMED)) {
1746+
$this->match(TokenType::T_NAMED);
1747+
$useNamedArguments = true;
1748+
}
1749+
17401750
$className = $this->AbstractSchemaName(); // note that this is not yet validated
17411751
$token = $this->lexer->token;
17421752

17431753
$this->match(TokenType::T_OPEN_PARENTHESIS);
17441754

1745-
$args[] = $this->NewObjectArg();
1755+
$this->addArgument($args, $useNamedArguments);
17461756

17471757
while ($this->lexer->isNextToken(TokenType::T_COMMA)) {
17481758
$this->match(TokenType::T_COMMA);
1749-
1750-
$args[] = $this->NewObjectArg();
1759+
$this->addArgument($args, $useNamedArguments);
17511760
}
17521761

17531762
$this->match(TokenType::T_CLOSE_PARENTHESIS);
@@ -1764,29 +1773,69 @@ public function NewObjectExpression(): AST\NewObjectExpression
17641773
return $expression;
17651774
}
17661775

1776+
/** @param array<mixed> $args */
1777+
public function addArgument(array &$args, bool $useNamedArguments): void
1778+
{
1779+
$fieldAlias = null;
1780+
1781+
if ($useNamedArguments) {
1782+
$newArg = $this->NewObjectArg($fieldAlias);
1783+
1784+
$key = $fieldAlias ?? $newArg->field;
1785+
1786+
if ($key === null) {
1787+
throw NoMatchingPropertyException::create();
1788+
}
1789+
1790+
if (array_key_exists($key, $args)) {
1791+
throw DuplicateFieldException::create($key);
1792+
}
1793+
1794+
$args[$key] = $newArg;
1795+
} else {
1796+
$args[] = $this->NewObjectArg($fieldAlias);
1797+
}
1798+
}
1799+
17671800
/**
17681801
* NewObjectArg ::= ScalarExpression | "(" Subselect ")"
17691802
*/
1770-
public function NewObjectArg(): mixed
1803+
public function NewObjectArg(string|null &$fieldAlias = null): mixed
17711804
{
1805+
$namedArg = false;
1806+
$fieldAlias = null;
1807+
17721808
assert($this->lexer->lookahead !== null);
17731809
$token = $this->lexer->lookahead;
17741810
$peek = $this->lexer->glimpse();
17751811

17761812
assert($peek !== null);
1813+
1814+
$expression = null;
1815+
1816+
if ($token->type === TokenType::T_IDENTIFIER && $peek->value === ':') {
1817+
$fieldAlias = $this->AliasIdentificationVariable();
1818+
$this->lexer->moveNext();
1819+
$namedArg = true;
1820+
$token = $this->lexer->lookahead;
1821+
}
1822+
17771823
if ($token->type === TokenType::T_OPEN_PARENTHESIS && $peek->type === TokenType::T_SELECT) {
17781824
$this->match(TokenType::T_OPEN_PARENTHESIS);
17791825
$expression = $this->Subselect();
17801826
$this->match(TokenType::T_CLOSE_PARENTHESIS);
1781-
1782-
return $expression;
1827+
} elseif ($token->type === TokenType::T_NEW) {
1828+
$expression = $this->NewObjectExpression();
1829+
} else {
1830+
$expression = $this->ScalarExpression();
17831831
}
17841832

1785-
if ($token->type === TokenType::T_NEW) {
1786-
return $this->NewObjectExpression();
1833+
if (! $namedArg && $this->lexer->isNextToken(TokenType::T_AS)) {
1834+
$this->match(TokenType::T_AS);
1835+
$fieldAlias = $this->AliasIdentificationVariable();
17871836
}
17881837

1789-
return $this->ScalarExpression();
1838+
return $expression;
17901839
}
17911840

17921841
/**

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
/**
@@ -552,25 +551,4 @@ public function addMetaResult(
552551

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

src/Query/SqlWalker.php

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1510,6 +1510,7 @@ public function walkNewObject(AST\NewObjectExpression $newObjectExpression, stri
15101510
$this->newObjectStack[] = [$objIndex, $argIndex];
15111511
$sqlSelectExpressions[] = $e->dispatch($this);
15121512
array_pop($this->newObjectStack);
1513+
$this->rsm->nestedNewObjectArguments[$columnAlias] = ['ownerIndex' => $objIndex, 'argIndex' => $argIndex];
15131514
break;
15141515

15151516
case $e instanceof AST\Subselect:
@@ -1563,10 +1564,6 @@ public function walkNewObject(AST\NewObjectExpression $newObjectExpression, stri
15631564
'objIndex' => $objIndex,
15641565
'argIndex' => $argIndex,
15651566
];
1566-
1567-
if ($objOwner !== null && $objOwnerIdx !== null) {
1568-
$this->rsm->addNewObjectAsArgument($objIndex, $objOwner, $objOwnerIdx);
1569-
}
15701567
}
15711568

15721569
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)