Skip to content

Commit 82b702d

Browse files
jotweajosef.wagnersoyuka
authored
GraphQL: Nested Collections (#6038)
* feat(graphql): support nested collections * null safe operator --------- Co-authored-by: josef.wagner <[email protected]> Co-authored-by: Antoine Bluchet <[email protected]>
1 parent 670e7fb commit 82b702d

File tree

14 files changed

+269
-34
lines changed

14 files changed

+269
-34
lines changed

features/graphql/query.feature

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ Feature: GraphQL query support
2222

2323
@createSchema
2424
Scenario: Retrieve an item with different relations to the same resource
25-
Given there are 2 multiRelationsDummy objects having each a manyToOneRelation, 2 manyToManyRelations and 3 oneToManyRelations
25+
Given there are 2 multiRelationsDummy objects having each a manyToOneRelation, 2 manyToManyRelations, 3 oneToManyRelations and 4 embeddedRelations
2626
When I send the following GraphQL request:
2727
"""
2828
{
@@ -49,6 +49,16 @@ Feature: GraphQL query support
4949
}
5050
}
5151
}
52+
nestedCollection {
53+
name
54+
}
55+
nestedPaginatedCollection {
56+
edges{
57+
node {
58+
name
59+
}
60+
}
61+
}
5262
}
5363
}
5464
"""
@@ -67,6 +77,15 @@ Feature: GraphQL query support
6777
And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges[1].node.id" should not be null
6878
And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges[0].node.name" should match "#RelatedOneToManyDummy(1|3)2#"
6979
And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges[2].node.name" should match "#RelatedOneToManyDummy(1|3)2#"
80+
And the JSON node "data.multiRelationsDummy.nestedCollection[0].name" should be equal to "NestedDummy1"
81+
And the JSON node "data.multiRelationsDummy.nestedCollection[1].name" should be equal to "NestedDummy2"
82+
And the JSON node "data.multiRelationsDummy.nestedCollection[2].name" should be equal to "NestedDummy3"
83+
And the JSON node "data.multiRelationsDummy.nestedCollection[3].name" should be equal to "NestedDummy4"
84+
And the JSON node "data.multiRelationsDummy.nestedPaginatedCollection.edges" should have 4 element
85+
And the JSON node "data.multiRelationsDummy.nestedPaginatedCollection.edges[0].node.name" should be equal to "NestedPaginatedDummy1"
86+
And the JSON node "data.multiRelationsDummy.nestedPaginatedCollection.edges[1].node.name" should be equal to "NestedPaginatedDummy2"
87+
And the JSON node "data.multiRelationsDummy.nestedPaginatedCollection.edges[2].node.name" should be equal to "NestedPaginatedDummy3"
88+
And the JSON node "data.multiRelationsDummy.nestedPaginatedCollection.edges[3].node.name" should be equal to "NestedPaginatedDummy4"
7089

7190
@createSchema @!mongodb
7291
Scenario: Retrieve an item with child relation to the same resource

src/GraphQl/Resolver/Factory/CollectionResolverFactory.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919
use ApiPlatform\GraphQl\Resolver\Stage\SecurityStageInterface;
2020
use ApiPlatform\GraphQl\Resolver\Stage\SerializeStageInterface;
2121
use ApiPlatform\Metadata\GraphQl\Operation;
22+
use ApiPlatform\Metadata\GraphQl\Query;
2223
use ApiPlatform\Metadata\Util\CloneTrait;
24+
use ApiPlatform\State\Pagination\ArrayPaginator;
2325
use GraphQL\Type\Definition\ResolveInfo;
2426
use Psr\Container\ContainerInterface;
2527

@@ -52,6 +54,10 @@ public function __invoke(string $resourceClass = null, string $rootClass = null,
5254

5355
$resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => true, 'is_mutation' => false, 'is_subscription' => false];
5456

57+
if ($operation instanceof Query && $operation->getNested() && !$operation->getResolver() && !$operation->getProvider() && $source && \array_key_exists($info->fieldName, $source)) {
58+
return ($this->serializeStage)(new ArrayPaginator($source[$info->fieldName], 0, \count($source[$info->fieldName])), $resourceClass, $operation, $resolverContext);
59+
}
60+
5561
$collection = ($this->readStage)($resourceClass, $rootClass, $operation, $resolverContext);
5662
if (!is_iterable($collection)) {
5763
throw new \LogicException('Collection from read stage should be iterable.');

src/GraphQl/Resolver/Factory/ResolverFactory.php

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use ApiPlatform\Metadata\GraphQl\Mutation;
1717
use ApiPlatform\Metadata\GraphQl\Operation;
1818
use ApiPlatform\Metadata\GraphQl\Query;
19+
use ApiPlatform\State\Pagination\ArrayPaginator;
1920
use ApiPlatform\State\ProcessorInterface;
2021
use ApiPlatform\State\ProviderInterface;
2122
use GraphQL\Type\Definition\ResolveInfo;
@@ -33,6 +34,11 @@ public function __invoke(string $resourceClass = null, string $rootClass = null,
3334
return function (?array $source, array $args, $context, ResolveInfo $info) use ($resourceClass, $rootClass, $operation) {
3435
// Data already fetched and normalized (field or nested resource)
3536
if ($body = $source[$info->fieldName] ?? null) {
37+
// special treatment for nested resources without a resolver/provider
38+
if ($operation instanceof Query && $operation->getNested() && !$operation->getResolver() && !$operation->getProvider()) {
39+
return $this->resolve($source, $args, $info, $rootClass, $operation, new ArrayPaginator($body, 0, \count($body)));
40+
}
41+
3642
return $body;
3743
}
3844

@@ -45,23 +51,28 @@ public function __invoke(string $resourceClass = null, string $rootClass = null,
4551
return null;
4652
}
4753

48-
// Handles relay nodes
49-
$operation ??= new Query();
54+
return $this->resolve($source, $args, $info, $rootClass, $operation, null);
55+
};
56+
}
57+
58+
private function resolve(?array $source, array $args, ResolveInfo $info, string $rootClass = null, Operation $operation = null, mixed $body)
59+
{
60+
// Handles relay nodes
61+
$operation ??= new Query();
5062

51-
$graphQlContext = [];
52-
$context = ['source' => $source, 'args' => $args, 'info' => $info, 'root_class' => $rootClass, 'graphql_context' => &$graphQlContext];
63+
$graphQlContext = [];
64+
$context = ['source' => $source, 'args' => $args, 'info' => $info, 'root_class' => $rootClass, 'graphql_context' => &$graphQlContext];
5365

54-
if (null === $operation->canValidate()) {
55-
$operation = $operation->withValidate($operation instanceof Mutation);
56-
}
66+
if (null === $operation->canValidate()) {
67+
$operation = $operation->withValidate($operation instanceof Mutation);
68+
}
5769

58-
$body = $this->provider->provide($operation, [], $context);
70+
$body ??= $this->provider->provide($operation, [], $context);
5971

60-
if (null === $operation->canWrite()) {
61-
$operation = $operation->withWrite($operation instanceof Mutation && null !== $body);
62-
}
72+
if (null === $operation->canWrite()) {
73+
$operation = $operation->withWrite($operation instanceof Mutation && null !== $body);
74+
}
6375

64-
return $this->processor->process($body, $operation, [], $context);
65-
};
76+
return $this->processor->process($body, $operation, [], $context);
6677
}
6778
}

src/GraphQl/Serializer/ItemNormalizer.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
namespace ApiPlatform\GraphQl\Serializer;
1515

1616
use ApiPlatform\Metadata\ApiProperty;
17+
use ApiPlatform\Metadata\GraphQl\Query;
1718
use ApiPlatform\Metadata\IdentifiersExtractorInterface;
1819
use ApiPlatform\Metadata\IriConverterInterface;
1920
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
@@ -110,6 +111,12 @@ public function normalize(mixed $object, string $format = null, array $context =
110111
*/
111112
protected function normalizeCollectionOfRelations(ApiProperty $propertyMetadata, iterable $attributeValue, string $resourceClass, ?string $format, array $context): array
112113
{
114+
// check for nested collection
115+
$operation = $this?->resourceMetadataCollectionFactory->create($resourceClass)->getOperation(forceCollection: true, forceGraphQl: true);
116+
if ($operation && $operation instanceof Query && $operation->getNested() && !$operation->getResolver() && !$operation->getProvider()) {
117+
return [...$attributeValue];
118+
}
119+
113120
// to-many are handled directly by the GraphQL resolver
114121
return [];
115122
}

src/GraphQl/Tests/Resolver/Factory/ResolverFactoryTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ public function testGraphQlResolver(string $resourceClass = null, string $rootCl
4545
$this->assertEquals($resolverFactory->__invoke($resourceClass, $rootClass, $operation)(['test' => null], [], [], $resolveInfo), $returnValue);
4646
}
4747

48-
public function graphQlQueries(): array
48+
public static function graphQlQueries(): array
4949
{
5050
return [
5151
['Dummy', 'Dummy', new Query()],

src/GraphQl/Tests/State/Processor/NormalizeProcessorTest.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ public function testProcess($body, $operation): void
4141
$processor->process($body, $operation, [], $context);
4242
}
4343

44-
public function processItems(): array
44+
public static function processItems(): array
4545
{
4646
return [
4747
[new \stdClass(), new Query(class: 'foo')],
@@ -68,7 +68,7 @@ public function testProcessCollection($body, $operation): void
6868
$processor->process($body, $operation, [], $context);
6969
}
7070

71-
public function processCollection(): array
71+
public static function processCollection(): array
7272
{
7373
return [
7474
[new ArrayPaginator([new \stdClass()], 0, 1), new QueryCollection(class: 'foo')],

src/Metadata/Resource/ResourceMetadataCollection.php

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ public function __construct(private readonly string $resourceClass, array $input
3535
parent::__construct($input);
3636
}
3737

38-
public function getOperation(string $operationName = null, bool $forceCollection = false, bool $httpOperation = false): Operation
38+
public function getOperation(string $operationName = null, bool $forceCollection = false, bool $httpOperation = false, bool $forceGraphQl = false): Operation
3939
{
4040
$operationName ??= '';
4141
$cachePrefix = ($forceCollection ? self::FORCE_COLLECTION : '').($httpOperation ? self::HTTP_OPERATION : '');
@@ -56,20 +56,22 @@ public function getOperation(string $operationName = null, bool $forceCollection
5656
/** @var ApiResource $metadata */
5757
$metadata = $it->current();
5858

59-
foreach ($metadata->getOperations() ?? [] as $name => $operation) {
60-
$isCollection = $operation instanceof CollectionOperationInterface;
61-
$method = $operation->getMethod() ?? 'GET';
62-
$isGetOperation = 'GET' === $method || 'OPTIONS' === $method || 'HEAD' === $method;
63-
if ('' === $operationName && $isGetOperation && ($forceCollection ? $isCollection : !$isCollection)) {
64-
return $this->operationCache[$httpCacheKey] = $operation;
65-
}
66-
67-
if ($name === $operationName) {
68-
return $this->operationCache[$httpCacheKey] = $operation;
69-
}
70-
71-
if ($operation->getUriTemplate() === $operationName) {
72-
return $this->operationCache[$httpCacheKey] = $operation;
59+
if (!$forceGraphQl) {
60+
foreach ($metadata->getOperations() ?? [] as $name => $operation) {
61+
$isCollection = $operation instanceof CollectionOperationInterface;
62+
$method = $operation->getMethod() ?? 'GET';
63+
$isGetOperation = 'GET' === $method || 'OPTIONS' === $method || 'HEAD' === $method;
64+
if ('' === $operationName && $isGetOperation && ($forceCollection ? $isCollection : !$isCollection)) {
65+
return $this->operationCache[$httpCacheKey] = $operation;
66+
}
67+
68+
if ($name === $operationName) {
69+
return $this->operationCache[$httpCacheKey] = $operation;
70+
}
71+
72+
if ($operation->getUriTemplate() === $operationName) {
73+
return $this->operationCache[$httpCacheKey] = $operation;
74+
}
7375
}
7476
}
7577

tests/Behat/DoctrineContext.php

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@
6666
use ApiPlatform\Tests\Fixtures\TestBundle\Document\LinkHandledDummy as LinkHandledDummyDocument;
6767
use ApiPlatform\Tests\Fixtures\TestBundle\Document\MaxDepthDummy as MaxDepthDummyDocument;
6868
use ApiPlatform\Tests\Fixtures\TestBundle\Document\MultiRelationsDummy as MultiRelationsDummyDocument;
69+
use ApiPlatform\Tests\Fixtures\TestBundle\Document\MultiRelationsNested as MultiRelationsNestedDocument;
70+
use ApiPlatform\Tests\Fixtures\TestBundle\Document\MultiRelationsNestedPaginated as MultiRelationsNestedPaginatedDocument;
6971
use ApiPlatform\Tests\Fixtures\TestBundle\Document\MultiRelationsRelatedDummy as MultiRelationsRelatedDummyDocument;
7072
use ApiPlatform\Tests\Fixtures\TestBundle\Document\MusicGroup as MusicGroupDocument;
7173
use ApiPlatform\Tests\Fixtures\TestBundle\Document\NetworkPathDummy as NetworkPathDummyDocument;
@@ -157,6 +159,8 @@
157159
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\LinkHandledDummy;
158160
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MaxDepthDummy;
159161
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsDummy;
162+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsNested;
163+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsNestedPaginated;
160164
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsRelatedDummy;
161165
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MusicGroup;
162166
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\NetworkPathDummy;
@@ -801,9 +805,9 @@ public function thereAreDummyObjectsWithRelatedDummies(int $nb, int $nbrelated):
801805
}
802806

803807
/**
804-
* @Given there are :nb multiRelationsDummy objects having each a manyToOneRelation, :nbmtmr manyToManyRelations and :nbotmr oneToManyRelations
808+
* @Given there are :nb multiRelationsDummy objects having each a manyToOneRelation, :nbmtmr manyToManyRelations, :nbotmr oneToManyRelations and :nber embeddedRelations
805809
*/
806-
public function thereAreMultiRelationsDummyObjectsHavingEachAManyToOneRelationManyToManyRelationsAndOneToManyRelations(int $nb, int $nbmtmr, int $nbotmr): void
810+
public function thereAreMultiRelationsDummyObjectsHavingEachAManyToOneRelationManyToManyRelationsOneToManyRelationsAndEmbeddedRelations(int $nb, int $nbmtmr, int $nbotmr, int $nber): void
807811
{
808812
for ($i = 1; $i <= $nb; ++$i) {
809813
$relatedDummy = $this->buildMultiRelationsRelatedDummy();
@@ -830,6 +834,22 @@ public function thereAreMultiRelationsDummyObjectsHavingEachAManyToOneRelationMa
830834
$dummy->addOneToManyRelation($oneToManyItem);
831835
}
832836

837+
$nested = new ArrayCollection();
838+
for ($j = 1; $j <= $nber; ++$j) {
839+
$embeddedItem = $this->buildMultiRelationsNested();
840+
$embeddedItem->name = 'NestedDummy'.$j;
841+
$nested->add($embeddedItem);
842+
}
843+
$dummy->setNestedCollection($nested);
844+
845+
$nestedPaginated = new ArrayCollection();
846+
for ($j = 1; $j <= $nber; ++$j) {
847+
$embeddedItem = $this->buildMultiRelationsNestedPaginated();
848+
$embeddedItem->name = 'NestedPaginatedDummy'.$j;
849+
$nestedPaginated->add($embeddedItem);
850+
}
851+
$dummy->setNestedPaginatedCollection($nestedPaginated);
852+
833853
$this->manager->persist($relatedDummy);
834854
$this->manager->persist($dummy);
835855
}
@@ -2599,6 +2619,16 @@ private function buildMultiRelationsRelatedDummy(): MultiRelationsRelatedDummy|M
25992619
return $this->isOrm() ? new MultiRelationsRelatedDummy() : new MultiRelationsRelatedDummyDocument();
26002620
}
26012621

2622+
private function buildMultiRelationsNested(): MultiRelationsNested|MultiRelationsNestedDocument
2623+
{
2624+
return $this->isOrm() ? new MultiRelationsNested() : new MultiRelationsNestedDocument();
2625+
}
2626+
2627+
private function buildMultiRelationsNestedPaginated(): MultiRelationsNestedPaginated|MultiRelationsNestedPaginatedDocument
2628+
{
2629+
return $this->isOrm() ? new MultiRelationsNestedPaginated() : new MultiRelationsNestedPaginatedDocument();
2630+
}
2631+
26022632
private function buildMusicGroup(): MusicGroup|MusicGroupDocument
26032633
{
26042634
return $this->isOrm() ? new MusicGroup() : new MusicGroupDocument();

tests/Fixtures/TestBundle/Document/MultiRelationsDummy.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,20 @@ class MultiRelationsDummy
4646
#[ODM\ReferenceMany(targetDocument: MultiRelationsRelatedDummy::class, mappedBy: 'oneToManyRelation', storeAs: 'id')]
4747
public Collection $oneToManyRelations;
4848

49+
/** @var array<MultiRelationsNested> */
50+
#[ODM\EmbedMany]
51+
private array $nestedCollection;
52+
53+
/** @var array<MultiRelationsNestedPaginated> */
54+
#[ODM\EmbedMany]
55+
private array $nestedPaginatedCollection;
56+
4957
public function __construct()
5058
{
5159
$this->manyToManyRelations = new ArrayCollection();
5260
$this->oneToManyRelations = new ArrayCollection();
61+
$this->nestedCollection = [];
62+
$this->nestedPaginatedCollection = [];
5363
}
5464

5565
public function getId(): ?int
@@ -76,4 +86,28 @@ public function addOneToManyRelation(MultiRelationsRelatedDummy $relatedMultiUse
7686
{
7787
$this->oneToManyRelations->add($relatedMultiUsedDummy);
7888
}
89+
90+
public function getNestedCollection(): Collection
91+
{
92+
return new ArrayCollection($this->nestedCollection);
93+
}
94+
95+
public function setNestedCollection(Collection $nestedCollection): self
96+
{
97+
$this->nestedCollection = $nestedCollection->toArray();
98+
99+
return $this;
100+
}
101+
102+
public function getNestedPaginatedCollection(): Collection
103+
{
104+
return new ArrayCollection($this->nestedPaginatedCollection);
105+
}
106+
107+
public function setNestedPaginatedCollection(Collection $nestedPaginatedCollection): self
108+
{
109+
$this->nestedPaginatedCollection = $nestedPaginatedCollection->toArray();
110+
111+
return $this;
112+
}
79113
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
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\Tests\Fixtures\TestBundle\Document;
15+
16+
use ApiPlatform\Metadata\ApiResource;
17+
use ApiPlatform\Metadata\GraphQl\QueryCollection;
18+
19+
#[ApiResource(graphQlOperations: [new QueryCollection(paginationEnabled: false, nested: true)])]
20+
class MultiRelationsNested
21+
{
22+
public ?string $name;
23+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
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\Tests\Fixtures\TestBundle\Document;
15+
16+
use ApiPlatform\Metadata\ApiResource;
17+
use ApiPlatform\Metadata\GraphQl\QueryCollection;
18+
19+
#[ApiResource(graphQlOperations: [new QueryCollection(nested: true)])]
20+
class MultiRelationsNestedPaginated
21+
{
22+
public ?string $name;
23+
}

0 commit comments

Comments
 (0)