diff --git a/docs/en/index.rst b/docs/en/index.rst index 4d23062cd0..32f8c07017 100644 --- a/docs/en/index.rst +++ b/docs/en/index.rst @@ -74,6 +74,7 @@ Advanced Topics * :doc:`Improving Performance ` * :doc:`Caching ` * :doc:`Partial Hydration ` +* :doc:`Partial Objects ` * :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 8608049d1f..e668c08fd8 100644 --- a/docs/en/reference/dql-doctrine-query-language.rst +++ b/docs/en/reference/dql-doctrine-query-language.rst @@ -533,14 +533,23 @@ 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: +If you want to select partial objects or fields in array hydration you can use the ``partial`` +DQL keyword: + +.. code-block:: php + + createQuery('SELECT partial u.{id, username} FROM CmsUser u'); + $users = $query->getResult(); // array of partially loaded CmsUser objects + +You can use the partial syntax when joining as well: .. 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 + $usersArray = $query->getArrayResult(); // array of partially loaded CmsUser and CmsArticle fields + $users = $query->getResult(); // array of partially loaded CmsUser objects "NEW" Operator Syntax ^^^^^^^^^^^^^^^^^^^^^ @@ -1427,6 +1436,15 @@ exist mostly internal query hints that are not be consumed in userland. However the following few hints are to be used in userland: + +- ``Query::HINT_FORCE_PARTIAL_LOAD`` - Allows to hydrate objects + although not all their columns are fetched. This query hint can be + used to handle memory consumption problems with large result-sets + that contain char or binary data. Doctrine has no way of implicitly + reloading this data. Partially loaded objects have to be passed to + ``EntityManager::refresh()`` if they are to be reloaded fully from + the database. This query hint is deprecated and will be removed + in the future (\ `Details `_) - ``Query::HINT_REFRESH`` - This query is used internally by ``EntityManager::refresh()`` and can be used in userland as well. If you specify this hint and a query returns the data for an entity diff --git a/docs/en/reference/partial-hydration.rst b/docs/en/reference/partial-hydration.rst index 16879c45c5..fda14b98ef 100644 --- a/docs/en/reference/partial-hydration.rst +++ b/docs/en/reference/partial-hydration.rst @@ -1,11 +1,6 @@ Partial Hydration ================= -.. note:: - - Creating Partial Objects through DQL was possible in ORM 2, - 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 and the nested results are still created based on the entity relationship structure. diff --git a/docs/en/reference/partial-objects.rst b/docs/en/reference/partial-objects.rst new file mode 100644 index 0000000000..3123c083f3 --- /dev/null +++ b/docs/en/reference/partial-objects.rst @@ -0,0 +1,88 @@ +Partial Objects +=============== + +A partial object is an object whose state is not fully initialized +after being reconstituted from the database and that is +disconnected from the rest of its data. The following section will +describe why partial objects are problematic and what the approach +of Doctrine to this problem is. + +.. note:: + + The partial object problem in general does not apply to + methods or queries where you do not retrieve the query result as + objects. Examples are: ``Query#getArrayResult()``, + ``Query#getScalarResult()``, ``Query#getSingleScalarResult()``, + etc. + +.. warning:: + + Use of partial objects is tricky. Fields that are not retrieved + from the database will not be updated by the UnitOfWork even if they + get changed in your objects. You can only promote a partial object + to a fully-loaded object by calling ``EntityManager#refresh()`` + or a DQL query with the refresh flag. + + +What is the problem? +-------------------- + +In short, partial objects are problematic because they are usually +objects with broken invariants. As such, code that uses these +partial objects tends to be very fragile and either needs to "know" +which fields or methods can be safely accessed or add checks around +every field access or method invocation. The same holds true for +the internals, i.e. the method implementations, of such objects. +You usually simply assume the state you need in the method is +available, after all you properly constructed this object before +you pushed it into the database, right? These blind assumptions can +quickly lead to null reference errors when working with such +partial objects. + +It gets worse with the scenario of an optional association (0..1 to +1). When the associated field is NULL, you don't know whether this +object does not have an associated object or whether it was simply +not loaded when the owning object was loaded from the database. + +These are reasons why many ORMs do not allow partial objects at all +and instead you always have to load an object with all its fields +(associations being proxied). One secure way to allow partial +objects is if the programming language/platform allows the ORM tool +to hook deeply into the object and instrument it in such a way that +individual fields (not only associations) can be loaded lazily on +first access. This is possible in Java, for example, through +bytecode instrumentation. In PHP though this is not possible, so +there is no way to have "secure" partial objects in an ORM with +transparent persistence. + +Doctrine, by default, does not allow partial objects. That means, +any query that only selects partial object data and wants to +retrieve the result as objects (i.e. ``Query#getResult()``) will +raise an exception telling you that partial objects are dangerous. +If you want to force a query to return you partial objects, +possibly as a performance tweak, you can use the ``partial`` +keyword as follows: + +.. code-block:: php + + createQuery("select partial u.{id,name} from MyApp\Domain\User u"); + +You can also get a partial reference instead of a proxy reference by +calling: + +.. code-block:: php + + getPartialReference('MyApp\Domain\User', 1); + +Partial references are objects with only the identifiers set as they +are passed to the second argument of the ``getPartialReference()`` method. +All other fields are null. + +When should I force partial objects? +------------------------------------ + +Mainly for optimization purposes, but be careful of premature +optimization as partial objects lead to potentially more fragile +code. diff --git a/docs/en/sidebar.rst b/docs/en/sidebar.rst index 7ccdfb3a05..f52801c6b3 100644 --- a/docs/en/sidebar.rst +++ b/docs/en/sidebar.rst @@ -38,6 +38,7 @@ reference/native-sql reference/change-tracking-policies reference/partial-hydration + reference/partial-objects reference/attributes-reference reference/xml-mapping reference/php-mapping diff --git a/src/Cache/DefaultQueryCache.php b/src/Cache/DefaultQueryCache.php index f3bb8ac95c..08e703cd4b 100644 --- a/src/Cache/DefaultQueryCache.php +++ b/src/Cache/DefaultQueryCache.php @@ -16,6 +16,7 @@ use Doctrine\ORM\PersistentCollection; use Doctrine\ORM\Query; use Doctrine\ORM\Query\ResultSetMapping; +use Doctrine\ORM\Query\SqlWalker; use Doctrine\ORM\UnitOfWork; use function array_map; @@ -210,6 +211,10 @@ public function put(QueryCacheKey $key, ResultSetMapping $rsm, mixed $result, ar throw FeatureNotImplemented::nonSelectStatements(); } + if (($hints[SqlWalker::HINT_PARTIAL] ?? false) === true || ($hints[Query::HINT_FORCE_PARTIAL_LOAD] ?? false) === true) { + throw FeatureNotImplemented::partialEntities(); + } + if (! ($key->cacheMode & Cache::MODE_PUT)) { return false; } diff --git a/src/Cache/Exception/FeatureNotImplemented.php b/src/Cache/Exception/FeatureNotImplemented.php index 8767d57419..7bae90b775 100644 --- a/src/Cache/Exception/FeatureNotImplemented.php +++ b/src/Cache/Exception/FeatureNotImplemented.php @@ -20,4 +20,9 @@ public static function nonSelectStatements(): self { return new self('Second-level cache query supports only select statements.'); } + + public static function partialEntities(): self + { + return new self('Second level cache does not support partial entities.'); + } } diff --git a/src/Query.php b/src/Query.php index f258e5ecbf..b97d4d9366 100644 --- a/src/Query.php +++ b/src/Query.php @@ -74,6 +74,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/Parser.php b/src/Query/Parser.php index 72dab3ac93..bcfad85c42 100644 --- a/src/Query/Parser.php +++ b/src/Query/Parser.php @@ -9,7 +9,6 @@ use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Exception\DuplicateFieldException; use Doctrine\ORM\Exception\NoMatchingPropertyException; -use Doctrine\ORM\Internal\Hydration\HydrationException; use Doctrine\ORM\Mapping\AssociationMapping; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Query; @@ -1702,10 +1701,6 @@ public function JoinAssociationDeclaration(): AST\JoinAssociationDeclaration */ public function PartialObjectExpression(): AST\PartialObjectExpression { - if ($this->query->getHydrationMode() === Query::HYDRATE_OBJECT) { - throw HydrationException::partialObjectHydrationDisallowed(); - } - $this->match(TokenType::T_PARTIAL); $partialFieldSet = []; diff --git a/src/Query/QueryException.php b/src/Query/QueryException.php index ae945b167f..5c82b20a7a 100644 --- a/src/Query/QueryException.php +++ b/src/Query/QueryException.php @@ -88,6 +88,15 @@ public static function iterateWithFetchJoinCollectionNotAllowed(AssociationMappi ); } + public static function partialObjectsAreDangerous(): self + { + return new self( + 'Loading partial objects is dangerous. Fetch full objects or consider ' . + 'using a different fetch mode. If you really want partial objects, ' . + 'set the doctrine.forcePartialLoad query hint to TRUE.', + ); + } + /** * @param string[] $assoc * @psalm-param array $assoc diff --git a/src/Query/SqlWalker.php b/src/Query/SqlWalker.php index 0be1c6d2b0..8164ae13bb 100644 --- a/src/Query/SqlWalker.php +++ b/src/Query/SqlWalker.php @@ -335,6 +335,11 @@ private function generateClassTableInheritanceJoins( $sql .= implode(' AND ', array_filter($sqlParts)); } + // Ignore subclassing inclusion if partial objects is disallowed + if ($this->query->getHint(Query::HINT_FORCE_PARTIAL_LOAD)) { + return $sql; + } + // LEFT JOIN child class tables foreach ($class->subClasses as $subClassName) { $subClass = $this->em->getClassMetadata($subClassName); @@ -669,7 +674,8 @@ public function walkSelectClause(AST\SelectClause $selectClause): string $this->query->setHint(self::HINT_DISTINCT, true); } - $addMetaColumns = $this->query->getHydrationMode() === Query::HYDRATE_OBJECT + $addMetaColumns = ! $this->query->getHint(Query::HINT_FORCE_PARTIAL_LOAD) && + $this->query->getHydrationMode() === Query::HYDRATE_OBJECT || $this->query->getHint(Query::HINT_INCLUDE_META_COLUMNS); foreach ($this->selectedClasses as $selectedClass) { @@ -1408,28 +1414,30 @@ public function walkSelectExpression(AST\SelectExpression $selectExpression): st // 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. - foreach ($class->subClasses as $subClassName) { - $subClass = $this->em->getClassMetadata($subClassName); - $sqlTableAlias = $this->getSQLTableAlias($subClass->getTableName(), $dqlAlias); + 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; - } + 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); + $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); + } } } diff --git a/src/UnitOfWork.php b/src/UnitOfWork.php index 4e0cf6ce26..58f2df5a33 100644 --- a/src/UnitOfWork.php +++ b/src/UnitOfWork.php @@ -28,7 +28,6 @@ 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; @@ -44,7 +43,6 @@ 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; @@ -2356,10 +2354,6 @@ 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/Performance/Hydration/MixedQueryFetchJoinPartialObjectHydrationPerformanceBench.php b/tests/Performance/Hydration/MixedQueryFetchJoinPartialObjectHydrationPerformanceBench.php new file mode 100644 index 0000000000..0694e8fff6 --- /dev/null +++ b/tests/Performance/Hydration/MixedQueryFetchJoinPartialObjectHydrationPerformanceBench.php @@ -0,0 +1,91 @@ + '1', + 'u__status' => 'developer', + 'u__username' => 'romanb', + 'u__name' => 'Roman', + 'sclr0' => 'ROMANB', + 'p__phonenumber' => '42', + 'a__id' => '1', + ], + [ + 'u__id' => '1', + 'u__status' => 'developer', + 'u__username' => 'romanb', + 'u__name' => 'Roman', + 'sclr0' => 'ROMANB', + 'p__phonenumber' => '43', + 'a__id' => '1', + ], + [ + 'u__id' => '2', + 'u__status' => 'developer', + 'u__username' => 'romanb', + 'u__name' => 'Roman', + 'sclr0' => 'JWAGE', + 'p__phonenumber' => '91', + 'a__id' => '1', + ], + ]; + + for ($i = 4; $i < 2000; ++$i) { + $resultSet[] = [ + 'u__id' => $i, + 'u__status' => 'developer', + 'u__username' => 'jwage', + 'u__name' => 'Jonathan', + 'sclr0' => 'JWAGE' . $i, + 'p__phonenumber' => '91', + 'a__id' => '1', + ]; + } + + $this->result = ArrayResultFactory::createWrapperResultFromArray($resultSet); + $this->hydrator = new ObjectHydrator(EntityManagerFactory::getEntityManager([])); + $this->rsm = new ResultSetMapping(); + + $this->rsm->addEntityResult(CmsUser::class, 'u'); + $this->rsm->addJoinedEntityResult(CmsPhonenumber::class, 'p', 'u', 'phonenumbers'); + $this->rsm->addFieldResult('u', 'u__id', 'id'); + $this->rsm->addFieldResult('u', 'u__status', 'status'); + $this->rsm->addFieldResult('u', 'u__username', 'username'); + $this->rsm->addFieldResult('u', 'u__name', 'name'); + $this->rsm->addScalarResult('sclr0', 'nameUpper'); + $this->rsm->addFieldResult('p', 'p__phonenumber', 'phonenumber'); + $this->rsm->addJoinedEntityResult(CmsAddress::class, 'a', 'u', 'address'); + $this->rsm->addFieldResult('a', 'a__id', 'id'); + } + + public function benchHydration(): void + { + $this->hydrator->hydrateAll($this->result, $this->rsm, [Query::HINT_FORCE_PARTIAL_LOAD => true]); + } +} diff --git a/tests/Performance/Hydration/SimpleQueryPartialObjectHydrationPerformanceBench.php b/tests/Performance/Hydration/SimpleQueryPartialObjectHydrationPerformanceBench.php new file mode 100644 index 0000000000..14ed606508 --- /dev/null +++ b/tests/Performance/Hydration/SimpleQueryPartialObjectHydrationPerformanceBench.php @@ -0,0 +1,79 @@ + '1', + 'u__status' => 'developer', + 'u__username' => 'romanb', + 'u__name' => 'Roman', + 'a__id' => '1', + ], + [ + 'u__id' => '1', + 'u__status' => 'developer', + 'u__username' => 'romanb', + 'u__name' => 'Roman', + 'a__id' => '1', + ], + [ + 'u__id' => '2', + 'u__status' => 'developer', + 'u__username' => 'romanb', + 'u__name' => 'Roman', + 'a__id' => '1', + ], + ]; + + for ($i = 4; $i < 10000; ++$i) { + $resultSet[] = [ + 'u__id' => $i, + 'u__status' => 'developer', + 'u__username' => 'jwage', + 'u__name' => 'Jonathan', + 'a__id' => '1', + ]; + } + + $this->result = ArrayResultFactory::createWrapperResultFromArray($resultSet); + $this->hydrator = new ObjectHydrator(EntityManagerFactory::getEntityManager([])); + $this->rsm = new ResultSetMapping(); + + $this->rsm->addEntityResult(CmsUser::class, 'u'); + $this->rsm->addFieldResult('u', 'u__id', 'id'); + $this->rsm->addFieldResult('u', 'u__status', 'status'); + $this->rsm->addFieldResult('u', 'u__username', 'username'); + $this->rsm->addFieldResult('u', 'u__name', 'name'); + $this->rsm->addJoinedEntityResult(CmsAddress::class, 'a', 'u', 'address'); + $this->rsm->addFieldResult('a', 'a__id', 'id'); + } + + public function benchHydration(): void + { + $this->hydrator->hydrateAll($this->result, $this->rsm, [Query::HINT_FORCE_PARTIAL_LOAD => true]); + } +} diff --git a/tests/Tests/ORM/Functional/OneToOneUnidirectionalAssociationTest.php b/tests/Tests/ORM/Functional/OneToOneUnidirectionalAssociationTest.php index cdb34bfcdb..b2baaaa254 100644 --- a/tests/Tests/ORM/Functional/OneToOneUnidirectionalAssociationTest.php +++ b/tests/Tests/ORM/Functional/OneToOneUnidirectionalAssociationTest.php @@ -5,6 +5,7 @@ namespace Doctrine\Tests\ORM\Functional; use Doctrine\ORM\Mapping\ClassMetadata; +use Doctrine\ORM\Query; use Doctrine\Tests\Models\ECommerce\ECommerceProduct; use Doctrine\Tests\Models\ECommerce\ECommerceShipping; use Doctrine\Tests\OrmFunctionalTestCase; @@ -78,6 +79,19 @@ public function testLazyLoadsObjects(): void self::assertEquals(1, $product->getShipping()->getDays()); } + public function testDoesNotLazyLoadObjectsIfConfigurationDoesNotAllowIt(): void + { + $this->createFixture(); + + $query = $this->_em->createQuery('select p from Doctrine\Tests\Models\ECommerce\ECommerceProduct p'); + $query->setHint(Query::HINT_FORCE_PARTIAL_LOAD, true); + + $result = $query->getResult(); + $product = $result[0]; + + self::assertNull($product->getShipping()); + } + protected function createFixture(): void { $product = new ECommerceProduct(); diff --git a/tests/Tests/ORM/Functional/PostLoadEventTest.php b/tests/Tests/ORM/Functional/PostLoadEventTest.php index cb7a98ab1a..b68a12a532 100644 --- a/tests/Tests/ORM/Functional/PostLoadEventTest.php +++ b/tests/Tests/ORM/Functional/PostLoadEventTest.php @@ -136,6 +136,27 @@ public function testLoadedProxyEntityShouldTriggerEvent(): void $userProxy->getName(); } + public function testLoadedProxyPartialShouldTriggerEvent(): void + { + $eventManager = $this->_em->getEventManager(); + + // Should not be invoked during getReference call + $mockListener = $this->createMock(PostLoadListener::class); + + // CmsUser (partially loaded), CmsAddress (inverse ToOne), 2 CmsPhonenumber + $mockListener + ->expects(self::exactly(4)) + ->method('postLoad') + ->will(self::returnValue(true)); + + $eventManager->addEventListener([Events::postLoad], $mockListener); + + $query = $this->_em->createQuery('SELECT PARTIAL u.{id, name}, p FROM Doctrine\Tests\Models\CMS\CmsUser u JOIN u.phonenumbers p WHERE u.id = :id'); + + $query->setParameter('id', $this->userId); + $query->getResult(); + } + public function testLoadedProxyAssociationToOneShouldTriggerEvent(): void { $user = $this->_em->find(CmsUser::class, $this->userId); diff --git a/tests/Tests/ORM/Functional/SecondLevelCacheQueryCacheTest.php b/tests/Tests/ORM/Functional/SecondLevelCacheQueryCacheTest.php index a3350aa5e6..e2dec64934 100644 --- a/tests/Tests/ORM/Functional/SecondLevelCacheQueryCacheTest.php +++ b/tests/Tests/ORM/Functional/SecondLevelCacheQueryCacheTest.php @@ -1086,6 +1086,31 @@ public function testHintClearEntityRegionDeleteStatement(): void self::assertFalse($this->cache->containsEntity(Country::class, $this->countries[1]->getId())); } + public function testCacheablePartialQueryException(): void + { + $this->expectException(CacheException::class); + $this->expectExceptionMessage('Second level cache does not support partial entities.'); + $this->evictRegions(); + $this->loadFixturesCountries(); + + $this->_em->createQuery('SELECT PARTIAL c.{id} FROM Doctrine\Tests\Models\Cache\Country c') + ->setCacheable(true) + ->getResult(); + } + + public function testCacheableForcePartialLoadHintQueryException(): void + { + $this->expectException(CacheException::class); + $this->expectExceptionMessage('Second level cache does not support partial entities.'); + $this->evictRegions(); + $this->loadFixturesCountries(); + + $this->_em->createQuery('SELECT c FROM Doctrine\Tests\Models\Cache\Country c') + ->setCacheable(true) + ->setHint(Query::HINT_FORCE_PARTIAL_LOAD, true) + ->getResult(); + } + public function testNonCacheableQueryDeleteStatementException(): void { $this->expectException(CacheException::class); diff --git a/tests/Tests/ORM/Functional/Ticket/DDC163Test.php b/tests/Tests/ORM/Functional/Ticket/DDC163Test.php index 83aebb4dca..6c0c7c94fe 100644 --- a/tests/Tests/ORM/Functional/Ticket/DDC163Test.php +++ b/tests/Tests/ORM/Functional/Ticket/DDC163Test.php @@ -46,7 +46,7 @@ public function testQueryWithOrConditionUsingTwoRelationOnSameEntity(): void $this->_em->flush(); $this->_em->clear(); - $dql = 'SELECT person.name as person_name, spouse.name as spouse_name,friend.name as friend_name + $dql = 'SELECT PARTIAL person.{id,name}, PARTIAL spouse.{id,name}, PARTIAL friend.{id,name} FROM Doctrine\Tests\Models\Company\CompanyPerson person LEFT JOIN person.spouse spouse LEFT JOIN person.friends friend diff --git a/tests/Tests/ORM/Functional/Ticket/DDC2519Test.php b/tests/Tests/ORM/Functional/Ticket/DDC2519Test.php new file mode 100644 index 0000000000..b8dfba13b9 --- /dev/null +++ b/tests/Tests/ORM/Functional/Ticket/DDC2519Test.php @@ -0,0 +1,76 @@ +useModelSet('legacy'); + + parent::setUp(); + + $this->loadFixture(); + } + + #[Group('DDC-2519')] + public function testIssue(): void + { + $dql = 'SELECT PARTIAL l.{_source, _target} FROM Doctrine\Tests\Models\Legacy\LegacyUserReference l'; + $result = $this->_em->createQuery($dql)->getResult(); + + self::assertCount(2, $result); + self::assertInstanceOf(LegacyUserReference::class, $result[0]); + self::assertInstanceOf(LegacyUserReference::class, $result[1]); + + self::assertInstanceOf(LegacyUser::class, $result[0]->source()); + self::assertInstanceOf(LegacyUser::class, $result[0]->target()); + self::assertInstanceOf(LegacyUser::class, $result[1]->source()); + self::assertInstanceOf(LegacyUser::class, $result[1]->target()); + + self::assertTrue($this->isUninitializedObject($result[0]->target())); + self::assertTrue($this->isUninitializedObject($result[0]->source())); + self::assertTrue($this->isUninitializedObject($result[1]->target())); + self::assertTrue($this->isUninitializedObject($result[1]->source())); + + self::assertNotNull($result[0]->source()->getId()); + self::assertNotNull($result[0]->target()->getId()); + self::assertNotNull($result[1]->source()->getId()); + self::assertNotNull($result[1]->target()->getId()); + } + + public function loadFixture(): void + { + $user1 = new LegacyUser(); + $user1->username = 'FabioBatSilva'; + $user1->name = 'Fabio B. Silva'; + + $user2 = new LegacyUser(); + $user2->username = 'doctrinebot'; + $user2->name = 'Doctrine Bot'; + + $user3 = new LegacyUser(); + $user3->username = 'test'; + $user3->name = 'Tester'; + + $this->_em->persist($user1); + $this->_em->persist($user2); + $this->_em->persist($user3); + + $this->_em->flush(); + + $this->_em->persist(new LegacyUserReference($user1, $user2, 'foo')); + $this->_em->persist(new LegacyUserReference($user1, $user3, 'bar')); + + $this->_em->flush(); + $this->_em->clear(); + } +} diff --git a/tests/Tests/ORM/Functional/Ticket/GH8443Test.php b/tests/Tests/ORM/Functional/Ticket/GH8443Test.php index 0b5352a6ea..44695f1856 100644 --- a/tests/Tests/ORM/Functional/Ticket/GH8443Test.php +++ b/tests/Tests/ORM/Functional/Ticket/GH8443Test.php @@ -14,6 +14,9 @@ use Doctrine\ORM\Mapping\JoinColumn; use Doctrine\ORM\Mapping\OneToOne; use Doctrine\ORM\Mapping\Table; +use Doctrine\ORM\Query; +use Doctrine\Tests\Models\Company\CompanyManager; +use Doctrine\Tests\Models\Company\CompanyPerson; use Doctrine\Tests\OrmFunctionalTestCase; use PHPUnit\Framework\Attributes\Group; @@ -30,6 +33,35 @@ protected function setUp(): void $this->createSchemaForModels(GH8443Foo::class); } + #[Group('GH-8443')] + public function testJoinRootEntityWithForcePartialLoad(): void + { + $person = new CompanyPerson(); + $person->setName('John'); + + $manager = new CompanyManager(); + $manager->setName('Adam'); + $manager->setSalary(1000); + $manager->setDepartment('IT'); + $manager->setTitle('manager'); + + $manager->setSpouse($person); + + $this->_em->persist($person); + $this->_em->persist($manager); + $this->_em->flush(); + $this->_em->clear(); + + $manager = $this->_em->createQuery( + "SELECT m from Doctrine\Tests\Models\Company\CompanyManager m + JOIN m.spouse s + WITH s.name = 'John'", + )->setHint(Query::HINT_FORCE_PARTIAL_LOAD, true)->getSingleResult(); + $this->_em->refresh($manager); + + $this->assertEquals('John', $manager->getSpouse()->getName()); + } + #[Group('GH-8443')] public function testJoinRootEntityWithOnlyOneEntityInHierarchy(): void { diff --git a/tests/Tests/ORM/Functional/ValueObjectsTest.php b/tests/Tests/ORM/Functional/ValueObjectsTest.php index 6656d916ee..3186e71d35 100644 --- a/tests/Tests/ORM/Functional/ValueObjectsTest.php +++ b/tests/Tests/ORM/Functional/ValueObjectsTest.php @@ -209,6 +209,58 @@ public function testDqlOnEmbeddedObjectsField(): void self::assertNull($this->_em->find(DDC93Person::class, $person->id)); } + public function testPartialDqlOnEmbeddedObjectsField(): void + { + $person = new DDC93Person('Karl', new DDC93Address('Foo', '12345', 'Gosport', new DDC93Country('England'))); + $this->_em->persist($person); + $this->_em->flush(); + $this->_em->clear(); + + // Prove that the entity was persisted correctly. + $dql = 'SELECT p FROM ' . __NAMESPACE__ . '\\DDC93Person p WHERE p.name = :name'; + + $person = $this->_em->createQuery($dql) + ->setParameter('name', 'Karl') + ->getSingleResult(); + + self::assertEquals('Gosport', $person->address->city); + self::assertEquals('Foo', $person->address->street); + self::assertEquals('12345', $person->address->zip); + self::assertEquals('England', $person->address->country->name); + + // Clear the EM and prove that the embeddable can be the subject of a partial query. + $this->_em->clear(); + + $dql = 'SELECT PARTIAL p.{id,address.city} FROM ' . __NAMESPACE__ . '\\DDC93Person p WHERE p.name = :name'; + + $person = $this->_em->createQuery($dql) + ->setParameter('name', 'Karl') + ->getSingleResult(); + + // Selected field must be equal, all other fields must be null. + self::assertEquals('Gosport', $person->address->city); + self::assertNull($person->address->street); + self::assertNull($person->address->zip); + self::assertNull($person->address->country); + self::assertNull($person->name); + + // Clear the EM and prove that the embeddable can be the subject of a partial query regardless of attributes positions. + $this->_em->clear(); + + $dql = 'SELECT PARTIAL p.{address.city, id} FROM ' . __NAMESPACE__ . '\\DDC93Person p WHERE p.name = :name'; + + $person = $this->_em->createQuery($dql) + ->setParameter('name', 'Karl') + ->getSingleResult(); + + // Selected field must be equal, all other fields must be null. + self::assertEquals('Gosport', $person->address->city); + self::assertNull($person->address->street); + self::assertNull($person->address->zip); + self::assertNull($person->address->country); + self::assertNull($person->name); + } + public function testDqlWithNonExistentEmbeddableField(): void { $this->expectException(QueryException::class); @@ -224,7 +276,7 @@ public function testPartialDqlWithNonExistentEmbeddableField(): void $this->expectExceptionMessage("no mapped field named 'address.asdfasdf'"); $this->_em->createQuery('SELECT PARTIAL p.{id,address.asdfasdf} FROM ' . __NAMESPACE__ . '\\DDC93Person p') - ->getArrayResult(); + ->execute(); } public function testEmbeddableWithInheritance(): void diff --git a/tests/Tests/ORM/Query/LanguageRecognitionTest.php b/tests/Tests/ORM/Query/LanguageRecognitionTest.php index 448b425b64..31181de1a4 100644 --- a/tests/Tests/ORM/Query/LanguageRecognitionTest.php +++ b/tests/Tests/ORM/Query/LanguageRecognitionTest.php @@ -533,13 +533,11 @@ public function testUnknownAbstractSchemaName(): void 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'); } diff --git a/tests/Tests/ORM/Query/ParserTest.php b/tests/Tests/ORM/Query/ParserTest.php index 6290bbc4da..430177b1fc 100644 --- a/tests/Tests/ORM/Query/ParserTest.php +++ b/tests/Tests/ORM/Query/ParserTest.php @@ -4,7 +4,6 @@ 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; @@ -116,15 +115,6 @@ 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/Query/SelectSqlGenerationTest.php b/tests/Tests/ORM/Query/SelectSqlGenerationTest.php index 3969fe5163..6b7d958546 100644 --- a/tests/Tests/ORM/Query/SelectSqlGenerationTest.php +++ b/tests/Tests/ORM/Query/SelectSqlGenerationTest.php @@ -1335,8 +1335,6 @@ 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_', @@ -1382,58 +1380,122 @@ public function testIdentityFunctionDoesNotAcceptStateField(): void } #[Group('DDC-1389')] - public function testInheritanceTypeJoinInRootClass(): void + public function testInheritanceTypeJoinInRootClassWithDisabledForcePartialLoad(): void { $this->assertSqlGeneration( 'SELECT p FROM Doctrine\Tests\Models\Company\CompanyPerson p', 'SELECT c0_.id AS id_0, c0_.name AS name_1, c1_.title AS title_2, c2_.salary AS salary_3, c2_.department AS department_4, c2_.startDate AS startDate_5, c0_.discr AS discr_6, c0_.spouse_id AS spouse_id_7, c1_.car_id AS car_id_8 FROM company_persons c0_ LEFT JOIN company_managers c1_ ON c0_.id = c1_.id LEFT JOIN company_employees c2_ ON c0_.id = c2_.id', + [ORMQuery::HINT_FORCE_PARTIAL_LOAD => false], ); } #[Group('DDC-1389')] - public function testInheritanceTypeJoinInChildClass(): void + public function testInheritanceTypeJoinInRootClassWithEnabledForcePartialLoad(): void + { + $this->assertSqlGeneration( + 'SELECT p FROM Doctrine\Tests\Models\Company\CompanyPerson p', + 'SELECT c0_.id AS id_0, c0_.name AS name_1, c0_.discr AS discr_2 FROM company_persons c0_', + [ORMQuery::HINT_FORCE_PARTIAL_LOAD => true], + ); + } + + #[Group('DDC-1389')] + public function testInheritanceTypeJoinInChildClassWithDisabledForcePartialLoad(): void { $this->assertSqlGeneration( 'SELECT e FROM Doctrine\Tests\Models\Company\CompanyEmployee e', 'SELECT c0_.id AS id_0, c0_.name AS name_1, c1_.salary AS salary_2, c1_.department AS department_3, c1_.startDate AS startDate_4, c2_.title AS title_5, c0_.discr AS discr_6, c0_.spouse_id AS spouse_id_7, c2_.car_id AS car_id_8 FROM company_employees c1_ INNER JOIN company_persons c0_ ON c1_.id = c0_.id LEFT JOIN company_managers c2_ ON c1_.id = c2_.id', + [ORMQuery::HINT_FORCE_PARTIAL_LOAD => false], + ); + } + + #[Group('DDC-1389')] + public function testInheritanceTypeJoinInChildClassWithEnabledForcePartialLoad(): void + { + $this->assertSqlGeneration( + 'SELECT e FROM Doctrine\Tests\Models\Company\CompanyEmployee e', + 'SELECT c0_.id AS id_0, c0_.name AS name_1, c1_.salary AS salary_2, c1_.department AS department_3, c1_.startDate AS startDate_4, c0_.discr AS discr_5 FROM company_employees c1_ INNER JOIN company_persons c0_ ON c1_.id = c0_.id', + [ORMQuery::HINT_FORCE_PARTIAL_LOAD => true], ); } #[Group('DDC-1389')] - public function testInheritanceTypeJoinInLeafClass(): void + public function testInheritanceTypeJoinInLeafClassWithDisabledForcePartialLoad(): void { $this->assertSqlGeneration( 'SELECT m FROM Doctrine\Tests\Models\Company\CompanyManager m', 'SELECT c0_.id AS id_0, c0_.name AS name_1, c1_.salary AS salary_2, c1_.department AS department_3, c1_.startDate AS startDate_4, c2_.title AS title_5, c0_.discr AS discr_6, c0_.spouse_id AS spouse_id_7, c2_.car_id AS car_id_8 FROM company_managers c2_ INNER JOIN company_employees c1_ ON c2_.id = c1_.id INNER JOIN company_persons c0_ ON c2_.id = c0_.id', + [ORMQuery::HINT_FORCE_PARTIAL_LOAD => false], + ); + } + + #[Group('DDC-1389')] + public function testInheritanceTypeJoinInLeafClassWithEnabledForcePartialLoad(): void + { + $this->assertSqlGeneration( + 'SELECT m FROM Doctrine\Tests\Models\Company\CompanyManager m', + 'SELECT c0_.id AS id_0, c0_.name AS name_1, c1_.salary AS salary_2, c1_.department AS department_3, c1_.startDate AS startDate_4, c2_.title AS title_5, c0_.discr AS discr_6 FROM company_managers c2_ INNER JOIN company_employees c1_ ON c2_.id = c1_.id INNER JOIN company_persons c0_ ON c2_.id = c0_.id', + [ORMQuery::HINT_FORCE_PARTIAL_LOAD => true], ); } #[Group('DDC-1389')] - public function testInheritanceTypeSingleTableInRootClass(): void + public function testInheritanceTypeSingleTableInRootClassWithDisabledForcePartialLoad(): void { $this->assertSqlGeneration( 'SELECT c FROM Doctrine\Tests\Models\Company\CompanyContract c', "SELECT c0_.id AS id_0, c0_.completed AS completed_1, c0_.fixPrice AS fixPrice_2, c0_.hoursWorked AS hoursWorked_3, c0_.pricePerHour AS pricePerHour_4, c0_.maxPrice AS maxPrice_5, c0_.discr AS discr_6, c0_.salesPerson_id AS salesPerson_id_7 FROM company_contracts c0_ WHERE c0_.discr IN ('fix', 'flexible', 'flexultra')", + [ORMQuery::HINT_FORCE_PARTIAL_LOAD => false], + ); + } + + #[Group('DDC-1389')] + public function testInheritanceTypeSingleTableInRootClassWithEnabledForcePartialLoad(): void + { + $this->assertSqlGeneration( + 'SELECT c FROM Doctrine\Tests\Models\Company\CompanyContract c', + "SELECT c0_.id AS id_0, c0_.completed AS completed_1, c0_.fixPrice AS fixPrice_2, c0_.hoursWorked AS hoursWorked_3, c0_.pricePerHour AS pricePerHour_4, c0_.maxPrice AS maxPrice_5, c0_.discr AS discr_6 FROM company_contracts c0_ WHERE c0_.discr IN ('fix', 'flexible', 'flexultra')", + [ORMQuery::HINT_FORCE_PARTIAL_LOAD => true], ); } #[Group('DDC-1389')] 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')", + [ORMQuery::HINT_FORCE_PARTIAL_LOAD => false], + ); + } + #[Group('DDC-1389')] + public function testInheritanceTypeSingleTableInChildClassWithEnabledForcePartialLoad(): void + { $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 FROM company_contracts c0_ WHERE c0_.discr IN ('flexible', 'flexultra')", + [ORMQuery::HINT_FORCE_PARTIAL_LOAD => true], ); } #[Group('DDC-1389')] - public function testInheritanceTypeSingleTableInLeafClass(): void + public function testInheritanceTypeSingleTableInLeafClassWithDisabledForcePartialLoad(): void { $this->assertSqlGeneration( 'SELECT fuc FROM Doctrine\Tests\Models\Company\CompanyFlexUltraContract fuc', "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 ('flexultra')", + [ORMQuery::HINT_FORCE_PARTIAL_LOAD => false], + ); + } + + #[Group('DDC-1389')] + public function testInheritanceTypeSingleTableInLeafClassWithEnabledForcePartialLoad(): void + { + $this->assertSqlGeneration( + 'SELECT fuc FROM Doctrine\Tests\Models\Company\CompanyFlexUltraContract fuc', + "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 ('flexultra')", + [ORMQuery::HINT_FORCE_PARTIAL_LOAD => true], ); } @@ -1712,8 +1774,6 @@ 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 { @@ -1722,7 +1782,7 @@ public function testCustomTypeValueSqlForPartialObject(): void $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_', + 'SELECT c0_.id AS id_0, -(c0_.customInteger) AS customInteger_1, c0_.child_id AS child_id_2 FROM customtype_parents c0_', ); } diff --git a/tests/Tests/ORM/Tools/Pagination/LimitSubqueryOutputWalkerTest.php b/tests/Tests/ORM/Tools/Pagination/LimitSubqueryOutputWalkerTest.php index a6275d08b3..2aeca48dd5 100644 --- a/tests/Tests/ORM/Tools/Pagination/LimitSubqueryOutputWalkerTest.php +++ b/tests/Tests/ORM/Tools/Pagination/LimitSubqueryOutputWalkerTest.php @@ -138,7 +138,7 @@ public function testCountQueryWithComplexScalarOrderByItemWithoutJoin(): void ); } - public function testCountQueryWithComplexScalarOrderByItemJoined(): void + public function testCountQueryWithComplexScalarOrderByItemJoinedWithoutPartial(): void { $this->entityManager = $this->createTestEntityManagerWithPlatform(new MySQLPlatform()); diff --git a/tests/Tests/ORM/UnitOfWorkTest.php b/tests/Tests/ORM/UnitOfWorkTest.php index 12dafa4d6b..2a421c6687 100644 --- a/tests/Tests/ORM/UnitOfWorkTest.php +++ b/tests/Tests/ORM/UnitOfWorkTest.php @@ -13,7 +13,6 @@ 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; @@ -23,7 +22,6 @@ 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; @@ -693,15 +691,6 @@ public function rollBack(): void self::assertSame('Commit failed', $e->getPrevious()->getMessage()); } } - - 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); - } }