Skip to content

Commit 582508e

Browse files
authored
fix(doctrine): fix partial fetch with same entity included multiple time with different fields (#7647)
1 parent d23ab43 commit 582508e

File tree

6 files changed

+457
-24
lines changed

6 files changed

+457
-24
lines changed

features/doctrine/eager_loading.feature

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ Feature: Eager Loading
1111
Then the response status code should be 200
1212
And the DQL should be equal to:
1313
"""
14-
SELECT o, thirdLevel_a1, fourthLevel_a2, relatedToDummyFriend_a3, dummyFriend_a4
14+
SELECT o, thirdLevel_a1, relatedToDummyFriend_a3, fourthLevel_a2, dummyFriend_a4
1515
FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy o
1616
LEFT JOIN o.thirdLevel thirdLevel_a1
1717
LEFT JOIN thirdLevel_a1.fourthLevel fourthLevel_a2
@@ -46,7 +46,7 @@ Feature: Eager Loading
4646
Then the response status code should be 200
4747
And the DQL should be equal to:
4848
"""
49-
SELECT o, thirdLevel_a4, fourthLevel_a5, relatedToDummyFriend_a1, dummyFriend_a6
49+
SELECT o, thirdLevel_a4, relatedToDummyFriend_a1, fourthLevel_a5, dummyFriend_a6
5050
FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy o
5151
INNER JOIN o.relatedToDummyFriend relatedToDummyFriend_a1
5252
LEFT JOIN o.thirdLevel thirdLevel_a4
@@ -83,7 +83,7 @@ Feature: Eager Loading
8383
Then the response status code should be 200
8484
And the DQL should be equal to:
8585
"""
86-
SELECT o, thirdLevel_a3, fourthLevel_a4, relatedToDummyFriend_a5, dummyFriend_a6
86+
SELECT o, thirdLevel_a3, relatedToDummyFriend_a5, fourthLevel_a4, dummyFriend_a6
8787
FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy o
8888
LEFT JOIN o.thirdLevel thirdLevel_a3
8989
LEFT JOIN thirdLevel_a3.fourthLevel fourthLevel_a4

src/Doctrine/Orm/Extension/EagerLoadingExtension.php

Lines changed: 64 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
2424
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
2525
use Doctrine\ORM\Mapping\ClassMetadata;
26+
use Doctrine\ORM\Query\AST\PartialObjectExpression;
2627
use Doctrine\ORM\Query\Expr\Join;
2728
use Doctrine\ORM\Query\Expr\Select;
2829
use Doctrine\ORM\QueryBuilder;
@@ -70,7 +71,7 @@ private function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $
7071
$options = [];
7172

7273
$forceEager = $operation?->getForceEager() ?? $this->forceEager;
73-
$fetchPartial = $operation?->getFetchPartial() ?? $this->fetchPartial;
74+
$fetchPartial = class_exists(PartialObjectExpression::class) && ($operation?->getFetchPartial() ?? $this->fetchPartial);
7475

7576
if (!isset($context['groups']) && !isset($context['attributes'])) {
7677
$contextType = isset($context['api_denormalize']) ? 'denormalization_context' : 'normalization_context';
@@ -95,7 +96,61 @@ private function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $
9596
$options['denormalization_groups'] = $denormalizationGroups;
9697
}
9798

98-
$this->joinRelations($queryBuilder, $queryNameGenerator, $resourceClass, $forceEager, $fetchPartial, $queryBuilder->getRootAliases()[0], $options, $context);
99+
$selects = $this->joinRelations($queryBuilder, $queryNameGenerator, $resourceClass, $forceEager, $fetchPartial, $queryBuilder->getRootAliases()[0], $options, $context);
100+
$selectsByClass = [];
101+
foreach ($selects as [$entity, $alias, $fields]) {
102+
if ($entity === $resourceClass) {
103+
// We don't perform partial select the root entity
104+
$fields = null;
105+
}
106+
107+
if (!isset($selectsByClass[$entity])) {
108+
$selectsByClass[$entity] = [
109+
'aliases' => [$alias => true],
110+
'fields' => null === $fields ? null : array_flip($fields),
111+
];
112+
} else {
113+
$selectsByClass[$entity]['aliases'][$alias] = true;
114+
if (null === $selectsByClass[$entity]['fields']) {
115+
continue;
116+
}
117+
118+
if (null === $fields) {
119+
$selectsByClass[$entity]['fields'] = null;
120+
continue;
121+
}
122+
123+
// Merge fields
124+
foreach ($fields as $field) {
125+
$selectsByClass[$entity]['fields'][$field] = true;
126+
}
127+
}
128+
}
129+
130+
$existingSelects = [];
131+
foreach ($queryBuilder->getDQLPart('select') ?? [] as $dqlSelect) {
132+
if (!$dqlSelect instanceof Select) {
133+
continue;
134+
}
135+
foreach ($dqlSelect->getParts() as $part) {
136+
$existingSelects[(string) $part] = true;
137+
}
138+
}
139+
140+
foreach ($selectsByClass as $data) {
141+
$fields = null === $data['fields'] ? null : array_keys($data['fields']);
142+
foreach (array_keys($data['aliases']) as $alias) {
143+
if (isset($existingSelects[$alias])) {
144+
continue;
145+
}
146+
147+
if (null === $fields) {
148+
$queryBuilder->addSelect($alias);
149+
} else {
150+
$queryBuilder->addSelect(\sprintf('partial %s.{%s}', $alias, implode(',', $fields)));
151+
}
152+
}
153+
}
99154
}
100155

101156
/**
@@ -107,7 +162,7 @@ private function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $
107162
*
108163
* @throws RuntimeException when the max number of joins has been reached
109164
*/
110-
private function joinRelations(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, bool $forceEager, bool $fetchPartial, string $parentAlias, array $options = [], array $normalizationContext = [], bool $wasLeftJoin = false, int &$joinCount = 0, ?int $currentDepth = null, ?string $parentAssociation = null): void
165+
private function joinRelations(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, bool $forceEager, bool $fetchPartial, string $parentAlias, array $options = [], array $normalizationContext = [], bool $wasLeftJoin = false, int &$joinCount = 0, ?int $currentDepth = null, ?string $parentAssociation = null): iterable
111166
{
112167
if ($joinCount > $this->maxJoins) {
113168
throw new RuntimeException('The total number of joined relations has exceeded the specified maximum. Raise the limit if necessary with the "api_platform.eager_loading.max_joins" configuration key (https://api-platform.com/docs/core/performance/#eager-loading), or limit the maximum serialization depth using the "enable_max_depth" option of the Symfony serializer (https://symfony.com/doc/current/components/serializer.html#handling-serialization-depth).');
@@ -200,13 +255,13 @@ private function joinRelations(QueryBuilder $queryBuilder, QueryNameGeneratorInt
200255
if (true === $fetchPartial) {
201256
try {
202257
$propertyOptions = $this->getPropertyContext($attributesMetadata[$association] ?? null, $options);
203-
$this->addSelect($queryBuilder, $mapping['targetEntity'], $associationAlias, $propertyOptions);
258+
yield from $this->addSelect($queryBuilder, $mapping['targetEntity'], $associationAlias, $propertyOptions);
204259
} catch (ResourceClassNotFoundException) {
205260
continue;
206261
}
207262
} else {
208263
$propertyOptions = null;
209-
$this->addSelectOnce($queryBuilder, $associationAlias);
264+
yield [$resourceClass, $associationAlias, null];
210265
}
211266

212267
// Avoid recursive joins for self-referencing relations
@@ -229,7 +284,7 @@ private function joinRelations(QueryBuilder $queryBuilder, QueryNameGeneratorInt
229284
}
230285

231286
$propertyOptions ??= $this->getPropertyContext($attributesMetadata[$association] ?? null, $options);
232-
$this->joinRelations($queryBuilder, $queryNameGenerator, $mapping['targetEntity'], $forceEager, $fetchPartial, $associationAlias, $propertyOptions, $childNormalizationContext, $isLeftJoin, $joinCount, $currentDepth, $association);
287+
yield from $this->joinRelations($queryBuilder, $queryNameGenerator, $mapping['targetEntity'], $forceEager, $fetchPartial, $associationAlias, $propertyOptions, $childNormalizationContext, $isLeftJoin, $joinCount, $currentDepth, $association);
233288
}
234289
}
235290

@@ -267,13 +322,13 @@ private function getPropertyContext(?AttributeMetadataInterface $attributeMetada
267322
return $propertyOptions;
268323
}
269324

270-
private function addSelect(QueryBuilder $queryBuilder, string $entity, string $associationAlias, array $propertyMetadataOptions): void
325+
private function addSelect(QueryBuilder $queryBuilder, string $entity, string $associationAlias, array $propertyMetadataOptions): iterable
271326
{
272327
$select = [];
273328
$entityManager = $queryBuilder->getEntityManager();
274329
$targetClassMetadata = $entityManager->getClassMetadata($entity);
275330
if (!empty($targetClassMetadata->subClasses)) {
276-
$this->addSelectOnce($queryBuilder, $associationAlias);
331+
yield [$entity, $associationAlias, null];
277332

278333
return;
279334
}
@@ -308,15 +363,6 @@ private function addSelect(QueryBuilder $queryBuilder, string $entity, string $a
308363
}
309364
}
310365

311-
$queryBuilder->addSelect(\sprintf('partial %s.{%s}', $associationAlias, implode(',', $select)));
312-
}
313-
314-
private function addSelectOnce(QueryBuilder $queryBuilder, string $alias): void
315-
{
316-
$existingSelects = array_reduce($queryBuilder->getDQLPart('select') ?? [], fn ($existing, $dqlSelect) => ($dqlSelect instanceof Select) ? array_merge($existing, $dqlSelect->getParts()) : $existing, []);
317-
318-
if (!\in_array($alias, $existingSelects, true)) {
319-
$queryBuilder->addSelect($alias);
320-
}
366+
yield [$entity, $associationAlias, $select];
321367
}
322368
}

0 commit comments

Comments
 (0)