Skip to content

Commit

Permalink
Add support for using nested DTOs
Browse files Browse the repository at this point in the history
This feature allow use of nested new operators

Co-authored-by: Tomas Norkūnas <[email protected]>
Co-authored-by: Sergey Protko <[email protected]>
Co-authored-by: Łukasz Zakrzewski <[email protected]>

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

Co-authored-by: Claudio Zizza <[email protected]>
  • Loading branch information
eltharin and SenseException committed Aug 19, 2024
1 parent 5f1fe15 commit 8c582a4
Show file tree
Hide file tree
Showing 16 changed files with 402 additions and 18 deletions.
29 changes: 28 additions & 1 deletion docs/en/reference/dql-doctrine-query-language.rst
Original file line number Diff line number Diff line change
Expand Up @@ -579,7 +579,34 @@ And then use the ``NEW`` DQL keyword :
$query = $em->createQuery('SELECT NEW CustomerDTO(c.name, e.email, a.city, SUM(o.value)) FROM Customer c JOIN c.email e JOIN c.address a JOIN c.orders o GROUP BY c');
$users = $query->getResult(); // array of CustomerDTO
Note that you can only pass scalar expressions to the constructor.
You can also nest several DTO :

.. code-block:: php
<?php
class CustomerDTO
{
public function __construct(string $name, string $email, AddressDTO $address, string|null $value = null)
{
// 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.

Using INDEX BY
~~~~~~~~~~~~~~
Expand Down
10 changes: 4 additions & 6 deletions psalm-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,10 @@
<code><![CDATA[return $rowData;]]></code>
<code><![CDATA[return $rowData;]]></code>
</ReferenceConstraintViolation>
<PossiblyUndefinedArrayOffset>
<code><![CDATA[$newObject['args']]]></code>
<code><![CDATA[$newObject['args']]]></code>
</PossiblyUndefinedArrayOffset>
</file>
<file src="src/Internal/Hydration/ArrayHydrator.php">
<PossiblyInvalidArgument>
Expand All @@ -228,9 +232,6 @@
<code><![CDATA[$result[$resultKey]]]></code>
<code><![CDATA[$result[$resultKey]]]></code>
</PossiblyNullArrayAssignment>
<PossiblyUndefinedArrayOffset>
<code><![CDATA[$newObject['args']]]></code>
</PossiblyUndefinedArrayOffset>
<ReferenceConstraintViolation>
<code><![CDATA[$result]]></code>
</ReferenceConstraintViolation>
Expand Down Expand Up @@ -265,9 +266,6 @@
<code><![CDATA[setValue]]></code>
<code><![CDATA[setValue]]></code>
</PossiblyNullReference>
<PossiblyUndefinedArrayOffset>
<code><![CDATA[$newObject['args']]]></code>
</PossiblyUndefinedArrayOffset>
</file>
<file src="src/Mapping/AssociationMapping.php">
<LessSpecificReturnStatement>
Expand Down
34 changes: 32 additions & 2 deletions src/Internal/Hydration/AbstractHydrator.php
Original file line number Diff line number Diff line change
Expand Up @@ -252,8 +252,9 @@ abstract protected function hydrateAllData(): mixed;
* @psalm-return array{
* data: array<array-key, array>,
* newObjects?: array<array-key, array{
* class: mixed,
* args?: array
* class: ReflectionClass,
* args: array,
* obj: object
* }>,
* scalars?: array
* }
Expand Down Expand Up @@ -281,6 +282,10 @@ 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 @@ -335,6 +340,31 @@ protected function gatherRowData(array $data, array &$id, array &$nonemptyCompon
}
}

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

Check warning on line 345 in src/Internal/Hydration/AbstractHydrator.php

View check run for this annotation

Codecov / codecov/patch

src/Internal/Hydration/AbstractHydrator.php#L345

Added line #L345 was not covered by tests
}

$newObject = $rowData['newObjects'][$objIndex];
unset($rowData['newObjects'][$objIndex]);

$class = $newObject['class'];
$args = $newObject['args'];
$obj = $class->newInstanceArgs($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);

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

return $rowData;
}

Expand Down
5 changes: 2 additions & 3 deletions src/Internal/Hydration/ArrayHydrator.php
Original file line number Diff line number Diff line change
Expand Up @@ -214,9 +214,8 @@ protected function hydrateRowData(array $row, array &$result): void
$scalarCount = (isset($rowData['scalars']) ? count($rowData['scalars']) : 0);

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

if (count($args) === $scalarCount || ($scalarCount === 0 && count($rowData['newObjects']) === 1)) {
$result[$resultKey] = $obj;
Expand Down
4 changes: 1 addition & 3 deletions src/Internal/Hydration/ObjectHydrator.php
Original file line number Diff line number Diff line change
Expand Up @@ -556,9 +556,7 @@ protected function hydrateRowData(array $row, array &$result): void
$scalarCount = (isset($rowData['scalars']) ? count($rowData['scalars']) : 0);

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

if ($scalarCount === 0 && count($rowData['newObjects']) === 1) {
$result[$resultKey] = $obj;
Expand Down
4 changes: 4 additions & 0 deletions src/Query/Parser.php
Original file line number Diff line number Diff line change
Expand Up @@ -1782,6 +1782,10 @@ public function NewObjectArg(): mixed
return $expression;
}

if ($token->type === TokenType::T_NEW) {
return $this->NewObjectExpression();
}

return $this->ScalarExpression();
}

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

namespace Doctrine\ORM\Query;

use function array_merge;
use function count;

/**
Expand Down Expand Up @@ -152,6 +153,13 @@ class ResultSetMapping
*/
public array $newObjectMappings = [];

/**
* Maps last argument for new objects in order to initiate object construction
*
* @psalm-var array<int|string, array{ownerIndex: string|int, argIndex: int|string}>
*/
public array $nestedNewObjectArguments = [];

/**
* Maps metadata parameter names to the metadata attribute.
*
Expand Down Expand Up @@ -544,4 +552,25 @@ 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;
}
}
24 changes: 23 additions & 1 deletion src/Query/SqlWalker.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@
use function array_keys;
use function array_map;
use function array_merge;
use function array_pop;
use function assert;
use function count;
use function end;
use function implode;
use function in_array;
use function is_array;
Expand Down Expand Up @@ -85,6 +87,13 @@ class SqlWalker
*/
private int $newObjectCounter = 0;

/**
* Contains nesting levels of new objects arguments
*
* @psalm-var array<int, array{0: string|int, 1: int}>
*/
private array $newObjectStack = [];

private readonly EntityManagerInterface $em;
private readonly Connection $conn;

Expand Down Expand Up @@ -1482,7 +1491,14 @@ public function walkParenthesisExpression(AST\ParenthesisExpression $parenthesis
public function walkNewObject(AST\NewObjectExpression $newObjectExpression, string|null $newObjectResultAlias = null): string
{
$sqlSelectExpressions = [];
$objIndex = $newObjectResultAlias ?: $this->newObjectCounter++;
$objOwner = $objOwnerIdx = null;

if ($this->newObjectStack !== []) {
[$objOwner, $objOwnerIdx] = end($this->newObjectStack);
$objIndex = $objOwner . ':' . $objOwnerIdx;
} else {
$objIndex = $newObjectResultAlias ?: $this->newObjectCounter++;
}

foreach ($newObjectExpression->args as $argIndex => $e) {
$resultAlias = $this->scalarResultCounter++;
Expand All @@ -1491,7 +1507,9 @@ public function walkNewObject(AST\NewObjectExpression $newObjectExpression, stri

switch (true) {
case $e instanceof AST\NewObjectExpression:
$this->newObjectStack[] = [$objIndex, $argIndex];
$sqlSelectExpressions[] = $e->dispatch($this);
array_pop($this->newObjectStack);
break;

case $e instanceof AST\Subselect:
Expand Down Expand Up @@ -1545,6 +1563,10 @@ 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
2 changes: 1 addition & 1 deletion tests/Tests/Models/CMS/CmsAddressDTO.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

class CmsAddressDTO
{
public function __construct(public string|null $country = null, public string|null $city = null, public string|null $zip = null)
public function __construct(public string|null $country = null, public string|null $city = null, public string|null $zip = null, public CmsAddressDTO|string|null $address = null)
{
}
}
2 changes: 1 addition & 1 deletion tests/Tests/Models/CMS/CmsUserDTO.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

class CmsUserDTO
{
public function __construct(public string|null $name = null, public string|null $email = null, public string|null $address = null, public int|null $phonenumbers = null)
public function __construct(public string|null $name = null, public string|null $email = null, public CmsAddressDTO|string|null $address = null, public int|null $phonenumbers = null)
{
}
}
17 changes: 17 additions & 0 deletions tests/Tests/Models/DDC6573/DDC6573Currency.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Doctrine\Tests\Models\DDC6573;

final class DDC6573Currency
{
public function __construct(private readonly string $code)
{
}

public function getCode(): string
{
return $this->code;
}
}
44 changes: 44 additions & 0 deletions tests/Tests/Models/DDC6573/DDC6573Item.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

namespace Doctrine\Tests\Models\DDC6573;

use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Entity;
use Doctrine\ORM\Mapping\GeneratedValue;
use Doctrine\ORM\Mapping\Id;
use Doctrine\ORM\Mapping\Table;

#[Entity]
#[Table(name: 'ddc6573_items')]
class DDC6573Item
{
/** @var int */
#[Id]
#[Column(type: Types::INTEGER)]
#[GeneratedValue(strategy: 'AUTO')]
public $id;

#[Column(type: Types::STRING)]
public string $name;

#[Column(type: Types::INTEGER)]
public int $priceAmount;

#[Column(type: Types::STRING, length: 3)]
public string $priceCurrency;

public function __construct(string $name, DDC6573Money $price)
{
$this->name = $name;
$this->priceAmount = $price->getAmount();
$this->priceCurrency = $price->getCurrency()->getCode();
}

public function getPrice(): DDC6573Money
{
return new DDC6573Money($this->priceAmount, new DDC6573Currency($this->priceCurrency));
}
}
24 changes: 24 additions & 0 deletions tests/Tests/Models/DDC6573/DDC6573Money.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace Doctrine\Tests\Models\DDC6573;

final class DDC6573Money
{
public function __construct(
private readonly int $amount,
private readonly DDC6573Currency $currency,
) {
}

public function getAmount(): int
{
return $this->amount;
}

public function getCurrency(): DDC6573Currency
{
return $this->currency;
}
}
Loading

0 comments on commit 8c582a4

Please sign in to comment.