Skip to content

Commit

Permalink
GraphQL: Nested Collections (api-platform#6038)
Browse files Browse the repository at this point in the history
* feat(graphql): support nested collections

* null safe operator

---------

Co-authored-by: josef.wagner <[email protected]>
Co-authored-by: Antoine Bluchet <[email protected]>
  • Loading branch information
3 people authored and priyadi committed Jan 3, 2024
1 parent 5cae9c1 commit 8149daf
Show file tree
Hide file tree
Showing 14 changed files with 269 additions and 34 deletions.
21 changes: 20 additions & 1 deletion features/graphql/query.feature
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Feature: GraphQL query support

@createSchema
Scenario: Retrieve an item with different relations to the same resource
Given there are 2 multiRelationsDummy objects having each a manyToOneRelation, 2 manyToManyRelations and 3 oneToManyRelations
Given there are 2 multiRelationsDummy objects having each a manyToOneRelation, 2 manyToManyRelations, 3 oneToManyRelations and 4 embeddedRelations
When I send the following GraphQL request:
"""
{
Expand All @@ -49,6 +49,16 @@ Feature: GraphQL query support
}
}
}
nestedCollection {
name
}
nestedPaginatedCollection {
edges{
node {
name
}
}
}
}
}
"""
Expand All @@ -67,6 +77,15 @@ Feature: GraphQL query support
And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges[1].node.id" should not be null
And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges[0].node.name" should match "#RelatedOneToManyDummy(1|3)2#"
And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges[2].node.name" should match "#RelatedOneToManyDummy(1|3)2#"
And the JSON node "data.multiRelationsDummy.nestedCollection[0].name" should be equal to "NestedDummy1"
And the JSON node "data.multiRelationsDummy.nestedCollection[1].name" should be equal to "NestedDummy2"
And the JSON node "data.multiRelationsDummy.nestedCollection[2].name" should be equal to "NestedDummy3"
And the JSON node "data.multiRelationsDummy.nestedCollection[3].name" should be equal to "NestedDummy4"
And the JSON node "data.multiRelationsDummy.nestedPaginatedCollection.edges" should have 4 element
And the JSON node "data.multiRelationsDummy.nestedPaginatedCollection.edges[0].node.name" should be equal to "NestedPaginatedDummy1"
And the JSON node "data.multiRelationsDummy.nestedPaginatedCollection.edges[1].node.name" should be equal to "NestedPaginatedDummy2"
And the JSON node "data.multiRelationsDummy.nestedPaginatedCollection.edges[2].node.name" should be equal to "NestedPaginatedDummy3"
And the JSON node "data.multiRelationsDummy.nestedPaginatedCollection.edges[3].node.name" should be equal to "NestedPaginatedDummy4"

@createSchema @!mongodb
Scenario: Retrieve an item with child relation to the same resource
Expand Down
6 changes: 6 additions & 0 deletions src/GraphQl/Resolver/Factory/CollectionResolverFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
use ApiPlatform\GraphQl\Resolver\Stage\SecurityStageInterface;
use ApiPlatform\GraphQl\Resolver\Stage\SerializeStageInterface;
use ApiPlatform\Metadata\GraphQl\Operation;
use ApiPlatform\Metadata\GraphQl\Query;
use ApiPlatform\Metadata\Util\CloneTrait;
use ApiPlatform\State\Pagination\ArrayPaginator;
use GraphQL\Type\Definition\ResolveInfo;
use Psr\Container\ContainerInterface;

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

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

if ($operation instanceof Query && $operation->getNested() && !$operation->getResolver() && !$operation->getProvider() && $source && \array_key_exists($info->fieldName, $source)) {
return ($this->serializeStage)(new ArrayPaginator($source[$info->fieldName], 0, \count($source[$info->fieldName])), $resourceClass, $operation, $resolverContext);
}

$collection = ($this->readStage)($resourceClass, $rootClass, $operation, $resolverContext);
if (!is_iterable($collection)) {
throw new \LogicException('Collection from read stage should be iterable.');
Expand Down
37 changes: 24 additions & 13 deletions src/GraphQl/Resolver/Factory/ResolverFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use ApiPlatform\Metadata\GraphQl\Mutation;
use ApiPlatform\Metadata\GraphQl\Operation;
use ApiPlatform\Metadata\GraphQl\Query;
use ApiPlatform\State\Pagination\ArrayPaginator;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\State\ProviderInterface;
use GraphQL\Type\Definition\ResolveInfo;
Expand All @@ -33,6 +34,11 @@ public function __invoke(string $resourceClass = null, string $rootClass = null,
return function (?array $source, array $args, $context, ResolveInfo $info) use ($resourceClass, $rootClass, $operation) {
// Data already fetched and normalized (field or nested resource)
if ($body = $source[$info->fieldName] ?? null) {
// special treatment for nested resources without a resolver/provider
if ($operation instanceof Query && $operation->getNested() && !$operation->getResolver() && !$operation->getProvider()) {
return $this->resolve($source, $args, $info, $rootClass, $operation, new ArrayPaginator($body, 0, \count($body)));
}

return $body;
}

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

// Handles relay nodes
$operation ??= new Query();
return $this->resolve($source, $args, $info, $rootClass, $operation, null);
};
}

private function resolve(?array $source, array $args, ResolveInfo $info, string $rootClass = null, Operation $operation = null, mixed $body)
{
// Handles relay nodes
$operation ??= new Query();

$graphQlContext = [];
$context = ['source' => $source, 'args' => $args, 'info' => $info, 'root_class' => $rootClass, 'graphql_context' => &$graphQlContext];
$graphQlContext = [];
$context = ['source' => $source, 'args' => $args, 'info' => $info, 'root_class' => $rootClass, 'graphql_context' => &$graphQlContext];

if (null === $operation->canValidate()) {
$operation = $operation->withValidate($operation instanceof Mutation);
}
if (null === $operation->canValidate()) {
$operation = $operation->withValidate($operation instanceof Mutation);
}

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

if (null === $operation->canWrite()) {
$operation = $operation->withWrite($operation instanceof Mutation && null !== $body);
}
if (null === $operation->canWrite()) {
$operation = $operation->withWrite($operation instanceof Mutation && null !== $body);
}

return $this->processor->process($body, $operation, [], $context);
};
return $this->processor->process($body, $operation, [], $context);
}
}
7 changes: 7 additions & 0 deletions src/GraphQl/Serializer/ItemNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
namespace ApiPlatform\GraphQl\Serializer;

use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\GraphQl\Query;
use ApiPlatform\Metadata\IdentifiersExtractorInterface;
use ApiPlatform\Metadata\IriConverterInterface;
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
Expand Down Expand Up @@ -110,6 +111,12 @@ public function normalize(mixed $object, string $format = null, array $context =
*/
protected function normalizeCollectionOfRelations(ApiProperty $propertyMetadata, iterable $attributeValue, string $resourceClass, ?string $format, array $context): array
{
// check for nested collection
$operation = $this?->resourceMetadataCollectionFactory->create($resourceClass)->getOperation(forceCollection: true, forceGraphQl: true);
if ($operation && $operation instanceof Query && $operation->getNested() && !$operation->getResolver() && !$operation->getProvider()) {
return [...$attributeValue];
}

// to-many are handled directly by the GraphQL resolver
return [];
}
Expand Down
2 changes: 1 addition & 1 deletion src/GraphQl/Tests/Resolver/Factory/ResolverFactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public function testGraphQlResolver(string $resourceClass = null, string $rootCl
$this->assertEquals($resolverFactory->__invoke($resourceClass, $rootClass, $operation)(['test' => null], [], [], $resolveInfo), $returnValue);
}

public function graphQlQueries(): array
public static function graphQlQueries(): array
{
return [
['Dummy', 'Dummy', new Query()],
Expand Down
4 changes: 2 additions & 2 deletions src/GraphQl/Tests/State/Processor/NormalizeProcessorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public function testProcess($body, $operation): void
$processor->process($body, $operation, [], $context);
}

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

public function processCollection(): array
public static function processCollection(): array
{
return [
[new ArrayPaginator([new \stdClass()], 0, 1), new QueryCollection(class: 'foo')],
Expand Down
32 changes: 17 additions & 15 deletions src/Metadata/Resource/ResourceMetadataCollection.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public function __construct(private readonly string $resourceClass, array $input
parent::__construct($input);
}

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

foreach ($metadata->getOperations() ?? [] as $name => $operation) {
$isCollection = $operation instanceof CollectionOperationInterface;
$method = $operation->getMethod() ?? 'GET';
$isGetOperation = 'GET' === $method || 'OPTIONS' === $method || 'HEAD' === $method;
if ('' === $operationName && $isGetOperation && ($forceCollection ? $isCollection : !$isCollection)) {
return $this->operationCache[$httpCacheKey] = $operation;
}

if ($name === $operationName) {
return $this->operationCache[$httpCacheKey] = $operation;
}

if ($operation->getUriTemplate() === $operationName) {
return $this->operationCache[$httpCacheKey] = $operation;
if (!$forceGraphQl) {
foreach ($metadata->getOperations() ?? [] as $name => $operation) {
$isCollection = $operation instanceof CollectionOperationInterface;
$method = $operation->getMethod() ?? 'GET';
$isGetOperation = 'GET' === $method || 'OPTIONS' === $method || 'HEAD' === $method;
if ('' === $operationName && $isGetOperation && ($forceCollection ? $isCollection : !$isCollection)) {
return $this->operationCache[$httpCacheKey] = $operation;
}

if ($name === $operationName) {
return $this->operationCache[$httpCacheKey] = $operation;
}

if ($operation->getUriTemplate() === $operationName) {
return $this->operationCache[$httpCacheKey] = $operation;
}
}
}

Expand Down
34 changes: 32 additions & 2 deletions tests/Behat/DoctrineContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@
use ApiPlatform\Tests\Fixtures\TestBundle\Document\LinkHandledDummy as LinkHandledDummyDocument;
use ApiPlatform\Tests\Fixtures\TestBundle\Document\MaxDepthDummy as MaxDepthDummyDocument;
use ApiPlatform\Tests\Fixtures\TestBundle\Document\MultiRelationsDummy as MultiRelationsDummyDocument;
use ApiPlatform\Tests\Fixtures\TestBundle\Document\MultiRelationsNested as MultiRelationsNestedDocument;
use ApiPlatform\Tests\Fixtures\TestBundle\Document\MultiRelationsNestedPaginated as MultiRelationsNestedPaginatedDocument;
use ApiPlatform\Tests\Fixtures\TestBundle\Document\MultiRelationsRelatedDummy as MultiRelationsRelatedDummyDocument;
use ApiPlatform\Tests\Fixtures\TestBundle\Document\MusicGroup as MusicGroupDocument;
use ApiPlatform\Tests\Fixtures\TestBundle\Document\NetworkPathDummy as NetworkPathDummyDocument;
Expand Down Expand Up @@ -157,6 +159,8 @@
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\LinkHandledDummy;
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MaxDepthDummy;
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsDummy;
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsNested;
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsNestedPaginated;
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsRelatedDummy;
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MusicGroup;
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\NetworkPathDummy;
Expand Down Expand Up @@ -801,9 +805,9 @@ public function thereAreDummyObjectsWithRelatedDummies(int $nb, int $nbrelated):
}

/**
* @Given there are :nb multiRelationsDummy objects having each a manyToOneRelation, :nbmtmr manyToManyRelations and :nbotmr oneToManyRelations
* @Given there are :nb multiRelationsDummy objects having each a manyToOneRelation, :nbmtmr manyToManyRelations, :nbotmr oneToManyRelations and :nber embeddedRelations
*/
public function thereAreMultiRelationsDummyObjectsHavingEachAManyToOneRelationManyToManyRelationsAndOneToManyRelations(int $nb, int $nbmtmr, int $nbotmr): void
public function thereAreMultiRelationsDummyObjectsHavingEachAManyToOneRelationManyToManyRelationsOneToManyRelationsAndEmbeddedRelations(int $nb, int $nbmtmr, int $nbotmr, int $nber): void
{
for ($i = 1; $i <= $nb; ++$i) {
$relatedDummy = $this->buildMultiRelationsRelatedDummy();
Expand All @@ -830,6 +834,22 @@ public function thereAreMultiRelationsDummyObjectsHavingEachAManyToOneRelationMa
$dummy->addOneToManyRelation($oneToManyItem);
}

$nested = new ArrayCollection();
for ($j = 1; $j <= $nber; ++$j) {
$embeddedItem = $this->buildMultiRelationsNested();
$embeddedItem->name = 'NestedDummy'.$j;
$nested->add($embeddedItem);
}
$dummy->setNestedCollection($nested);

$nestedPaginated = new ArrayCollection();
for ($j = 1; $j <= $nber; ++$j) {
$embeddedItem = $this->buildMultiRelationsNestedPaginated();
$embeddedItem->name = 'NestedPaginatedDummy'.$j;
$nestedPaginated->add($embeddedItem);
}
$dummy->setNestedPaginatedCollection($nestedPaginated);

$this->manager->persist($relatedDummy);
$this->manager->persist($dummy);
}
Expand Down Expand Up @@ -2599,6 +2619,16 @@ private function buildMultiRelationsRelatedDummy(): MultiRelationsRelatedDummy|M
return $this->isOrm() ? new MultiRelationsRelatedDummy() : new MultiRelationsRelatedDummyDocument();
}

private function buildMultiRelationsNested(): MultiRelationsNested|MultiRelationsNestedDocument
{
return $this->isOrm() ? new MultiRelationsNested() : new MultiRelationsNestedDocument();
}

private function buildMultiRelationsNestedPaginated(): MultiRelationsNestedPaginated|MultiRelationsNestedPaginatedDocument
{
return $this->isOrm() ? new MultiRelationsNestedPaginated() : new MultiRelationsNestedPaginatedDocument();
}

private function buildMusicGroup(): MusicGroup|MusicGroupDocument
{
return $this->isOrm() ? new MusicGroup() : new MusicGroupDocument();
Expand Down
34 changes: 34 additions & 0 deletions tests/Fixtures/TestBundle/Document/MultiRelationsDummy.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,20 @@ class MultiRelationsDummy
#[ODM\ReferenceMany(targetDocument: MultiRelationsRelatedDummy::class, mappedBy: 'oneToManyRelation', storeAs: 'id')]
public Collection $oneToManyRelations;

/** @var array<MultiRelationsNested> */
#[ODM\EmbedMany]
private array $nestedCollection;

/** @var array<MultiRelationsNestedPaginated> */
#[ODM\EmbedMany]
private array $nestedPaginatedCollection;

public function __construct()
{
$this->manyToManyRelations = new ArrayCollection();
$this->oneToManyRelations = new ArrayCollection();
$this->nestedCollection = [];
$this->nestedPaginatedCollection = [];
}

public function getId(): ?int
Expand All @@ -76,4 +86,28 @@ public function addOneToManyRelation(MultiRelationsRelatedDummy $relatedMultiUse
{
$this->oneToManyRelations->add($relatedMultiUsedDummy);
}

public function getNestedCollection(): Collection
{
return new ArrayCollection($this->nestedCollection);
}

public function setNestedCollection(Collection $nestedCollection): self
{
$this->nestedCollection = $nestedCollection->toArray();

return $this;
}

public function getNestedPaginatedCollection(): Collection
{
return new ArrayCollection($this->nestedPaginatedCollection);
}

public function setNestedPaginatedCollection(Collection $nestedPaginatedCollection): self
{
$this->nestedPaginatedCollection = $nestedPaginatedCollection->toArray();

return $this;
}
}
23 changes: 23 additions & 0 deletions tests/Fixtures/TestBundle/Document/MultiRelationsNested.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Tests\Fixtures\TestBundle\Document;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GraphQl\QueryCollection;

#[ApiResource(graphQlOperations: [new QueryCollection(paginationEnabled: false, nested: true)])]
class MultiRelationsNested
{
public ?string $name;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Tests\Fixtures\TestBundle\Document;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GraphQl\QueryCollection;

#[ApiResource(graphQlOperations: [new QueryCollection(nested: true)])]
class MultiRelationsNestedPaginated
{
public ?string $name;
}
Loading

0 comments on commit 8149daf

Please sign in to comment.