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 a983ab2
Show file tree
Hide file tree
Showing 14 changed files with 612 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 ``named`` keyword in your DQL querie 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 fields you want in the order you want, and the ORM will try to match argument names with the selected field 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'}
In order to alias a field you can either use the SQL keyword ``AS``, or 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 fields have the same name/alias, a ``RepeatedAliasException`` is thrown.
If a column is not a field and don't have an alias set (when using functions for example), a ``MissingAliasException`` is thrown.

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
15 changes: 15 additions & 0 deletions src/Exception/MissingAliasException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Doctrine\ORM\Exception;

use LogicException;

class MissingAliasException extends LogicException implements ORMException
{
public static function create(): self
{
return new self('Alias is missing on a select part.');
}
}
17 changes: 17 additions & 0 deletions src/Exception/RepeatedAliasException.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 RepeatedAliasException extends LogicException implements ORMException
{
public static function create(string $arg): self
{
return new self(sprintf('Two aliases are found for : %s', $arg));
}
}
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
69 changes: 59 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\MissingAliasException;
use Doctrine\ORM\Exception\RepeatedAliasException;
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 Down Expand Up @@ -1734,20 +1737,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 +1773,69 @@ public function NewObjectExpression(): AST\NewObjectExpression
return $expression;
}

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

if ($useNamedArguments) {
$newArg = $this->NewObjectArg($fieldAlias);

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

if ($key === null) {
throw MissingAliasException::create();
}

if (array_key_exists($key, $args)) {
throw RepeatedAliasException::create($key);
}

$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 a983ab2

Please sign in to comment.