diff --git a/docs/guide/pagination.md b/docs/guide/pagination.md index 339c199a..7851d7a7 100644 --- a/docs/guide/pagination.md +++ b/docs/guide/pagination.md @@ -12,20 +12,190 @@ An interface for limit/offset pagination can be use for basic pagination needs: @strawberry_django.type(models.Fruit, pagination=True) class Fruit: name: auto + + +@strawberry.type +class Query: + fruits: list[Fruit] = strawberry_django.field() +``` + +Would produce the following schema: + +```graphql title="schema.graphql" +type Fruit { + name: String! +} + +type Query { + fruits(pagination: PaginationInput): [Fruit!]! +} ``` +And can be queried like: + ```graphql title="schema.graphql" query { fruits(pagination: { offset: 0, limit: 2 }) { name - color } } ``` -There is not default limit defined. All elements are returned if no pagination limit is defined. +The `pagination` argument can be given to the type, which will enforce the pagination +argument every time the field is annotated as a list, but you can also give it directly +to the field for more control, like: + +```python title="types.py" +@strawberry_django.type(models.Fruit) +class Fruit: + name: auto + + +@strawberry.type +class Query: + fruits: list[Fruit] = strawberry_django.field(pagination=True) +``` + +Which will produce the exact same schema. + +> [!NOTE] +> There is no default limit defined. All elements are returned if no pagination limit is defined. + +## Paginated Generic + +For more complex pagination needs, you can use the `Paginated` generic, which alongside +the `pagination` argument, will wrap the results in an object that contains the results +and the pagination information, together with the `totalCount` of elements excluding pagination. + +```python title="types.py" +from strawberry_django.pagination import Paginated + + +@strawberry_django.type(models.Fruit) +class Fruit: + name: auto + + +@strawberry.type +class Query: + fruits: Paginated[Fruit] = strawberry_django.field() +``` + +Would produce the following schema: + +```graphql title="schema.graphql" +type Fruit { + name: String! +} + +type PaginatedInfo { + limit: Int! + offset: Int! +} + +type FruitPaginated { + pageInfo: PaginatedInfo! + totalCount: Int! + results: [Fruit]! +} + +type Query { + fruits(pagination: PaginationInput): [FruitPaginated!]! +} +``` + +Which can be queried like: + +```graphql title="schema.graphql" +query { + fruits(pagination: { offset: 0, limit: 2 }) { + totalCount + pageInfo { + limit + offset + } + results { + name + } + } +} +``` + +### Customizing the pagination + +Like other generics, `Paginated` can be customized to modify its behavior or to +add extra functionality in it. For example, suppose we want to add the average +price of the fruits in the pagination: + +```python title="types.py" +from strawberry_django.pagination import Paginated + + +@strawberry_django.type(models.Fruit) +class Fruit: + name: auto + price: auto + + +@strawberry.type +class FruitPaginated(Paginated[Fruit]): + @strawberry_django.field + def average_price(self) -> Decimal: + if self.queryset is None: + return Decimal(0) + + return self.queryset.aggregate(Avg("price"))["price__avg"] + + @strawberry_django.field + def paginated_average_price(self) -> Decimal: + paginated_queryset = self.get_paginated_queryset() + if paginated_queryset is None: + return Decimal(0) + + return paginated_queryset.aggregate(Avg("price"))["price__avg"] + + +@strawberry.type +class Query: + fruits: FruitPaginated = strawberry_django.field() +``` + +Would produce the following schema: + +```graphql title="schema.graphql" +type Fruit { + name: String! +} + +type PaginatedInfo { + limit: Int! + offset: Int! +} + +type FruitPaginated { + pageInfo: PaginatedInfo! + totalCount: Int! + results: [Fruit]! + averagePrice: Decimal! + paginatedAveragePrice: Decimal! +} + +type Query { + fruits(pagination: PaginationInput): [FruitPaginated!]! +} +``` + +The following attributes/methods can be accessed in the `Paginated` class: + +- `queryset`: The queryset original queryset with any filters/ordering applied, + but not paginated yet +- `pagination`: The `OffsetPaginationInput` object, with the `offset` and `limit` for pagination +- `get_total_count()`: Returns the total count of elements in the queryset without pagination +- `get_paginated_queryset()`: Returns the queryset with pagination applied -## Relay pagination +## Cursor pagination (aka Relay style pagination) -For more complex scenarios, a cursor pagination would be better. For this, -use the [relay integration](./relay.md) to define those. +Another option for pagination is to use a +[relay style cursor pagination](https://graphql.org/learn/pagination). For this, +you can leverage the [relay integration](./relay.md) provided by strawberry +to create a relay connection. diff --git a/strawberry_django/pagination.py b/strawberry_django/pagination.py index 34145afa..2a8e9961 100644 --- a/strawberry_django/pagination.py +++ b/strawberry_django/pagination.py @@ -30,41 +30,58 @@ class OffsetPaginationInput: limit: int = DEFAULT_LIMIT +@strawberry.type +class PaginatedInfo: + limit: int + offset: int + + @strawberry.type class Paginated(Generic[NodeType]): queryset: strawberry.Private[Optional[QuerySet]] pagination: strawberry.Private[OffsetPaginationInput] @strawberry.field - def limit(self) -> int: - return self.pagination.limit - - @strawberry.field - def offset(self) -> int: - return self.pagination.limit + def page_info(self) -> PaginatedInfo: + return PaginatedInfo( + limit=self.pagination.limit, + offset=self.pagination.offset, + ) @strawberry.field(description="Total count of existing results.") @django_resolver - def total_count(self, root) -> int: - if self.queryset is None: - return 0 - - return get_total_count(self.queryset) + def total_count(self) -> int: + return self.get_total_count() @strawberry.field(description="List of paginated results.") @django_resolver def results(self) -> list[NodeType]: + paginated_queryset = self.get_paginated_queryset() + + return cast( + list[NodeType], paginated_queryset if paginated_queryset is not None else [] + ) + + def get_total_count(self) -> int: + """Retrieve tht total count of the queryset without pagination.""" + return get_total_count(self.queryset) if self.queryset is not None else 0 + + def get_paginated_queryset(self) -> Optional[QuerySet]: + """Retrieve the queryset with pagination applied. + + This will apply the paginated arguments to the queryset and return it. + To use the original queryset, access `.queryset` directly. + """ from strawberry_django.optimizer import is_optimized_by_prefetching if self.queryset is None: - return [] - - if is_optimized_by_prefetching(self.queryset): - results = self.queryset._result_cache # type: ignore - else: - results = apply(self.pagination, self.queryset) + return None - return cast(list[NodeType], results) + return ( + self.queryset._result_cache # type: ignore + if is_optimized_by_prefetching(self.queryset) + else apply(self.pagination, self.queryset) + ) def apply( diff --git a/tests/projects/snapshots/schema.gql b/tests/projects/snapshots/schema.gql index 2fc46b1c..e6c8f5d6 100644 --- a/tests/projects/snapshots/schema.gql +++ b/tests/projects/snapshots/schema.gql @@ -337,8 +337,7 @@ type IssueTypeEdge { } type IssueTypePaginated { - limit: Int! - offset: Int! + pageInfo: PaginatedInfo! """Total count of existing results.""" totalCount: Int! @@ -522,6 +521,11 @@ type PageInfo { endCursor: String } +type PaginatedInfo { + limit: Int! + offset: Int! +} + type ProjectConnection { """Pagination data for this connection""" pageInfo: PageInfo! diff --git a/tests/projects/snapshots/schema_with_inheritance.gql b/tests/projects/snapshots/schema_with_inheritance.gql index 2dcf1e1a..a0b0eca9 100644 --- a/tests/projects/snapshots/schema_with_inheritance.gql +++ b/tests/projects/snapshots/schema_with_inheritance.gql @@ -137,8 +137,7 @@ type IssueTypeEdge { } type IssueTypePaginated { - limit: Int! - offset: Int! + pageInfo: PaginatedInfo! """Total count of existing results.""" totalCount: Int! @@ -300,6 +299,11 @@ type PageInfo { endCursor: String } +type PaginatedInfo { + limit: Int! + offset: Int! +} + input ProjectOrder { id: Ordering name: Ordering diff --git a/tests/test_paginated_type.py b/tests/test_paginated_type.py index 08641485..c88d494f 100644 --- a/tests/test_paginated_type.py +++ b/tests/test_paginated_type.py @@ -8,7 +8,7 @@ from tests import models -def test_pagination_schema(): +def test_paginated_schema(): @strawberry_django.type(models.Fruit) class Fruit: id: int @@ -35,8 +35,7 @@ class Query: } type ColorPaginated { - limit: Int! - offset: Int! + pageInfo: PaginatedInfo! """Total count of existing results.""" totalCount: Int! @@ -51,8 +50,7 @@ class Query: } type FruitPaginated { - limit: Int! - offset: Int! + pageInfo: PaginatedInfo! """Total count of existing results.""" totalCount: Int! @@ -66,6 +64,11 @@ class Query: limit: Int! = -1 } + type PaginatedInfo { + limit: Int! + offset: Int! + } + type Query { fruits(pagination: OffsetPaginationInput): FruitPaginated! colors(pagination: OffsetPaginationInput): ColorPaginated!