diff --git a/docs/en/reference/dql-doctrine-query-language.rst b/docs/en/reference/dql-doctrine-query-language.rst
index 12b08823811..c2b31cd326d 100644
--- a/docs/en/reference/dql-doctrine-query-language.rst
+++ b/docs/en/reference/dql-doctrine-query-language.rst
@@ -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
+
+ 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
~~~~~~~~~~~~~~
diff --git a/psalm-baseline.xml b/psalm-baseline.xml
index 7a84afd4f83..b83ae43a889 100644
--- a/psalm-baseline.xml
+++ b/psalm-baseline.xml
@@ -216,6 +216,10 @@
+
+
+
+
@@ -228,9 +232,6 @@
-
-
-
@@ -265,9 +266,6 @@
-
-
-
diff --git a/src/Internal/Hydration/AbstractHydrator.php b/src/Internal/Hydration/AbstractHydrator.php
index d8bffe4ad39..c8186171466 100644
--- a/src/Internal/Hydration/AbstractHydrator.php
+++ b/src/Internal/Hydration/AbstractHydrator.php
@@ -252,8 +252,9 @@ abstract protected function hydrateAllData(): mixed;
* @psalm-return array{
* data: array,
* newObjects?: array,
* scalars?: array
* }
@@ -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;
@@ -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;
+ }
+
+ $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;
}
diff --git a/src/Internal/Hydration/ArrayHydrator.php b/src/Internal/Hydration/ArrayHydrator.php
index 7115c16c47b..576b89174d3 100644
--- a/src/Internal/Hydration/ArrayHydrator.php
+++ b/src/Internal/Hydration/ArrayHydrator.php
@@ -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;
diff --git a/src/Internal/Hydration/ObjectHydrator.php b/src/Internal/Hydration/ObjectHydrator.php
index d0fc101f215..f151fb813ca 100644
--- a/src/Internal/Hydration/ObjectHydrator.php
+++ b/src/Internal/Hydration/ObjectHydrator.php
@@ -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;
diff --git a/src/Query/Parser.php b/src/Query/Parser.php
index 42b0027f579..38858cbf876 100644
--- a/src/Query/Parser.php
+++ b/src/Query/Parser.php
@@ -1782,6 +1782,10 @@ public function NewObjectArg(): mixed
return $expression;
}
+ if ($token->type === TokenType::T_NEW) {
+ return $this->NewObjectExpression();
+ }
+
return $this->ScalarExpression();
}
diff --git a/src/Query/ResultSetMapping.php b/src/Query/ResultSetMapping.php
index 612474db1d2..c95b089a73b 100644
--- a/src/Query/ResultSetMapping.php
+++ b/src/Query/ResultSetMapping.php
@@ -4,6 +4,7 @@
namespace Doctrine\ORM\Query;
+use function array_merge;
use function count;
/**
@@ -152,6 +153,13 @@ class ResultSetMapping
*/
public array $newObjectMappings = [];
+ /**
+ * Maps last argument for new objects in order to initiate object construction
+ *
+ * @psalm-var array
+ */
+ public array $nestedNewObjectArguments = [];
+
/**
* Maps metadata parameter names to the metadata attribute.
*
@@ -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;
+ }
}
diff --git a/src/Query/SqlWalker.php b/src/Query/SqlWalker.php
index 7f9bb110cac..46296e719e7 100644
--- a/src/Query/SqlWalker.php
+++ b/src/Query/SqlWalker.php
@@ -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;
@@ -85,6 +87,13 @@ class SqlWalker
*/
private int $newObjectCounter = 0;
+ /**
+ * Contains nesting levels of new objects arguments
+ *
+ * @psalm-var array
+ */
+ private array $newObjectStack = [];
+
private readonly EntityManagerInterface $em;
private readonly Connection $conn;
@@ -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++;
@@ -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:
@@ -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);
diff --git a/tests/Tests/Models/CMS/CmsAddressDTO.php b/tests/Tests/Models/CMS/CmsAddressDTO.php
index cfe1579aaf9..502644ed25e 100644
--- a/tests/Tests/Models/CMS/CmsAddressDTO.php
+++ b/tests/Tests/Models/CMS/CmsAddressDTO.php
@@ -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)
{
}
}
diff --git a/tests/Tests/Models/CMS/CmsUserDTO.php b/tests/Tests/Models/CMS/CmsUserDTO.php
index 36b639aeb73..f2dc43114db 100644
--- a/tests/Tests/Models/CMS/CmsUserDTO.php
+++ b/tests/Tests/Models/CMS/CmsUserDTO.php
@@ -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)
{
}
}
diff --git a/tests/Tests/Models/DDC6573/DDC6573Currency.php b/tests/Tests/Models/DDC6573/DDC6573Currency.php
new file mode 100644
index 00000000000..9aa5b0eb9e1
--- /dev/null
+++ b/tests/Tests/Models/DDC6573/DDC6573Currency.php
@@ -0,0 +1,17 @@
+code;
+ }
+}
diff --git a/tests/Tests/Models/DDC6573/DDC6573Item.php b/tests/Tests/Models/DDC6573/DDC6573Item.php
new file mode 100644
index 00000000000..29b99a2d6fb
--- /dev/null
+++ b/tests/Tests/Models/DDC6573/DDC6573Item.php
@@ -0,0 +1,44 @@
+name = $name;
+ $this->priceAmount = $price->getAmount();
+ $this->priceCurrency = $price->getCurrency()->getCode();
+ }
+
+ public function getPrice(): DDC6573Money
+ {
+ return new DDC6573Money($this->priceAmount, new DDC6573Currency($this->priceCurrency));
+ }
+}
diff --git a/tests/Tests/Models/DDC6573/DDC6573Money.php b/tests/Tests/Models/DDC6573/DDC6573Money.php
new file mode 100644
index 00000000000..f0d0d59ea48
--- /dev/null
+++ b/tests/Tests/Models/DDC6573/DDC6573Money.php
@@ -0,0 +1,24 @@
+amount;
+ }
+
+ public function getCurrency(): DDC6573Currency
+ {
+ return $this->currency;
+ }
+}
diff --git a/tests/Tests/ORM/Functional/NewOperatorTest.php b/tests/Tests/ORM/Functional/NewOperatorTest.php
index 7f89a938e88..4497af517bf 100644
--- a/tests/Tests/ORM/Functional/NewOperatorTest.php
+++ b/tests/Tests/ORM/Functional/NewOperatorTest.php
@@ -1013,6 +1013,73 @@ public function testClassCantBeInstantiatedException(): void
$dql = 'SELECT new Doctrine\Tests\ORM\Functional\ClassWithPrivateConstructor(u.name) FROM Doctrine\Tests\Models\CMS\CmsUser u';
$this->_em->createQuery($dql)->getResult();
}
+
+ public function testShouldSupportNestedNewOperators(): void
+ {
+ $dql = '
+ SELECT
+ new CmsUserDTO(
+ u.name,
+ e.email,
+ new CmsAddressDTO(
+ a.country,
+ a.city,
+ a.zip,
+ new CmsAddressDTO(
+ a.country,
+ a.city,
+ a.zip
+ )
+ )
+ ) 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($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']);
+ }
}
class ClassWithTooMuchArgs
diff --git a/tests/Tests/ORM/Functional/Ticket/DDC6573Test.php b/tests/Tests/ORM/Functional/Ticket/DDC6573Test.php
new file mode 100644
index 00000000000..4802e596bfb
--- /dev/null
+++ b/tests/Tests/ORM/Functional/Ticket/DDC6573Test.php
@@ -0,0 +1,108 @@
+ */
+ private $fixtures;
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->createSchemaForModels(
+ DDC6573Item::class,
+ );
+
+ $item1 = new DDC6573Item('Plate', new DDC6573Money(5, new DDC6573Currency('GBP')));
+ $item2 = new DDC6573Item('Iron', new DDC6573Money(50, new DDC6573Currency('EUR')));
+ $item3 = new DDC6573Item('Teapot', new DDC6573Money(10, new DDC6573Currency('GBP')));
+
+ $this->_em->persist($item1);
+ $this->_em->persist($item2);
+ $this->_em->persist($item3);
+
+ $this->_em->flush();
+ $this->_em->clear();
+
+ $this->fixtures = [$item1, $item2, $item3];
+ }
+
+ protected function tearDown(): void
+ {
+ $this->_em->createQuery('DELETE FROM Doctrine\Tests\Models\DDC6573\DDC6573Item i')->execute();
+ }
+
+ public static function provideDataForHydrationMode(): iterable
+ {
+ yield [AbstractQuery::HYDRATE_ARRAY];
+ yield [AbstractQuery::HYDRATE_OBJECT];
+ }
+
+ #[DataProvider('provideDataForHydrationMode')]
+ public function testShouldSupportsMultipleNewOperator(int $hydrationMode): void
+ {
+ $dql = '
+ SELECT
+ new Doctrine\Tests\Models\DDC6573\DDC6573Money(
+ i.priceAmount,
+ new Doctrine\Tests\Models\DDC6573\DDC6573Currency(i.priceCurrency)
+ )
+ FROM
+ Doctrine\Tests\Models\DDC6573\DDC6573Item i
+ ORDER BY
+ i.priceAmount ASC';
+
+ $query = $this->_em->createQuery($dql);
+ $result = $query->getResult($hydrationMode);
+
+ self::assertCount(3, $result);
+
+ self::assertInstanceOf(DDC6573Money::class, $result[0]);
+ self::assertInstanceOf(DDC6573Money::class, $result[1]);
+ self::assertInstanceOf(DDC6573Money::class, $result[2]);
+
+ self::assertEquals($this->fixtures[0]->getPrice(), $result[0]);
+ self::assertEquals($this->fixtures[2]->getPrice(), $result[1]);
+ self::assertEquals($this->fixtures[1]->getPrice(), $result[2]);
+ }
+
+ #[DataProvider('provideDataForHydrationMode')]
+ public function testShouldSupportsBasicUsage(int $hydrationMode): void
+ {
+ $dql = '
+ SELECT
+ new Doctrine\Tests\Models\DDC6573\DDC6573Currency(
+ i.priceCurrency
+ )
+ FROM
+ Doctrine\Tests\Models\DDC6573\DDC6573Item i
+ ORDER BY
+ i.priceAmount';
+
+ $query = $this->_em->createQuery($dql);
+ $result = $query->getResult($hydrationMode);
+
+ self::assertCount(3, $result);
+
+ self::assertInstanceOf(DDC6573Currency::class, $result[0]);
+ self::assertInstanceOf(DDC6573Currency::class, $result[1]);
+ self::assertInstanceOf(DDC6573Currency::class, $result[2]);
+
+ self::assertEquals($this->fixtures[0]->getPrice()->getCurrency(), $result[0]);
+ self::assertEquals($this->fixtures[1]->getPrice()->getCurrency(), $result[2]);
+ self::assertEquals($this->fixtures[2]->getPrice()->getCurrency(), $result[1]);
+ }
+}
diff --git a/tests/Tests/ORM/Hydration/ResultSetMappingTest.php b/tests/Tests/ORM/Hydration/ResultSetMappingTest.php
index 0c20eab0866..14b9205abfe 100644
--- a/tests/Tests/ORM/Hydration/ResultSetMappingTest.php
+++ b/tests/Tests/ORM/Hydration/ResultSetMappingTest.php
@@ -102,4 +102,21 @@ public function testIndexByMetadataColumn(): void
self::assertTrue($this->_rsm->hasIndexBy('lu'));
}
+
+ public function testNewObjectNestedArgumentsDeepestLeavesShouldComeFirst(): void
+ {
+ $this->_rsm->addNewObjectAsArgument('objALevel2', 'objALevel1', 0);
+ $this->_rsm->addNewObjectAsArgument('objALevel3', 'objALevel2', 1);
+ $this->_rsm->addNewObjectAsArgument('objBLevel3', 'objBLevel2', 0);
+ $this->_rsm->addNewObjectAsArgument('objBLevel2', 'objBLevel1', 1);
+
+ $expectedArgumentMapping = [
+ 'objALevel3' => ['ownerIndex' => 'objALevel2', 'argIndex' => 1],
+ 'objALevel2' => ['ownerIndex' => 'objALevel1', 'argIndex' => 0],
+ 'objBLevel3' => ['ownerIndex' => 'objBLevel2', 'argIndex' => 0],
+ 'objBLevel2' => ['ownerIndex' => 'objBLevel1', 'argIndex' => 1],
+ ];
+
+ self::assertSame($expectedArgumentMapping, $this->_rsm->nestedNewObjectArguments);
+ }
}