Skip to content

Commit ca5bcea

Browse files
committed
feat(doctrine): stateOptions can handleLinks for query optimization
1 parent f02f379 commit ca5bcea

36 files changed

+726
-55
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: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
namespace ApiPlatform\Doctrine\Common;
1515

16+
use Doctrine\ORM\Mapping\ClassMetadataInfo;
1617
use Doctrine\Persistence\ManagerRegistry;
1718
use Doctrine\Persistence\Mapping\ClassMetadata;
1819

@@ -131,9 +132,14 @@ protected function getNestedMetadata(string $resourceClass, array $associations)
131132
*/
132133
protected function getClassMetadata(string $resourceClass): ClassMetadata
133134
{
134-
return $this
135+
$manager = $this
135136
->getManagerRegistry()
136-
->getManagerForClass($resourceClass)
137-
->getClassMetadata($resourceClass);
137+
->getManagerForClass($resourceClass);
138+
139+
if ($manager) {
140+
return $manager->getClassMetadata($resourceClass);
141+
}
142+
143+
return new ClassMetadataInfo($resourceClass);
138144
}
139145
}

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')) {
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 (\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/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(['documentClass' => $documentClass, 'operation' => $operation] + $context, $aggregationBuilder, $uriVariables);
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(['documentClass' => $documentClass, 'operation' => $operation] + $context, $aggregationBuilder, $uriVariables);
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
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Doctrine\Odm\State;
15+
16+
use ApiPlatform\Metadata\Operation;
17+
use Doctrine\ODM\MongoDB\Aggregation\Builder;
18+
19+
interface LinksHandlerInterface
20+
{
21+
/**
22+
* Handle Doctrine ORM links.
23+
*
24+
* @see ApiPlatform\Doctrine\Odm\State\LinksHandlerTrait
25+
*
26+
* @param array{entityClass: string, operation: Operation}&array<string, mixed> $context
27+
* @param array<string, mixed> $uriVariables
28+
*/
29+
public function handleLinks(array $context, Builder $aggregationBuilder, array $uriVariables): void;
30+
}

0 commit comments

Comments
 (0)