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 Sep 15, 2024
1 parent 5724e62 commit c4b9991
Show file tree
Hide file tree
Showing 14 changed files with 620 additions and 74 deletions.
68 changes: 65 additions & 3 deletions docs/en/reference/dql-doctrine-query-language.rst
Original file line number Diff line number Diff line change
Expand Up @@ -591,23 +591,85 @@ 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 but not necessarily with the same parameters,
you can use named arguments or variadic arguments, add the ``named`` keyword in your DQL query before your DTO :

.. 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,
) {
}
}
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 :

.. 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 look column aliases before columns 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, or with PHP's named arguments syntax.

.. code-block:: php
<?php
$query = $em->createQuery('SELECT NEW NAMED CustomerDTO(name: c.name, value: CONCAT(a.city, ' ' , a.zip)) FROM Customer c JOIN c.address a');
$users = $query->getResult(); // array of CustomerDTO
// CustomerDTO => {name : 'DOE', email: null, city: null, value: 'New York 10011'}
The ``NAMED`` keyword must precede all DTO you want to instantiate :

.. code-block:: php
<?php
$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');
$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
1 change: 1 addition & 0 deletions psalm-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -961,6 +961,7 @@
<code><![CDATA[$lookaheadType->value]]></code>
<code><![CDATA[$lookaheadType->value]]></code>
<code><![CDATA[$this->lexer->glimpse()->type]]></code>
<code><![CDATA[$token->type]]></code>
<code><![CDATA[$token->value]]></code>
<code><![CDATA[$token->value]]></code>
</PossiblyNullPropertyFetch>
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` has no property name or alias.', $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
72 changes: 62 additions & 10 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 ")"
*/
public function NewObjectArg(): mixed
public function NewObjectArg(string|null &$fieldAlias = null): mixed
{
$namedArg = false;
$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_IDENTIFIER && $peek->value === ':') {
$fieldAlias = $this->AliasIdentificationVariable();
$this->lexer->moveNext();
$namedArg = true;
$token = $this->lexer->lookahead;
}

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 (! $namedArg && $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 c4b9991

Please sign in to comment.