From d5fdd676f4a807b6be37d5e18f9a9e3adea48850 Mon Sep 17 00:00:00 2001 From: Benjamin Eberlei Date: Sat, 16 Mar 2024 22:20:34 +0100 Subject: [PATCH 1/5] Reintroduce PARTIAL, but only for non-object hydration. --- UPGRADE.md | 8 +- docs/en/index.rst | 1 + .../reference/dql-doctrine-query-language.rst | 23 +++- docs/en/reference/partial-hydration.rst | 20 +++ docs/en/sidebar.rst | 1 + src/Internal/Hydration/HydrationException.php | 5 + src/Query.php | 8 ++ src/Query/AST/PartialObjectExpression.php | 15 +++ src/Query/Parser.php | 118 +++++++++++++++++- src/Query/SqlWalker.php | 27 +++- src/Query/TokenType.php | 1 + src/UnitOfWork.php | 6 + .../Tests/ORM/Functional/ValueObjectsTest.php | 9 ++ .../ORM/Query/LanguageRecognitionTest.php | 15 +++ .../ORM/Query/SelectSqlGenerationTest.php | 40 +++++- .../LimitSubqueryOutputWalkerTest.php | 17 +++ 16 files changed, 303 insertions(+), 11 deletions(-) create mode 100644 docs/en/reference/partial-hydration.rst create mode 100644 src/Query/AST/PartialObjectExpression.php diff --git a/UPGRADE.md b/UPGRADE.md index 7d49322ca8e..85a492d7758 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -75,9 +75,11 @@ now they throw an exception. ## BC BREAK: Partial objects are removed -- The `PARTIAL` keyword in DQL no longer exists. -- `Doctrine\ORM\Query\AST\PartialObjectExpression`is removed. -- `Doctrine\ORM\Query\SqlWalker::HINT_PARTIAL` and +WARNING: This was relaxed in ORM 3.2 when partial was re-allowed for array-hydration. + +- The `PARTIAL` keyword in DQL no longer exists (reintroduced in ORM 3.2) +- `Doctrine\ORM\Query\AST\PartialObjectExpression` is removed. (reintroduced in ORM 3.2) +- `Doctrine\ORM\Query\SqlWalker::HINT_PARTIAL` (reintroduced in ORM 3.2) and `Doctrine\ORM\Query::HINT_FORCE_PARTIAL_LOAD` are removed. - `Doctrine\ORM\EntityManager*::getPartialReference()` is removed. diff --git a/docs/en/index.rst b/docs/en/index.rst index 83071ad9597..4d23062cd0d 100644 --- a/docs/en/index.rst +++ b/docs/en/index.rst @@ -73,6 +73,7 @@ Advanced Topics * :doc:`TypedFieldMapper ` * :doc:`Improving Performance ` * :doc:`Caching ` +* :doc:`Partial Hydration ` * :doc:`Change Tracking Policies ` * :doc:`Best Practices ` * :doc:`Metadata Drivers ` diff --git a/docs/en/reference/dql-doctrine-query-language.rst b/docs/en/reference/dql-doctrine-query-language.rst index 80d41f17002..b9adecd27b8 100644 --- a/docs/en/reference/dql-doctrine-query-language.rst +++ b/docs/en/reference/dql-doctrine-query-language.rst @@ -523,6 +523,25 @@ when the DQL is switched to an arbitrary join. - HAVING is applied to the results of a query after aggregation (GROUP BY) + +Partial Hydration Syntax +^^^^^^^^^^^^^^^^^^^^^^^^ + +By default when you run a DQL query in Doctrine and select only a +subset of the fields for a given entity, you do not receive objects +back. Instead, you receive only arrays as a flat rectangular result +set, similar to how you would if you were just using SQL directly +and joining some data. + +If you want to select a partial number of fields for hydration entity in +the context of array hydration and joins you can use the ``partial`` DQL keyword: + +.. code-block:: php + + createQuery('SELECT partial u.{id, username}, partial a.{id, name} FROM CmsUser u JOIN u.articles a'); + $users = $query->getArrayResult(); // array of partially loaded CmsUser and CmsArticle fields + "NEW" Operator Syntax ^^^^^^^^^^^^^^^^^^^^^ @@ -1647,8 +1666,10 @@ Select Expressions .. code-block:: php - SelectExpression ::= (IdentificationVariable | ScalarExpression | AggregateExpression | FunctionDeclaration | "(" Subselect ")" | CaseExpression | NewObjectExpression) [["AS"] ["HIDDEN"] 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 ")" diff --git a/docs/en/reference/partial-hydration.rst b/docs/en/reference/partial-hydration.rst new file mode 100644 index 00000000000..a2cca3d186a --- /dev/null +++ b/docs/en/reference/partial-hydration.rst @@ -0,0 +1,20 @@ +Partial Hydration +================= + +.. note:: + + Creating Partial Objects through DQL was possible in ORM 2, + but is only supported for array hydration as of ORM 3 anymore. + +Partial hydration of entities is allowed in the array hydrator, when +only a subset of the fields of an entity are loaded from the database +and the nested results are still created based on the entity relationship structure. + +.. code-block:: php + + createQuery("SELECT PARTIAL u.{id,name}, partial a.{id,street} FROM MyApp\Domain\User u JOIN u.addresses a") + ->getArrayResult(); + +This is a useful optimization when you are not interested in all fields of an entity +for performance reasons, for example in use-cases for exporting or rendering lots of data. diff --git a/docs/en/sidebar.rst b/docs/en/sidebar.rst index 619c6e2511a..628aecfceab 100644 --- a/docs/en/sidebar.rst +++ b/docs/en/sidebar.rst @@ -43,6 +43,7 @@ reference/query-builder reference/native-sql reference/change-tracking-policies + reference/partial-hydration reference/attributes-reference reference/xml-mapping reference/php-mapping diff --git a/src/Internal/Hydration/HydrationException.php b/src/Internal/Hydration/HydrationException.php index 710114f7e6b..a59644300b4 100644 --- a/src/Internal/Hydration/HydrationException.php +++ b/src/Internal/Hydration/HydrationException.php @@ -64,4 +64,9 @@ public static function invalidDiscriminatorValue(string $discrValue, array $disc implode('", "', $discrValues), )); } + + public static function partialObjectHydrationDisallowed(): self + { + return new self('Hydration of entity objects is not allowed when DQL PARTIAL keyword is used.'); + } } diff --git a/src/Query.php b/src/Query.php index 5b0ceb7a97c..8c44eba8afa 100644 --- a/src/Query.php +++ b/src/Query.php @@ -70,6 +70,14 @@ class Query extends AbstractQuery */ public const HINT_REFRESH_ENTITY = 'doctrine.refresh.entity'; + /** + * The forcePartialLoad query hint forces a particular query to return + * partial objects. + * + * @todo Rename: HINT_OPTIMIZE + */ + public const HINT_FORCE_PARTIAL_LOAD = 'doctrine.forcePartialLoad'; + /** * The includeMetaColumns query hint causes meta columns like foreign keys and * discriminator columns to be selected and returned as part of the query result. diff --git a/src/Query/AST/PartialObjectExpression.php b/src/Query/AST/PartialObjectExpression.php new file mode 100644 index 00000000000..875f4453fe1 --- /dev/null +++ b/src/Query/AST/PartialObjectExpression.php @@ -0,0 +1,15 @@ + */ private array $deferredIdentificationVariables = []; + /** @psalm-var list */ + private array $deferredPartialObjectExpressions = []; + /** @psalm-var list */ private array $deferredPathExpressions = []; @@ -224,6 +229,10 @@ public function getAST(): AST\SelectStatement|AST\UpdateStatement|AST\DeleteStat // This also allows post-processing of the AST for modification purposes. $this->processDeferredIdentificationVariables(); + if ($this->deferredPartialObjectExpressions) { + $this->processDeferredPartialObjectExpressions(); + } + if ($this->deferredPathExpressions) { $this->processDeferredPathExpressions(); } @@ -599,6 +608,44 @@ private function processDeferredNewObjectExpressions(AST\SelectStatement $AST): } } + /** + * Validates that the given PartialObjectExpression is semantically correct. + * It must exist in query components list. + */ + private function processDeferredPartialObjectExpressions(): void + { + foreach ($this->deferredPartialObjectExpressions as $deferredItem) { + $expr = $deferredItem['expression']; + $class = $this->getMetadataForDqlAlias($expr->identificationVariable); + + foreach ($expr->partialFieldSet as $field) { + if (isset($class->fieldMappings[$field])) { + continue; + } + + if ( + isset($class->associationMappings[$field]) && + $class->associationMappings[$field]->isToOneOwningSide() + ) { + continue; + } + + $this->semanticalError(sprintf( + "There is no mapped field named '%s' on class %s.", + $field, + $class->name, + ), $deferredItem['token']); + } + + if (array_intersect($class->identifier, $expr->partialFieldSet) !== $class->identifier) { + $this->semanticalError( + 'The partial field selection of class ' . $class->name . ' must contain the identifier.', + $deferredItem['token'], + ); + } + } + } + /** * Validates that the given ResultVariable is semantically correct. * It must exist in query components list. @@ -1621,6 +1668,67 @@ public function JoinAssociationDeclaration(): AST\JoinAssociationDeclaration return new AST\JoinAssociationDeclaration($joinAssociationPathExpression, $aliasIdentificationVariable, $indexBy); } + /** + * PartialObjectExpression ::= "PARTIAL" IdentificationVariable "." PartialFieldSet + * PartialFieldSet ::= "{" SimpleStateField {"," SimpleStateField}* "}" + */ + public function PartialObjectExpression(): AST\PartialObjectExpression + { + if ($this->query->getHydrationMode() === Query::HYDRATE_OBJECT) { + throw HydrationException::partialObjectHydrationDisallowed(); + } + + $this->match(TokenType::T_PARTIAL); + + $partialFieldSet = []; + + $identificationVariable = $this->IdentificationVariable(); + + $this->match(TokenType::T_DOT); + $this->match(TokenType::T_OPEN_CURLY_BRACE); + $this->match(TokenType::T_IDENTIFIER); + + assert($this->lexer->token !== null); + $field = $this->lexer->token->value; + + // First field in partial expression might be embeddable property + while ($this->lexer->isNextToken(TokenType::T_DOT)) { + $this->match(TokenType::T_DOT); + $this->match(TokenType::T_IDENTIFIER); + $field .= '.' . $this->lexer->token->value; + } + + $partialFieldSet[] = $field; + + while ($this->lexer->isNextToken(TokenType::T_COMMA)) { + $this->match(TokenType::T_COMMA); + $this->match(TokenType::T_IDENTIFIER); + + $field = $this->lexer->token->value; + + while ($this->lexer->isNextToken(TokenType::T_DOT)) { + $this->match(TokenType::T_DOT); + $this->match(TokenType::T_IDENTIFIER); + $field .= '.' . $this->lexer->token->value; + } + + $partialFieldSet[] = $field; + } + + $this->match(TokenType::T_CLOSE_CURLY_BRACE); + + $partialObjectExpression = new AST\PartialObjectExpression($identificationVariable, $partialFieldSet); + + // Defer PartialObjectExpression validation + $this->deferredPartialObjectExpressions[] = [ + 'expression' => $partialObjectExpression, + 'nestingLevel' => $this->nestingLevel, + 'token' => $this->lexer->token, + ]; + + return $partialObjectExpression; + } + /** * NewObjectExpression ::= "NEW" AbstractSchemaName "(" NewObjectArg {"," NewObjectArg}* ")" */ @@ -1920,7 +2028,7 @@ public function SimpleWhenClause(): AST\SimpleWhenClause /** * SelectExpression ::= ( * IdentificationVariable | ScalarExpression | AggregateExpression | FunctionDeclaration | - * "(" Subselect ")" | CaseExpression | NewObjectExpression + * PartialObjectExpression | "(" Subselect ")" | CaseExpression | NewObjectExpression * ) [["AS"] ["HIDDEN"] AliasResultVariable] */ public function SelectExpression(): AST\SelectExpression @@ -1961,6 +2069,12 @@ public function SelectExpression(): AST\SelectExpression break; + // PartialObjectExpression (PARTIAL u.{id, name}) + case $lookaheadType === TokenType::T_PARTIAL: + $expression = $this->PartialObjectExpression(); + $identVariable = $expression->identificationVariable; + break; + // Subselect case $lookaheadType === TokenType::T_OPEN_PARENTHESIS && $peek->type === TokenType::T_SELECT: $this->match(TokenType::T_OPEN_PARENTHESIS); @@ -1986,7 +2100,7 @@ public function SelectExpression(): AST\SelectExpression default: $this->syntaxError( - 'IdentificationVariable | ScalarExpression | AggregateExpression | FunctionDeclaration | "(" Subselect ")" | CaseExpression', + 'IdentificationVariable | ScalarExpression | AggregateExpression | FunctionDeclaration | PartialObjectExpression | "(" Subselect ")" | CaseExpression', $this->lexer->lookahead, ); } diff --git a/src/Query/SqlWalker.php b/src/Query/SqlWalker.php index 018c2455e49..b70e37a03b3 100644 --- a/src/Query/SqlWalker.php +++ b/src/Query/SqlWalker.php @@ -28,6 +28,7 @@ use function assert; use function count; use function implode; +use function in_array; use function is_array; use function is_float; use function is_numeric; @@ -51,6 +52,11 @@ class SqlWalker public const HINT_DISTINCT = 'doctrine.distinct'; + /** + * Used to mark a query as containing a PARTIAL expression, which needs to be known by SLC. + */ + public const HINT_PARTIAL = 'doctrine.partial'; + private readonly ResultSetMapping $rsm; /** @@ -1318,7 +1324,17 @@ public function walkSelectExpression(AST\SelectExpression $selectExpression): st break; default: - $dqlAlias = $expr; + // IdentificationVariable or PartialObjectExpression + if ($expr instanceof AST\PartialObjectExpression) { + $this->query->setHint(self::HINT_PARTIAL, true); + + $dqlAlias = $expr->identificationVariable; + $partialFieldSet = $expr->partialFieldSet; + } else { + $dqlAlias = $expr; + $partialFieldSet = []; + } + $class = $this->getMetadataForDqlAlias($dqlAlias); $resultAlias = $selectExpression->fieldIdentificationVariable ?: null; @@ -1334,6 +1350,10 @@ public function walkSelectExpression(AST\SelectExpression $selectExpression): st // Select all fields from the queried class foreach ($class->fieldMappings as $fieldName => $mapping) { + if ($partialFieldSet && ! in_array($fieldName, $partialFieldSet, true)) { + continue; + } + $tableName = isset($mapping->inherited) ? $this->em->getClassMetadata($mapping->inherited)->getTableName() : $class->getTableName(); @@ -1360,13 +1380,14 @@ public function walkSelectExpression(AST\SelectExpression $selectExpression): st // Add any additional fields of subclasses (excluding inherited fields) // 1) on Single Table Inheritance: always, since its marginal overhead - // 2) on Class Table Inheritance + // 2) on Class Table Inheritance only if partial objects are disallowed, + // since it requires outer joining subtables. 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)) { + if (isset($mapping->inherited) || ($partialFieldSet && ! in_array($fieldName, $partialFieldSet, true))) { continue; } diff --git a/src/Query/TokenType.php b/src/Query/TokenType.php index e745e4a1da3..bf1c351c2a6 100644 --- a/src/Query/TokenType.php +++ b/src/Query/TokenType.php @@ -77,6 +77,7 @@ enum TokenType: int case T_OR = 242; case T_ORDER = 243; case T_OUTER = 244; + case T_PARTIAL = 245; case T_SELECT = 246; case T_SET = 247; case T_SOME = 248; diff --git a/src/UnitOfWork.php b/src/UnitOfWork.php index e1336140640..ec7175c5df4 100644 --- a/src/UnitOfWork.php +++ b/src/UnitOfWork.php @@ -28,6 +28,7 @@ use Doctrine\ORM\Exception\ORMException; use Doctrine\ORM\Exception\UnexpectedAssociationValue; use Doctrine\ORM\Id\AssignedGenerator; +use Doctrine\ORM\Internal\Hydration\HydrationException; use Doctrine\ORM\Internal\HydrationCompleteHandler; use Doctrine\ORM\Internal\StronglyConnectedComponents; use Doctrine\ORM\Internal\TopologicalSort; @@ -43,6 +44,7 @@ use Doctrine\ORM\Persisters\Entity\JoinedSubclassPersister; use Doctrine\ORM\Persisters\Entity\SingleTablePersister; use Doctrine\ORM\Proxy\InternalProxy; +use Doctrine\ORM\Query\SqlWalker; use Doctrine\ORM\Utility\IdentifierFlattener; use Doctrine\Persistence\PropertyChangedListener; use Exception; @@ -2353,6 +2355,10 @@ public function isCollectionScheduledForDeletion(PersistentCollection $coll): bo */ public function createEntity(string $className, array $data, array &$hints = []): object { + if (isset($hints[SqlWalker::HINT_PARTIAL])) { + throw HydrationException::partialObjectHydrationDisallowed(); + } + $class = $this->em->getClassMetadata($className); $id = $this->identifierFlattener->flattenIdentifier($class, $data); diff --git a/tests/Tests/ORM/Functional/ValueObjectsTest.php b/tests/Tests/ORM/Functional/ValueObjectsTest.php index 08ce4b17d89..6656d916ee0 100644 --- a/tests/Tests/ORM/Functional/ValueObjectsTest.php +++ b/tests/Tests/ORM/Functional/ValueObjectsTest.php @@ -218,6 +218,15 @@ public function testDqlWithNonExistentEmbeddableField(): void ->execute(); } + public function testPartialDqlWithNonExistentEmbeddableField(): void + { + $this->expectException(QueryException::class); + $this->expectExceptionMessage("no mapped field named 'address.asdfasdf'"); + + $this->_em->createQuery('SELECT PARTIAL p.{id,address.asdfasdf} FROM ' . __NAMESPACE__ . '\\DDC93Person p') + ->getArrayResult(); + } + public function testEmbeddableWithInheritance(): void { $car = new DDC93Car(new DDC93Address('Foo', '12345', 'Asdf')); diff --git a/tests/Tests/ORM/Query/LanguageRecognitionTest.php b/tests/Tests/ORM/Query/LanguageRecognitionTest.php index 0bdf2f37b38..ec57b1f1382 100644 --- a/tests/Tests/ORM/Query/LanguageRecognitionTest.php +++ b/tests/Tests/ORM/Query/LanguageRecognitionTest.php @@ -4,6 +4,7 @@ namespace Doctrine\Tests\ORM\Query; +use Doctrine\ORM\AbstractQuery; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\Column; use Doctrine\ORM\Mapping\Entity; @@ -22,6 +23,7 @@ class LanguageRecognitionTest extends OrmTestCase { private EntityManagerInterface $entityManager; + private int $hydrationMode = AbstractQuery::HYDRATE_OBJECT; protected function setUp(): void { @@ -45,6 +47,7 @@ public function parseDql(string $dql, array $hints = []): ParserResult { $query = $this->entityManager->createQuery($dql); $query->setDQL($dql); + $query->setHydrationMode($this->hydrationMode); foreach ($hints as $key => $value) { $query->setHint($key, $value); @@ -527,6 +530,18 @@ public function testUnknownAbstractSchemaName(): void $this->assertInvalidDQL('SELECT u FROM UnknownClassName u'); } + public function testCorrectPartialObjectLoad(): void + { + $this->hydrationMode = AbstractQuery::HYDRATE_ARRAY; + $this->assertValidDQL('SELECT PARTIAL u.{id,name} FROM Doctrine\Tests\Models\CMS\CmsUser u'); + } + + public function testIncorrectPartialObjectLoadBecauseOfMissingIdentifier(): void + { + $this->hydrationMode = AbstractQuery::HYDRATE_ARRAY; + $this->assertInvalidDQL('SELECT PARTIAL u.{name} FROM Doctrine\Tests\Models\CMS\CmsUser u'); + } + public function testScalarExpressionInSelect(): void { $this->assertValidDQL('SELECT u, 42 + u.id AS someNumber FROM Doctrine\Tests\Models\CMS\CmsUser u'); diff --git a/tests/Tests/ORM/Query/SelectSqlGenerationTest.php b/tests/Tests/ORM/Query/SelectSqlGenerationTest.php index 681562c1dd8..3969fe51636 100644 --- a/tests/Tests/ORM/Query/SelectSqlGenerationTest.php +++ b/tests/Tests/ORM/Query/SelectSqlGenerationTest.php @@ -40,6 +40,7 @@ class_exists('Doctrine\\DBAL\\Platforms\\SqlitePlatform'); class SelectSqlGenerationTest extends OrmTestCase { private EntityManagerInterface $entityManager; + private int $hydrationMode = ORMQuery::HYDRATE_OBJECT; protected function setUp(): void { @@ -56,6 +57,7 @@ public function assertSqlGeneration( array $queryParams = [], ): void { $query = $this->entityManager->createQuery($dqlToBeTested); + $query->setHydrationMode($this->hydrationMode); foreach ($queryParams as $name => $value) { $query->setParameter($name, $value); @@ -1330,6 +1332,22 @@ public function testIdentityFunctionWithCompositePrimaryKey(): void ); } + #[Group('DDC-2519')] + public function testPartialWithAssociationIdentifier(): void + { + $this->hydrationMode = ORMQuery::HYDRATE_ARRAY; + + $this->assertSqlGeneration( + 'SELECT PARTIAL l.{_source, _target} FROM Doctrine\Tests\Models\Legacy\LegacyUserReference l', + 'SELECT l0_.iUserIdSource AS iUserIdSource_0, l0_.iUserIdTarget AS iUserIdTarget_1 FROM legacy_users_reference l0_', + ); + + $this->assertSqlGeneration( + 'SELECT PARTIAL l.{_description, _source, _target} FROM Doctrine\Tests\Models\Legacy\LegacyUserReference l', + 'SELECT l0_.description AS description_0, l0_.iUserIdSource AS iUserIdSource_1, l0_.iUserIdTarget AS iUserIdTarget_2 FROM legacy_users_reference l0_', + ); + } + #[Group('DDC-1339')] public function testIdentityFunctionInSelectClause(): void { @@ -1400,11 +1418,13 @@ public function testInheritanceTypeSingleTableInRootClass(): void } #[Group('DDC-1389')] - public function testInheritanceTypeSingleTableInChildClass(): void + public function testInheritanceTypeSingleTableInChildClassWithDisabledForcePartialLoad(): void { + $this->hydrationMode = ORMQuery::HYDRATE_ARRAY; + $this->assertSqlGeneration( 'SELECT fc FROM Doctrine\Tests\Models\Company\CompanyFlexContract fc', - "SELECT c0_.id AS id_0, c0_.completed AS completed_1, c0_.hoursWorked AS hoursWorked_2, c0_.pricePerHour AS pricePerHour_3, c0_.maxPrice AS maxPrice_4, c0_.discr AS discr_5, c0_.salesPerson_id AS salesPerson_id_6 FROM company_contracts c0_ WHERE c0_.discr IN ('flexible', 'flexultra')", + "SELECT c0_.id AS id_0, c0_.completed AS completed_1, c0_.hoursWorked AS hoursWorked_2, c0_.pricePerHour AS pricePerHour_3, c0_.maxPrice AS maxPrice_4, c0_.discr AS discr_5 FROM company_contracts c0_ WHERE c0_.discr IN ('flexible', 'flexultra')", ); } @@ -1690,6 +1710,22 @@ public function testCustomTypeValueSqlForAllFields(): void ); } + public function testCustomTypeValueSqlForPartialObject(): void + { + $this->hydrationMode = ORMQuery::HYDRATE_ARRAY; + + if (DBALType::hasType('negative_to_positive')) { + DBALType::overrideType('negative_to_positive', NegativeToPositiveType::class); + } else { + DBALType::addType('negative_to_positive', NegativeToPositiveType::class); + } + + $this->assertSqlGeneration( + 'SELECT partial p.{id, customInteger} FROM Doctrine\Tests\Models\CustomType\CustomTypeParent p', + 'SELECT c0_.id AS id_0, -(c0_.customInteger) AS customInteger_1 FROM customtype_parents c0_', + ); + } + #[Group('DDC-1529')] public function testMultipleFromAndInheritanceCondition(): void { diff --git a/tests/Tests/ORM/Tools/Pagination/LimitSubqueryOutputWalkerTest.php b/tests/Tests/ORM/Tools/Pagination/LimitSubqueryOutputWalkerTest.php index 65d0dbabf23..0f5bac25b72 100644 --- a/tests/Tests/ORM/Tools/Pagination/LimitSubqueryOutputWalkerTest.php +++ b/tests/Tests/ORM/Tools/Pagination/LimitSubqueryOutputWalkerTest.php @@ -147,6 +147,23 @@ public function testCountQueryWithComplexScalarOrderByItemJoined(): void ); } + public function testCountQueryWithComplexScalarOrderByItemJoinedWithPartial(): void + { + $entityManager = $this->createTestEntityManagerWithPlatform(new MySQLPlatform()); + + $query = $entityManager->createQuery( + 'SELECT u, partial a.{id, imageAltDesc} FROM Doctrine\Tests\ORM\Tools\Pagination\User u JOIN u.avatar a ORDER BY a.imageHeight * a.imageWidth DESC', + ); + + $query->setHydrationMode(Query::HYDRATE_ARRAY); + $query->setHint(Query::HINT_CUSTOM_OUTPUT_WALKER, LimitSubqueryOutputWalker::class); + + self::assertSame( + 'SELECT DISTINCT id_0 FROM (SELECT DISTINCT id_0, imageHeight_5 * imageWidth_6 FROM (SELECT u0_.id AS id_0, a1_.id AS id_1, a1_.imageAltDesc AS imageAltDesc_2, a1_.id AS id_3, a1_.image AS image_4, a1_.imageHeight AS imageHeight_5, a1_.imageWidth AS imageWidth_6, a1_.imageAltDesc AS imageAltDesc_7 FROM User u0_ INNER JOIN Avatar a1_ ON u0_.id = a1_.user_id) dctrn_result_inner ORDER BY imageHeight_5 * imageWidth_6 DESC) dctrn_result', + $query->getSQL(), + ); + } + public function testCountQueryWithComplexScalarOrderByItemOracle(): void { $this->entityManager = $this->createTestEntityManagerWithPlatform(new OraclePlatform()); From eb8510ff5c7c4ca508556b135ed194a0e3318e1e Mon Sep 17 00:00:00 2001 From: Benjamin Eberlei Date: Sat, 16 Mar 2024 22:34:54 +0100 Subject: [PATCH 2/5] Add tests for adjusted functionality. --- tests/Tests/ORM/Functional/QueryTest.php | 46 ++++++++++++++++++++++++ tests/Tests/ORM/Query/ParserTest.php | 10 ++++++ tests/Tests/ORM/UnitOfWorkTest.php | 11 ++++++ 3 files changed, 67 insertions(+) diff --git a/tests/Tests/ORM/Functional/QueryTest.php b/tests/Tests/ORM/Functional/QueryTest.php index 00efce3f080..01d876fbbd1 100644 --- a/tests/Tests/ORM/Functional/QueryTest.php +++ b/tests/Tests/ORM/Functional/QueryTest.php @@ -109,6 +109,52 @@ public function testJoinQueries(): void self::assertEquals('Symfony 2', $users[0]->articles[1]->topic); } + public function testJoinPartialArrayHydration(): void + { + $user = new CmsUser(); + $user->name = 'Guilherme'; + $user->username = 'gblanco'; + $user->status = 'developer'; + + $article1 = new CmsArticle(); + $article1->topic = 'Doctrine 2'; + $article1->text = 'This is an introduction to Doctrine 2.'; + $user->addArticle($article1); + + $article2 = new CmsArticle(); + $article2->topic = 'Symfony 2'; + $article2->text = 'This is an introduction to Symfony 2.'; + $user->addArticle($article2); + + $this->_em->persist($user); + $this->_em->persist($article1); + $this->_em->persist($article2); + + $this->_em->flush(); + $this->_em->clear(); + + $query = $this->_em->createQuery('select partial u.{id, username}, partial a.{id, topic} from ' . CmsUser::class . ' u join u.articles a ORDER BY a.topic'); + $users = $query->getArrayResult(); + + $this->assertEquals([ + [ + 'id' => 13, + 'username' => 'gblanco', + 'articles' => + [ + [ + 'id' => 3, + 'topic' => 'Doctrine 2', + ], + [ + 'id' => 4, + 'topic' => 'Symfony 2', + ], + ], + ], + ], $users); + } + public function testUsingZeroBasedQueryParameterShouldWork(): void { $user = new CmsUser(); diff --git a/tests/Tests/ORM/Query/ParserTest.php b/tests/Tests/ORM/Query/ParserTest.php index 430177b1fcc..6290bbc4dab 100644 --- a/tests/Tests/ORM/Query/ParserTest.php +++ b/tests/Tests/ORM/Query/ParserTest.php @@ -4,6 +4,7 @@ namespace Doctrine\Tests\ORM\Query; +use Doctrine\ORM\Internal\Hydration\HydrationException; use Doctrine\ORM\Query; use Doctrine\ORM\Query\Parser; use Doctrine\ORM\Query\QueryException; @@ -115,6 +116,15 @@ public function testNullLookahead(): void $parser->match(TokenType::T_SELECT); } + public function testPartialExpressionWithObjectHydratorThrows(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage('Hydration of entity objects is not allowed when DQL PARTIAL keyword is used.'); + + $parser = $this->createParser(CmsUser::class); + $parser->PartialObjectExpression(); + } + private function createParser(string $dql): Parser { $query = new Query($this->getTestEntityManager()); diff --git a/tests/Tests/ORM/UnitOfWorkTest.php b/tests/Tests/ORM/UnitOfWorkTest.php index 550b1cfe1c8..ff5bab7895e 100644 --- a/tests/Tests/ORM/UnitOfWorkTest.php +++ b/tests/Tests/ORM/UnitOfWorkTest.php @@ -13,6 +13,7 @@ use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\ORM\EntityNotFoundException; use Doctrine\ORM\Exception\EntityIdentityCollisionException; +use Doctrine\ORM\Internal\Hydration\HydrationException; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Mapping\Column; use Doctrine\ORM\Mapping\Entity; @@ -22,6 +23,7 @@ use Doctrine\ORM\Mapping\Version; use Doctrine\ORM\OptimisticLockException; use Doctrine\ORM\ORMInvalidArgumentException; +use Doctrine\ORM\Query\SqlWalker; use Doctrine\ORM\UnitOfWork; use Doctrine\Tests\Mocks\EntityManagerMock; use Doctrine\Tests\Mocks\EntityPersisterMock; @@ -643,6 +645,15 @@ public function testItThrowsWhenApplicationProvidedIdsCollide(): void $this->_unitOfWork->persist($phone2); } + + public function testItThrowsWhenCreateEntityWithSqlWalkerPartialQueryHint(): void + { + $this->expectException(HydrationException::class); + $this->expectExceptionMessage('Hydration of entity objects is not allowed when DQL PARTIAL keyword is used.'); + + $hints = [SqlWalker::HINT_PARTIAL => true]; + $this->_unitOfWork->createEntity(VersionedAssignedIdentifierEntity::class, ['id' => 1], $hints); + } } From 758f0d7605132869c4713dcf487e7adb2b655c0c Mon Sep 17 00:00:00 2001 From: Benjamin Eberlei Date: Sat, 16 Mar 2024 22:36:21 +0100 Subject: [PATCH 3/5] Remove Query::HINT_FORCE_PARTIAL_LOAD constant, not needed to be reintroduced. --- src/Query.php | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/Query.php b/src/Query.php index 8c44eba8afa..5b0ceb7a97c 100644 --- a/src/Query.php +++ b/src/Query.php @@ -70,14 +70,6 @@ class Query extends AbstractQuery */ public const HINT_REFRESH_ENTITY = 'doctrine.refresh.entity'; - /** - * The forcePartialLoad query hint forces a particular query to return - * partial objects. - * - * @todo Rename: HINT_OPTIMIZE - */ - public const HINT_FORCE_PARTIAL_LOAD = 'doctrine.forcePartialLoad'; - /** * The includeMetaColumns query hint causes meta columns like foreign keys and * discriminator columns to be selected and returned as part of the query result. From 90962f060a39bedc541d199f7196705297b10e2a Mon Sep 17 00:00:00 2001 From: Benjamin Eberlei Date: Sat, 16 Mar 2024 22:39:38 +0100 Subject: [PATCH 4/5] Use id dynamically in array hydration test. --- tests/Tests/ORM/Functional/QueryTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Tests/ORM/Functional/QueryTest.php b/tests/Tests/ORM/Functional/QueryTest.php index 01d876fbbd1..10e26df023f 100644 --- a/tests/Tests/ORM/Functional/QueryTest.php +++ b/tests/Tests/ORM/Functional/QueryTest.php @@ -138,16 +138,16 @@ public function testJoinPartialArrayHydration(): void $this->assertEquals([ [ - 'id' => 13, + 'id' => $user->id, 'username' => 'gblanco', 'articles' => [ [ - 'id' => 3, + 'id' => $article1->id, 'topic' => 'Doctrine 2', ], [ - 'id' => 4, + 'id' => $article2->id, 'topic' => 'Symfony 2', ], ], From 80278c545eb2679184ef549c1040bb93545c920b Mon Sep 17 00:00:00 2001 From: Benjamin Eberlei Date: Sat, 16 Mar 2024 23:36:13 +0100 Subject: [PATCH 5/5] Update docs/en/reference/partial-hydration.rst MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Grégoire Paris --- docs/en/reference/partial-hydration.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/en/reference/partial-hydration.rst b/docs/en/reference/partial-hydration.rst index a2cca3d186a..16879c45c52 100644 --- a/docs/en/reference/partial-hydration.rst +++ b/docs/en/reference/partial-hydration.rst @@ -4,7 +4,7 @@ Partial Hydration .. note:: Creating Partial Objects through DQL was possible in ORM 2, - but is only supported for array hydration as of ORM 3 anymore. + but is only supported for array hydration as of ORM 3. Partial hydration of entities is allowed in the array hydrator, when only a subset of the fields of an entity are loaded from the database