Skip to content

Commit

Permalink
Allow named Arguments to be passed to Dto
Browse files Browse the repository at this point in the history
Allow to change argument order or use variadic argument in dto constructor using new named keyword
  • Loading branch information
eltharin committed Oct 10, 2024
1 parent 434b7ce commit c223b8f
Show file tree
Hide file tree
Showing 13 changed files with 550 additions and 76 deletions.
65 changes: 61 additions & 4 deletions docs/en/reference/dql-doctrine-query-language.rst
Original file line number Diff line number Diff line change
Expand Up @@ -591,23 +591,80 @@ You can also nest several DTO :
// Bind values to the object properties.
}
}
class AddressDTO
{
public function __construct(string $street, string $city, string $zip)
{
// Bind values to the object properties.
}
}
.. code-block:: php
<?php
$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');
$users = $query->getResult(); // array of CustomerDTO
Note that you can only pass scalar expressions or other Data Transfer Objects to the constructor.

If you use your data transfer objects for multiple queries, and you would rather not have to
specify arguments that precede the ones you are really interested in, you can use named arguments.

Consider the following DTO, which uses optional arguments:

.. code-block:: php
<?php
class CustomerDTO
{
public function __construct(
public string|null $name = null,
public string|null $email = null,
public string|null $city = null,
public mixed|null $value = null,
public AddressDTO|null $address = null,
) {
}
}
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.
The syntax relies on the NAMED keyword, like so:

.. code-block:: php
<?php
$query = $em->createQuery('SELECT NEW NAMED CustomerDTO(a.city, c.name) FROM Customer c JOIN c.address a');
$users = $query->getResult(); // array of CustomerDTO
// CustomerDTO => {name : 'SMITH', email: null, city: 'London', value: null}
ORM will also give precedence to column aliases over column names :

.. code-block:: php
<?php
$query = $em->createQuery('SELECT NEW NAMED CustomerDTO(c.name, CONCAT(a.city, ' ' , a.zip) AS value) FROM Customer c JOIN c.address a');
$users = $query->getResult(); // array of CustomerDTO
// CustomerDTO => {name : 'DOE', email: null, city: null, value: 'New York 10011'}
To define a custom name for a DTO constructor argument, you can either alias the column with the ``AS`` keyword.

The ``NAMED`` keyword must precede all DTO you want to instantiate :

.. code-block:: php
<?php
$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');
$users = $query->getResult(); // array of CustomerDTO
// CustomerDTO => {name : 'DOE', email: null, city: null, value: 'New York 10011'}
If two arguments have the same name, a ``DuplicateFieldException`` is thrown.
If a field cannot be matched with a property name, a ``NoMatchingPropertyException`` is thrown. This typically happens when using functions without aliasing them.

Using INDEX BY
~~~~~~~~~~~~~~

Expand Down Expand Up @@ -1627,7 +1684,7 @@ Select Expressions
PartialObjectExpression ::= "PARTIAL" IdentificationVariable "." PartialFieldSet
PartialFieldSet ::= "{" SimpleStateField {"," SimpleStateField}* "}"
NewObjectExpression ::= "NEW" AbstractSchemaName "(" NewObjectArg {"," NewObjectArg}* ")"
NewObjectArg ::= ScalarExpression | "(" Subselect ")" | NewObjectExpression
NewObjectArg ::= (ScalarExpression | "(" Subselect ")" | NewObjectExpression) ["AS" AliasResultVariable]
Conditional Expressions
~~~~~~~~~~~~~~~~~~~~~~~
Expand Down
17 changes: 17 additions & 0 deletions src/Exception/DuplicateFieldException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Doctrine\ORM\Exception;

use LogicException;

use function sprintf;

class DuplicateFieldException extends LogicException implements ORMException
{
public static function create(string $argName, string $columnName): self
{
return new self(sprintf('Name "%s" for "%s" already in use.', $argName, $columnName));
}
}
17 changes: 17 additions & 0 deletions src/Exception/NoMatchingPropertyException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Doctrine\ORM\Exception;

use LogicException;

use function sprintf;

class NoMatchingPropertyException extends LogicException implements ORMException
{
public static function create(string $property): self
{
return new self(sprintf('Column name "%s" does not match any property name. Consider aliasing it to the name of an existing property.', $property));
}
}
26 changes: 8 additions & 18 deletions src/Internal/Hydration/AbstractHydrator.php
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ abstract protected function hydrateAllData(): mixed;
*/
protected function gatherRowData(array $data, array &$id, array &$nonemptyComponents): array
{
$rowData = ['data' => []];
$rowData = ['data' => [], 'newObjects' => []];

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

if (! isset($rowData['newObjects'])) {
$rowData['newObjects'] = [];
}

$rowData['newObjects'][$objIndex]['class'] = $cacheKeyInfo['class'];
$rowData['newObjects'][$objIndex]['args'][$argIndex] = $value;
break;
Expand Down Expand Up @@ -341,28 +337,22 @@ protected function gatherRowData(array $data, array &$id, array &$nonemptyCompon
}

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

$newObject = $rowData['newObjects'][$objIndex];
unset($rowData['newObjects'][$objIndex]);
$newObject = $rowData['newObjects'][$ownerIndex . ':' . $argIndex];
unset($rowData['newObjects'][$ownerIndex . ':' . $argIndex]);

$class = $newObject['class'];
$args = $newObject['args'];
$obj = $class->newInstanceArgs($args);
$obj = $newObject['class']->newInstanceArgs($newObject['args']);

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

if (isset($rowData['newObjects'])) {
foreach ($rowData['newObjects'] as $objIndex => $newObject) {
$class = $newObject['class'];
$args = $newObject['args'];
$obj = $class->newInstanceArgs($args);
foreach ($rowData['newObjects'] as $objIndex => $newObject) {
$obj = $newObject['class']->newInstanceArgs($newObject['args']);

$rowData['newObjects'][$objIndex]['obj'] = $obj;
}
$rowData['newObjects'][$objIndex]['obj'] = $obj;
}

return $rowData;
Expand Down
74 changes: 63 additions & 11 deletions src/Query/Parser.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

use Doctrine\Common\Lexer\Token;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Exception\DuplicateFieldException;
use Doctrine\ORM\Exception\NoMatchingPropertyException;
use Doctrine\ORM\Internal\Hydration\HydrationException;
use Doctrine\ORM\Mapping\AssociationMapping;
use Doctrine\ORM\Mapping\ClassMetadata;
Expand All @@ -15,6 +17,7 @@
use ReflectionClass;

use function array_intersect;
use function array_key_exists;
use function array_search;
use function assert;
use function class_exists;
Expand All @@ -30,6 +33,7 @@
use function strrpos;
use function strtolower;
use function substr;
use function trim;

/**
* An LL(*) recursive-descent parser for the context-free grammar of the Doctrine Query Language.
Expand Down Expand Up @@ -1734,20 +1738,26 @@ public function PartialObjectExpression(): AST\PartialObjectExpression
*/
public function NewObjectExpression(): AST\NewObjectExpression
{
$args = [];
$useNamedArguments = false;
$args = [];
$argFieldAlias = [];
$this->match(TokenType::T_NEW);

if ($this->lexer->isNextToken(TokenType::T_NAMED)) {
$this->match(TokenType::T_NAMED);
$useNamedArguments = true;
}

$className = $this->AbstractSchemaName(); // note that this is not yet validated
$token = $this->lexer->token;

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

$args[] = $this->NewObjectArg();
$this->addArgument($args, $useNamedArguments);

while ($this->lexer->isNextToken(TokenType::T_COMMA)) {
$this->match(TokenType::T_COMMA);

$args[] = $this->NewObjectArg();
$this->addArgument($args, $useNamedArguments);
}

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

/** @param array<mixed> $args */
public function addArgument(array &$args, bool $useNamedArguments): void
{
$fieldAlias = null;

if ($useNamedArguments) {
$startToken = $this->lexer->lookahead?->position ?? 0;

$newArg = $this->NewObjectArg($fieldAlias);

$key = $fieldAlias ?? $newArg->field ?? null;

if ($key === null) {
throw NoMatchingPropertyException::create(trim(substr(
($this->query->getDQL() ?? ''),
$startToken,
($this->lexer->lookahead->position ?? 0) - $startToken,
)));
}

if (array_key_exists($key, $args)) {
throw DuplicateFieldException::create($key, trim(substr(
($this->query->getDQL() ?? ''),
$startToken,
($this->lexer->lookahead->position ?? 0) - $startToken,
)));
}

$args[$key] = $newArg;
} else {
$args[] = $this->NewObjectArg($fieldAlias);
}
}

/**
* NewObjectArg ::= ScalarExpression | "(" Subselect ")" | NewObjectExpression
* NewObjectArg ::= (ScalarExpression | "(" Subselect ")" | NewObjectExpression) ["AS" AliasResultVariable]
*/
public function NewObjectArg(): mixed
public function NewObjectArg(string|null &$fieldAlias = null): mixed
{
$fieldAlias = 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);

return $expression;
} elseif ($token->type === TokenType::T_NEW) {
$expression = $this->NewObjectExpression();
} else {
$expression = $this->ScalarExpression();
}

if ($token->type === TokenType::T_NEW) {
return $this->NewObjectExpression();
if ($this->lexer->isNextToken(TokenType::T_AS)) {
$this->match(TokenType::T_AS);
$fieldAlias = $this->AliasIdentificationVariable();
}

return $this->ScalarExpression();
return $expression;
}

/**
Expand Down
22 changes: 0 additions & 22 deletions src/Query/ResultSetMapping.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

namespace Doctrine\ORM\Query;

use function array_merge;
use function count;

/**
Expand Down Expand Up @@ -552,25 +551,4 @@ public function addMetaResult(

return $this;
}

public function addNewObjectAsArgument(string|int $alias, string|int $objOwner, int $objOwnerIdx): static
{
$owner = [
'ownerIndex' => $objOwner,
'argIndex' => $objOwnerIdx,
];

if (! isset($this->nestedNewObjectArguments[$owner['ownerIndex']])) {
$this->nestedNewObjectArguments[$alias] = $owner;

return $this;
}

$this->nestedNewObjectArguments = array_merge(
[$alias => $owner],
$this->nestedNewObjectArguments,
);

return $this;
}
}
5 changes: 1 addition & 4 deletions src/Query/SqlWalker.php
Original file line number Diff line number Diff line change
Expand Up @@ -1510,6 +1510,7 @@ public function walkNewObject(AST\NewObjectExpression $newObjectExpression, stri
$this->newObjectStack[] = [$objIndex, $argIndex];
$sqlSelectExpressions[] = $e->dispatch($this);
array_pop($this->newObjectStack);
$this->rsm->nestedNewObjectArguments[$columnAlias] = ['ownerIndex' => $objIndex, 'argIndex' => $argIndex];
break;

case $e instanceof AST\Subselect:
Expand Down Expand Up @@ -1563,10 +1564,6 @@ public function walkNewObject(AST\NewObjectExpression $newObjectExpression, stri
'objIndex' => $objIndex,
'argIndex' => $argIndex,
];

if ($objOwner !== null && $objOwnerIdx !== null) {
$this->rsm->addNewObjectAsArgument($objIndex, $objOwner, $objOwnerIdx);
}
}

return implode(', ', $sqlSelectExpressions);
Expand Down
1 change: 1 addition & 0 deletions src/Query/TokenType.php
Original file line number Diff line number Diff line change
Expand Up @@ -89,4 +89,5 @@ enum TokenType: int
case T_WHEN = 254;
case T_WHERE = 255;
case T_WITH = 256;
case T_NAMED = 257;
}
16 changes: 16 additions & 0 deletions tests/Tests/Models/CMS/CmsAddressDTONamedArgs.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace Doctrine\Tests\Models\CMS;

class CmsAddressDTONamedArgs
{
public function __construct(
public string|null $country = null,
public string|null $city = null,
public string|null $zip = null,
public CmsAddressDTO|string|null $address = null,
) {
}
}
Loading

0 comments on commit c223b8f

Please sign in to comment.