From 4ceb0d0cb0167f3eda018ebb424f205077c9144f Mon Sep 17 00:00:00 2001 From: raoul clais Date: Tue, 15 Oct 2019 10:51:29 +0200 Subject: [PATCH 1/2] Add page-based pagination to GraphQL --- CHANGELOG.md | 1 + features/graphql/collection.feature | 99 +++++++++++++++++++ .../Bundle/Resources/config/graphql.xml | 1 + src/DataProvider/Pagination.php | 15 +++ src/GraphQl/Resolver/Stage/SerializeStage.php | 40 +++++++- src/GraphQl/Type/FieldsBuilder.php | 66 +++++++++---- src/GraphQl/Type/TypeBuilder.php | 76 ++++++++++---- src/GraphQl/Type/TypeBuilderInterface.php | 2 +- .../Fixtures/TestBundle/Document/FooDummy.php | 11 ++- tests/Fixtures/TestBundle/Entity/FooDummy.php | 11 ++- tests/GraphQl/Type/FieldsBuilderTest.php | 25 ++++- tests/GraphQl/Type/TypeBuilderTest.php | 68 ++++++++++++- 12 files changed, 360 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63a47239a6a..fc25a0d1eeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * MongoDB: Possibility to add execute options (aggregate command fields) for a resource, like `allowDiskUse` (#3144) * GraphQL: Allow to format GraphQL errors based on exceptions (#3063) +* GraphQL: Add page-based pagination (#3175) ## 2.5.2 diff --git a/features/graphql/collection.feature b/features/graphql/collection.feature index eb581455912..1080036b166 100644 --- a/features/graphql/collection.feature +++ b/features/graphql/collection.feature @@ -680,3 +680,102 @@ Feature: GraphQL collection support And the JSON node "data.dummyDifferentGraphQlSerializationGroups.edges[0].node.title" should not exist And the JSON node "data.dummyDifferentGraphQlSerializationGroups.edges[1].node.title" should not exist And the JSON node "data.dummyDifferentGraphQlSerializationGroups.edges[2].node.title" should not exist + + @createSchema + Scenario: Retrieve a paginated collection using page-based pagination + Given there are 5 fooDummy objects with fake names + When I send the following GraphQL request: + """ + { + fooDummies(page: 1) { + collection { + id + } + paginationInfo { + itemsPerPage + lastPage + totalCount + } + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the JSON node "data.fooDummies.collection" should have 3 elements + And the JSON node "data.fooDummies.collection[0].id" should exist + And the JSON node "data.fooDummies.collection[1].id" should exist + And the JSON node "data.fooDummies.collection[2].id" should exist + And the JSON node "data.fooDummies.paginationInfo.itemsPerPage" should be equal to the number 3 + And the JSON node "data.fooDummies.paginationInfo.lastPage" should be equal to the number 2 + And the JSON node "data.fooDummies.paginationInfo.totalCount" should be equal to the number 5 + When I send the following GraphQL request: + """ + { + fooDummies(page: 2) { + collection { + id + } + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the JSON node "data.fooDummies.collection" should have 2 elements + When I send the following GraphQL request: + """ + { + fooDummies(page: 3) { + collection { + id + } + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the JSON node "data.fooDummies.collection" should have 0 elements + + @createSchema + Scenario: Retrieve a paginated collection using page-based pagination and client-defined limit + Given there are 5 fooDummy objects with fake names + When I send the following GraphQL request: + """ + { + fooDummies(page: 1, itemsPerPage: 2) { + collection { + id + } + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the JSON node "data.fooDummies.collection" should have 2 elements + And the JSON node "data.fooDummies.collection[0].id" should exist + And the JSON node "data.fooDummies.collection[1].id" should exist + When I send the following GraphQL request: + """ + { + fooDummies(page: 2, itemsPerPage: 2) { + collection { + id + } + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the JSON node "data.fooDummies.collection" should have 2 elements + When I send the following GraphQL request: + """ + { + fooDummies(page: 3, itemsPerPage: 2) { + collection { + id + } + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the JSON node "data.fooDummies.collection" should have 1 element diff --git a/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml b/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml index 117b1ab8f87..ef5cc6d0590 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml @@ -127,6 +127,7 @@ + diff --git a/src/DataProvider/Pagination.php b/src/DataProvider/Pagination.php index 8a9bc41b9cd..482f526cd51 100644 --- a/src/DataProvider/Pagination.php +++ b/src/DataProvider/Pagination.php @@ -196,6 +196,21 @@ public function isPartialEnabled(string $resourceClass = null, string $operation return $this->getEnabled($context, $resourceClass, $operationName, true); } + public function getItemsPerPageOptions(): array + { + return array_intersect_key($this->options, array_flip([ + 'client_items_per_page', + 'items_per_page_parameter_name', + ])); + } + + public function getGraphQlPaginationType(string $resourceClass, string $operationName): string + { + $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + + return (string) $resourceMetadata->getGraphqlAttribute($operationName, 'paginationType', 'cursor', true); + } + /** * Is the classic or partial pagination enabled? */ diff --git a/src/GraphQl/Resolver/Stage/SerializeStage.php b/src/GraphQl/Resolver/Stage/SerializeStage.php index 18e72b51a8e..92cc52a7022 100644 --- a/src/GraphQl/Resolver/Stage/SerializeStage.php +++ b/src/GraphQl/Resolver/Stage/SerializeStage.php @@ -54,7 +54,9 @@ public function __invoke($itemOrCollection, string $resourceClass, string $opera if (!$resourceMetadata->getGraphqlAttribute($operationName, 'serialize', true, true)) { if ($isCollection) { if ($this->pagination->isGraphQlEnabled($resourceClass, $operationName, $context)) { - return $this->getDefaultPaginatedData(); + return 'cursor' === $this->pagination->getGraphQlPaginationType($resourceClass, $operationName) ? + $this->getDefaultCursorBasedPaginatedData() : + $this->getDefaultPageBasedPaginatedData(); } return []; @@ -87,7 +89,9 @@ public function __invoke($itemOrCollection, string $resourceClass, string $opera $data[$index] = $this->normalizer->normalize($object, ItemNormalizer::FORMAT, $normalizationContext); } } else { - $data = $this->serializePaginatedCollection($itemOrCollection, $normalizationContext, $context); + $data = 'cursor' === $this->pagination->getGraphQlPaginationType($resourceClass, $operationName) ? + $this->serializeCursorBasedPaginatedCollection($itemOrCollection, $normalizationContext, $context) : + $this->serializePageBasedPaginatedCollection($itemOrCollection, $normalizationContext); } } @@ -108,7 +112,7 @@ public function __invoke($itemOrCollection, string $resourceClass, string $opera * @throws \LogicException * @throws \UnexpectedValueException */ - private function serializePaginatedCollection(iterable $collection, array $normalizationContext, array $context): array + private function serializeCursorBasedPaginatedCollection(iterable $collection, array $normalizationContext, array $context): array { $args = $context['args']; @@ -138,7 +142,7 @@ private function serializePaginatedCollection(iterable $collection, array $norma } $offset = 0 > $offset ? 0 : $offset; - $data = $this->getDefaultPaginatedData(); + $data = $this->getDefaultCursorBasedPaginatedData(); if (($totalItems = $collection->getTotalItems()) > 0) { $data['totalCount'] = $totalItems; @@ -161,11 +165,37 @@ private function serializePaginatedCollection(iterable $collection, array $norma return $data; } - private function getDefaultPaginatedData(): array + /** + * @throws \LogicException + */ + private function serializePageBasedPaginatedCollection(iterable $collection, array $normalizationContext): array + { + if (!($collection instanceof PaginatorInterface)) { + throw new \LogicException(sprintf('Collection returned by the collection data provider must implement %s.', PaginatorInterface::class)); + } + + $data = $this->getDefaultPageBasedPaginatedData(); + $data['paginationInfo']['totalCount'] = $collection->getTotalItems(); + $data['paginationInfo']['lastPage'] = $collection->getLastPage(); + $data['paginationInfo']['itemsPerPage'] = $collection->getItemsPerPage(); + + foreach ($collection as $object) { + $data['collection'][] = $this->normalizer->normalize($object, ItemNormalizer::FORMAT, $normalizationContext); + } + + return $data; + } + + private function getDefaultCursorBasedPaginatedData(): array { return ['totalCount' => 0., 'edges' => [], 'pageInfo' => ['startCursor' => null, 'endCursor' => null, 'hasNextPage' => false, 'hasPreviousPage' => false]]; } + private function getDefaultPageBasedPaginatedData(): array + { + return ['collection' => [], 'paginationInfo' => ['itemsPerPage' => 0., 'totalCount' => 0., 'lastPage' => 0.]]; + } + private function getDefaultMutationData(array $context): array { return ['clientMutationId' => $context['args']['input']['clientMutationId'] ?? null]; diff --git a/src/GraphQl/Type/FieldsBuilder.php b/src/GraphQl/Type/FieldsBuilder.php index 64d0d47a75c..8ef66b563a3 100644 --- a/src/GraphQl/Type/FieldsBuilder.php +++ b/src/GraphQl/Type/FieldsBuilder.php @@ -254,24 +254,7 @@ private function getResourceFieldConfiguration(?string $property, ?string $field $args = []; if (!$input && null === $mutationName && !$isStandardGraphqlType && $this->typeBuilder->isCollection($type)) { if ($this->pagination->isGraphQlEnabled($resourceClass, $queryName)) { - $args = [ - 'first' => [ - 'type' => GraphQLType::int(), - 'description' => 'Returns the first n elements from the list.', - ], - 'last' => [ - 'type' => GraphQLType::int(), - 'description' => 'Returns the last n elements from the list.', - ], - 'before' => [ - 'type' => GraphQLType::string(), - 'description' => 'Returns the elements in the list that come before the specified cursor.', - ], - 'after' => [ - 'type' => GraphQLType::string(), - 'description' => 'Returns the elements in the list that come after the specified cursor.', - ], - ]; + $args = $this->getGraphQlPaginationArgs($resourceClass, $queryName); } $args = $this->getFilterArgs($args, $resourceClass, $resourceMetadata, $rootResource, $property, $queryName, $mutationName, $depth); @@ -299,6 +282,49 @@ private function getResourceFieldConfiguration(?string $property, ?string $field return null; } + private function getGraphQlPaginationArgs(string $resourceClass, string $queryName): array + { + $paginationType = $this->pagination->getGraphQlPaginationType($resourceClass, $queryName); + + if ('cursor' === $paginationType) { + return [ + 'first' => [ + 'type' => GraphQLType::int(), + 'description' => 'Returns the first n elements from the list.', + ], + 'last' => [ + 'type' => GraphQLType::int(), + 'description' => 'Returns the last n elements from the list.', + ], + 'before' => [ + 'type' => GraphQLType::string(), + 'description' => 'Returns the elements in the list that come before the specified cursor.', + ], + 'after' => [ + 'type' => GraphQLType::string(), + 'description' => 'Returns the elements in the list that come after the specified cursor.', + ], + ]; + } + + $args = [ + 'page' => [ + 'type' => GraphQLType::int(), + 'description' => 'Returns the current page.', + ], + ]; + + $itemsPerPageOptions = $this->pagination->getItemsPerPageOptions(); + if ($itemsPerPageOptions['client_items_per_page']) { + $args[$itemsPerPageOptions['items_per_page_parameter_name']] = [ + 'type' => GraphQLType::int(), + 'description' => 'Returns the number of items per page.', + ]; + } + + return $args; + } + private function getFilterArgs(array $args, ?string $resourceClass, ?ResourceMetadata $resourceMetadata, string $rootResource, ?string $property, ?string $queryName, ?string $mutationName, int $depth): array { if (null === $resourceMetadata || null === $resourceClass) { @@ -418,7 +444,9 @@ private function convertType(Type $type, bool $input, ?string $queryName, ?strin } if ($this->typeBuilder->isCollection($type)) { - return $this->pagination->isGraphQlEnabled($resourceClass, $queryName ?? $mutationName) && !$input ? $this->typeBuilder->getResourcePaginatedCollectionType($graphqlType) : GraphQLType::listOf($graphqlType); + $operationName = $queryName ?? $mutationName; + + return $this->pagination->isGraphQlEnabled($resourceClass, $operationName) && !$input ? $this->typeBuilder->getResourcePaginatedCollectionType($graphqlType, $resourceClass, $operationName) : GraphQLType::listOf($graphqlType); } return !$graphqlType instanceof NullableType || $type->isNullable() || (null !== $mutationName && 'update' === $mutationName) diff --git a/src/GraphQl/Type/TypeBuilder.php b/src/GraphQl/Type/TypeBuilder.php index 66b6b23fcc3..c4c3d87d65c 100644 --- a/src/GraphQl/Type/TypeBuilder.php +++ b/src/GraphQl/Type/TypeBuilder.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\GraphQl\Type; +use ApiPlatform\Core\DataProvider\Pagination; use ApiPlatform\Core\GraphQl\Serializer\ItemNormalizer; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use GraphQL\Type\Definition\InputObjectType; @@ -35,12 +36,14 @@ final class TypeBuilder implements TypeBuilderInterface private $typesContainer; private $defaultFieldResolver; private $fieldsBuilderLocator; + private $pagination; - public function __construct(TypesContainerInterface $typesContainer, callable $defaultFieldResolver, ContainerInterface $fieldsBuilderLocator) + public function __construct(TypesContainerInterface $typesContainer, callable $defaultFieldResolver, ContainerInterface $fieldsBuilderLocator, Pagination $pagination) { $this->typesContainer = $typesContainer; $this->defaultFieldResolver = $defaultFieldResolver; $this->fieldsBuilderLocator = $fieldsBuilderLocator; + $this->pagination = $pagination; } /** @@ -171,7 +174,7 @@ public function getNodeInterface(): InterfaceType /** * {@inheritdoc} */ - public function getResourcePaginatedCollectionType(GraphQLType $resourceType): GraphQLType + public function getResourcePaginatedCollectionType(GraphQLType $resourceType, string $resourceClass, string $operationName): GraphQLType { $shortName = $resourceType->name; @@ -179,6 +182,36 @@ public function getResourcePaginatedCollectionType(GraphQLType $resourceType): G return $this->typesContainer->get("{$shortName}Connection"); } + $paginationType = $this->pagination->getGraphQlPaginationType($resourceClass, $operationName); + + $fields = 'cursor' === $paginationType ? + $this->getCursorBasedPaginationFields($resourceType) : + $this->getPageBasedPaginationFields($resourceType); + + $configuration = [ + 'name' => "{$shortName}Connection", + 'description' => "Connection for $shortName.", + 'fields' => $fields, + ]; + + $resourcePaginatedCollectionType = new ObjectType($configuration); + $this->typesContainer->set("{$shortName}Connection", $resourcePaginatedCollectionType); + + return $resourcePaginatedCollectionType; + } + + /** + * {@inheritdoc} + */ + public function isCollection(Type $type): bool + { + return $type->isCollection() && Type::BUILTIN_TYPE_OBJECT === $type->getBuiltinType(); + } + + private function getCursorBasedPaginationFields(GraphQLType $resourceType): array + { + $shortName = $resourceType->name; + $edgeObjectTypeConfiguration = [ 'name' => "{$shortName}Edge", 'description' => "Edge of $shortName.", @@ -203,27 +236,32 @@ public function getResourcePaginatedCollectionType(GraphQLType $resourceType): G $pageInfoObjectType = new ObjectType($pageInfoObjectTypeConfiguration); $this->typesContainer->set("{$shortName}PageInfo", $pageInfoObjectType); - $configuration = [ - 'name' => "{$shortName}Connection", - 'description' => "Connection for $shortName.", + return [ + 'edges' => GraphQLType::listOf($edgeObjectType), + 'pageInfo' => GraphQLType::nonNull($pageInfoObjectType), + 'totalCount' => GraphQLType::nonNull(GraphQLType::int()), + ]; + } + + private function getPageBasedPaginationFields(GraphQLType $resourceType): array + { + $shortName = $resourceType->name; + + $paginationInfoObjectTypeConfiguration = [ + 'name' => "{$shortName}PaginationInfo", + 'description' => 'Information about the pagination.', 'fields' => [ - 'edges' => GraphQLType::listOf($edgeObjectType), - 'pageInfo' => GraphQLType::nonNull($pageInfoObjectType), + 'itemsPerPage' => GraphQLType::nonNull(GraphQLType::int()), + 'lastPage' => GraphQLType::nonNull(GraphQLType::int()), 'totalCount' => GraphQLType::nonNull(GraphQLType::int()), ], ]; + $paginationInfoObjectType = new ObjectType($paginationInfoObjectTypeConfiguration); + $this->typesContainer->set("{$shortName}PaginationInfo", $paginationInfoObjectType); - $resourcePaginatedCollectionType = new ObjectType($configuration); - $this->typesContainer->set("{$shortName}Connection", $resourcePaginatedCollectionType); - - return $resourcePaginatedCollectionType; - } - - /** - * {@inheritdoc} - */ - public function isCollection(Type $type): bool - { - return $type->isCollection() && Type::BUILTIN_TYPE_OBJECT === $type->getBuiltinType(); + return [ + 'collection' => GraphQLType::listOf($resourceType), + 'paginationInfo' => GraphQLType::nonNull($paginationInfoObjectType), + ]; } } diff --git a/src/GraphQl/Type/TypeBuilderInterface.php b/src/GraphQl/Type/TypeBuilderInterface.php index 138cf8bd3e0..b59c2093e5a 100644 --- a/src/GraphQl/Type/TypeBuilderInterface.php +++ b/src/GraphQl/Type/TypeBuilderInterface.php @@ -44,7 +44,7 @@ public function getNodeInterface(): InterfaceType; /** * Gets the type of a paginated collection of the given resource type. */ - public function getResourcePaginatedCollectionType(GraphQLType $resourceType): GraphQLType; + public function getResourcePaginatedCollectionType(GraphQLType $resourceType, string $resourceClass, string $operationName): GraphQLType; /** * Returns true if a type is a collection. diff --git a/tests/Fixtures/TestBundle/Document/FooDummy.php b/tests/Fixtures/TestBundle/Document/FooDummy.php index 2cb5ef90f57..90c9de2aec8 100644 --- a/tests/Fixtures/TestBundle/Document/FooDummy.php +++ b/tests/Fixtures/TestBundle/Document/FooDummy.php @@ -21,9 +21,14 @@ * * @author Vincent Chalamon * - * @ApiResource(attributes={ - * "order"={"dummy.name"} - * }) + * @ApiResource( + * attributes={ + * "order"={"dummy.name"} + * }, + * graphql={ + * "collection_query"={"paginationType"="page"} + * } + * ) * @ODM\Document */ class FooDummy diff --git a/tests/Fixtures/TestBundle/Entity/FooDummy.php b/tests/Fixtures/TestBundle/Entity/FooDummy.php index a06658a5707..1ec0a15a8cb 100644 --- a/tests/Fixtures/TestBundle/Entity/FooDummy.php +++ b/tests/Fixtures/TestBundle/Entity/FooDummy.php @@ -21,9 +21,14 @@ * * @author Vincent Chalamon * - * @ApiResource(attributes={ - * "order"={"dummy.name"} - * }) + * @ApiResource( + * attributes={ + * "order"={"dummy.name"} + * }, + * graphql={ + * "collection_query"={"paginationType"="page"} + * } + * ) * @ORM\Entity */ class FooDummy diff --git a/tests/GraphQl/Type/FieldsBuilderTest.php b/tests/GraphQl/Type/FieldsBuilderTest.php index 014113d45ed..5ae8f81fd29 100644 --- a/tests/GraphQl/Type/FieldsBuilderTest.php +++ b/tests/GraphQl/Type/FieldsBuilderTest.php @@ -206,7 +206,7 @@ public function testGetCollectionQueryFields(string $resourceClass, ResourceMeta $this->typeConverterProphecy->convertType(Argument::type(Type::class), false, $queryName, null, $resourceClass, $resourceClass, null, 0)->willReturn($graphqlType); $this->typeConverterProphecy->resolveType(Argument::type('string'))->willReturn(GraphQLType::string()); $this->typeBuilderProphecy->isCollection(Argument::type(Type::class))->willReturn(true); - $this->typeBuilderProphecy->getResourcePaginatedCollectionType($graphqlType)->willReturn($graphqlType); + $this->typeBuilderProphecy->getResourcePaginatedCollectionType($graphqlType, $resourceClass, $queryName)->willReturn($graphqlType); $this->resourceMetadataFactoryProphecy->create($resourceClass)->willReturn($resourceMetadata); $this->collectionResolverFactoryProphecy->__invoke($resourceClass, $resourceClass, $queryName)->willReturn($resolver); $this->filterLocatorProphecy->has('my_filter')->willReturn(true); @@ -325,6 +325,27 @@ public function collectionQueryFieldsProvider(): array ], ], ], + 'collection with page-based pagination enabled' => ['resourceClass', (new ResourceMetadata('ShortName', null, null, null, null, ['paginationType' => 'page']))->withGraphql(['action' => ['filters' => ['my_filter']]]), 'action', [], $graphqlType = GraphQLType::listOf(new ObjectType(['name' => 'collection'])), $resolver = function () { + }, + [ + 'actionShortNames' => [ + 'type' => $graphqlType, + 'description' => null, + 'args' => [ + 'page' => [ + 'type' => GraphQLType::int(), + 'description' => 'Returns the current page.', + ], + 'boolField' => $graphqlType, + 'boolField_list' => GraphQLType::listOf($graphqlType), + 'parent__child' => new InputObjectType(['name' => 'ShortNameFilter_parent__child', 'fields' => ['related__nested' => $graphqlType]]), + 'dateField' => new InputObjectType(['name' => 'ShortNameFilter_dateField', 'fields' => ['before' => $graphqlType]]), + ], + 'resolve' => $resolver, + 'deprecationReason' => '', + ], + ], + ], ]; } @@ -336,7 +357,7 @@ public function testGetMutationFields(string $resourceClass, ResourceMetadata $r $this->typeConverterProphecy->convertType(Argument::type(Type::class), false, null, $mutationName, $resourceClass, $resourceClass, null, 0)->willReturn($graphqlType); $this->typeConverterProphecy->convertType(Argument::type(Type::class), true, null, $mutationName, $resourceClass, $resourceClass, null, 0)->willReturn($graphqlType); $this->typeBuilderProphecy->isCollection(Argument::type(Type::class))->willReturn($isTypeCollection); - $this->typeBuilderProphecy->getResourcePaginatedCollectionType($graphqlType)->willReturn($graphqlType); + $this->typeBuilderProphecy->getResourcePaginatedCollectionType($graphqlType, $resourceClass, $mutationName)->willReturn($graphqlType); $this->resourceMetadataFactoryProphecy->create($resourceClass)->willReturn(new ResourceMetadata()); $this->collectionResolverFactoryProphecy->__invoke($resourceClass, $resourceClass, null)->willReturn($collectionResolver); $this->itemMutationResolverFactoryProphecy->__invoke($resourceClass, null, $mutationName)->willReturn($mutationResolver); diff --git a/tests/GraphQl/Type/TypeBuilderTest.php b/tests/GraphQl/Type/TypeBuilderTest.php index 557686991b8..4875ebf0905 100644 --- a/tests/GraphQl/Type/TypeBuilderTest.php +++ b/tests/GraphQl/Type/TypeBuilderTest.php @@ -13,10 +13,12 @@ namespace ApiPlatform\Core\Tests\GraphQl\Type; +use ApiPlatform\Core\DataProvider\Pagination; use ApiPlatform\Core\GraphQl\Serializer\ItemNormalizer; use ApiPlatform\Core\GraphQl\Type\FieldsBuilderInterface; use ApiPlatform\Core\GraphQl\Type\TypeBuilder; use ApiPlatform\Core\GraphQl\Type\TypesContainerInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; use GraphQL\Type\Definition\InputObjectType; @@ -46,6 +48,9 @@ class TypeBuilderTest extends TestCase /** @var ObjectProphecy */ private $fieldsBuilderLocatorProphecy; + /** @var ObjectProphecy */ + private $resourceMetadataFactoryProphecy; + /** @var TypeBuilder */ private $typeBuilder; @@ -58,7 +63,13 @@ protected function setUp(): void $this->defaultFieldResolver = function () { }; $this->fieldsBuilderLocatorProphecy = $this->prophesize(ContainerInterface::class); - $this->typeBuilder = new TypeBuilder($this->typesContainerProphecy->reveal(), $this->defaultFieldResolver, $this->fieldsBuilderLocatorProphecy->reveal()); + $this->resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $this->typeBuilder = new TypeBuilder( + $this->typesContainerProphecy->reveal(), + $this->defaultFieldResolver, + $this->fieldsBuilderLocatorProphecy->reveal(), + new Pagination($this->resourceMetadataFactoryProphecy->reveal()) + ); } public function testGetResourceObjectType(): void @@ -316,15 +327,24 @@ public function testGetNodeInterface(): void $this->assertSame(GraphQLType::string(), $resolvedType); } - public function testGetResourcePaginatedCollectionType(): void + public function testCursorBasedGetResourcePaginatedCollectionType(): void { $this->typesContainerProphecy->has('StringConnection')->shouldBeCalled()->willReturn(false); $this->typesContainerProphecy->set('StringConnection', Argument::type(ObjectType::class))->shouldBeCalled(); $this->typesContainerProphecy->set('StringEdge', Argument::type(ObjectType::class))->shouldBeCalled(); $this->typesContainerProphecy->set('StringPageInfo', Argument::type(ObjectType::class))->shouldBeCalled(); + $this->resourceMetadataFactoryProphecy->create('StringResourceClass')->shouldBeCalled()->willReturn(new ResourceMetadata( + null, + null, + null, + null, + null, + ['paginationType' => 'cursor'] + )); + /** @var ObjectType $resourcePaginatedCollectionType */ - $resourcePaginatedCollectionType = $this->typeBuilder->getResourcePaginatedCollectionType(GraphQLType::string()); + $resourcePaginatedCollectionType = $this->typeBuilder->getResourcePaginatedCollectionType(GraphQLType::string(), 'StringResourceClass', 'operationName'); $this->assertSame('StringConnection', $resourcePaginatedCollectionType->name); $this->assertSame('Connection for String.', $resourcePaginatedCollectionType->description); @@ -370,6 +390,48 @@ public function testGetResourcePaginatedCollectionType(): void $this->assertSame(GraphQLType::int(), $totalCountType->getWrappedType()); } + public function testPageBasedGetResourcePaginatedCollectionType(): void + { + $this->typesContainerProphecy->has('StringConnection')->shouldBeCalled()->willReturn(false); + $this->typesContainerProphecy->set('StringConnection', Argument::type(ObjectType::class))->shouldBeCalled(); + $this->typesContainerProphecy->set('StringPaginationInfo', Argument::type(ObjectType::class))->shouldBeCalled(); + + $this->resourceMetadataFactoryProphecy->create('StringResourceClass')->shouldBeCalled()->willReturn(new ResourceMetadata( + null, + null, + null, + null, + null, + ['paginationType' => 'page'] + )); + + /** @var ObjectType $resourcePaginatedCollectionType */ + $resourcePaginatedCollectionType = $this->typeBuilder->getResourcePaginatedCollectionType(GraphQLType::string(), 'StringResourceClass', 'operationName'); + $this->assertSame('StringConnection', $resourcePaginatedCollectionType->name); + $this->assertSame('Connection for String.', $resourcePaginatedCollectionType->description); + + $resourcePaginatedCollectionTypeFields = $resourcePaginatedCollectionType->getFields(); + $this->assertArrayHasKey('collection', $resourcePaginatedCollectionTypeFields); + $this->assertArrayHasKey('paginationInfo', $resourcePaginatedCollectionTypeFields); + + /** @var NonNull $paginationInfoType */ + $paginationInfoType = $resourcePaginatedCollectionTypeFields['paginationInfo']->getType(); + /** @var ObjectType $wrappedType */ + $wrappedType = $paginationInfoType->getWrappedType(); + $this->assertSame('StringPaginationInfo', $wrappedType->name); + $this->assertSame('Information about the pagination.', $wrappedType->description); + $paginationInfoObjectTypeFields = $wrappedType->getFields(); + $this->assertArrayHasKey('itemsPerPage', $paginationInfoObjectTypeFields); + $this->assertArrayHasKey('lastPage', $paginationInfoObjectTypeFields); + $this->assertArrayHasKey('totalCount', $paginationInfoObjectTypeFields); + $this->assertInstanceOf(NonNull::class, $paginationInfoObjectTypeFields['itemsPerPage']->getType()); + $this->assertSame(GraphQLType::int(), $paginationInfoObjectTypeFields['itemsPerPage']->getType()->getWrappedType()); + $this->assertInstanceOf(NonNull::class, $paginationInfoObjectTypeFields['lastPage']->getType()); + $this->assertSame(GraphQLType::int(), $paginationInfoObjectTypeFields['lastPage']->getType()->getWrappedType()); + $this->assertInstanceOf(NonNull::class, $paginationInfoObjectTypeFields['totalCount']->getType()); + $this->assertSame(GraphQLType::int(), $paginationInfoObjectTypeFields['totalCount']->getType()->getWrappedType()); + } + /** * @dataProvider typesProvider */ From 66539170388331ee81c4abd4a216cdb60736aa3f Mon Sep 17 00:00:00 2001 From: Alan Poulain Date: Tue, 19 Nov 2019 13:29:51 +0100 Subject: [PATCH 2/2] Use page_parameter_name --- src/DataProvider/Pagination.php | 7 ++----- src/GraphQl/Type/FieldsBuilder.php | 9 +++++---- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/DataProvider/Pagination.php b/src/DataProvider/Pagination.php index 482f526cd51..b91ea79bd0b 100644 --- a/src/DataProvider/Pagination.php +++ b/src/DataProvider/Pagination.php @@ -196,12 +196,9 @@ public function isPartialEnabled(string $resourceClass = null, string $operation return $this->getEnabled($context, $resourceClass, $operationName, true); } - public function getItemsPerPageOptions(): array + public function getOptions(): array { - return array_intersect_key($this->options, array_flip([ - 'client_items_per_page', - 'items_per_page_parameter_name', - ])); + return $this->options; } public function getGraphQlPaginationType(string $resourceClass, string $operationName): string diff --git a/src/GraphQl/Type/FieldsBuilder.php b/src/GraphQl/Type/FieldsBuilder.php index 8ef66b563a3..a18e24e654c 100644 --- a/src/GraphQl/Type/FieldsBuilder.php +++ b/src/GraphQl/Type/FieldsBuilder.php @@ -307,16 +307,17 @@ private function getGraphQlPaginationArgs(string $resourceClass, string $queryNa ]; } + $paginationOptions = $this->pagination->getOptions(); + $args = [ - 'page' => [ + $paginationOptions['page_parameter_name'] => [ 'type' => GraphQLType::int(), 'description' => 'Returns the current page.', ], ]; - $itemsPerPageOptions = $this->pagination->getItemsPerPageOptions(); - if ($itemsPerPageOptions['client_items_per_page']) { - $args[$itemsPerPageOptions['items_per_page_parameter_name']] = [ + if ($paginationOptions['client_items_per_page']) { + $args[$paginationOptions['items_per_page_parameter_name']] = [ 'type' => GraphQLType::int(), 'description' => 'Returns the number of items per page.', ];