Skip to content

Commit 4c042b9

Browse files
committed
add capability to hydrate an entity in a dto
this PR allow to hydrate data in an entity nested in a dto
1 parent 708bd84 commit 4c042b9

File tree

8 files changed

+378
-62
lines changed

8 files changed

+378
-62
lines changed

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

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,16 @@ The ``NAMED`` keyword must precede all DTO you want to instantiate :
674674
If two arguments have the same name, a ``DuplicateFieldException`` is thrown.
675675
If a field cannot be matched with a property name, a ``NoMatchingPropertyException`` is thrown. This typically happens when using functions without aliasing them.
676676

677+
You can hydrate entities in a Dto :
678+
679+
.. code-block:: php
680+
681+
<?php
682+
$query = $em->createQuery('SELECT NEW CustomerDTO(c.name, a AS address) FROM Customer c JOIN c.address a');
683+
$users = $query->getResult(); // array of CustomerDTO
684+
685+
// CustomerDTO => {name : 'DOE', email: null, address : {city: 'New York', zip: '10011', address: 'Abbey Road'}
686+
677687
Using INDEX BY
678688
~~~~~~~~~~~~~~
679689

@@ -1697,12 +1707,13 @@ Select Expressions
16971707

16981708
.. code-block:: php
16991709
1700-
SelectExpression ::= (IdentificationVariable | ScalarExpression | AggregateExpression | FunctionDeclaration | PartialObjectExpression | "(" Subselect ")" | CaseExpression | NewObjectExpression) [["AS"] ["HIDDEN"] AliasResultVariable]
1701-
SimpleSelectExpression ::= (StateFieldPathExpression | IdentificationVariable | FunctionDeclaration | AggregateExpression | "(" Subselect ")" | ScalarExpression) [["AS"] AliasResultVariable]
1702-
PartialObjectExpression ::= "PARTIAL" IdentificationVariable "." PartialFieldSet
1703-
PartialFieldSet ::= "{" SimpleStateField {"," SimpleStateField}* "}"
1704-
NewObjectExpression ::= "NEW" AbstractSchemaName "(" NewObjectArg {"," NewObjectArg}* ")"
1705-
NewObjectArg ::= (ScalarExpression | "(" Subselect ")" | NewObjectExpression) ["AS" AliasResultVariable]
1710+
SelectExpression ::= (IdentificationVariable | ScalarExpression | AggregateExpression | FunctionDeclaration | PartialObjectExpression | "(" Subselect ")" | CaseExpression | NewObjectExpression) [["AS"] ["HIDDEN"] AliasResultVariable]
1711+
SimpleSelectExpression ::= (StateFieldPathExpression | IdentificationVariable | FunctionDeclaration | AggregateExpression | "(" Subselect ")" | ScalarExpression) [["AS"] AliasResultVariable]
1712+
PartialObjectExpression ::= "PARTIAL" IdentificationVariable "." PartialFieldSet
1713+
PartialFieldSet ::= "{" SimpleStateField {"," SimpleStateField}* "}"
1714+
NewObjectExpression ::= "NEW" AbstractSchemaName "(" NewObjectArg {"," NewObjectArg}* ")"
1715+
NewObjectArg ::= (ScalarExpression | "(" Subselect ")" | NewObjectExpression | EntityAsDtoArgumentExpression) ["AS" AliasResultVariable]
1716+
EntityAsDtoArgumentExpression ::= IdentificationVariable
17061717
17071718
Conditional Expressions
17081719
~~~~~~~~~~~~~~~~~~~~~~~

src/Internal/Hydration/AbstractHydrator.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use ReflectionClass;
2020

2121
use function array_key_exists;
22+
use function array_keys;
2223
use function array_map;
2324
use function array_merge;
2425
use function count;
@@ -348,14 +349,28 @@ protected function gatherRowData(array $data, array &$id, array &$nonemptyCompon
348349
}
349350
}
350351

352+
$nestedEntities = [];
351353
foreach ($this->resultSetMapping()->nestedNewObjectArguments as ['ownerIndex' => $ownerIndex, 'argIndex' => $argIndex, 'argAlias' => $argAlias]) {
352354
if (array_key_exists($argAlias, $rowData['newObjects'])) {
353355
ksort($rowData['newObjects'][$argAlias]['args']);
354356
$rowData['newObjects'][$ownerIndex]['args'][$argIndex] = $rowData['newObjects'][$argAlias]['class']->newInstanceArgs($rowData['newObjects'][$argAlias]['args']);
355357
unset($rowData['newObjects'][$argAlias]);
358+
} elseif (array_key_exists($argAlias, $rowData['data'])) {
359+
if (! array_key_exists($argAlias, $nestedEntities)) {
360+
$nestedEntities[$argAlias] = '';
361+
$rowData['data'][$argAlias] = $this->hydrateNestedEnity($rowData['data'][$argAlias], $argAlias);
362+
}
363+
364+
$rowData['newObjects'][$ownerIndex]['args'][$argIndex] = $rowData['data'][$argAlias];
365+
} else {
366+
throw new LogicException($argAlias . ' not exist');
356367
}
357368
}
358369

370+
foreach (array_keys($nestedEntities) as $entity) {
371+
unset($rowData['data'][$entity]);
372+
}
373+
359374
foreach ($rowData['newObjects'] as $objIndex => $newObject) {
360375
ksort($rowData['newObjects'][$objIndex]['args']);
361376
$obj = $rowData['newObjects'][$objIndex]['class']->newInstanceArgs($rowData['newObjects'][$objIndex]['args']);
@@ -366,6 +381,12 @@ protected function gatherRowData(array $data, array &$id, array &$nonemptyCompon
366381
return $rowData;
367382
}
368383

384+
/** @param mixed[] $data pre-hydrated SQL Result Row. */
385+
protected function hydrateNestedEnity(array $data, string $dqlAlias): mixed
386+
{
387+
return $data;
388+
}
389+
369390
/**
370391
* Processes a row of the result set.
371392
*

src/Internal/Hydration/ObjectHydrator.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ protected function prepare(): void
7070
$parent = $this->resultSetMapping()->parentAliasMap[$dqlAlias];
7171

7272
if (! isset($this->resultSetMapping()->aliasMap[$parent])) {
73+
if (isset($this->resultSetMapping()->nestedEntities[$dqlAlias])) {
74+
continue;
75+
}
76+
7377
throw HydrationException::parentObjectOfRelationNotFound($dqlAlias, $parent);
7478
}
7579

@@ -569,6 +573,16 @@ protected function hydrateRowData(array $row, array &$result): void
569573
}
570574
}
571575

576+
/** @param mixed[] $data pre-hydrated SQL Result Row. */
577+
protected function hydrateNestedEnity(array $data, string $dqlAlias): mixed
578+
{
579+
if (isset($this->resultSetMapping()->nestedEntities[$dqlAlias])) {
580+
return $this->getEntity($data, $dqlAlias);
581+
}
582+
583+
return $data;
584+
}
585+
572586
/**
573587
* When executed in a hydrate() loop we may have to clear internal state to
574588
* decrease memory consumption.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Doctrine\ORM\Query\AST;
6+
7+
use Doctrine\ORM\Query\SqlWalker;
8+
9+
/**
10+
* EntityAsDtoArgumentExpression ::= IdentificationVariable
11+
*
12+
* @link www.doctrine-project.org
13+
*/
14+
class EntityAsDtoArgumentExpression extends Node
15+
{
16+
public function __construct(
17+
public mixed $expression,
18+
public string|null $identificationVariable,
19+
) {
20+
}
21+
22+
public function dispatch(SqlWalker $walker): string
23+
{
24+
return $walker->walkEntityAsDtoArgumentExpression($this);
25+
}
26+
}

src/Query/Parser.php

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1106,6 +1106,50 @@ public function CollectionValuedPathExpression(): AST\PathExpression
11061106
return $this->PathExpression(AST\PathExpression::TYPE_COLLECTION_VALUED_ASSOCIATION);
11071107
}
11081108

1109+
/**
1110+
* EntityAsDtoArgumentExpression ::= IdentificationVariable
1111+
*/
1112+
public function EntityAsDtoArgumentExpression(): AST\EntityAsDtoArgumentExpression
1113+
{
1114+
assert($this->lexer->lookahead !== null);
1115+
$expression = null;
1116+
$identVariable = null;
1117+
$peek = $this->lexer->glimpse();
1118+
$lookaheadType = $this->lexer->lookahead->type;
1119+
assert($peek !== null);
1120+
1121+
assert($lookaheadType === TokenType::T_IDENTIFIER);
1122+
assert($peek->type !== TokenType::T_DOT);
1123+
assert($peek->type !== TokenType::T_OPEN_PARENTHESIS);
1124+
1125+
$expression = $identVariable = $this->IdentificationVariable();
1126+
1127+
// [["AS"] AliasResultVariable]
1128+
$mustHaveAliasResultVariable = false;
1129+
1130+
if ($this->lexer->isNextToken(TokenType::T_AS)) {
1131+
$this->match(TokenType::T_AS);
1132+
1133+
$mustHaveAliasResultVariable = true;
1134+
}
1135+
1136+
$aliasResultVariable = null;
1137+
1138+
if ($mustHaveAliasResultVariable || $this->lexer->isNextToken(TokenType::T_IDENTIFIER)) {
1139+
$token = $this->lexer->lookahead;
1140+
$aliasResultVariable = $this->AliasResultVariable();
1141+
1142+
// Include AliasResultVariable in query components.
1143+
$this->queryComponents[$aliasResultVariable] = [
1144+
'resultVariable' => $expression,
1145+
'nestingLevel' => $this->nestingLevel,
1146+
'token' => $token,
1147+
];
1148+
}
1149+
1150+
return new AST\EntityAsDtoArgumentExpression($expression, $identVariable);
1151+
}
1152+
11091153
/**
11101154
* SelectClause ::= "SELECT" ["DISTINCT"] SelectExpression {"," SelectExpression}
11111155
*/
@@ -1849,6 +1893,8 @@ public function NewObjectArg(string|null &$fieldAlias = null): mixed
18491893
$this->match(TokenType::T_CLOSE_PARENTHESIS);
18501894
} elseif ($token->type === TokenType::T_NEW) {
18511895
$expression = $this->NewObjectExpression();
1896+
} elseif ($token->type === TokenType::T_IDENTIFIER && $peek->type !== TokenType::T_DOT && $peek->type !== TokenType::T_OPEN_PARENTHESIS) {
1897+
$expression = $this->EntityAsDtoArgumentExpression();
18521898
} else {
18531899
$expression = $this->ScalarExpression();
18541900
}

src/Query/ResultSetMapping.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,13 @@ class ResultSetMapping
187187
*/
188188
public array $discriminatorParameters = [];
189189

190+
/**
191+
* Entities nested in Dto's
192+
*
193+
* @phpstan-var array<string, string>
194+
*/
195+
public array $nestedEntities = [];
196+
190197
/**
191198
* Adds an entity result to this ResultSetMapping.
192199
*

src/Query/SqlWalker.php

Lines changed: 83 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -575,6 +575,14 @@ public function walkEntityIdentificationVariable(string $identVariable): string
575575
return implode(', ', $sqlParts);
576576
}
577577

578+
/**
579+
* Walks down an EntityAsDtoArgumentExpression AST node, thereby generating the appropriate SQL.
580+
*/
581+
public function walkEntityAsDtoArgumentExpression(AST\EntityAsDtoArgumentExpression $expr): string
582+
{
583+
return implode(', ', $this->walkObjectExpression($expr->expression, [], $expr->identificationVariable ?: null));
584+
}
585+
578586
/**
579587
* Walks down an IdentificationVariable (no AST node associated), thereby generating the SQL.
580588
*/
@@ -1356,84 +1364,95 @@ public function walkSelectExpression(AST\SelectExpression $selectExpression): st
13561364
$partialFieldSet = [];
13571365
}
13581366

1359-
$class = $this->getMetadataForDqlAlias($dqlAlias);
1360-
$resultAlias = $selectExpression->fieldIdentificationVariable ?: null;
1367+
$sql .= implode(', ', $this->walkObjectExpression($dqlAlias, $partialFieldSet, $selectExpression->fieldIdentificationVariable ?: null));
1368+
}
13611369

1362-
if (! isset($this->selectedClasses[$dqlAlias])) {
1363-
$this->selectedClasses[$dqlAlias] = [
1364-
'class' => $class,
1365-
'dqlAlias' => $dqlAlias,
1366-
'resultAlias' => $resultAlias,
1367-
];
1368-
}
1370+
return $sql;
1371+
}
13691372

1370-
$sqlParts = [];
1373+
/**
1374+
* Walks down an Object Expression AST node and return Sql Parts
1375+
*
1376+
* @param mixed[] $partialFieldSet
1377+
*
1378+
* @return string[]
1379+
*/
1380+
public function walkObjectExpression(string $dqlAlias, array $partialFieldSet, string|null $resultAlias): array
1381+
{
1382+
$class = $this->getMetadataForDqlAlias($dqlAlias);
13711383

1372-
// Select all fields from the queried class
1373-
foreach ($class->fieldMappings as $fieldName => $mapping) {
1374-
if ($partialFieldSet && ! in_array($fieldName, $partialFieldSet, true)) {
1375-
continue;
1376-
}
1384+
if (! isset($this->selectedClasses[$dqlAlias])) {
1385+
$this->selectedClasses[$dqlAlias] = [
1386+
'class' => $class,
1387+
'dqlAlias' => $dqlAlias,
1388+
'resultAlias' => $resultAlias,
1389+
];
1390+
}
13771391

1378-
$tableName = isset($mapping->inherited)
1379-
? $this->em->getClassMetadata($mapping->inherited)->getTableName()
1380-
: $class->getTableName();
1392+
$sqlParts = [];
13811393

1382-
$sqlTableAlias = $this->getSQLTableAlias($tableName, $dqlAlias);
1383-
$columnAlias = $this->getSQLColumnAlias($mapping->columnName);
1384-
$quotedColumnName = $this->quoteStrategy->getColumnName($fieldName, $class, $this->platform);
1394+
// Select all fields from the queried class
1395+
foreach ($class->fieldMappings as $fieldName => $mapping) {
1396+
if ($partialFieldSet && ! in_array($fieldName, $partialFieldSet, true)) {
1397+
continue;
1398+
}
13851399

1386-
$col = $sqlTableAlias . '.' . $quotedColumnName;
1400+
$tableName = isset($mapping->inherited)
1401+
? $this->em->getClassMetadata($mapping->inherited)->getTableName()
1402+
: $class->getTableName();
13871403

1388-
$type = Type::getType($mapping->type);
1389-
$col = $type->convertToPHPValueSQL($col, $this->platform);
1404+
$sqlTableAlias = $this->getSQLTableAlias($tableName, $dqlAlias);
1405+
$columnAlias = $this->getSQLColumnAlias($mapping->columnName);
1406+
$quotedColumnName = $this->quoteStrategy->getColumnName($fieldName, $class, $this->platform);
13901407

1391-
$sqlParts[] = $col . ' AS ' . $columnAlias;
1408+
$col = $sqlTableAlias . '.' . $quotedColumnName;
13921409

1393-
$this->scalarResultAliasMap[$resultAlias][] = $columnAlias;
1410+
$type = Type::getType($mapping->type);
1411+
$col = $type->convertToPHPValueSQL($col, $this->platform);
13941412

1395-
$this->rsm->addFieldResult($dqlAlias, $columnAlias, $fieldName, $class->name);
1413+
$sqlParts[] = $col . ' AS ' . $columnAlias;
13961414

1397-
if (! empty($mapping->enumType)) {
1398-
$this->rsm->addEnumResult($columnAlias, $mapping->enumType);
1399-
}
1400-
}
1415+
$this->scalarResultAliasMap[$resultAlias][] = $columnAlias;
14011416

1402-
// Add any additional fields of subclasses (excluding inherited fields)
1403-
// 1) on Single Table Inheritance: always, since its marginal overhead
1404-
// 2) on Class Table Inheritance only if partial objects are disallowed,
1405-
// since it requires outer joining subtables.
1406-
if ($class->isInheritanceTypeSingleTable() || ! $this->query->getHint(Query::HINT_FORCE_PARTIAL_LOAD)) {
1407-
foreach ($class->subClasses as $subClassName) {
1408-
$subClass = $this->em->getClassMetadata($subClassName);
1409-
$sqlTableAlias = $this->getSQLTableAlias($subClass->getTableName(), $dqlAlias);
1417+
$this->rsm->addFieldResult($dqlAlias, $columnAlias, $fieldName, $class->name);
14101418

1411-
foreach ($subClass->fieldMappings as $fieldName => $mapping) {
1412-
if (isset($mapping->inherited) || ($partialFieldSet && ! in_array($fieldName, $partialFieldSet, true))) {
1413-
continue;
1414-
}
1419+
if (! empty($mapping->enumType)) {
1420+
$this->rsm->addEnumResult($columnAlias, $mapping->enumType);
1421+
}
1422+
}
14151423

1416-
$columnAlias = $this->getSQLColumnAlias($mapping->columnName);
1417-
$quotedColumnName = $this->quoteStrategy->getColumnName($fieldName, $subClass, $this->platform);
1424+
// Add any additional fields of subclasses (excluding inherited fields)
1425+
// 1) on Single Table Inheritance: always, since its marginal overhead
1426+
// 2) on Class Table Inheritance only if partial objects are disallowed,
1427+
// since it requires outer joining subtables.
1428+
if ($class->isInheritanceTypeSingleTable() || ! $this->query->getHint(Query::HINT_FORCE_PARTIAL_LOAD)) {
1429+
foreach ($class->subClasses as $subClassName) {
1430+
$subClass = $this->em->getClassMetadata($subClassName);
1431+
$sqlTableAlias = $this->getSQLTableAlias($subClass->getTableName(), $dqlAlias);
1432+
1433+
foreach ($subClass->fieldMappings as $fieldName => $mapping) {
1434+
if (isset($mapping->inherited) || ($partialFieldSet && ! in_array($fieldName, $partialFieldSet, true))) {
1435+
continue;
1436+
}
1437+
1438+
$columnAlias = $this->getSQLColumnAlias($mapping->columnName);
1439+
$quotedColumnName = $this->quoteStrategy->getColumnName($fieldName, $subClass, $this->platform);
14181440

1419-
$col = $sqlTableAlias . '.' . $quotedColumnName;
1441+
$col = $sqlTableAlias . '.' . $quotedColumnName;
14201442

1421-
$type = Type::getType($mapping->type);
1422-
$col = $type->convertToPHPValueSQL($col, $this->platform);
1443+
$type = Type::getType($mapping->type);
1444+
$col = $type->convertToPHPValueSQL($col, $this->platform);
14231445

1424-
$sqlParts[] = $col . ' AS ' . $columnAlias;
1446+
$sqlParts[] = $col . ' AS ' . $columnAlias;
14251447

1426-
$this->scalarResultAliasMap[$resultAlias][] = $columnAlias;
1448+
$this->scalarResultAliasMap[$resultAlias][] = $columnAlias;
14271449

1428-
$this->rsm->addFieldResult($dqlAlias, $columnAlias, $fieldName, $subClassName);
1429-
}
1430-
}
1450+
$this->rsm->addFieldResult($dqlAlias, $columnAlias, $fieldName, $subClassName);
14311451
}
1432-
1433-
$sql .= implode(', ', $sqlParts);
1452+
}
14341453
}
14351454

1436-
return $sql;
1455+
return $sqlParts;
14371456
}
14381457

14391458
public function walkQuantifiedExpression(AST\QuantifiedExpression $qExpr): string
@@ -1549,6 +1568,14 @@ public function walkNewObject(AST\NewObjectExpression $newObjectExpression, stri
15491568
$sqlSelectExpressions[] = trim($e->dispatch($this)) . ' AS ' . $columnAlias;
15501569
break;
15511570

1571+
case $e instanceof AST\EntityAsDtoArgumentExpression:
1572+
$alias = $e->identificationVariable ?: $columnAlias;
1573+
$this->rsm->nestedNewObjectArguments[$columnAlias] = ['ownerIndex' => $objIndex, 'argIndex' => $argIndex, 'argAlias' => $alias];
1574+
$this->rsm->nestedEntities[$alias] = ['parent' => $objIndex, 'argIndex' => $argIndex, 'type' => 'entity'];
1575+
1576+
$sqlSelectExpressions[] = trim($e->dispatch($this));
1577+
break;
1578+
15521579
default:
15531580
$sqlSelectExpressions[] = trim($e->dispatch($this)) . ' AS ' . $columnAlias;
15541581
break;

0 commit comments

Comments
 (0)