Skip to content

Commit 44337dd

Browse files
authored
fix(graphql): use right nested operation (#5102)
1 parent a4cd12b commit 44337dd

25 files changed

+446
-115
lines changed

features/graphql/collection.feature

+46
Original file line numberDiff line numberDiff line change
@@ -910,3 +910,49 @@ Feature: GraphQL collection support
910910
Then the response status code should be 200
911911
And the response should be in JSON
912912
And the JSON node "data.fooDummies.collection" should have 1 element
913+
914+
@createSchema
915+
Scenario: Retrieve paginated collections using mixed pagination
916+
Given there are 5 fooDummy objects with fake names
917+
When I send the following GraphQL request:
918+
"""
919+
{
920+
fooDummies(page: 1) {
921+
collection {
922+
id
923+
name
924+
soManies(first: 2) {
925+
edges {
926+
node {
927+
content
928+
}
929+
cursor
930+
}
931+
pageInfo {
932+
startCursor
933+
endCursor
934+
hasNextPage
935+
hasPreviousPage
936+
}
937+
}
938+
}
939+
paginationInfo {
940+
itemsPerPage
941+
lastPage
942+
totalCount
943+
}
944+
}
945+
}
946+
"""
947+
Then the response status code should be 200
948+
And the response should be in JSON
949+
And the JSON node "data.fooDummies.collection" should have 3 elements
950+
And the JSON node "data.fooDummies.collection[2].id" should exist
951+
And the JSON node "data.fooDummies.collection[2].name" should exist
952+
And the JSON node "data.fooDummies.collection[2].soManies" should exist
953+
And the JSON node "data.fooDummies.collection[2].soManies.edges" should have 2 elements
954+
And the JSON node "data.fooDummies.collection[2].soManies.edges[1].node.content" should be equal to "So many 1"
955+
And the JSON node "data.fooDummies.collection[2].soManies.pageInfo.startCursor" should be equal to "MA=="
956+
And the JSON node "data.fooDummies.paginationInfo.itemsPerPage" should be equal to the number 3
957+
And the JSON node "data.fooDummies.paginationInfo.lastPage" should be equal to the number 2
958+
And the JSON node "data.fooDummies.paginationInfo.totalCount" should be equal to the number 5

features/main/default_order.feature

+31-5
Original file line numberDiff line numberDiff line change
@@ -79,35 +79,61 @@ Feature: Default order
7979
"@type": "FooDummy",
8080
"id": 5,
8181
"name": "Balbo",
82-
"dummy": "/dummies/5"
82+
"dummy": "/dummies/5",
83+
"soManies": [
84+
"/so_manies/13",
85+
"/so_manies/14",
86+
"/so_manies/15"
87+
]
88+
8389
},
8490
{
8591
"@id": "/foo_dummies/3",
8692
"@type": "FooDummy",
8793
"id": 3,
8894
"name": "Sthenelus",
89-
"dummy": "/dummies/3"
95+
"dummy": "/dummies/3",
96+
"soManies": [
97+
"/so_manies/7",
98+
"/so_manies/8",
99+
"/so_manies/9"
100+
]
90101
},
91102
{
92103
"@id": "/foo_dummies/2",
93104
"@type": "FooDummy",
94105
"id": 2,
95106
"name": "Ephesian",
96-
"dummy": "/dummies/2"
107+
"dummy": "/dummies/2",
108+
"soManies": [
109+
"/so_manies/4",
110+
"/so_manies/5",
111+
"/so_manies/6"
112+
]
97113
},
98114
{
99115
"@id": "/foo_dummies/1",
100116
"@type": "FooDummy",
101117
"id": 1,
102118
"name": "Hawsepipe",
103-
"dummy": "/dummies/1"
119+
"dummy": "/dummies/1",
120+
"soManies": [
121+
"/so_manies/1",
122+
"/so_manies/2",
123+
"/so_manies/3"
124+
]
104125
},
105126
{
106127
"@id": "/foo_dummies/4",
107128
"@type": "FooDummy",
108129
"id": 4,
109130
"name": "Separativeness",
110-
"dummy": "/dummies/4"
131+
"dummy": "/dummies/4",
132+
"soManies": [
133+
"/so_manies/10",
134+
"/so_manies/11",
135+
"/so_manies/12"
136+
]
111137
}
112138
],
113139
"hydra:totalItems": 5,

src/GraphQl/Type/FieldsBuilder.php

+35-40
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,10 @@
1717
use ApiPlatform\Exception\OperationNotFoundException;
1818
use ApiPlatform\GraphQl\Resolver\Factory\ResolverFactoryInterface;
1919
use ApiPlatform\GraphQl\Type\Definition\TypeInterface;
20+
use ApiPlatform\Metadata\Extractor\DynamicResourceExtractorInterface;
2021
use ApiPlatform\Metadata\GraphQl\Mutation;
2122
use ApiPlatform\Metadata\GraphQl\Operation;
22-
use ApiPlatform\Metadata\GraphQl\Query;
23-
use ApiPlatform\Metadata\GraphQl\QueryCollection;
2423
use ApiPlatform\Metadata\GraphQl\Subscription;
25-
use ApiPlatform\Metadata\Operation as AbstractOperation;
2624
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
2725
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
2826
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
@@ -47,7 +45,7 @@
4745
*/
4846
final class FieldsBuilder implements FieldsBuilderInterface
4947
{
50-
public function __construct(private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ResourceClassResolverInterface $resourceClassResolver, private readonly TypesContainerInterface $typesContainer, private readonly TypeBuilderInterface $typeBuilder, private readonly TypeConverterInterface $typeConverter, private readonly ResolverFactoryInterface $itemResolverFactory, private readonly ResolverFactoryInterface $collectionResolverFactory, private readonly ResolverFactoryInterface $itemMutationResolverFactory, private readonly ResolverFactoryInterface $itemSubscriptionResolverFactory, private readonly ContainerInterface $filterLocator, private readonly Pagination $pagination, private readonly ?NameConverterInterface $nameConverter, private readonly string $nestingSeparator)
48+
public function __construct(private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly DynamicResourceExtractorInterface $dynamicResourceExtractor, private readonly ResourceClassResolverInterface $resourceClassResolver, private readonly TypesContainerInterface $typesContainer, private readonly TypeBuilderInterface $typeBuilder, private readonly TypeConverterInterface $typeConverter, private readonly ResolverFactoryInterface $itemResolverFactory, private readonly ResolverFactoryInterface $collectionResolverFactory, private readonly ResolverFactoryInterface $itemMutationResolverFactory, private readonly ResolverFactoryInterface $itemSubscriptionResolverFactory, private readonly ContainerInterface $filterLocator, private readonly Pagination $pagination, private readonly ?NameConverterInterface $nameConverter, private readonly string $nestingSeparator)
5149
{
5250
}
5351

@@ -256,7 +254,25 @@ private function getResourceFieldConfiguration(?string $property, ?string $field
256254
$resourceClass = $type->getClassName();
257255
}
258256

259-
$graphqlType = $this->convertType($type, $input, $rootOperation, $resourceClass ?? '', $rootResource, $property, $depth, $forceNullable);
257+
$resourceOperation = $rootOperation;
258+
if ($resourceClass && $rootOperation->getClass() && $this->resourceClassResolver->isResourceClass($resourceClass) && $rootOperation->getClass() !== $resourceClass) {
259+
$resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($resourceClass);
260+
try {
261+
$resourceOperation = $resourceMetadataCollection->getOperation($isCollectionType ? 'collection_query' : 'item_query');
262+
} catch (OperationNotFoundException) {
263+
// If there is no query operation for a nested resource, use a dynamic resource to get one.
264+
$dynamicResourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($this->dynamicResourceExtractor->addResource($resourceClass));
265+
266+
$resourceOperation = $dynamicResourceMetadataCollection->getOperation($isCollectionType ? 'collection_query' : 'item_query')
267+
->withResource($resourceMetadataCollection[0]);
268+
}
269+
}
270+
271+
if (!$resourceOperation instanceof Operation) {
272+
throw new \LogicException('The resource operation should be a GraphQL operation.');
273+
}
274+
275+
$graphqlType = $this->convertType($type, $input, $resourceOperation, $rootOperation, $resourceClass ?? '', $rootResource, $property, $depth, $forceNullable);
260276

261277
$graphqlWrappedType = $graphqlType instanceof WrappingType ? $graphqlType->getWrappedType(true) : $graphqlType;
262278
$isStandardGraphqlType = \in_array($graphqlWrappedType, GraphQLType::getStandardTypes(), true);
@@ -271,43 +287,22 @@ private function getResourceFieldConfiguration(?string $property, ?string $field
271287

272288
$args = [];
273289

274-
$resolverOperation = $rootOperation;
275-
276-
if ($resourceClass && $this->resourceClassResolver->isResourceClass($resourceClass) && $rootOperation->getClass() !== $resourceClass) {
277-
$resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($resourceClass);
278-
$resolverOperation = $resourceMetadataCollection->getOperation(null, $isCollectionType);
279-
280-
if (!$resolverOperation instanceof Operation) {
281-
$resolverOperation = ($isCollectionType ? new QueryCollection() : new Query())->withOperation($resolverOperation);
282-
}
283-
}
284-
285290
if (!$input && !$rootOperation instanceof Mutation && !$rootOperation instanceof Subscription && !$isStandardGraphqlType && $isCollectionType) {
286-
if ($this->pagination->isGraphQlEnabled($rootOperation)) {
287-
$args = $this->getGraphQlPaginationArgs($rootOperation);
288-
}
289-
290-
// Find the collection operation to get filters, there might be a smarter way to do this
291-
$operation = null;
292-
if (!empty($resourceClass)) {
293-
$resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($resourceClass);
294-
try {
295-
$operation = $resourceMetadataCollection->getOperation(null, true);
296-
} catch (OperationNotFoundException) {
297-
}
291+
if ($this->pagination->isGraphQlEnabled($resourceOperation)) {
292+
$args = $this->getGraphQlPaginationArgs($resourceOperation);
298293
}
299294

300-
$args = $this->getFilterArgs($args, $resourceClass, $rootResource, $rootOperation, $property, $depth, $operation);
295+
$args = $this->getFilterArgs($args, $resourceClass, $rootResource, $resourceOperation, $rootOperation, $property, $depth);
301296
}
302297

303298
if ($isStandardGraphqlType || $input) {
304299
$resolve = null;
305300
} elseif (($rootOperation instanceof Mutation || $rootOperation instanceof Subscription) && $depth <= 0) {
306-
$resolve = $rootOperation instanceof Mutation ? ($this->itemMutationResolverFactory)($resourceClass, $rootResource, $resolverOperation) : ($this->itemSubscriptionResolverFactory)($resourceClass, $rootResource, $resolverOperation);
301+
$resolve = $rootOperation instanceof Mutation ? ($this->itemMutationResolverFactory)($resourceClass, $rootResource, $resourceOperation) : ($this->itemSubscriptionResolverFactory)($resourceClass, $rootResource, $resourceOperation);
307302
} elseif ($this->typeBuilder->isCollection($type)) {
308-
$resolve = ($this->collectionResolverFactory)($resourceClass, $rootResource, $resolverOperation);
303+
$resolve = ($this->collectionResolverFactory)($resourceClass, $rootResource, $resourceOperation);
309304
} else {
310-
$resolve = ($this->itemResolverFactory)($resourceClass, $rootResource, $resolverOperation);
305+
$resolve = ($this->itemResolverFactory)($resourceClass, $rootResource, $resourceOperation);
311306
}
312307

313308
return [
@@ -368,21 +363,21 @@ private function getGraphQlPaginationArgs(Operation $queryOperation): array
368363
return $args;
369364
}
370365

371-
private function getFilterArgs(array $args, ?string $resourceClass, string $rootResource, Operation $rootOperation, ?string $property, int $depth, ?AbstractOperation $operation = null): array
366+
private function getFilterArgs(array $args, ?string $resourceClass, string $rootResource, Operation $resourceOperation, Operation $rootOperation, ?string $property, int $depth): array
372367
{
373-
if (null === $operation || null === $resourceClass) {
368+
if (null === $resourceClass) {
374369
return $args;
375370
}
376371

377-
foreach ($operation->getFilters() ?? [] as $filterId) {
372+
foreach ($resourceOperation->getFilters() ?? [] as $filterId) {
378373
if (!$this->filterLocator->has($filterId)) {
379374
continue;
380375
}
381376

382377
foreach ($this->filterLocator->get($filterId)->getDescription($resourceClass) as $key => $value) {
383378
$nullable = isset($value['required']) ? !$value['required'] : true;
384379
$filterType = \in_array($value['type'], Type::$builtinTypes, true) ? new Type($value['type'], $nullable) : new Type('object', $nullable, $value['type']);
385-
$graphqlFilterType = $this->convertType($filterType, false, $rootOperation, $resourceClass, $rootResource, $property, $depth);
380+
$graphqlFilterType = $this->convertType($filterType, false, $resourceOperation, $rootOperation, $resourceClass, $rootResource, $property, $depth);
386381

387382
if (str_ends_with($key, '[]')) {
388383
$graphqlFilterType = GraphQLType::listOf($graphqlFilterType);
@@ -399,14 +394,14 @@ private function getFilterArgs(array $args, ?string $resourceClass, string $root
399394
array_walk_recursive($parsed, static function (&$value) use ($graphqlFilterType): void {
400395
$value = $graphqlFilterType;
401396
});
402-
$args = $this->mergeFilterArgs($args, $parsed, $operation, $key);
397+
$args = $this->mergeFilterArgs($args, $parsed, $resourceOperation, $key);
403398
}
404399
}
405400

406401
return $this->convertFilterArgsToTypes($args);
407402
}
408403

409-
private function mergeFilterArgs(array $args, array $parsed, ?AbstractOperation $operation = null, string $original = ''): array
404+
private function mergeFilterArgs(array $args, array $parsed, ?Operation $operation = null, string $original = ''): array
410405
{
411406
foreach ($parsed as $key => $value) {
412407
// Never override keys that cannot be merged
@@ -470,7 +465,7 @@ private function convertFilterArgsToTypes(array $args): array
470465
*
471466
* @throws InvalidTypeException
472467
*/
473-
private function convertType(Type $type, bool $input, Operation $rootOperation, string $resourceClass, string $rootResource, ?string $property, int $depth, bool $forceNullable = false): GraphQLType|ListOfType|NonNull
468+
private function convertType(Type $type, bool $input, Operation $resourceOperation, Operation $rootOperation, string $resourceClass, string $rootResource, ?string $property, int $depth, bool $forceNullable = false): GraphQLType|ListOfType|NonNull
474469
{
475470
$graphqlType = $this->typeConverter->convertType($type, $input, $rootOperation, $resourceClass, $rootResource, $property, $depth);
476471

@@ -487,7 +482,7 @@ private function convertType(Type $type, bool $input, Operation $rootOperation,
487482
}
488483

489484
if ($this->typeBuilder->isCollection($type)) {
490-
return $this->pagination->isGraphQlEnabled($rootOperation) && !$input ? $this->typeBuilder->getResourcePaginatedCollectionType($graphqlType, $resourceClass, $rootOperation) : GraphQLType::listOf($graphqlType);
485+
return $this->pagination->isGraphQlEnabled($resourceOperation) && !$input ? $this->typeBuilder->getResourcePaginatedCollectionType($graphqlType, $resourceOperation) : GraphQLType::listOf($graphqlType);
491486
}
492487

493488
return $forceNullable || !$graphqlType instanceof NullableType || $type->isNullable() || ($rootOperation instanceof Mutation && 'update' === $rootOperation->getName())

src/GraphQl/Type/TypeBuilder.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ public function getNodeInterface(): InterfaceType
210210
/**
211211
* {@inheritdoc}
212212
*/
213-
public function getResourcePaginatedCollectionType(GraphQLType $resourceType, string $resourceClass, Operation $operation): GraphQLType
213+
public function getResourcePaginatedCollectionType(GraphQLType $resourceType, Operation $operation): GraphQLType
214214
{
215215
$shortName = $resourceType->name;
216216
$paginationType = $this->pagination->getGraphQlPaginationType($operation);

src/GraphQl/Type/TypeBuilderInterface.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ public function getNodeInterface(): InterfaceType;
4343
/**
4444
* Gets the type of a paginated collection of the given resource type.
4545
*/
46-
public function getResourcePaginatedCollectionType(GraphQLType $resourceType, string $resourceClass, Operation $operation): GraphQLType;
46+
public function getResourcePaginatedCollectionType(GraphQLType $resourceType, Operation $operation): GraphQLType;
4747

4848
/**
4949
* Returns true if a type is a collection.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
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\Metadata\Extractor;
15+
16+
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
17+
18+
/**
19+
* Extracts a dynamic resource (used by GraphQL for nested resources).
20+
*
21+
* @author Alan Poulain <[email protected]>
22+
*/
23+
final class DynamicResourceExtractor implements DynamicResourceExtractorInterface
24+
{
25+
private array $dynamicResources = [];
26+
27+
/**
28+
* {@inheritdoc}
29+
*/
30+
public function getResources(): array
31+
{
32+
return $this->dynamicResources;
33+
}
34+
35+
public function addResource(string $resourceClass, array $config = []): string
36+
{
37+
$dynamicResourceName = $this->getDynamicResourceName($resourceClass);
38+
39+
$this->dynamicResources[$dynamicResourceName] = [
40+
array_merge(['class' => $resourceClass], $config),
41+
];
42+
43+
return $dynamicResourceName;
44+
}
45+
46+
private function getDynamicResourceName(string $resourceClass): string
47+
{
48+
return ResourceMetadataCollection::DYNAMIC_RESOURCE_CLASS_PREFIX.$resourceClass;
49+
}
50+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
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\Metadata\Extractor;
15+
16+
/**
17+
* Extracts a dynamic resource (used by GraphQL for nested resources).
18+
*
19+
* @author Alan Poulain <[email protected]>
20+
*/
21+
interface DynamicResourceExtractorInterface extends ResourceExtractorInterface
22+
{
23+
public function addResource(string $resourceClass, array $config = []): string;
24+
}

src/Metadata/Resource/Factory/AttributesResourceMetadataCollectionFactory.php

+4
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ public function create(string $resourceClass): ResourceMetadataCollection
6464
$resourceMetadataCollection = $this->decorated->create($resourceClass);
6565
}
6666

67+
if ($resourceMetadataCollection->isDynamic()) {
68+
return $resourceMetadataCollection;
69+
}
70+
6771
try {
6872
$reflectionClass = new \ReflectionClass($resourceClass);
6973
} catch (\ReflectionException) {

0 commit comments

Comments
 (0)