diff --git a/src/Internal/Hydration/ObjectHydrator.php b/src/Internal/Hydration/ObjectHydrator.php index e65b14d14a..ca5c866a44 100644 --- a/src/Internal/Hydration/ObjectHydrator.php +++ b/src/Internal/Hydration/ObjectHydrator.php @@ -264,6 +264,7 @@ private function getEntity(array $data, string $dqlAlias): object } $this->hints['fetchAlias'] = $dqlAlias; + $this->hints['isPartial'] = $this->rsm->partialAliases[$dqlAlias] ?? false; return $this->uow->createEntity($className, $data, $this->hints); } diff --git a/src/Internal/Hydration/SimpleObjectHydrator.php b/src/Internal/Hydration/SimpleObjectHydrator.php index 6f808f82fb..ea3b5238e8 100644 --- a/src/Internal/Hydration/SimpleObjectHydrator.php +++ b/src/Internal/Hydration/SimpleObjectHydrator.php @@ -39,6 +39,8 @@ protected function prepare(): void } $this->class = $this->getClassMetadata(reset($this->resultSetMapping()->aliasMap)); + + $this->hints['isPartial'] = count($this->rsm->partialAliases) > 0; } protected function cleanup(): void diff --git a/src/Proxy/ProxyFactory.php b/src/Proxy/ProxyFactory.php index 7118811d27..087da68500 100644 --- a/src/Proxy/ProxyFactory.php +++ b/src/Proxy/ProxyFactory.php @@ -206,7 +206,7 @@ public function __construct( * @param class-string $className * @param array $identifier */ - public function getProxy(string $className, array $identifier): object + public function getProxy(string $className, array $identifier, bool $assignIdentifiers = true): object { if ($this->em->getConfiguration()->isNativeLazyObjectsEnabled()) { $classMetadata = $this->em->getClassMetadata($className); @@ -228,8 +228,10 @@ public function getProxy(string $className, array $identifier): object } }, ReflectionClass::SKIP_INITIALIZATION_ON_SERIALIZE); - foreach ($identifier as $idField => $value) { - $classMetadata->propertyAccessors[$idField]->setValue($proxy, $value); + if ($assignIdentifiers) { + foreach ($identifier as $idField => $value) { + $classMetadata->propertyAccessors[$idField]->setValue($proxy, $value); + } } return $proxy; diff --git a/src/Query/ResultSetMapping.php b/src/Query/ResultSetMapping.php index 351d55ebe2..64c8a40dda 100644 --- a/src/Query/ResultSetMapping.php +++ b/src/Query/ResultSetMapping.php @@ -42,6 +42,13 @@ class ResultSetMapping */ public array $aliasMap = []; + /** + * Weather this alias is for a partially loaded entity + * + * @var array + */ + public array $partialAliases = []; + /** * Maps alias names to related association field names. * @@ -207,7 +214,7 @@ class ResultSetMapping * * @todo Rename: addRootEntity */ - public function addEntityResult(string $class, string $alias, string|null $resultAlias = null): static + public function addEntityResult(string $class, string $alias, string|null $resultAlias = null, bool $isPartial = false): static { $this->aliasMap[$alias] = $class; $this->entityMappings[$alias] = $resultAlias; @@ -216,6 +223,10 @@ public function addEntityResult(string $class, string $alias, string|null $resul $this->isMixed = true; } + if ($isPartial) { + $this->partialAliases[$alias] = true; + } + return $this; } @@ -388,12 +399,16 @@ public function getColumnAliasByField(string $alias, string $fieldName): string * * @todo Rename: addJoinedEntity */ - public function addJoinedEntityResult(string $class, string $alias, string $parentAlias, string $relation): static + public function addJoinedEntityResult(string $class, string $alias, string $parentAlias, string $relation, bool $isPartial = false): static { $this->aliasMap[$alias] = $class; $this->parentAliasMap[$alias] = $parentAlias; $this->relationMap[$alias] = $relation; + if ($isPartial) { + $this->partialAliases[$alias] = true; + } + return $this; } diff --git a/src/Query/SqlWalker.php b/src/Query/SqlWalker.php index ce0400368f..99329478fe 100644 --- a/src/Query/SqlWalker.php +++ b/src/Query/SqlWalker.php @@ -112,7 +112,7 @@ class SqlWalker /** * A list of classes that appear in non-scalar SelectExpressions. * - * @phpstan-var array + * @phpstan-var array> */ private array $selectedClasses = []; @@ -679,10 +679,11 @@ public function walkSelectClause(AST\SelectClause $selectClause): string $class = $selectedClass['class']; $dqlAlias = $selectedClass['dqlAlias']; $resultAlias = $selectedClass['resultAlias']; + $isPartial = $selectedClass['partial']; // Register as entity or joined entity result if (! isset($this->queryComponents[$dqlAlias]['relation'])) { - $this->rsm->addEntityResult($class->name, $dqlAlias, $resultAlias); + $this->rsm->addEntityResult($class->name, $dqlAlias, $resultAlias, $isPartial); } else { assert(isset($this->queryComponents[$dqlAlias]['parent'])); @@ -691,6 +692,7 @@ public function walkSelectClause(AST\SelectClause $selectClause): string $dqlAlias, $this->queryComponents[$dqlAlias]['parent'], $this->queryComponents[$dqlAlias]['relation']->fieldName, + $isPartial, ); } @@ -1386,6 +1388,7 @@ public function walkObjectExpression(string $dqlAlias, array $partialFieldSet, s 'class' => $class, 'dqlAlias' => $dqlAlias, 'resultAlias' => $resultAlias, + 'partial' => $partialFieldSet !== [], ]; } diff --git a/src/UnitOfWork.php b/src/UnitOfWork.php index 4c55b72877..e6726ccf54 100644 --- a/src/UnitOfWork.php +++ b/src/UnitOfWork.php @@ -2395,19 +2395,22 @@ public function createEntity(string $className, array $data, array &$hints = []) Hydrator::hydrate($entity, (array) $class->reflClass->newInstanceWithoutConstructor()); } - } else { - if ( + } elseif ( ! isset($hints[Query::HINT_REFRESH]) || (isset($hints[Query::HINT_REFRESH_ENTITY]) && $hints[Query::HINT_REFRESH_ENTITY] !== $entity) - ) { - return $entity; - } + ) { + return $entity; } $this->originalEntityData[$oid] = $data; } else { - $entity = $class->newInstance(); - $oid = spl_object_id($entity); + if ($this->em->getConfiguration()->isNativeLazyObjectsEnabled() && isset($hints['isPartial']) && $hints['isPartial']) { + $entity = $this->em->getProxyFactory()->getProxy($class->name, $id, false); + } else { + $entity = $class->newInstance(); + } + + $oid = spl_object_id($entity); $this->registerManaged($entity, $id, $data); if (isset($hints[Query::HINT_READ_ONLY]) && $hints[Query::HINT_READ_ONLY] === true) { diff --git a/tests/Tests/ORM/Functional/QueryTest.php b/tests/Tests/ORM/Functional/QueryTest.php index e866ab99b2..95798a9daa 100644 --- a/tests/Tests/ORM/Functional/QueryTest.php +++ b/tests/Tests/ORM/Functional/QueryTest.php @@ -109,6 +109,55 @@ public function testJoinQueries(): void self::assertEquals('Symfony 2', $users[0]->articles[1]->topic); } + public function testJoinPartialObjectHydration(): void + { + if (!$this->_em->getConfiguration()->isNativeLazyObjectsEnabled()) { + $this->markTestSkipped('Test requires native lazy objects to be enabled.'); + } + + $user = new CmsUser(); + $user->name = 'Guilherme'; + $user->username = 'gblanco'; + $user->status = 'developer'; + + $article1 = new CmsArticle(); + $article1->topic = 'Doctrine 2'; + $article1->text = 'This is an introduction to Doctrine 2.'; + $user->addArticle($article1); + + $article2 = new CmsArticle(); + $article2->topic = 'Symfony 2'; + $article2->text = 'This is an introduction to Symfony 2.'; + $user->addArticle($article2); + + $this->_em->persist($user); + $this->_em->persist($article1); + $this->_em->persist($article2); + + $this->_em->flush(); + $this->_em->clear(); + + $query = $this->_em->createQuery('select partial u.{id, username}, partial a.{id, topic} from ' . CmsUser::class . ' u join u.articles a ORDER BY a.topic'); + $users = $query->getResult(); + + $queries = count($this->getQueryLog()->queries); + $topicsByUsername = []; + foreach ($users as $user) { + $topicsByUsername[$user->username] = $user->articles->map(static fn ($article) => $article->topic)->toArray(); + } + + self::assertQueryCount($queries); + self::assertEquals(['gblanco' => ['Doctrine 2', 'Symfony 2']], $topicsByUsername); + + $userNames = []; + foreach ($users as $user) { + $userNames[] = $user->name; + } + + self::assertQueryCount($queries + 1); + self::assertEquals(['Guilherme'], $userNames); + } + public function testJoinPartialArrayHydration(): void { $user = new CmsUser(); diff --git a/tests/Tests/ORM/Functional/ValueObjectsTest.php b/tests/Tests/ORM/Functional/ValueObjectsTest.php index 9c0fcbd53a..4d15653825 100644 --- a/tests/Tests/ORM/Functional/ValueObjectsTest.php +++ b/tests/Tests/ORM/Functional/ValueObjectsTest.php @@ -182,6 +182,10 @@ public function testDqlOnEmbeddedObjectsField(): void public function testPartialDqlOnEmbeddedObjectsField(): void { + if (!$this->_em->getConfiguration()->isNativeLazyObjectsEnabled()) { + $this->markTestSkipped('Test requires native lazy objects to be enabled.'); + } + $person = new DDC93Person('Karl', new DDC93Address('Foo', '12345', 'Gosport', new DDC93Country('England'))); $this->_em->persist($person); $this->_em->flush(); @@ -226,9 +230,11 @@ public function testPartialDqlOnEmbeddedObjectsField(): void // Selected field must be equal, all other fields must be null. self::assertEquals('Gosport', $person->address->city); + // TODO: What about lazy loading embeddables? *shrug* self::assertNull($person->address->street); self::assertNull($person->address->zip); self::assertNull($person->address->country); + // this actually loads the name field lazily self::assertNull($person->name); } diff --git a/tests/Tests/ORM/Hydration/ObjectHydratorTest.php b/tests/Tests/ORM/Hydration/ObjectHydratorTest.php index cbfac5fa6b..79aaf77d87 100644 --- a/tests/Tests/ORM/Hydration/ObjectHydratorTest.php +++ b/tests/Tests/ORM/Hydration/ObjectHydratorTest.php @@ -1029,12 +1029,12 @@ public function testCreatesProxyForLazyLoadingWithForeignKeys(): void 'Proxies', ProxyFactory::AUTOGENERATE_ALWAYS, ) extends ProxyFactory { - public function getProxy(string $className, array $identifier): object + public function getProxy(string $className, array $identifier, bool $assignIdentifiers = false): object { TestCase::assertSame(ECommerceShipping::class, $className); TestCase::assertSame(['id' => 42], $identifier); - return parent::getProxy($className, $identifier); + return parent::getProxy($className, $identifier, $assignIdentifiers); } }; @@ -1083,12 +1083,12 @@ public function testCreatesProxyForLazyLoadingWithForeignKeysWithAliasedProductE 'Proxies', ProxyFactory::AUTOGENERATE_ALWAYS, ) extends ProxyFactory { - public function getProxy(string $className, array $identifier): object + public function getProxy(string $className, array $identifier, bool $assignIdentifiers = false): object { TestCase::assertSame(ECommerceShipping::class, $className); TestCase::assertSame(['id' => 42], $identifier); - return parent::getProxy($className, $identifier); + return parent::getProxy($className, $identifier, $assignIdentifiers); } };