Skip to content

Commit 131c91b

Browse files
authored
Merge pull request #1228 from hercemer42/eager-loading
Implement maxDepth in eager loading extension
2 parents 9c4a116 + 24f44cb commit 131c91b

File tree

6 files changed

+314
-23
lines changed

6 files changed

+314
-23
lines changed

features/main/recursive.feature

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
Feature: Max depth handling
2+
In order to handle recursive resources
3+
As a developer
4+
I need to be able to limit their depth with @maxDepth
5+
6+
@createSchema
7+
Scenario: Create a non-recursive resource
8+
When I add "Content-Type" header equal to "application/ld+json"
9+
And I send a "POST" request to "/recursives" with body:
10+
"""
11+
{
12+
"name": "Fry's grandpa",
13+
"child": {
14+
"name": "Fry"
15+
}
16+
}
17+
"""
18+
Then the response status code should be 201
19+
And the response should be in JSON
20+
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
21+
And the JSON should be equal to:
22+
"""
23+
{
24+
"@context": "/contexts/Recursive",
25+
"@id": "/recursives/1",
26+
"@type": "Recursive",
27+
"id": 1,
28+
"name": "Fry's grandpa",
29+
"child": {
30+
"@id": "/recursive_children/1",
31+
"@type": "RecursiveChild",
32+
"id": 1,
33+
"name": "Fry",
34+
"parent": null
35+
}
36+
}
37+
"""
38+
39+
@dropSchema
40+
Scenario: Make the resource recursive
41+
When I add "Content-Type" header equal to "application/ld+json"
42+
And I send a "PUT" request to "recursives/1" with body:
43+
"""
44+
{
45+
"@id": "/recursives/1",
46+
"child": {
47+
"@id": "/recursive_children/1",
48+
"parent": "/recursives/1"
49+
}
50+
}
51+
"""
52+
Then print last JSON response
53+
And the response status code should be 200
54+
And the response should be in JSON
55+
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
56+
And the JSON should be equal to:
57+
"""
58+
{
59+
"@context": "/contexts/Recursive",
60+
"@id": "/recursives/1",
61+
"@type": "Recursive",
62+
"id": 1,
63+
"name": "Fry's grandpa",
64+
"child": {
65+
"@id": "/recursive_children/1",
66+
"@type": "RecursiveChild",
67+
"id": 1,
68+
"name": "Fry",
69+
"parent": "/recursives/1"
70+
}
71+
}
72+
"""

src/Bridge/Doctrine/Orm/Extension/EagerLoadingExtension.php

Lines changed: 52 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
use Doctrine\ORM\Mapping\ClassMetadataInfo;
2626
use Doctrine\ORM\QueryBuilder;
2727
use Symfony\Component\HttpFoundation\RequestStack;
28+
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
2829

2930
/**
3031
* Eager loads relations.
@@ -40,18 +41,20 @@ final class EagerLoadingExtension implements QueryCollectionExtensionInterface,
4041

4142
private $propertyNameCollectionFactory;
4243
private $propertyMetadataFactory;
44+
private $classMetadataFactory;
4345
private $maxJoins;
4446
private $serializerContextBuilder;
4547
private $requestStack;
4648

4749
/**
4850
* @TODO move $fetchPartial after $forceEager (@soyuka) in 3.0
4951
*/
50-
public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, int $maxJoins = 30, bool $forceEager = true, RequestStack $requestStack = null, SerializerContextBuilderInterface $serializerContextBuilder = null, bool $fetchPartial = false)
52+
public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, int $maxJoins = 30, bool $forceEager = true, RequestStack $requestStack = null, SerializerContextBuilderInterface $serializerContextBuilder = null, bool $fetchPartial = false, ClassMetadataFactoryInterface $classMetadataFactory = null)
5153
{
5254
$this->propertyNameCollectionFactory = $propertyNameCollectionFactory;
5355
$this->propertyMetadataFactory = $propertyMetadataFactory;
5456
$this->resourceMetadataFactory = $resourceMetadataFactory;
57+
$this->classMetadataFactory = $classMetadataFactory;
5558
$this->maxJoins = $maxJoins;
5659
$this->forceEager = $forceEager;
5760
$this->fetchPartial = $fetchPartial;
@@ -72,10 +75,11 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator
7275

7376
$forceEager = $this->shouldOperationForceEager($resourceClass, $options);
7477
$fetchPartial = $this->shouldOperationFetchPartial($resourceClass, $options);
78+
$serializerContext = $this->getSerializerContext($resourceClass, 'normalization_context', $options);
7579

76-
$groups = $this->getSerializerGroups($resourceClass, $options, 'normalization_context');
80+
$groups = $this->getSerializerGroups($options, $serializerContext);
7781

78-
$this->joinRelations($queryBuilder, $queryNameGenerator, $resourceClass, $forceEager, $fetchPartial, $queryBuilder->getRootAliases()[0], $groups);
82+
$this->joinRelations($queryBuilder, $queryNameGenerator, $resourceClass, $forceEager, $fetchPartial, $queryBuilder->getRootAliases()[0], $groups, $serializerContext);
7983
}
8084

8185
/**
@@ -92,16 +96,16 @@ public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterf
9296

9397
$forceEager = $this->shouldOperationForceEager($resourceClass, $options);
9498
$fetchPartial = $this->shouldOperationFetchPartial($resourceClass, $options);
99+
$contextType = isset($context['api_denormalize']) ? 'denormalization_context' : 'normalization_context';
100+
$serializerContext = $this->getSerializerContext($context['resource_class'] ?? $resourceClass, $contextType, $options);
95101

96102
if (isset($context['groups'])) {
97103
$groups = ['serializer_groups' => $context['groups']];
98-
} elseif (isset($context['resource_class'])) {
99-
$groups = $this->getSerializerGroups($context['resource_class'], $options, isset($context['api_denormalize']) ? 'denormalization_context' : 'normalization_context');
100104
} else {
101-
$groups = $this->getSerializerGroups($resourceClass, $options, 'normalization_context');
105+
$groups = $this->getSerializerGroups($options, $serializerContext);
102106
}
103107

104-
$this->joinRelations($queryBuilder, $queryNameGenerator, $resourceClass, $forceEager, $fetchPartial, $queryBuilder->getRootAliases()[0], $groups);
108+
$this->joinRelations($queryBuilder, $queryNameGenerator, $resourceClass, $forceEager, $fetchPartial, $queryBuilder->getRootAliases()[0], $groups, $serializerContext);
105109
}
106110

107111
/**
@@ -113,21 +117,30 @@ public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterf
113117
* @param bool $forceEager
114118
* @param string $parentAlias
115119
* @param array $propertyMetadataOptions
120+
* @param array $context
116121
* @param bool $wasLeftJoin if the relation containing the new one had a left join, we have to force the new one to left join too
117122
* @param int $joinCount the number of joins
123+
* @param int $currentDepth the current max depth
118124
*
119125
* @throws RuntimeException when the max number of joins has been reached
120126
*/
121-
private function joinRelations(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, bool $forceEager, bool $fetchPartial, string $parentAlias, array $propertyMetadataOptions = [], bool $wasLeftJoin = false, int &$joinCount = 0)
127+
private function joinRelations(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, bool $forceEager, bool $fetchPartial, string $parentAlias, array $propertyMetadataOptions = [], array $context = [], bool $wasLeftJoin = false, int &$joinCount = 0, int $currentDepth = null)
122128
{
123129
if ($joinCount > $this->maxJoins) {
124-
throw new RuntimeException('The total number of joined relations has exceeded the specified maximum. Raise the limit if necessary.');
130+
throw new RuntimeException('The total number of joined relations has exceeded the specified maximum. Raise the limit if necessary, or use the "max_depth" option of the Symfony serializer.');
125131
}
126132

133+
$currentDepth = $currentDepth > 0 ? $currentDepth - 1 : $currentDepth;
127134
$entityManager = $queryBuilder->getEntityManager();
128135
$classMetadata = $entityManager->getClassMetadata($resourceClass);
136+
$attributesMetadata = $this->classMetadataFactory ? $this->classMetadataFactory->getMetadataFor($resourceClass)->getAttributesMetadata() : null;
129137

130138
foreach ($classMetadata->associationMappings as $association => $mapping) {
139+
//Don't join if max depth is enabled and the current depth limit is reached
140+
if (isset($context['enable_max_depth']) && 0 === $currentDepth) {
141+
continue;
142+
}
143+
131144
try {
132145
$propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $association, $propertyMetadataOptions);
133146
} catch (PropertyNotFoundException $propertyNotFoundException) {
@@ -174,7 +187,16 @@ private function joinRelations(QueryBuilder $queryBuilder, QueryNameGeneratorInt
174187
continue;
175188
}
176189

177-
$this->joinRelations($queryBuilder, $queryNameGenerator, $mapping['targetEntity'], $forceEager, $fetchPartial, $associationAlias, $propertyMetadataOptions, $method === 'leftJoin', $joinCount);
190+
if (isset($attributesMetadata[$association])) {
191+
$maxDepth = $attributesMetadata[$association]->getMaxDepth();
192+
193+
// The current depth is the lowest max depth available in the ancestor tree.
194+
if (null !== $maxDepth && (null === $currentDepth || $maxDepth < $currentDepth)) {
195+
$currentDepth = $maxDepth;
196+
}
197+
}
198+
199+
$this->joinRelations($queryBuilder, $queryNameGenerator, $mapping['targetEntity'], $forceEager, $fetchPartial, $associationAlias, $propertyMetadataOptions, $context, $method === 'leftJoin', $joinCount, $currentDepth);
178200
}
179201
}
180202

@@ -215,15 +237,15 @@ private function addSelect(QueryBuilder $queryBuilder, string $entity, string $a
215237
}
216238

217239
/**
218-
* Gets serializer groups if available, if not it returns the $options array.
240+
* Gets serializer context.
219241
*
220242
* @param string $resourceClass
243+
* @param string $contextType normalization_context or denormalization_context
221244
* @param array $options represents the operation name so that groups are the one of the specific operation
222-
* @param string $context normalization_context or denormalization_context
223245
*
224246
* @return array
225247
*/
226-
private function getSerializerGroups(string $resourceClass, array $options, string $context): array
248+
private function getSerializerContext(string $resourceClass, string $contextType, array $options): array
227249
{
228250
$request = null;
229251

@@ -232,23 +254,32 @@ private function getSerializerGroups(string $resourceClass, array $options, stri
232254
}
233255

234256
if (null !== $this->serializerContextBuilder && null !== $request) {
235-
$contextFromRequest = $this->serializerContextBuilder->createFromRequest($request, $context === 'normalization_context');
236-
237-
if (isset($contextFromRequest['groups'])) {
238-
return ['serializer_groups' => $contextFromRequest['groups']];
239-
}
257+
return $this->serializerContextBuilder->createFromRequest($request, 'normalization_context' === $contextType);
240258
}
241259

242260
$resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
243261

244262
if (isset($options['collection_operation_name'])) {
245-
$context = $resourceMetadata->getCollectionOperationAttribute($options['collection_operation_name'], $context, null, true);
263+
$context = $resourceMetadata->getCollectionOperationAttribute($options['collection_operation_name'], $contextType, null, true);
246264
} elseif (isset($options['item_operation_name'])) {
247-
$context = $resourceMetadata->getItemOperationAttribute($options['item_operation_name'], $context, null, true);
265+
$context = $resourceMetadata->getItemOperationAttribute($options['item_operation_name'], $contextType, null, true);
248266
} else {
249-
$context = $resourceMetadata->getAttribute($context);
267+
$context = $resourceMetadata->getAttribute($contextType);
250268
}
251269

270+
return $context ? $context : [];
271+
}
272+
273+
/**
274+
* Gets serializer groups if available, if not it returns the $options array.
275+
*
276+
* @param array $options represents the operation name so that groups are the one of the specific operation
277+
* @param array $context
278+
*
279+
* @return array
280+
*/
281+
private function getSerializerGroups(array $options, array $context): array
282+
{
252283
if (empty($context['groups'])) {
253284
return $options;
254285
}

src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@
114114
<argument type="service" id="request_stack" />
115115
<argument type="service" id="api_platform.serializer.context_builder" />
116116
<argument>%api_platform.eager_loading.fetch_partial%</argument>
117+
<argument type="service" id="serializer.mapping.class_metadata_factory"></argument>
117118

118119
<tag name="api_platform.doctrine.orm.query_extension.item" priority="64" />
119120
<tag name="api_platform.doctrine.orm.query_extension.collection" priority="64" />

tests/Bridge/Doctrine/Orm/Extension/EagerLoadingExtensionTest.php

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@
3636
use Prophecy\Argument;
3737
use Symfony\Component\HttpFoundation\Request;
3838
use Symfony\Component\HttpFoundation\RequestStack;
39+
use Symfony\Component\Serializer\Mapping\AttributeMetadata;
40+
use Symfony\Component\Serializer\Mapping\ClassMetadataInterface;
41+
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
3942

4043
/**
4144
* @author Amrouche Hamza <[email protected]>
@@ -316,9 +319,9 @@ public function testDenormalizeItemWithExistingGroups()
316319

317320
/**
318321
* @expectedException \ApiPlatform\Core\Exception\RuntimeException
319-
* @expectedExceptionMessage The total number of joined relations has exceeded the specified maximum. Raise the limit if necessary.
322+
* @expectedExceptionMessage The total number of joined relations has exceeded the specified maximum. Raise the limit if necessary, or use the "max_depth" option of the Symfony serializer.
320323
*/
321-
public function testMaxDepthReached()
324+
public function testMaxJoinsReached()
322325
{
323326
$resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class);
324327
$resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata());
@@ -367,6 +370,74 @@ public function testMaxDepthReached()
367370
$eagerExtensionTest->applyToCollection($queryBuilderProphecy->reveal(), new QueryNameGenerator(), Dummy::class);
368371
}
369372

373+
public function testMaxDepth()
374+
{
375+
$resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class);
376+
$resourceMetadata = new ResourceMetadata();
377+
$resourceMetadata = $resourceMetadata->withAttributes(['normalization_context' => ['enable_max_depth' => 'true']]);
378+
379+
$resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn($resourceMetadata);
380+
381+
$propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class);
382+
383+
$relatedNameCollection = new PropertyNameCollection(['dummy']);
384+
$dummyNameCollection = new PropertyNameCollection(['relatedDummy']);
385+
386+
$propertyNameCollectionFactoryProphecy->create(RelatedDummy::class)->willReturn($relatedNameCollection)->shouldBeCalled();
387+
$propertyNameCollectionFactoryProphecy->create(Dummy::class)->willReturn($dummyNameCollection)->shouldBeCalled();
388+
389+
$propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class);
390+
$relationPropertyMetadata = new PropertyMetadata();
391+
$relationPropertyMetadata = $relationPropertyMetadata->withReadableLink(true);
392+
393+
$propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', [])->willReturn($relationPropertyMetadata)->shouldBeCalled();
394+
395+
$relatedPropertyMetadata = new PropertyMetadata();
396+
$relatedPropertyMetadata = $relatedPropertyMetadata->withReadableLink(true);
397+
398+
$propertyMetadataFactoryProphecy->create(RelatedDummy::class, 'dummy', [])->willReturn($relatedPropertyMetadata)->shouldBeCalled();
399+
400+
$classMetadataProphecy = $this->prophesize(ClassMetadata::class);
401+
$classMetadataProphecy->associationMappings = [
402+
'relatedDummy' => ['fetch' => 3, 'joinColumns' => [['nullable' => false]], 'targetEntity' => RelatedDummy::class],
403+
];
404+
405+
$relatedClassMetadataProphecy = $this->prophesize(ClassMetadata::class);
406+
$relatedClassMetadataProphecy->associationMappings = [
407+
'dummy' => ['fetch' => 3, 'joinColumns' => [['nullable' => false]], 'targetEntity' => Dummy::class],
408+
];
409+
410+
$dummyClassMetadataInterfaceProphecy = $this->prophesize(ClassMetadataInterface::class);
411+
$relatedClassMetadataInterfaceProphecy = $this->prophesize(ClassMetadataInterface::class);
412+
$classMetadataFactoryProphecy = $this->prophesize(ClassMetadataFactoryInterface::class);
413+
414+
$dummyAttributeMetadata = new AttributeMetadata('dummy');
415+
$dummyAttributeMetadata->setMaxDepth(2);
416+
417+
$relatedAttributeMetadata = new AttributeMetadata('relatedDummy');
418+
$relatedAttributeMetadata->setMaxDepth(4);
419+
420+
$dummyClassMetadataInterfaceProphecy->getAttributesMetadata()->willReturn(['relatedDummy' => $dummyAttributeMetadata]);
421+
$relatedClassMetadataInterfaceProphecy->getAttributesMetadata()->willReturn(['dummy' => $relatedAttributeMetadata]);
422+
423+
$classMetadataFactoryProphecy->getMetadataFor(RelatedDummy::class)->willReturn($relatedClassMetadataInterfaceProphecy->reveal());
424+
$classMetadataFactoryProphecy->getMetadataFor(Dummy::class)->willReturn($dummyClassMetadataInterfaceProphecy->reveal());
425+
426+
$emProphecy = $this->prophesize(EntityManager::class);
427+
$emProphecy->getClassMetadata(Dummy::class)->shouldBeCalled()->willReturn($classMetadataProphecy->reveal());
428+
$emProphecy->getClassMetadata(RelatedDummy::class)->shouldBeCalled()->willReturn($relatedClassMetadataProphecy->reveal());
429+
430+
$queryBuilderProphecy = $this->prophesize(QueryBuilder::class);
431+
$queryBuilderProphecy->getRootAliases()->willReturn(['o']);
432+
$queryBuilderProphecy->getEntityManager()->willReturn($emProphecy);
433+
434+
$queryBuilderProphecy->innerJoin(Argument::type('string'), Argument::type('string'))->shouldBeCalledTimes(2);
435+
$queryBuilderProphecy->addSelect(Argument::type('string'))->shouldBeCalled();
436+
437+
$eagerExtensionTest = new EagerLoadingExtension($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $resourceMetadataFactoryProphecy->reveal(), 30, false, null, null, true, $classMetadataFactoryProphecy->reveal());
438+
$eagerExtensionTest->applyToCollection($queryBuilderProphecy->reveal(), new QueryNameGenerator(), Dummy::class);
439+
}
440+
370441
public function testForceEager()
371442
{
372443
$resourceMetadata = new ResourceMetadata();

0 commit comments

Comments
 (0)