From 4a3c7f05bf4319e17f355dcea2fdfa19d232ed5a Mon Sep 17 00:00:00 2001 From: Benjamin Eberlei Date: Wed, 9 Oct 2024 17:13:58 +0200 Subject: [PATCH 01/10] Revert "Remove unused exception" This reverts commit 689da1f251957f49f1126b92bcaf8fb12df6f56e. --- src/Query/QueryException.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/Query/QueryException.php b/src/Query/QueryException.php index ae945b167fe..5c82b20a7a4 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 From f71725575c605cb77ecc29c7131d5b39ab9e03ef Mon Sep 17 00:00:00 2001 From: Benjamin Eberlei Date: Wed, 9 Oct 2024 17:34:59 +0200 Subject: [PATCH 02/10] Revert undprecate PARTIAL for objects in DQL. --- docs/en/index.rst | 1 + .../reference/dql-doctrine-query-language.rst | 24 ++++- docs/en/reference/partial-objects.rst | 98 +++++++++++++++++++ docs/en/sidebar.rst | 1 + src/Cache/DefaultQueryCache.php | 5 + src/Cache/Exception/FeatureNotImplemented.php | 5 + src/Decorator/EntityManagerDecorator.php | 1 + src/EntityManager.php | 1 + src/Query.php | 8 ++ src/Query/Parser.php | 5 +- src/Query/SqlWalker.php | 40 +++++--- src/UnitOfWork.php | 17 +++- ...PartialObjectHydrationPerformanceBench.php | 84 ++++++++++++++++ ...PartialObjectHydrationPerformanceBench.php | 72 ++++++++++++++ .../OneToOneUnidirectionalAssociationTest.php | 14 +++ .../ORM/Functional/PostLoadEventTest.php | 21 ++++ .../SecondLevelCacheQueryCacheTest.php | 25 +++++ .../ORM/Functional/Ticket/DDC163Test.php | 2 +- .../ORM/Functional/Ticket/DDC2519Test.php | 76 ++++++++++++++ .../ORM/Functional/Ticket/GH8443Test.php | 32 ++++++ .../Tests/ORM/Functional/ValueObjectsTest.php | 54 +++++++++- .../ORM/Query/LanguageRecognitionTest.php | 2 - tests/Tests/ORM/Query/ParserTest.php | 9 -- .../ORM/Query/SelectSqlGenerationTest.php | 82 +++++++++++++--- .../LimitSubqueryOutputWalkerTest.php | 2 +- tests/Tests/ORM/UnitOfWorkTest.php | 9 -- 26 files changed, 629 insertions(+), 61 deletions(-) create mode 100644 docs/en/reference/partial-objects.rst create mode 100644 tests/Performance/Hydration/MixedQueryFetchJoinPartialObjectHydrationPerformanceBench.php create mode 100644 tests/Performance/Hydration/SimpleQueryPartialObjectHydrationPerformanceBench.php create mode 100644 tests/Tests/ORM/Functional/Ticket/DDC2519Test.php diff --git a/docs/en/index.rst b/docs/en/index.rst index 4d23062cd0d..32f8c070175 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 ab3cb138889..29d1a163d78 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 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 ^^^^^^^^^^^^^^^^^^^^^ @@ -1370,6 +1379,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-objects.rst b/docs/en/reference/partial-objects.rst new file mode 100644 index 00000000000..51f173adf6c --- /dev/null +++ b/docs/en/reference/partial-objects.rst @@ -0,0 +1,98 @@ +Partial Objects +=============== + + +.. note:: + + Creating Partial Objects through DQL is deprecated and + will be removed in the future, use data transfer object + support in DQL instead. (\ `Details + `_) + +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 Doctrine2 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 7ccdfb3a054..f52801c6b37 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 f3bb8ac95c8..08e703cd4b0 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 8767d574190..7bae90b775d 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/Decorator/EntityManagerDecorator.php b/src/Decorator/EntityManagerDecorator.php index 6f1b0419686..212e01e511b 100644 --- a/src/Decorator/EntityManagerDecorator.php +++ b/src/Decorator/EntityManagerDecorator.php @@ -8,6 +8,7 @@ use Doctrine\Common\EventManager; use Doctrine\DBAL\Connection; use Doctrine\DBAL\LockMode; +use Doctrine\Deprecations\Deprecation; use Doctrine\ORM\Cache; use Doctrine\ORM\Configuration; use Doctrine\ORM\EntityManagerInterface; diff --git a/src/EntityManager.php b/src/EntityManager.php index 8045ac2f5e3..9de636e5b75 100644 --- a/src/EntityManager.php +++ b/src/EntityManager.php @@ -9,6 +9,7 @@ use Doctrine\Common\EventManager; use Doctrine\DBAL\Connection; use Doctrine\DBAL\LockMode; +use Doctrine\Deprecations\Deprecation; use Doctrine\ORM\Exception\EntityManagerClosed; use Doctrine\ORM\Exception\InvalidHydrationMode; use Doctrine\ORM\Exception\MissingIdentifierField; diff --git a/src/Query.php b/src/Query.php index a869316d3e7..d8a88e8fe83 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/Parser.php b/src/Query/Parser.php index 875783c87d4..12fe0c97691 100644 --- a/src/Query/Parser.php +++ b/src/Query/Parser.php @@ -5,6 +5,7 @@ namespace Doctrine\ORM\Query; use Doctrine\Common\Lexer\Token; +use Doctrine\Deprecations\Deprecation; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Internal\Hydration\HydrationException; use Doctrine\ORM\Mapping\AssociationMapping; @@ -1674,10 +1675,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/SqlWalker.php b/src/Query/SqlWalker.php index 46296e719e7..9beeff337be 100644 --- a/src/Query/SqlWalker.php +++ b/src/Query/SqlWalker.php @@ -321,6 +321,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); @@ -659,7 +664,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) { @@ -1398,28 +1404,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 85a33620aa8..811aa895b42 100644 --- a/src/UnitOfWork.php +++ b/src/UnitOfWork.php @@ -12,6 +12,7 @@ use Doctrine\DBAL; use Doctrine\DBAL\Connections\PrimaryReadReplicaConnection; use Doctrine\DBAL\LockMode; +use Doctrine\Deprecations\Deprecation; use Doctrine\ORM\Cache\Persister\CachedPersister; use Doctrine\ORM\Event\ListenersInvoker; use Doctrine\ORM\Event\OnClearEventArgs; @@ -2355,10 +2356,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); @@ -2417,6 +2414,18 @@ public function createEntity(string $className, array $data, array &$hints = []) unset($this->eagerLoadingEntities[$class->rootEntityName]); } + // Properly initialize any unfetched associations, if partial objects are not allowed. + if (isset($hints[Query::HINT_FORCE_PARTIAL_LOAD])) { + Deprecation::trigger( + 'doctrine/orm', + 'https://github.com/doctrine/orm/issues/8471', + 'Partial Objects are deprecated (here entity %s)', + $className, + ); + + return $entity; + } + foreach ($class->associationMappings as $field => $assoc) { // Check if the association is not among the fetch-joined associations already. if (isset($hints['fetchAlias'], $hints['fetched'][$hints['fetchAlias']][$field])) { diff --git a/tests/Performance/Hydration/MixedQueryFetchJoinPartialObjectHydrationPerformanceBench.php b/tests/Performance/Hydration/MixedQueryFetchJoinPartialObjectHydrationPerformanceBench.php new file mode 100644 index 00000000000..6a1638afcf5 --- /dev/null +++ b/tests/Performance/Hydration/MixedQueryFetchJoinPartialObjectHydrationPerformanceBench.php @@ -0,0 +1,84 @@ + '1', + 'u__status' => 'developer', + 'u__username' => 'romanb', + 'u__name' => 'Roman', + 'sclr0' => 'ROMANB', + 'p__phonenumber' => '42', + ], + [ + 'u__id' => '1', + 'u__status' => 'developer', + 'u__username' => 'romanb', + 'u__name' => 'Roman', + 'sclr0' => 'ROMANB', + 'p__phonenumber' => '43', + ], + [ + 'u__id' => '2', + 'u__status' => 'developer', + 'u__username' => 'romanb', + 'u__name' => 'Roman', + 'sclr0' => 'JWAGE', + 'p__phonenumber' => '91', + ], + ]; + + 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', + ]; + } + + $this->result = ArrayResultFactory::createFromArray($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'); + } + + 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 00000000000..b894068eb2c --- /dev/null +++ b/tests/Performance/Hydration/SimpleQueryPartialObjectHydrationPerformanceBench.php @@ -0,0 +1,72 @@ + '1', + 'u__status' => 'developer', + 'u__username' => 'romanb', + 'u__name' => 'Roman', + ], + [ + 'u__id' => '1', + 'u__status' => 'developer', + 'u__username' => 'romanb', + 'u__name' => 'Roman', + ], + [ + 'u__id' => '2', + 'u__status' => 'developer', + 'u__username' => 'romanb', + 'u__name' => 'Roman', + ], + ]; + + for ($i = 4; $i < 10000; ++$i) { + $resultSet[] = [ + 'u__id' => $i, + 'u__status' => 'developer', + 'u__username' => 'jwage', + 'u__name' => 'Jonathan', + ]; + } + + $this->result = ArrayResultFactory::createFromArray($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'); + } + + 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 cdb34bfcdbf..b2baaaa254e 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 839a8313983..d76a044fd05 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 a3350aa5e6c..e2dec649342 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 83aebb4dcae..6c0c7c94fe3 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 00000000000..b8dfba13b9c --- /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 0b5352a6ea8..44695f1856e 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 6656d916ee0..3186e71d357 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 ec57b1f1382..de212ebdda5 100644 --- a/tests/Tests/ORM/Query/LanguageRecognitionTest.php +++ b/tests/Tests/ORM/Query/LanguageRecognitionTest.php @@ -532,13 +532,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 6290bbc4dab..ed75be517b0 100644 --- a/tests/Tests/ORM/Query/ParserTest.php +++ b/tests/Tests/ORM/Query/ParserTest.php @@ -116,15 +116,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 3969fe51636..6b7d9585462 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 0f5bac25b72..0e14402b5a0 100644 --- a/tests/Tests/ORM/Tools/Pagination/LimitSubqueryOutputWalkerTest.php +++ b/tests/Tests/ORM/Tools/Pagination/LimitSubqueryOutputWalkerTest.php @@ -137,7 +137,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 8de0eb03e25..dba967fe87f 100644 --- a/tests/Tests/ORM/UnitOfWorkTest.php +++ b/tests/Tests/ORM/UnitOfWorkTest.php @@ -651,15 +651,6 @@ 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 7ef1f0a379dc122a252fe90aba5d556199025831 Mon Sep 17 00:00:00 2001 From: Benjamin Eberlei Date: Wed, 9 Oct 2024 22:14:01 +0200 Subject: [PATCH 03/10] Phpcs fixes --- src/Decorator/EntityManagerDecorator.php | 1 - src/EntityManager.php | 1 - src/Query/Parser.php | 2 -- src/Query/SqlWalker.php | 8 ++++---- src/UnitOfWork.php | 2 -- tests/Tests/ORM/Query/ParserTest.php | 1 - tests/Tests/ORM/UnitOfWorkTest.php | 2 -- 7 files changed, 4 insertions(+), 13 deletions(-) diff --git a/src/Decorator/EntityManagerDecorator.php b/src/Decorator/EntityManagerDecorator.php index 212e01e511b..6f1b0419686 100644 --- a/src/Decorator/EntityManagerDecorator.php +++ b/src/Decorator/EntityManagerDecorator.php @@ -8,7 +8,6 @@ use Doctrine\Common\EventManager; use Doctrine\DBAL\Connection; use Doctrine\DBAL\LockMode; -use Doctrine\Deprecations\Deprecation; use Doctrine\ORM\Cache; use Doctrine\ORM\Configuration; use Doctrine\ORM\EntityManagerInterface; diff --git a/src/EntityManager.php b/src/EntityManager.php index 9de636e5b75..8045ac2f5e3 100644 --- a/src/EntityManager.php +++ b/src/EntityManager.php @@ -9,7 +9,6 @@ use Doctrine\Common\EventManager; use Doctrine\DBAL\Connection; use Doctrine\DBAL\LockMode; -use Doctrine\Deprecations\Deprecation; use Doctrine\ORM\Exception\EntityManagerClosed; use Doctrine\ORM\Exception\InvalidHydrationMode; use Doctrine\ORM\Exception\MissingIdentifierField; diff --git a/src/Query/Parser.php b/src/Query/Parser.php index 12fe0c97691..76e6757052d 100644 --- a/src/Query/Parser.php +++ b/src/Query/Parser.php @@ -5,9 +5,7 @@ namespace Doctrine\ORM\Query; use Doctrine\Common\Lexer\Token; -use Doctrine\Deprecations\Deprecation; use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\Internal\Hydration\HydrationException; use Doctrine\ORM\Mapping\AssociationMapping; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Query; diff --git a/src/Query/SqlWalker.php b/src/Query/SqlWalker.php index 9beeff337be..155461ab2dd 100644 --- a/src/Query/SqlWalker.php +++ b/src/Query/SqlWalker.php @@ -1406,21 +1406,21 @@ public function walkSelectExpression(AST\SelectExpression $selectExpression): st // since it requires outer joining subtables. if ($class->isInheritanceTypeSingleTable() || ! $this->query->getHint(Query::HINT_FORCE_PARTIAL_LOAD)) { foreach ($class->subClasses as $subClassName) { - $subClass = $this->em->getClassMetadata($subClassName); + $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))) { + if (isset($mapping->inherited) || ($partialFieldSet && ! in_array($fieldName, $partialFieldSet, true))) { continue; } - $columnAlias = $this->getSQLColumnAlias($mapping->columnName); + $columnAlias = $this->getSQLColumnAlias($mapping->columnName); $quotedColumnName = $this->quoteStrategy->getColumnName($fieldName, $subClass, $this->platform); $col = $sqlTableAlias . '.' . $quotedColumnName; $type = Type::getType($mapping->type); - $col = $type->convertToPHPValueSQL($col, $this->platform); + $col = $type->convertToPHPValueSQL($col, $this->platform); $sqlParts[] = $col . ' AS ' . $columnAlias; diff --git a/src/UnitOfWork.php b/src/UnitOfWork.php index 811aa895b42..ef31a6bbcd6 100644 --- a/src/UnitOfWork.php +++ b/src/UnitOfWork.php @@ -29,7 +29,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; @@ -45,7 +44,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; diff --git a/tests/Tests/ORM/Query/ParserTest.php b/tests/Tests/ORM/Query/ParserTest.php index ed75be517b0..430177b1fcc 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; diff --git a/tests/Tests/ORM/UnitOfWorkTest.php b/tests/Tests/ORM/UnitOfWorkTest.php index dba967fe87f..a9112fa5d51 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; From 5f4ecfd1d8a03522b3742d2c12f4403e55320a22 Mon Sep 17 00:00:00 2001 From: Benjamin Eberlei Date: Thu, 10 Oct 2024 10:38:33 +0200 Subject: [PATCH 04/10] Fix imports --- ...ixedQueryFetchJoinPartialObjectHydrationPerformanceBench.php | 2 +- .../SimpleQueryPartialObjectHydrationPerformanceBench.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Performance/Hydration/MixedQueryFetchJoinPartialObjectHydrationPerformanceBench.php b/tests/Performance/Hydration/MixedQueryFetchJoinPartialObjectHydrationPerformanceBench.php index 6a1638afcf5..822a019c7d7 100644 --- a/tests/Performance/Hydration/MixedQueryFetchJoinPartialObjectHydrationPerformanceBench.php +++ b/tests/Performance/Hydration/MixedQueryFetchJoinPartialObjectHydrationPerformanceBench.php @@ -8,8 +8,8 @@ use Doctrine\ORM\Internal\Hydration\ObjectHydrator; use Doctrine\ORM\Query; use Doctrine\ORM\Query\ResultSetMapping; -use Doctrine\Performance\ArrayResultFactory; use Doctrine\Performance\EntityManagerFactory; +use Doctrine\Tests\Mocks\ArrayResultFactory; use Doctrine\Tests\Models\CMS\CmsPhonenumber; use Doctrine\Tests\Models\CMS\CmsUser; use PhpBench\Benchmark\Metadata\Annotations\BeforeMethods; diff --git a/tests/Performance/Hydration/SimpleQueryPartialObjectHydrationPerformanceBench.php b/tests/Performance/Hydration/SimpleQueryPartialObjectHydrationPerformanceBench.php index b894068eb2c..72c2ec73d98 100644 --- a/tests/Performance/Hydration/SimpleQueryPartialObjectHydrationPerformanceBench.php +++ b/tests/Performance/Hydration/SimpleQueryPartialObjectHydrationPerformanceBench.php @@ -8,8 +8,8 @@ use Doctrine\ORM\Internal\Hydration\ObjectHydrator; use Doctrine\ORM\Query; use Doctrine\ORM\Query\ResultSetMapping; -use Doctrine\Performance\ArrayResultFactory; use Doctrine\Performance\EntityManagerFactory; +use Doctrine\Tests\Mocks\ArrayResultFactory; use Doctrine\Tests\Models\CMS\CmsUser; use PhpBench\Benchmark\Metadata\Annotations\BeforeMethods; From da7854f586507872e6db421d7273e7666ecd71bf Mon Sep 17 00:00:00 2001 From: Benjamin Eberlei Date: Thu, 10 Oct 2024 10:41:04 +0200 Subject: [PATCH 05/10] Fix imports --- ...ixedQueryFetchJoinPartialObjectHydrationPerformanceBench.php | 2 +- .../SimpleQueryPartialObjectHydrationPerformanceBench.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Performance/Hydration/MixedQueryFetchJoinPartialObjectHydrationPerformanceBench.php b/tests/Performance/Hydration/MixedQueryFetchJoinPartialObjectHydrationPerformanceBench.php index 822a019c7d7..ef1d1b4bdca 100644 --- a/tests/Performance/Hydration/MixedQueryFetchJoinPartialObjectHydrationPerformanceBench.php +++ b/tests/Performance/Hydration/MixedQueryFetchJoinPartialObjectHydrationPerformanceBench.php @@ -63,7 +63,7 @@ public function init(): void ]; } - $this->result = ArrayResultFactory::createFromArray($resultSet); + $this->result = ArrayResultFactory::createWrapperResultFromArray($resultSet); $this->hydrator = new ObjectHydrator(EntityManagerFactory::getEntityManager([])); $this->rsm = new ResultSetMapping(); diff --git a/tests/Performance/Hydration/SimpleQueryPartialObjectHydrationPerformanceBench.php b/tests/Performance/Hydration/SimpleQueryPartialObjectHydrationPerformanceBench.php index 72c2ec73d98..a5c78670727 100644 --- a/tests/Performance/Hydration/SimpleQueryPartialObjectHydrationPerformanceBench.php +++ b/tests/Performance/Hydration/SimpleQueryPartialObjectHydrationPerformanceBench.php @@ -54,7 +54,7 @@ public function init(): void ]; } - $this->result = ArrayResultFactory::createFromArray($resultSet); + $this->result = ArrayResultFactory::createWrapperResultFromArray($resultSet); $this->hydrator = new ObjectHydrator(EntityManagerFactory::getEntityManager([])); $this->rsm = new ResultSetMapping(); From f5fb400d0f13fc58431ac06185e53edac4bea704 Mon Sep 17 00:00:00 2001 From: Benjamin Eberlei Date: Sat, 12 Oct 2024 02:32:15 +0200 Subject: [PATCH 06/10] Address review comments. --- docs/en/reference/partial-hydration.rst | 5 ----- docs/en/reference/partial-objects.rst | 10 ---------- src/UnitOfWork.php | 12 ------------ 3 files changed, 27 deletions(-) diff --git a/docs/en/reference/partial-hydration.rst b/docs/en/reference/partial-hydration.rst index 16879c45c52..fda14b98efa 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 index 51f173adf6c..3835123257a 100644 --- a/docs/en/reference/partial-objects.rst +++ b/docs/en/reference/partial-objects.rst @@ -1,14 +1,6 @@ Partial Objects =============== - -.. note:: - - Creating Partial Objects through DQL is deprecated and - will be removed in the future, use data transfer object - support in DQL instead. (\ `Details - `_) - 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 @@ -94,5 +86,3 @@ 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/src/UnitOfWork.php b/src/UnitOfWork.php index ef31a6bbcd6..751d36ee21a 100644 --- a/src/UnitOfWork.php +++ b/src/UnitOfWork.php @@ -2412,18 +2412,6 @@ public function createEntity(string $className, array $data, array &$hints = []) unset($this->eagerLoadingEntities[$class->rootEntityName]); } - // Properly initialize any unfetched associations, if partial objects are not allowed. - if (isset($hints[Query::HINT_FORCE_PARTIAL_LOAD])) { - Deprecation::trigger( - 'doctrine/orm', - 'https://github.com/doctrine/orm/issues/8471', - 'Partial Objects are deprecated (here entity %s)', - $className, - ); - - return $entity; - } - foreach ($class->associationMappings as $field => $assoc) { // Check if the association is not among the fetch-joined associations already. if (isset($hints['fetchAlias'], $hints['fetched'][$hints['fetchAlias']][$field])) { From 516b5931934a07d3071bb2cd4b833f1d0729a22c Mon Sep 17 00:00:00 2001 From: Benjamin Eberlei Date: Sat, 12 Oct 2024 15:41:31 +0200 Subject: [PATCH 07/10] Fix phpcs --- src/UnitOfWork.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/UnitOfWork.php b/src/UnitOfWork.php index df2996ce3bd..4cf3e9e2d9f 100644 --- a/src/UnitOfWork.php +++ b/src/UnitOfWork.php @@ -12,7 +12,6 @@ use Doctrine\DBAL; use Doctrine\DBAL\Connections\PrimaryReadReplicaConnection; use Doctrine\DBAL\LockMode; -use Doctrine\Deprecations\Deprecation; use Doctrine\ORM\Cache\Persister\CachedPersister; use Doctrine\ORM\Event\ListenersInvoker; use Doctrine\ORM\Event\OnClearEventArgs; From c7e5605d116cca2229db3ee012eb78a420cd174c Mon Sep 17 00:00:00 2001 From: Benjamin Eberlei Date: Sat, 12 Oct 2024 15:43:46 +0200 Subject: [PATCH 08/10] Fix test --- src/Query/Parser.php | 1 - tests/Tests/ORM/UnitOfWorkTest.php | 9 --------- 2 files changed, 10 deletions(-) diff --git a/src/Query/Parser.php b/src/Query/Parser.php index 3b1779e4307..bcfad85c422 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; diff --git a/tests/Tests/ORM/UnitOfWorkTest.php b/tests/Tests/ORM/UnitOfWorkTest.php index 900ee218300..2a421c6687d 100644 --- a/tests/Tests/ORM/UnitOfWorkTest.php +++ b/tests/Tests/ORM/UnitOfWorkTest.php @@ -691,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); - } } From 7c9b74221fd2c0e3245fb43a607a0ec113dd037e Mon Sep 17 00:00:00 2001 From: Benjamin Eberlei Date: Sat, 12 Oct 2024 16:31:02 +0200 Subject: [PATCH 09/10] fix phpbench tests. --- ...ueryFetchJoinPartialObjectHydrationPerformanceBench.php | 7 +++++++ .../SimpleQueryPartialObjectHydrationPerformanceBench.php | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/tests/Performance/Hydration/MixedQueryFetchJoinPartialObjectHydrationPerformanceBench.php b/tests/Performance/Hydration/MixedQueryFetchJoinPartialObjectHydrationPerformanceBench.php index ef1d1b4bdca..0694e8fff6a 100644 --- a/tests/Performance/Hydration/MixedQueryFetchJoinPartialObjectHydrationPerformanceBench.php +++ b/tests/Performance/Hydration/MixedQueryFetchJoinPartialObjectHydrationPerformanceBench.php @@ -10,6 +10,7 @@ use Doctrine\ORM\Query\ResultSetMapping; use Doctrine\Performance\EntityManagerFactory; use Doctrine\Tests\Mocks\ArrayResultFactory; +use Doctrine\Tests\Models\CMS\CmsAddress; use Doctrine\Tests\Models\CMS\CmsPhonenumber; use Doctrine\Tests\Models\CMS\CmsUser; use PhpBench\Benchmark\Metadata\Annotations\BeforeMethods; @@ -33,6 +34,7 @@ public function init(): void 'u__name' => 'Roman', 'sclr0' => 'ROMANB', 'p__phonenumber' => '42', + 'a__id' => '1', ], [ 'u__id' => '1', @@ -41,6 +43,7 @@ public function init(): void 'u__name' => 'Roman', 'sclr0' => 'ROMANB', 'p__phonenumber' => '43', + 'a__id' => '1', ], [ 'u__id' => '2', @@ -49,6 +52,7 @@ public function init(): void 'u__name' => 'Roman', 'sclr0' => 'JWAGE', 'p__phonenumber' => '91', + 'a__id' => '1', ], ]; @@ -60,6 +64,7 @@ public function init(): void 'u__name' => 'Jonathan', 'sclr0' => 'JWAGE' . $i, 'p__phonenumber' => '91', + 'a__id' => '1', ]; } @@ -75,6 +80,8 @@ public function init(): void $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 diff --git a/tests/Performance/Hydration/SimpleQueryPartialObjectHydrationPerformanceBench.php b/tests/Performance/Hydration/SimpleQueryPartialObjectHydrationPerformanceBench.php index a5c78670727..14ed606508c 100644 --- a/tests/Performance/Hydration/SimpleQueryPartialObjectHydrationPerformanceBench.php +++ b/tests/Performance/Hydration/SimpleQueryPartialObjectHydrationPerformanceBench.php @@ -10,6 +10,7 @@ use Doctrine\ORM\Query\ResultSetMapping; use Doctrine\Performance\EntityManagerFactory; use Doctrine\Tests\Mocks\ArrayResultFactory; +use Doctrine\Tests\Models\CMS\CmsAddress; use Doctrine\Tests\Models\CMS\CmsUser; use PhpBench\Benchmark\Metadata\Annotations\BeforeMethods; @@ -30,18 +31,21 @@ public function init(): void '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', ], ]; @@ -51,6 +55,7 @@ public function init(): void 'u__status' => 'developer', 'u__username' => 'jwage', 'u__name' => 'Jonathan', + 'a__id' => '1', ]; } @@ -63,6 +68,8 @@ public function init(): void $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 From cf8f5f9f935455976f637051dacdcc7f5b5d42e1 Mon Sep 17 00:00:00 2001 From: Benjamin Eberlei Date: Sat, 12 Oct 2024 21:01:04 +0200 Subject: [PATCH 10/10] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Grégoire Paris --- docs/en/reference/dql-doctrine-query-language.rst | 2 +- docs/en/reference/partial-objects.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/en/reference/dql-doctrine-query-language.rst b/docs/en/reference/dql-doctrine-query-language.rst index 889013a3ba5..e668c08fd82 100644 --- a/docs/en/reference/dql-doctrine-query-language.rst +++ b/docs/en/reference/dql-doctrine-query-language.rst @@ -542,7 +542,7 @@ DQL keyword: $query = $em->createQuery('SELECT partial u.{id, username} FROM CmsUser u'); $users = $query->getResult(); // array of partially loaded CmsUser objects -You use the partial syntax when joining as well: +You can use the partial syntax when joining as well: .. code-block:: php diff --git a/docs/en/reference/partial-objects.rst b/docs/en/reference/partial-objects.rst index 3835123257a..3123c083f3f 100644 --- a/docs/en/reference/partial-objects.rst +++ b/docs/en/reference/partial-objects.rst @@ -5,7 +5,7 @@ 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 Doctrine2 to this problem is. +of Doctrine to this problem is. .. note::