From 8c582a49d375e6909073820f2ca37ba5d06c2d7e Mon Sep 17 00:00:00 2001 From: eltharin Date: Mon, 12 Aug 2024 09:12:53 +0200 Subject: [PATCH] Add support for using nested DTOs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This feature allow use of nested new operators Co-authored-by: Tomas Norkūnas Co-authored-by: Sergey Protko Co-authored-by: Łukasz Zakrzewski Update docs/en/reference/dql-doctrine-query-language.rst Co-authored-by: Claudio Zizza <859964+SenseException@users.noreply.github.com> --- .../reference/dql-doctrine-query-language.rst | 29 ++++- psalm-baseline.xml | 10 +- src/Internal/Hydration/AbstractHydrator.php | 34 +++++- src/Internal/Hydration/ArrayHydrator.php | 5 +- src/Internal/Hydration/ObjectHydrator.php | 4 +- src/Query/Parser.php | 4 + src/Query/ResultSetMapping.php | 29 +++++ src/Query/SqlWalker.php | 24 +++- tests/Tests/Models/CMS/CmsAddressDTO.php | 2 +- tests/Tests/Models/CMS/CmsUserDTO.php | 2 +- .../Tests/Models/DDC6573/DDC6573Currency.php | 17 +++ tests/Tests/Models/DDC6573/DDC6573Item.php | 44 +++++++ tests/Tests/Models/DDC6573/DDC6573Money.php | 24 ++++ .../Tests/ORM/Functional/NewOperatorTest.php | 67 +++++++++++ .../ORM/Functional/Ticket/DDC6573Test.php | 108 ++++++++++++++++++ .../ORM/Hydration/ResultSetMappingTest.php | 17 +++ 16 files changed, 402 insertions(+), 18 deletions(-) create mode 100644 tests/Tests/Models/DDC6573/DDC6573Currency.php create mode 100644 tests/Tests/Models/DDC6573/DDC6573Item.php create mode 100644 tests/Tests/Models/DDC6573/DDC6573Money.php create mode 100644 tests/Tests/ORM/Functional/Ticket/DDC6573Test.php 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); + } }