Skip to content

Commit c7dcd36

Browse files
authored
feat(doctrine): stateOptions can handleLinks for query optimization (#5732)
1 parent 46e84ff commit c7dcd36

39 files changed

+776
-67
lines changed

.php-cs-fixer.dist.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
'src/Core/Bridge/Symfony/Maker/Resources/skeleton',
1818
'tests/Fixtures/app/var',
1919
'docs/guides',
20+
'docs/var',
2021
])
2122
->notPath('src/Symfony/Bundle/DependencyInjection/Configuration.php')
2223
->notPath('src/Annotation/ApiFilter.php') // temporary
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
Feature: Use a link handler to retrieve a resource
2+
3+
@createSchema
4+
Scenario: Get collection
5+
Given there are a few link handled dummies
6+
When I send a "GET" request to "/link_handled_dummies"
7+
Then the response status code should be 200
8+
And the response should be in JSON
9+
And the JSON node "hydra:totalItems" should be equal to 1
10+
11+
@createSchema
12+
Scenario: Get item
13+
Given there are a few link handled dummies
14+
When I send a "GET" request to "/link_handled_dummies/1"
15+
Then the response status code should be 200
16+
And the response should be in JSON
17+
And the JSON node "slug" should be equal to "foo"

features/doctrine/separated_resource.feature

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -52,15 +52,6 @@ Feature: Use state options to use an entity that is not a resource
5252
And the response should be in JSON
5353
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
5454

55-
@!mongodb
56-
@createSchema
57-
Scenario: Get item
58-
Given there are 5 separated entities
59-
When I send a "GET" request to "/separated_entities/1"
60-
Then the response status code should be 200
61-
And the response should be in JSON
62-
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
63-
6455
@!mongodb
6556
@createSchema
6657
Scenario: Get all EntityClassAndCustomProviderResources
@@ -74,3 +65,52 @@ Feature: Use state options to use an entity that is not a resource
7465
Given there are 1 separated entities
7566
When I send a "GET" request to "/entityClassAndCustomProviderResources/1"
7667
Then the response status code should be 200
68+
69+
@mongodb
70+
@createSchema
71+
Scenario: Get collection
72+
Given there are 5 separated entities
73+
When I send a "GET" request to "/separated_documents"
74+
Then the response status code should be 200
75+
And the response should be in JSON
76+
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
77+
Then the JSON should be valid according to this schema:
78+
"""
79+
{
80+
"type": "object",
81+
"properties": {
82+
"@context": {"pattern": "^/contexts/SeparatedDocument"},
83+
"@id": {"pattern": "^/separated_documents"},
84+
"@type": {"pattern": "^hydra:Collection$"},
85+
"hydra:member": {
86+
"type": "array",
87+
"items": {
88+
"type": "object"
89+
}
90+
},
91+
"hydra:totalItems": {"type":"number"},
92+
"hydra:view": {
93+
"type": "object"
94+
}
95+
}
96+
}
97+
"""
98+
99+
@mongodb
100+
@createSchema
101+
Scenario: Get ordered collection
102+
Given there are 5 separated entities
103+
When I send a "GET" request to "/separated_documents?order[value]=desc"
104+
Then the response status code should be 200
105+
And the response should be in JSON
106+
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
107+
And the JSON node "hydra:member[0].value" should be equal to "5"
108+
109+
@mongodb
110+
@createSchema
111+
Scenario: Get item
112+
Given there are 5 separated entities
113+
When I send a "GET" request to "/separated_documents/1"
114+
Then the response status code should be 200
115+
And the response should be in JSON
116+
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"

src/Doctrine/Common/Filter/OrderFilterTrait.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ public function getDescription(string $resourceClass): array
3939
$description = [];
4040

4141
$properties = $this->getProperties();
42-
if (null === $properties) {
43-
$properties = array_fill_keys($this->getClassMetadata($resourceClass)->getFieldNames(), null);
42+
if (null === $properties && $fieldNames = $this->getClassMetadata($resourceClass)->getFieldNames()) {
43+
$properties = array_fill_keys($fieldNames, null);
4444
}
4545

4646
foreach ($properties as $property => $propertyOptions) {

src/Doctrine/Common/PropertyHelperTrait.php

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313

1414
namespace ApiPlatform\Doctrine\Common;
1515

16-
use Doctrine\Persistence\ManagerRegistry;
1716
use Doctrine\Persistence\Mapping\ClassMetadata;
1817

1918
/**
@@ -25,7 +24,10 @@
2524
*/
2625
trait PropertyHelperTrait
2726
{
28-
abstract protected function getManagerRegistry(): ManagerRegistry;
27+
/**
28+
* Gets class metadata for the given resource.
29+
*/
30+
abstract protected function getClassMetadata(string $resourceClass): ClassMetadata;
2931

3032
/**
3133
* Determines whether the given property is mapped.
@@ -125,15 +127,4 @@ protected function getNestedMetadata(string $resourceClass, array $associations)
125127

126128
return $metadata;
127129
}
128-
129-
/**
130-
* Gets class metadata for the given resource.
131-
*/
132-
protected function getClassMetadata(string $resourceClass): ClassMetadata
133-
{
134-
return $this
135-
->getManagerRegistry()
136-
->getManagerForClass($resourceClass)
137-
->getClassMetadata($resourceClass);
138-
}
139130
}

src/Doctrine/Common/State/LinksHandlerTrait.php

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,19 @@
1414
namespace ApiPlatform\Doctrine\Common\State;
1515

1616
use ApiPlatform\Exception\OperationNotFoundException;
17-
use ApiPlatform\Exception\RuntimeException;
17+
use ApiPlatform\Metadata\Exception\RuntimeException;
1818
use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation;
1919
use ApiPlatform\Metadata\GraphQl\Query;
2020
use ApiPlatform\Metadata\HttpOperation;
2121
use ApiPlatform\Metadata\Link;
2222
use ApiPlatform\Metadata\Operation;
2323
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
24+
use Psr\Container\ContainerInterface;
2425

2526
trait LinksHandlerTrait
2627
{
2728
private ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory;
29+
private ?ContainerInterface $handleLinksLocator;
2830

2931
/**
3032
* @return Link[]
@@ -112,4 +114,22 @@ private function getOperationLinks(Operation $operation = null): array
112114

113115
return [];
114116
}
117+
118+
private function getLinksHandler(Operation $operation): ?callable
119+
{
120+
if (!($options = $operation->getStateOptions()) || !method_exists($options, 'getHandleLinks') || null === $options->getHandleLinks()) {
121+
return null;
122+
}
123+
124+
$handleLinks = $options->getHandleLinks(); // @phpstan-ignore-line method_exists called above
125+
if (\is_callable($handleLinks)) {
126+
return $handleLinks;
127+
}
128+
129+
if ($this->handleLinksLocator && \is_string($handleLinks) && $this->handleLinksLocator->has($handleLinks)) {
130+
return [$this->handleLinksLocator->get($handleLinks), 'handleLinks'];
131+
}
132+
133+
throw new RuntimeException(sprintf('Could not find handleLinks service "%s"', $handleLinks));
134+
}
115135
}

src/Doctrine/Odm/Metadata/Resource/DoctrineMongoDbOdmResourceCollectionMetadataFactory.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
use ApiPlatform\Doctrine\Odm\State\CollectionProvider;
1717
use ApiPlatform\Doctrine\Odm\State\ItemProvider;
18+
use ApiPlatform\Doctrine\Odm\State\Options;
1819
use ApiPlatform\Metadata\ApiResource;
1920
use ApiPlatform\Metadata\CollectionOperationInterface;
2021
use ApiPlatform\Metadata\DeleteOperationInterface;
@@ -44,7 +45,12 @@ public function create(string $resourceClass): ResourceMetadataCollection
4445
if ($operations) {
4546
/** @var Operation $operation */
4647
foreach ($resourceMetadata->getOperations() as $operationName => $operation) {
47-
if (!$this->managerRegistry->getManagerForClass($operation->getClass()) instanceof DocumentManager) {
48+
$documentClass = $operation->getClass();
49+
if (($options = $operation->getStateOptions()) && $options instanceof Options && $options->getDocumentClass()) {
50+
$documentClass = $options->getDocumentClass();
51+
}
52+
53+
if (!$this->managerRegistry->getManagerForClass($documentClass) instanceof DocumentManager) {
4854
continue;
4955
}
5056

src/Doctrine/Odm/PropertyHelperTrait.php

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use Doctrine\ODM\MongoDB\Aggregation\Builder;
1818
use Doctrine\ODM\MongoDB\Mapping\ClassMetadata as MongoDbOdmClassMetadata;
1919
use Doctrine\ODM\MongoDB\Mapping\MappingException;
20+
use Doctrine\Persistence\ManagerRegistry;
2021
use Doctrine\Persistence\Mapping\ClassMetadata;
2122

2223
/**
@@ -26,6 +27,8 @@
2627
*/
2728
trait PropertyHelperTrait
2829
{
30+
abstract protected function getManagerRegistry(): ManagerRegistry;
31+
2932
/**
3033
* Splits the given property into parts.
3134
*/
@@ -34,7 +37,18 @@ abstract protected function splitPropertyParts(string $property, string $resourc
3437
/**
3538
* Gets class metadata for the given resource.
3639
*/
37-
abstract protected function getClassMetadata(string $resourceClass): ClassMetadata;
40+
protected function getClassMetadata(string $resourceClass): ClassMetadata
41+
{
42+
$manager = $this
43+
->getManagerRegistry()
44+
->getManagerForClass($resourceClass);
45+
46+
if ($manager) {
47+
return $manager->getClassMetadata($resourceClass);
48+
}
49+
50+
return new MongoDbOdmClassMetadata($resourceClass);
51+
}
3852

3953
/**
4054
* Adds the necessary lookups for a nested property.

src/Doctrine/Odm/State/CollectionProvider.php

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
use Doctrine\ODM\MongoDB\DocumentManager;
2323
use Doctrine\ODM\MongoDB\Repository\DocumentRepository;
2424
use Doctrine\Persistence\ManagerRegistry;
25+
use Psr\Container\ContainerInterface;
2526

2627
/**
2728
* Collection state provider using the Doctrine ODM.
@@ -33,37 +34,46 @@ final class CollectionProvider implements ProviderInterface
3334
/**
3435
* @param AggregationCollectionExtensionInterface[] $collectionExtensions
3536
*/
36-
public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ManagerRegistry $managerRegistry, private readonly iterable $collectionExtensions = [])
37+
public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ManagerRegistry $managerRegistry, private readonly iterable $collectionExtensions = [], ContainerInterface $handleLinksLocator = null)
3738
{
3839
$this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory;
40+
$this->handleLinksLocator = $handleLinksLocator;
3941
}
4042

4143
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable
4244
{
43-
$resourceClass = $operation->getClass();
45+
$documentClass = $operation->getClass();
46+
if (($options = $operation->getStateOptions()) && $options instanceof Options && $options->getDocumentClass()) {
47+
$documentClass = $options->getDocumentClass();
48+
}
49+
4450
/** @var DocumentManager $manager */
45-
$manager = $this->managerRegistry->getManagerForClass($resourceClass);
51+
$manager = $this->managerRegistry->getManagerForClass($documentClass);
4652

47-
$repository = $manager->getRepository($resourceClass);
53+
$repository = $manager->getRepository($documentClass);
4854
if (!$repository instanceof DocumentRepository) {
49-
throw new RuntimeException(sprintf('The repository for "%s" must be an instance of "%s".', $resourceClass, DocumentRepository::class));
55+
throw new RuntimeException(sprintf('The repository for "%s" must be an instance of "%s".', $documentClass, DocumentRepository::class));
5056
}
5157

5258
$aggregationBuilder = $repository->createAggregationBuilder();
5359

54-
$this->handleLinks($aggregationBuilder, $uriVariables, $context, $resourceClass, $operation);
60+
if ($handleLinks = $this->getLinksHandler($operation)) {
61+
$handleLinks($aggregationBuilder, $uriVariables, ['documentClass' => $documentClass, 'operation' => $operation] + $context);
62+
} else {
63+
$this->handleLinks($aggregationBuilder, $uriVariables, $context, $documentClass, $operation);
64+
}
5565

5666
foreach ($this->collectionExtensions as $extension) {
57-
$extension->applyToCollection($aggregationBuilder, $resourceClass, $operation, $context);
67+
$extension->applyToCollection($aggregationBuilder, $documentClass, $operation, $context);
5868

59-
if ($extension instanceof AggregationResultCollectionExtensionInterface && $extension->supportsResult($resourceClass, $operation, $context)) {
60-
return $extension->getResult($aggregationBuilder, $resourceClass, $operation, $context);
69+
if ($extension instanceof AggregationResultCollectionExtensionInterface && $extension->supportsResult($documentClass, $operation, $context)) {
70+
return $extension->getResult($aggregationBuilder, $documentClass, $operation, $context);
6171
}
6272
}
6373

6474
$attribute = $operation->getExtraProperties()['doctrine_mongodb'] ?? [];
6575
$executeOptions = $attribute['execute_options'] ?? [];
6676

67-
return $aggregationBuilder->hydrate($resourceClass)->execute($executeOptions);
77+
return $aggregationBuilder->hydrate($documentClass)->execute($executeOptions);
6878
}
6979
}

src/Doctrine/Odm/State/ItemProvider.php

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
use Doctrine\ODM\MongoDB\DocumentManager;
2323
use Doctrine\ODM\MongoDB\Repository\DocumentRepository;
2424
use Doctrine\Persistence\ManagerRegistry;
25+
use Psr\Container\ContainerInterface;
2526

2627
/**
2728
* Item state provider using the Doctrine ODM.
@@ -36,41 +37,50 @@ final class ItemProvider implements ProviderInterface
3637
/**
3738
* @param AggregationItemExtensionInterface[] $itemExtensions
3839
*/
39-
public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ManagerRegistry $managerRegistry, private readonly iterable $itemExtensions = [])
40+
public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ManagerRegistry $managerRegistry, private readonly iterable $itemExtensions = [], ContainerInterface $handleLinksLocator = null)
4041
{
4142
$this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory;
43+
$this->handleLinksLocator = $handleLinksLocator;
4244
}
4345

4446
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?object
4547
{
46-
$resourceClass = $operation->getClass();
48+
$documentClass = $operation->getClass();
49+
if (($options = $operation->getStateOptions()) && $options instanceof Options && $options->getDocumentClass()) {
50+
$documentClass = $options->getDocumentClass();
51+
}
52+
4753
/** @var DocumentManager $manager */
48-
$manager = $this->managerRegistry->getManagerForClass($resourceClass);
54+
$manager = $this->managerRegistry->getManagerForClass($documentClass);
4955

5056
$fetchData = $context['fetch_data'] ?? true;
5157
if (!$fetchData) {
52-
return $manager->getReference($resourceClass, reset($uriVariables));
58+
return $manager->getReference($documentClass, reset($uriVariables));
5359
}
5460

55-
$repository = $manager->getRepository($resourceClass);
61+
$repository = $manager->getRepository($documentClass);
5662
if (!$repository instanceof DocumentRepository) {
57-
throw new RuntimeException(sprintf('The repository for "%s" must be an instance of "%s".', $resourceClass, DocumentRepository::class));
63+
throw new RuntimeException(sprintf('The repository for "%s" must be an instance of "%s".', $documentClass, DocumentRepository::class));
5864
}
5965

6066
$aggregationBuilder = $repository->createAggregationBuilder();
6167

62-
$this->handleLinks($aggregationBuilder, $uriVariables, $context, $resourceClass, $operation);
68+
if ($handleLinks = $this->getLinksHandler($operation)) {
69+
$handleLinks($aggregationBuilder, $uriVariables, ['documentClass' => $documentClass, 'operation' => $operation] + $context);
70+
} else {
71+
$this->handleLinks($aggregationBuilder, $uriVariables, $context, $documentClass, $operation);
72+
}
6373

6474
foreach ($this->itemExtensions as $extension) {
65-
$extension->applyToItem($aggregationBuilder, $resourceClass, $uriVariables, $operation, $context);
75+
$extension->applyToItem($aggregationBuilder, $documentClass, $uriVariables, $operation, $context);
6676

67-
if ($extension instanceof AggregationResultItemExtensionInterface && $extension->supportsResult($resourceClass, $operation, $context)) {
68-
return $extension->getResult($aggregationBuilder, $resourceClass, $operation, $context);
77+
if ($extension instanceof AggregationResultItemExtensionInterface && $extension->supportsResult($documentClass, $operation, $context)) {
78+
return $extension->getResult($aggregationBuilder, $documentClass, $operation, $context);
6979
}
7080
}
7181

7282
$executeOptions = $operation->getExtraProperties()['doctrine_mongodb']['execute_options'] ?? [];
7383

74-
return $aggregationBuilder->hydrate($resourceClass)->execute($executeOptions)->current() ?: null;
84+
return $aggregationBuilder->hydrate($documentClass)->execute($executeOptions)->current() ?: null;
7585
}
7686
}

0 commit comments

Comments
 (0)