Skip to content

Commit c4b9991

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 c4b9991

14 files changed

+620
-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 the ``named`` keyword in your DQL query 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 $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` has no property name or alias.', $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: 62 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\DuplicateFieldException;
10+
use Doctrine\ORM\Exception\NoMatchingPropertyException;
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;
@@ -30,6 +33,7 @@
3033
use function strrpos;
3134
use function strtolower;
3235
use function substr;
36+
use function trim;
3337

3438
/**
3539
* An LL(*) recursive-descent parser for the context-free grammar of the Doctrine Query Language.
@@ -1734,20 +1738,26 @@ public function PartialObjectExpression(): AST\PartialObjectExpression
17341738
*/
17351739
public function NewObjectExpression(): AST\NewObjectExpression
17361740
{
1737-
$args = [];
1741+
$useNamedArguments = false;
1742+
$args = [];
1743+
$argFieldAlias = [];
17381744
$this->match(TokenType::T_NEW);
17391745

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

17431754
$this->match(TokenType::T_OPEN_PARENTHESIS);
17441755

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

17471758
while ($this->lexer->isNextToken(TokenType::T_COMMA)) {
17481759
$this->match(TokenType::T_COMMA);
1749-
1750-
$args[] = $this->NewObjectArg();
1760+
$this->addArgument($args, $useNamedArguments);
17511761
}
17521762

17531763
$this->match(TokenType::T_CLOSE_PARENTHESIS);
@@ -1764,29 +1774,71 @@ public function NewObjectExpression(): AST\NewObjectExpression
17641774
return $expression;
17651775
}
17661776

1777+
/** @param array<mixed> $args */
1778+
public function addArgument(array &$args, bool $useNamedArguments): void
1779+
{
1780+
$fieldAlias = null;
1781+
1782+
if ($useNamedArguments) {
1783+
$startToken = $this->lexer->lookahead?->position ?? 0;
1784+
1785+
$newArg = $this->NewObjectArg($fieldAlias);
1786+
1787+
$key = $fieldAlias ?? $newArg->field ?? null;
1788+
1789+
if ($key === null) {
1790+
throw NoMatchingPropertyException::create(trim(substr(($this->query->getDQL() ?? ''), $startToken, ($this->lexer->lookahead->position ?? 0) - $startToken)));
1791+
}
1792+
1793+
if (array_key_exists($key, $args)) {
1794+
throw DuplicateFieldException::create($key, trim(substr(($this->query->getDQL() ?? ''), $startToken, ($this->lexer->lookahead->position ?? 0) - $startToken)));
1795+
}
1796+
1797+
$args[$key] = $newArg;
1798+
} else {
1799+
$args[] = $this->NewObjectArg($fieldAlias);
1800+
}
1801+
}
1802+
17671803
/**
17681804
* NewObjectArg ::= ScalarExpression | "(" Subselect ")"
17691805
*/
1770-
public function NewObjectArg(): mixed
1806+
public function NewObjectArg(string|null &$fieldAlias = null): mixed
17711807
{
1808+
$namedArg = false;
1809+
$fieldAlias = null;
1810+
17721811
assert($this->lexer->lookahead !== null);
17731812
$token = $this->lexer->lookahead;
17741813
$peek = $this->lexer->glimpse();
17751814

17761815
assert($peek !== null);
1816+
1817+
$expression = null;
1818+
1819+
if ($token->type === TokenType::T_IDENTIFIER && $peek->value === ':') {
1820+
$fieldAlias = $this->AliasIdentificationVariable();
1821+
$this->lexer->moveNext();
1822+
$namedArg = true;
1823+
$token = $this->lexer->lookahead;
1824+
}
1825+
17771826
if ($token->type === TokenType::T_OPEN_PARENTHESIS && $peek->type === TokenType::T_SELECT) {
17781827
$this->match(TokenType::T_OPEN_PARENTHESIS);
17791828
$expression = $this->Subselect();
17801829
$this->match(TokenType::T_CLOSE_PARENTHESIS);
1781-
1782-
return $expression;
1830+
} elseif ($token->type === TokenType::T_NEW) {
1831+
$expression = $this->NewObjectExpression();
1832+
} else {
1833+
$expression = $this->ScalarExpression();
17831834
}
17841835

1785-
if ($token->type === TokenType::T_NEW) {
1786-
return $this->NewObjectExpression();
1836+
if (! $namedArg && $this->lexer->isNextToken(TokenType::T_AS)) {
1837+
$this->match(TokenType::T_AS);
1838+
$fieldAlias = $this->AliasIdentificationVariable();
17871839
}
17881840

1789-
return $this->ScalarExpression();
1841+
return $expression;
17901842
}
17911843

17921844
/**

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)