From 958aaec7b3c412aa721c7a805a284b5497f45745 Mon Sep 17 00:00:00 2001 From: eltharin Date: Fri, 21 Feb 2025 20:42:32 +0100 Subject: [PATCH] add capability to hydrate an entity in a dto this PR allow to hydrate data in an entity nested in a dto --- .../reference/dql-doctrine-query-language.rst | 23 +- src/Internal/Hydration/AbstractHydrator.php | 30 +- src/Internal/Hydration/ObjectHydrator.php | 14 + .../AST/EntityAsDtoArgumentExpression.php | 26 ++ src/Query/Parser.php | 46 +++ src/Query/ResultSetMapping.php | 7 + src/Query/SqlWalker.php | 139 +++++---- .../Tests/ORM/Functional/NewOperatorTest.php | 293 ++++++++++++++++++ 8 files changed, 513 insertions(+), 65 deletions(-) create mode 100644 src/Query/AST/EntityAsDtoArgumentExpression.php diff --git a/docs/en/reference/dql-doctrine-query-language.rst b/docs/en/reference/dql-doctrine-query-language.rst index e668c08fd82..d81ad15ebec 100644 --- a/docs/en/reference/dql-doctrine-query-language.rst +++ b/docs/en/reference/dql-doctrine-query-language.rst @@ -674,6 +674,16 @@ The ``NAMED`` keyword must precede all DTO you want to instantiate : 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. +You can hydrate entities in a Dto : + +.. code-block:: php + + createQuery('SELECT NEW CustomerDTO(c.name, a AS address) FROM Customer c JOIN c.address a'); + $users = $query->getResult(); // array of CustomerDTO + + // CustomerDTO => {name : 'DOE', email: null, address : {city: 'New York', zip: '10011', address: 'Abbey Road'} + Using INDEX BY ~~~~~~~~~~~~~~ @@ -1697,12 +1707,13 @@ Select Expressions .. code-block:: php - SelectExpression ::= (IdentificationVariable | ScalarExpression | AggregateExpression | FunctionDeclaration | PartialObjectExpression | "(" Subselect ")" | CaseExpression | NewObjectExpression) [["AS"] ["HIDDEN"] AliasResultVariable] - SimpleSelectExpression ::= (StateFieldPathExpression | IdentificationVariable | FunctionDeclaration | AggregateExpression | "(" Subselect ")" | ScalarExpression) [["AS"] AliasResultVariable] - PartialObjectExpression ::= "PARTIAL" IdentificationVariable "." PartialFieldSet - PartialFieldSet ::= "{" SimpleStateField {"," SimpleStateField}* "}" - NewObjectExpression ::= "NEW" AbstractSchemaName "(" NewObjectArg {"," NewObjectArg}* ")" - NewObjectArg ::= (ScalarExpression | "(" Subselect ")" | NewObjectExpression) ["AS" AliasResultVariable] + SelectExpression ::= (IdentificationVariable | ScalarExpression | AggregateExpression | FunctionDeclaration | PartialObjectExpression | "(" Subselect ")" | CaseExpression | NewObjectExpression) [["AS"] ["HIDDEN"] AliasResultVariable] + SimpleSelectExpression ::= (StateFieldPathExpression | IdentificationVariable | FunctionDeclaration | AggregateExpression | "(" Subselect ")" | ScalarExpression) [["AS"] AliasResultVariable] + PartialObjectExpression ::= "PARTIAL" IdentificationVariable "." PartialFieldSet + PartialFieldSet ::= "{" SimpleStateField {"," SimpleStateField}* "}" + NewObjectExpression ::= "NEW" AbstractSchemaName "(" NewObjectArg {"," NewObjectArg}* ")" + NewObjectArg ::= (ScalarExpression | "(" Subselect ")" | NewObjectExpression | EntityAsDtoArgumentExpression) ["AS" AliasResultVariable] + EntityAsDtoArgumentExpression ::= IdentificationVariable Conditional Expressions ~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/Internal/Hydration/AbstractHydrator.php b/src/Internal/Hydration/AbstractHydrator.php index 0a44d3a02b5..a46dcd63a76 100644 --- a/src/Internal/Hydration/AbstractHydrator.php +++ b/src/Internal/Hydration/AbstractHydrator.php @@ -18,6 +18,8 @@ use LogicException; use ReflectionClass; +use function array_key_exists; +use function array_keys; use function array_map; use function array_merge; use function count; @@ -336,9 +338,21 @@ protected function gatherRowData(array $data, array &$id, array &$nonemptyCompon } } - foreach ($this->resultSetMapping()->nestedNewObjectArguments as $objIndex => ['ownerIndex' => $ownerIndex, 'argIndex' => $argIndex]) { - if (! isset($rowData['newObjects'][$ownerIndex . ':' . $argIndex])) { - continue; + $nestedEntities = []; + foreach ($this->resultSetMapping()->nestedNewObjectArguments as ['ownerIndex' => $ownerIndex, 'argIndex' => $argIndex, 'argAlias' => $argAlias]) { + if (array_key_exists($argAlias, $rowData['newObjects'])) { + ksort($rowData['newObjects'][$argAlias]['args']); + $rowData['newObjects'][$ownerIndex]['args'][$argIndex] = $rowData['newObjects'][$argAlias]['class']->newInstanceArgs($rowData['newObjects'][$argAlias]['args']); + unset($rowData['newObjects'][$argAlias]); + } elseif (array_key_exists($argAlias, $rowData['data'])) { + if (! array_key_exists($argAlias, $nestedEntities)) { + $nestedEntities[$argAlias] = ''; + $rowData['data'][$argAlias] = $this->hydrateNestedEnity($rowData['data'][$argAlias], $argAlias); + } + + $rowData['newObjects'][$ownerIndex]['args'][$argIndex] = $rowData['data'][$argAlias]; + } else { + throw new LogicException($argAlias . ' not exist'); } $newObject = $rowData['newObjects'][$ownerIndex . ':' . $argIndex]; @@ -349,6 +363,10 @@ protected function gatherRowData(array $data, array &$id, array &$nonemptyCompon $rowData['newObjects'][$ownerIndex]['args'][$argIndex] = $obj; } + foreach (array_keys($nestedEntities) as $entity) { + unset($rowData['data'][$entity]); + } + foreach ($rowData['newObjects'] as $objIndex => $newObject) { $obj = $newObject['class']->newInstanceArgs($newObject['args']); @@ -358,6 +376,12 @@ protected function gatherRowData(array $data, array &$id, array &$nonemptyCompon return $rowData; } + /** @param mixed[] $data pre-hydrated SQL Result Row. */ + protected function hydrateNestedEnity(array $data, string $dqlAlias): mixed + { + return $data; + } + /** * Processes a row of the result set. * diff --git a/src/Internal/Hydration/ObjectHydrator.php b/src/Internal/Hydration/ObjectHydrator.php index 21383e8c16e..b5a64442444 100644 --- a/src/Internal/Hydration/ObjectHydrator.php +++ b/src/Internal/Hydration/ObjectHydrator.php @@ -70,6 +70,10 @@ protected function prepare(): void $parent = $this->resultSetMapping()->parentAliasMap[$dqlAlias]; if (! isset($this->resultSetMapping()->aliasMap[$parent])) { + if (isset($this->resultSetMapping()->nestedEntities[$dqlAlias])) { + continue; + } + throw HydrationException::parentObjectOfRelationNotFound($dqlAlias, $parent); } @@ -569,6 +573,16 @@ protected function hydrateRowData(array $row, array &$result): void } } + /** @param mixed[] $data pre-hydrated SQL Result Row. */ + protected function hydrateNestedEnity(array $data, string $dqlAlias): mixed + { + if (isset($this->resultSetMapping()->nestedEntities[$dqlAlias])) { + return $this->getEntity($data, $dqlAlias); + } + + return $data; + } + /** * When executed in a hydrate() loop we may have to clear internal state to * decrease memory consumption. diff --git a/src/Query/AST/EntityAsDtoArgumentExpression.php b/src/Query/AST/EntityAsDtoArgumentExpression.php new file mode 100644 index 00000000000..bcfa5e6dc3d --- /dev/null +++ b/src/Query/AST/EntityAsDtoArgumentExpression.php @@ -0,0 +1,26 @@ +walkEntityAsDtoArgumentExpression($this); + } +} diff --git a/src/Query/Parser.php b/src/Query/Parser.php index 86fb8243b49..9680b9ad4b9 100644 --- a/src/Query/Parser.php +++ b/src/Query/Parser.php @@ -1106,6 +1106,50 @@ public function CollectionValuedPathExpression(): AST\PathExpression return $this->PathExpression(AST\PathExpression::TYPE_COLLECTION_VALUED_ASSOCIATION); } + /** + * EntityAsDtoArgumentExpression ::= IdentificationVariable + */ + public function EntityAsDtoArgumentExpression(): AST\EntityAsDtoArgumentExpression + { + assert($this->lexer->lookahead !== null); + $expression = null; + $identVariable = null; + $peek = $this->lexer->glimpse(); + $lookaheadType = $this->lexer->lookahead->type; + assert($peek !== null); + + assert($lookaheadType === TokenType::T_IDENTIFIER); + assert($peek->type !== TokenType::T_DOT); + assert($peek->type !== TokenType::T_OPEN_PARENTHESIS); + + $expression = $identVariable = $this->IdentificationVariable(); + + // [["AS"] AliasResultVariable] + $mustHaveAliasResultVariable = false; + + if ($this->lexer->isNextToken(TokenType::T_AS)) { + $this->match(TokenType::T_AS); + + $mustHaveAliasResultVariable = true; + } + + $aliasResultVariable = null; + + if ($mustHaveAliasResultVariable || $this->lexer->isNextToken(TokenType::T_IDENTIFIER)) { + $token = $this->lexer->lookahead; + $aliasResultVariable = $this->AliasResultVariable(); + + // Include AliasResultVariable in query components. + $this->queryComponents[$aliasResultVariable] = [ + 'resultVariable' => $expression, + 'nestingLevel' => $this->nestingLevel, + 'token' => $token, + ]; + } + + return new AST\EntityAsDtoArgumentExpression($expression, $identVariable); + } + /** * SelectClause ::= "SELECT" ["DISTINCT"] SelectExpression {"," SelectExpression} */ @@ -1848,6 +1892,8 @@ public function NewObjectArg(string|null &$fieldAlias = null): mixed $this->match(TokenType::T_CLOSE_PARENTHESIS); } elseif ($token->type === TokenType::T_NEW) { $expression = $this->NewObjectExpression(); + } elseif ($token->type === TokenType::T_IDENTIFIER && $peek->type !== TokenType::T_DOT && $peek->type !== TokenType::T_OPEN_PARENTHESIS) { + $expression = $this->EntityAsDtoArgumentExpression(); } else { $expression = $this->ScalarExpression(); } diff --git a/src/Query/ResultSetMapping.php b/src/Query/ResultSetMapping.php index c0ccc127fd5..8a1a8a4f608 100644 --- a/src/Query/ResultSetMapping.php +++ b/src/Query/ResultSetMapping.php @@ -180,6 +180,13 @@ class ResultSetMapping */ public array $discriminatorParameters = []; + /** + * Entities nested in Dto's + * + * @phpstan-var array + */ + public array $nestedEntities = []; + /** * Adds an entity result to this ResultSetMapping. * diff --git a/src/Query/SqlWalker.php b/src/Query/SqlWalker.php index 9089995e432..5218b76d552 100644 --- a/src/Query/SqlWalker.php +++ b/src/Query/SqlWalker.php @@ -584,6 +584,14 @@ public function walkEntityIdentificationVariable(string $identVariable): string return implode(', ', $sqlParts); } + /** + * Walks down an EntityAsDtoArgumentExpression AST node, thereby generating the appropriate SQL. + */ + public function walkEntityAsDtoArgumentExpression(AST\EntityAsDtoArgumentExpression $expr): string + { + return implode(', ', $this->walkObjectExpression($expr->expression, [], $expr->identificationVariable ?: null)); + } + /** * Walks down an IdentificationVariable (no AST node associated), thereby generating the SQL. */ @@ -1365,84 +1373,95 @@ public function walkSelectExpression(AST\SelectExpression $selectExpression): st $partialFieldSet = []; } - $class = $this->getMetadataForDqlAlias($dqlAlias); - $resultAlias = $selectExpression->fieldIdentificationVariable ?: null; + $sql .= implode(', ', $this->walkObjectExpression($dqlAlias, $partialFieldSet, $selectExpression->fieldIdentificationVariable ?: null)); + } - if (! isset($this->selectedClasses[$dqlAlias])) { - $this->selectedClasses[$dqlAlias] = [ - 'class' => $class, - 'dqlAlias' => $dqlAlias, - 'resultAlias' => $resultAlias, - ]; - } + return $sql; + } - $sqlParts = []; + /** + * Walks down an Object Expression AST node and return Sql Parts + * + * @param mixed[] $partialFieldSet + * + * @return string[] + */ + public function walkObjectExpression(string $dqlAlias, array $partialFieldSet, string|null $resultAlias): array + { + $class = $this->getMetadataForDqlAlias($dqlAlias); - // Select all fields from the queried class - foreach ($class->fieldMappings as $fieldName => $mapping) { - if ($partialFieldSet && ! in_array($fieldName, $partialFieldSet, true)) { - continue; - } + if (! isset($this->selectedClasses[$dqlAlias])) { + $this->selectedClasses[$dqlAlias] = [ + 'class' => $class, + 'dqlAlias' => $dqlAlias, + 'resultAlias' => $resultAlias, + ]; + } - $tableName = isset($mapping->inherited) - ? $this->em->getClassMetadata($mapping->inherited)->getTableName() - : $class->getTableName(); + $sqlParts = []; - $sqlTableAlias = $this->getSQLTableAlias($tableName, $dqlAlias); - $columnAlias = $this->getSQLColumnAlias($mapping->columnName); - $quotedColumnName = $this->quoteStrategy->getColumnName($fieldName, $class, $this->platform); + // Select all fields from the queried class + foreach ($class->fieldMappings as $fieldName => $mapping) { + if ($partialFieldSet && ! in_array($fieldName, $partialFieldSet, true)) { + continue; + } - $col = $sqlTableAlias . '.' . $quotedColumnName; + $tableName = isset($mapping->inherited) + ? $this->em->getClassMetadata($mapping->inherited)->getTableName() + : $class->getTableName(); - $type = Type::getType($mapping->type); - $col = $type->convertToPHPValueSQL($col, $this->platform); + $sqlTableAlias = $this->getSQLTableAlias($tableName, $dqlAlias); + $columnAlias = $this->getSQLColumnAlias($mapping->columnName); + $quotedColumnName = $this->quoteStrategy->getColumnName($fieldName, $class, $this->platform); - $sqlParts[] = $col . ' AS ' . $columnAlias; + $col = $sqlTableAlias . '.' . $quotedColumnName; - $this->scalarResultAliasMap[$resultAlias][] = $columnAlias; + $type = Type::getType($mapping->type); + $col = $type->convertToPHPValueSQL($col, $this->platform); - $this->rsm->addFieldResult($dqlAlias, $columnAlias, $fieldName, $class->name); + $sqlParts[] = $col . ' AS ' . $columnAlias; - if (! empty($mapping->enumType)) { - $this->rsm->addEnumResult($columnAlias, $mapping->enumType); - } - } + $this->scalarResultAliasMap[$resultAlias][] = $columnAlias; - // Add any additional fields of subclasses (excluding inherited fields) - // 1) on Single Table Inheritance: always, since its marginal overhead - // 2) on Class Table Inheritance only if partial objects are disallowed, - // since it requires outer joining subtables. - if ($class->isInheritanceTypeSingleTable() || ! $this->query->getHint(Query::HINT_FORCE_PARTIAL_LOAD)) { - foreach ($class->subClasses as $subClassName) { - $subClass = $this->em->getClassMetadata($subClassName); - $sqlTableAlias = $this->getSQLTableAlias($subClass->getTableName(), $dqlAlias); + $this->rsm->addFieldResult($dqlAlias, $columnAlias, $fieldName, $class->name); - foreach ($subClass->fieldMappings as $fieldName => $mapping) { - if (isset($mapping->inherited) || ($partialFieldSet && ! in_array($fieldName, $partialFieldSet, true))) { - continue; - } + if (! empty($mapping->enumType)) { + $this->rsm->addEnumResult($columnAlias, $mapping->enumType); + } + } - $columnAlias = $this->getSQLColumnAlias($mapping->columnName); - $quotedColumnName = $this->quoteStrategy->getColumnName($fieldName, $subClass, $this->platform); + // Add any additional fields of subclasses (excluding inherited fields) + // 1) on Single Table Inheritance: always, since its marginal overhead + // 2) on Class Table Inheritance only if partial objects are disallowed, + // since it requires outer joining subtables. + if ($class->isInheritanceTypeSingleTable() || ! $this->query->getHint(Query::HINT_FORCE_PARTIAL_LOAD)) { + foreach ($class->subClasses as $subClassName) { + $subClass = $this->em->getClassMetadata($subClassName); + $sqlTableAlias = $this->getSQLTableAlias($subClass->getTableName(), $dqlAlias); + + foreach ($subClass->fieldMappings as $fieldName => $mapping) { + if (isset($mapping->inherited) || ($partialFieldSet && ! in_array($fieldName, $partialFieldSet, true))) { + continue; + } + + $columnAlias = $this->getSQLColumnAlias($mapping->columnName); + $quotedColumnName = $this->quoteStrategy->getColumnName($fieldName, $subClass, $this->platform); - $col = $sqlTableAlias . '.' . $quotedColumnName; + $col = $sqlTableAlias . '.' . $quotedColumnName; - $type = Type::getType($mapping->type); - $col = $type->convertToPHPValueSQL($col, $this->platform); + $type = Type::getType($mapping->type); + $col = $type->convertToPHPValueSQL($col, $this->platform); - $sqlParts[] = $col . ' AS ' . $columnAlias; + $sqlParts[] = $col . ' AS ' . $columnAlias; - $this->scalarResultAliasMap[$resultAlias][] = $columnAlias; + $this->scalarResultAliasMap[$resultAlias][] = $columnAlias; - $this->rsm->addFieldResult($dqlAlias, $columnAlias, $fieldName, $subClassName); - } - } + $this->rsm->addFieldResult($dqlAlias, $columnAlias, $fieldName, $subClassName); } - - $sql .= implode(', ', $sqlParts); + } } - return $sql; + return $sqlParts; } public function walkQuantifiedExpression(AST\QuantifiedExpression $qExpr): string @@ -1567,6 +1586,14 @@ public function walkNewObject(AST\NewObjectExpression $newObjectExpression, stri $sqlSelectExpressions[] = trim($e->dispatch($this)) . ' AS ' . $columnAlias; break; + case $e instanceof AST\EntityAsDtoArgumentExpression: + $alias = $e->identificationVariable ?: $columnAlias; + $this->rsm->nestedNewObjectArguments[$columnAlias] = ['ownerIndex' => $objIndex, 'argIndex' => $argIndex, 'argAlias' => $alias]; + $this->rsm->nestedEntities[$alias] = ['parent' => $objIndex, 'argIndex' => $argIndex, 'type' => 'entity']; + + $sqlSelectExpressions[] = trim($e->dispatch($this)); + break; + default: $sqlSelectExpressions[] = trim($e->dispatch($this)) . ' AS ' . $columnAlias; break; diff --git a/tests/Tests/ORM/Functional/NewOperatorTest.php b/tests/Tests/ORM/Functional/NewOperatorTest.php index 2394b6fd880..5dd7c4df7fa 100644 --- a/tests/Tests/ORM/Functional/NewOperatorTest.php +++ b/tests/Tests/ORM/Functional/NewOperatorTest.php @@ -1087,6 +1087,299 @@ public function testShouldSupportNestedNewOperators(): void self::assertSame($this->fixtures[2]->username, $result[2]['cmsUserUsername']); } + public function testShouldSupportNestedNewOperatorsWithDtoFirst(): void + { + $dql = ' + SELECT + new CmsUserDTO( + u.name, + e.email, + new CmsAddressDTO( + a.country, + a.city, + a.zip + ), + 555812452 + ) as user, + u.status, + u.username as cmsUserUsername + FROM + Doctrine\Tests\Models\CMS\CmsUser u + JOIN + u.email e + JOIN + u.address a + ORDER BY + u.name'; + + $query = $this->getEntityManager()->createQuery($dql); + $result = $query->getResult(); + + self::assertCount(3, $result); + + self::assertInstanceOf(CmsUserDTO::class, $result[0]['user']); + self::assertInstanceOf(CmsUserDTO::class, $result[1]['user']); + self::assertInstanceOf(CmsUserDTO::class, $result[2]['user']); + + self::assertInstanceOf(CmsAddressDTO::class, $result[0]['user']->address); + self::assertInstanceOf(CmsAddressDTO::class, $result[1]['user']->address); + self::assertInstanceOf(CmsAddressDTO::class, $result[2]['user']->address); + + self::assertSame($this->fixtures[0]->name, $result[0]['user']->name); + self::assertSame($this->fixtures[1]->name, $result[1]['user']->name); + self::assertSame($this->fixtures[2]->name, $result[2]['user']->name); + + self::assertSame($this->fixtures[0]->email->email, $result[0]['user']->email); + self::assertSame($this->fixtures[1]->email->email, $result[1]['user']->email); + self::assertSame($this->fixtures[2]->email->email, $result[2]['user']->email); + + self::assertSame($this->fixtures[0]->address->city, $result[0]['user']->address->city); + self::assertSame($this->fixtures[1]->address->city, $result[1]['user']->address->city); + self::assertSame($this->fixtures[2]->address->city, $result[2]['user']->address->city); + + self::assertSame($this->fixtures[0]->address->country, $result[0]['user']->address->country); + self::assertSame($this->fixtures[1]->address->country, $result[1]['user']->address->country); + self::assertSame($this->fixtures[2]->address->country, $result[2]['user']->address->country); + + self::assertSame(555812452, $result[0]['user']->phonenumbers); + self::assertSame(555812452, $result[1]['user']->phonenumbers); + self::assertSame(555812452, $result[2]['user']->phonenumbers); + + self::assertSame($this->fixtures[0]->status, $result[0]['status']); + self::assertSame($this->fixtures[1]->status, $result[1]['status']); + self::assertSame($this->fixtures[2]->status, $result[2]['status']); + + self::assertSame($this->fixtures[0]->username, $result[0]['cmsUserUsername']); + self::assertSame($this->fixtures[1]->username, $result[1]['cmsUserUsername']); + self::assertSame($this->fixtures[2]->username, $result[2]['cmsUserUsername']); + } + + public function testOnlyObjectInObject(): void + { + $dql = ' + SELECT + new CmsDumbDTO( + new CmsDumbDTO( + u.name, + e.email + ), + new CmsAddressDTO( + a.country, + a.city, + a.zip + ) + ) as user + FROM + Doctrine\Tests\Models\CMS\CmsUser u + JOIN + u.email e + JOIN + u.address a + ORDER BY + u.name'; + + $query = $this->getEntityManager()->createQuery($dql); + $result = $query->getResult(); + + self::assertCount(3, $result); + + self::assertInstanceOf(CmsDumbDTO::class, $result[0]); + self::assertInstanceOf(CmsDumbDTO::class, $result[1]); + self::assertInstanceOf(CmsDumbDTO::class, $result[2]); + + self::assertInstanceOf(CmsDumbDTO::class, $result[0]->val1); + self::assertInstanceOf(CmsDumbDTO::class, $result[1]->val1); + self::assertInstanceOf(CmsDumbDTO::class, $result[2]->val1); + + self::assertSame($this->fixtures[0]->name, $result[0]->val1->val1); + self::assertSame($this->fixtures[1]->name, $result[1]->val1->val1); + self::assertSame($this->fixtures[2]->name, $result[2]->val1->val1); + + self::assertSame($this->fixtures[0]->email->email, $result[0]->val1->val2); + self::assertSame($this->fixtures[1]->email->email, $result[1]->val1->val2); + self::assertSame($this->fixtures[2]->email->email, $result[2]->val1->val2); + + self::assertInstanceOf(CmsAddressDTO::class, $result[0]->val2); + self::assertInstanceOf(CmsAddressDTO::class, $result[1]->val2); + self::assertInstanceOf(CmsAddressDTO::class, $result[2]->val2); + + self::assertSame($this->fixtures[0]->address->country, $result[0]->val2->country); + self::assertSame($this->fixtures[1]->address->country, $result[1]->val2->country); + self::assertSame($this->fixtures[2]->address->country, $result[2]->val2->country); + + self::assertSame($this->fixtures[0]->address->city, $result[0]->val2->city); + self::assertSame($this->fixtures[1]->address->city, $result[1]->val2->city); + self::assertSame($this->fixtures[2]->address->city, $result[2]->val2->city); + + self::assertSame($this->fixtures[0]->address->zip, $result[0]->val2->zip); + self::assertSame($this->fixtures[1]->address->zip, $result[1]->val2->zip); + self::assertSame($this->fixtures[2]->address->zip, $result[2]->val2->zip); + } + + public function testEntityInDtoWithRoot(): void + { + $dql = ' + SELECT + new CmsDumbDTO( + u.id, + u, + a, + e.email + ) + FROM + Doctrine\Tests\Models\CMS\CmsUser u + LEFT JOIN + u.email e + LEFT JOIN + u.address a + ORDER BY + u.name'; + + $query = $this->getEntityManager()->createQuery($dql); + $result = $query->getResult(); + + self::assertCount(3, $result); + + self::assertInstanceOf(CmsDumbDTO::class, $result[0]); + self::assertInstanceOf(CmsDumbDTO::class, $result[1]); + self::assertInstanceOf(CmsDumbDTO::class, $result[2]); + + self::assertInstanceOf(CmsUser::class, $result[0]->val2); + self::assertInstanceOf(CmsUser::class, $result[1]->val2); + self::assertInstanceOf(CmsUser::class, $result[2]->val2); + + self::assertSame($this->fixtures[0]->name, $result[0]->val2->name); + self::assertSame($this->fixtures[1]->name, $result[1]->val2->name); + self::assertSame($this->fixtures[2]->name, $result[2]->val2->name); + + self::assertSame($this->fixtures[0]->username, $result[0]->val2->username); + self::assertSame($this->fixtures[1]->username, $result[1]->val2->username); + self::assertSame($this->fixtures[2]->username, $result[2]->val2->username); + + self::assertSame($this->fixtures[0]->status, $result[0]->val2->status); + self::assertSame($this->fixtures[1]->status, $result[1]->val2->status); + self::assertSame($this->fixtures[2]->status, $result[2]->val2->status); + + self::assertInstanceOf(CmsAddress::class, $result[0]->val3); + self::assertInstanceOf(CmsAddress::class, $result[1]->val3); + self::assertInstanceOf(CmsAddress::class, $result[2]->val3); + + self::assertSame($this->fixtures[0]->address->city, $result[0]->val3->city); + self::assertSame($this->fixtures[1]->address->city, $result[1]->val3->city); + self::assertSame($this->fixtures[2]->address->city, $result[2]->val3->city); + + self::assertSame($this->fixtures[0]->address->country, $result[0]->val3->country); + self::assertSame($this->fixtures[1]->address->country, $result[1]->val3->country); + self::assertSame($this->fixtures[2]->address->country, $result[2]->val3->country); + + self::assertSame($this->fixtures[0]->email->email, $result[0]->val4); + self::assertSame($this->fixtures[1]->email->email, $result[1]->val4); + self::assertSame($this->fixtures[2]->email->email, $result[2]->val4); + } + + public function testEntityInDtoWithoutRoot(): void + { + $dql = ' + SELECT + new CmsDumbDTO( + u.id, + u.name, + a, + e.email + ) + FROM + Doctrine\Tests\Models\CMS\CmsUser u + LEFT JOIN + u.email e + LEFT JOIN + u.address a + ORDER BY + u.name'; + + $query = $this->getEntityManager()->createQuery($dql); + $result = $query->getResult(); + + self::assertCount(3, $result); + + self::assertInstanceOf(CmsDumbDTO::class, $result[0]); + self::assertInstanceOf(CmsDumbDTO::class, $result[1]); + self::assertInstanceOf(CmsDumbDTO::class, $result[2]); + + self::assertSame($this->fixtures[0]->name, $result[0]->val2); + self::assertSame($this->fixtures[1]->name, $result[1]->val2); + self::assertSame($this->fixtures[2]->name, $result[2]->val2); + + self::assertInstanceOf(CmsAddress::class, $result[0]->val3); + self::assertInstanceOf(CmsAddress::class, $result[1]->val3); + self::assertInstanceOf(CmsAddress::class, $result[2]->val3); + + self::assertSame($this->fixtures[0]->address->city, $result[0]->val3->city); + self::assertSame($this->fixtures[1]->address->city, $result[1]->val3->city); + self::assertSame($this->fixtures[2]->address->city, $result[2]->val3->city); + + self::assertSame($this->fixtures[0]->address->country, $result[0]->val3->country); + self::assertSame($this->fixtures[1]->address->country, $result[1]->val3->country); + self::assertSame($this->fixtures[2]->address->country, $result[2]->val3->country); + + self::assertSame($this->fixtures[0]->email->email, $result[0]->val4); + self::assertSame($this->fixtures[1]->email->email, $result[1]->val4); + self::assertSame($this->fixtures[2]->email->email, $result[2]->val4); + } + + public function testOnlyObjectInDto(): void + { + $dql = ' + SELECT + new CmsDumbDTO( + a, + new CmsDumbDTO( + u.name, + e.email + ) + ) + FROM + Doctrine\Tests\Models\CMS\CmsUser u + LEFT JOIN + u.email e + LEFT JOIN + u.address a + ORDER BY + u.name'; + + $query = $this->getEntityManager()->createQuery($dql); + $result = $query->getResult(); + + self::assertCount(3, $result); + + self::assertInstanceOf(CmsDumbDTO::class, $result[0]); + self::assertInstanceOf(CmsDumbDTO::class, $result[1]); + self::assertInstanceOf(CmsDumbDTO::class, $result[2]); + + self::assertInstanceOf(CmsAddress::class, $result[0]->val1); + self::assertInstanceOf(CmsAddress::class, $result[1]->val1); + self::assertInstanceOf(CmsAddress::class, $result[2]->val1); + + self::assertSame($this->fixtures[0]->address->city, $result[0]->val1->city); + self::assertSame($this->fixtures[1]->address->city, $result[1]->val1->city); + self::assertSame($this->fixtures[2]->address->city, $result[2]->val1->city); + + self::assertSame($this->fixtures[0]->address->country, $result[0]->val1->country); + self::assertSame($this->fixtures[1]->address->country, $result[1]->val1->country); + self::assertSame($this->fixtures[2]->address->country, $result[2]->val1->country); + + self::assertInstanceOf(CmsDumbDTO::class, $result[0]->val2); + self::assertInstanceOf(CmsDumbDTO::class, $result[1]->val2); + self::assertInstanceOf(CmsDumbDTO::class, $result[2]->val2); + + self::assertSame($this->fixtures[0]->name, $result[0]->val2->val1); + self::assertSame($this->fixtures[1]->name, $result[1]->val2->val1); + self::assertSame($this->fixtures[2]->name, $result[2]->val2->val1); + + self::assertSame($this->fixtures[0]->email->email, $result[0]->val2->val2); + self::assertSame($this->fixtures[1]->email->email, $result[1]->val2->val2); + self::assertSame($this->fixtures[2]->email->email, $result[2]->val2->val2); + } + public function testNamedArguments(): void { $dql = <<<'SQL'