Skip to content

Commit

Permalink
Add page-based pagination to GraphQL (#3175)
Browse files Browse the repository at this point in the history
* Add page-based pagination to GraphQL

* Use page_parameter_name


Co-authored-by: Alan Poulain <contact@alanpoulain.eu>
  • Loading branch information
raoulclais and alanpoulain committed Nov 19, 2019
1 parent 313f4d0 commit bf8afd3
Show file tree
Hide file tree
Showing 12 changed files with 358 additions and 55 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -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

Expand Down
99 changes: 99 additions & 0 deletions features/graphql/collection.feature
Expand Up @@ -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
1 change: 1 addition & 0 deletions src/Bridge/Symfony/Bundle/Resources/config/graphql.xml
Expand Up @@ -127,6 +127,7 @@
<argument type="service" id="api_platform.graphql.types_container" />
<argument type="service" id="api_platform.graphql.resolver.resource_field" />
<argument type="service" id="api_platform.graphql.fields_builder_locator" />
<argument type="service" id="api_platform.pagination" />
</service>

<service id="api_platform.graphql.fields_builder" class="ApiPlatform\Core\GraphQl\Type\FieldsBuilder" public="false">
Expand Down
12 changes: 12 additions & 0 deletions src/DataProvider/Pagination.php
Expand Up @@ -196,6 +196,18 @@ public function isPartialEnabled(string $resourceClass = null, string $operation
return $this->getEnabled($context, $resourceClass, $operationName, true);
}

public function getOptions(): array
{
return $this->options;
}

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?
*/
Expand Down
40 changes: 35 additions & 5 deletions src/GraphQl/Resolver/Stage/SerializeStage.php
Expand Up @@ -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 [];
Expand Down Expand Up @@ -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);
}
}

Expand All @@ -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'];

Expand Down Expand Up @@ -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;
Expand All @@ -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];
Expand Down
67 changes: 48 additions & 19 deletions src/GraphQl/Type/FieldsBuilder.php
Expand Up @@ -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);
Expand Down Expand Up @@ -299,6 +282,50 @@ 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.',
],
];
}

$paginationOptions = $this->pagination->getOptions();

$args = [
$paginationOptions['page_parameter_name'] => [
'type' => GraphQLType::int(),
'description' => 'Returns the current page.',
],
];

if ($paginationOptions['client_items_per_page']) {
$args[$paginationOptions['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) {
Expand Down Expand Up @@ -418,7 +445,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)
Expand Down

0 comments on commit bf8afd3

Please sign in to comment.