Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: async pagination evaluate fix for queryset object #1354

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 22 additions & 30 deletions docs/docs/guides/response/pagination.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

**Django Ninja** comes with a pagination support. This allows you to split large result sets into individual pages.


To apply pagination to a function - just apply `paginate` decorator:

```python hl_lines="1 4"
Expand All @@ -14,7 +13,6 @@ def list_users(request):
return User.objects.all()
```


That's it!

Now you can query users with `limit` and `offset` GET parameters
Expand All @@ -25,7 +23,6 @@ Now you can query users with `limit` and `offset` GET parameters

by default limit is set to `100` (you can change it in your settings.py using `NINJA_PAGINATION_PER_PAGE`)


## Built in Pagination Classes

### LimitOffsetPagination (default)
Expand All @@ -42,17 +39,18 @@ def list_users(request):
```

Example query:

```
/api/users?limit=10&offset=0
```

this class has two input parameters:

- `limit` - defines a number of queryset on the page (default = 100, change in NINJA_PAGINATION_PER_PAGE)
- `offset` - set's the page window offset (default: 0, indexing starts with 0)

- `limit` - defines a number of queryset on the page (default = 100, change in NINJA_PAGINATION_PER_PAGE)
- `offset` - set's the page window offset (default: 0, indexing starts with 0)

### PageNumberPagination

```python hl_lines="1 4"
from ninja.pagination import paginate, PageNumberPagination

Expand All @@ -63,50 +61,46 @@ def list_users(request):
```

Example query:

```
/api/users?page=2
```

this class has one parameter `page` and outputs 100 queryset per page by default (can be changed with settings.py)
this class has two parameters `page` and `page_size`, outputs 100 queryset per page by default (can be changed with settings.py)

Page numbering start with 1

you can also set custom page_size value individually per view:
if you need to limit certain endpoint higher or lower your default config you can inherit
and customize `Input` class like this

```python hl_lines="2"
@api.get("/users")
@paginate(PageNumberPagination, page_size=50)
def list_users(...
class MyLargerPageNumberPagination(PageNumberPagination):
class Input(PageNumberPagination.Input):
page_size: int = Field(20, ge=1, le=1000)
```



## Accessing paginator parameters in view function

If you need access to `Input` parameters used for pagination in your view function - use `pass_parameter` argument

In that case input data will be available in `**kwargs`:

```python hl_lines="2 4"
@api.get("/someview")
@paginate(pass_parameter="pagination_info")
def someview(request, **kwargs):
page = kwargs["pagination_info"].page
return ...
class MyLargerPageNumberPagination(PageNumberPagination):
class Input(PageNumberPagination.Input):
page_size: int = Field(1000, ge=1, le=1000)
```


## Creating Custom Pagination Class

To create a custom pagination class you should subclass `ninja.pagination.PaginationBase` and override the `Input` and `Output` schema classes and `paginate_queryset(self, queryset, request, **params)` method:

- The `Input` schema is a Schema class that describes parameters that should be passed to your paginator (f.e. page-number or limit/offset values).
- The `Output` schema describes schema for page output (f.e. count/next-page/items/etc).
- The `paginate_queryset` method is passed the initial queryset and should return an iterable object that contains only the data in the requested page. This method accepts the following arguments:
- `queryset`: a queryset (or iterable) returned by the api function
- `pagination` - the paginator.Input parameters (parsed and validated)
- `**params`: kwargs that will contain all the arguments that decorated function received

- The `Input` schema is a Schema class that describes parameters that should be passed to your paginator (f.e. page-number or limit/offset values).
- The `Output` schema describes schema for page output (f.e. count/next-page/items/etc).
- The `paginate_queryset` method is passed the initial queryset and should return an iterable object that contains only the data in the requested page. This method accepts the following arguments:
- `queryset`: a queryset (or iterable) returned by the api function
- `pagination` - the paginator.Input parameters (parsed and validated)
- `**params`: kwargs that will contain all the arguments that decorated function received

Example:

Expand All @@ -119,7 +113,7 @@ class CustomPagination(PaginationBase):
# only `skip` param, defaults to 5 per page
class Input(Schema):
skip: int


class Output(Schema):
items: List[Any] # `items` is a default attribute
Expand Down Expand Up @@ -163,12 +157,11 @@ class CustomPagination(PaginationBase):
results: List[Any]
total: int
per_page: int

items_attribute: str = "results"

```


## Apply pagination to multiple operations at once

There is often a case when you need to add pagination to all views that returns querysets or list
Expand All @@ -195,7 +188,6 @@ In this example both operations will have pagination enabled

to apply pagination to main `api` instance use `default_router` argument:


```python
api = NinjaAPI(default_router=RouterPaginated())

Expand Down
1 change: 1 addition & 0 deletions ninja/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class Settings(BaseModel):
"ninja.pagination.LimitOffsetPagination", alias="NINJA_PAGINATION_CLASS"
)
PAGINATION_PER_PAGE: int = Field(100, alias="NINJA_PAGINATION_PER_PAGE")
PAGINATION_MAX_PER_PAGE: int = Field(100, alias="NINJA_PAGINATION_MAX_PER_PAGE")
PAGINATION_MAX_LIMIT: int = Field(inf, alias="NINJA_PAGINATION_MAX_LIMIT") # type: ignore

# Throttling
Expand Down
29 changes: 17 additions & 12 deletions ninja/pagination.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from math import inf
from typing import Any, AsyncGenerator, Callable, List, Optional, Tuple, Type, Union

from asgiref.sync import sync_to_async
from django.db.models import QuerySet
from django.http import HttpRequest
from django.utils.module_loading import import_string
Expand Down Expand Up @@ -80,9 +81,11 @@ class Input(Schema):
limit: int = Field(
settings.PAGINATION_PER_PAGE,
ge=1,
le=settings.PAGINATION_MAX_LIMIT
if settings.PAGINATION_MAX_LIMIT != inf
else None,
le=(
settings.PAGINATION_MAX_LIMIT
if settings.PAGINATION_MAX_LIMIT != inf
else None
),
)
offset: int = Field(0, ge=0)

Expand All @@ -108,19 +111,19 @@ async def apaginate_queryset(
offset = pagination.offset
limit: int = min(pagination.limit, settings.PAGINATION_MAX_LIMIT)
return {
"items": queryset[offset : offset + limit],
"items": await sync_to_async(list)(queryset[offset : offset + limit]),
"count": await self._aitems_count(queryset),
} # noqa: E203


class PageNumberPagination(AsyncPaginationBase):
class Input(Schema):
page: int = Field(1, ge=1)
page_size: int = Field(
settings.PAGINATION_PER_PAGE, ge=1, le=settings.PAGINATION_MAX_PER_PAGE
)

def __init__(
self, page_size: int = settings.PAGINATION_PER_PAGE, **kwargs: Any
) -> None:
self.page_size = page_size
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)

def paginate_queryset(
Expand All @@ -129,9 +132,9 @@ def paginate_queryset(
pagination: Input,
**params: Any,
) -> Any:
offset = (pagination.page - 1) * self.page_size
offset = (pagination.page - 1) * pagination.page_size
return {
"items": queryset[offset : offset + self.page_size],
"items": queryset[offset : offset + pagination.page_size],
"count": self._items_count(queryset),
} # noqa: E203

Expand All @@ -141,9 +144,11 @@ async def apaginate_queryset(
pagination: Input,
**params: Any,
) -> Any:
offset = (pagination.page - 1) * self.page_size
offset = (pagination.page - 1) * pagination.page_size
return {
"items": queryset[offset : offset + self.page_size],
"items": await sync_to_async(list)(
queryset[offset : offset + pagination.page_size]
),
"count": await self._aitems_count(queryset),
} # noqa: E203

Expand Down
63 changes: 50 additions & 13 deletions tests/test_pagination.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,19 @@

import pytest
from django.test import override_settings
from pydantic.errors import PydanticSchemaGenerationError

from ninja import NinjaAPI, Schema
from ninja.errors import ConfigError
from ninja.operation import Operation
from ninja.pagination import (
LimitOffsetPagination,
PageNumberPagination,
PaginationBase,
make_response_paginated,
PageNumberPagination,
paginate,
PaginationBase,
)
from ninja.testing import TestClient
from pydantic.errors import PydanticSchemaGenerationError

api = NinjaAPI()

Expand Down Expand Up @@ -119,19 +119,19 @@ def items_3(request, **kwargs):


@api.get("/items_4", response=List[int])
@paginate(PageNumberPagination, page_size=10)
@paginate(PageNumberPagination)
def items_4(request, **kwargs):
return ITEMS


@api.get("/items_5", response=List[int])
@paginate(PageNumberPagination, page_size=10)
@paginate(PageNumberPagination)
def items_5(request):
return ITEMS


@api.get("/items_6", response={101: int, 200: List[Any]})
@paginate(PageNumberPagination, page_size=10, pass_parameter="page_info")
@paginate(PageNumberPagination, pass_parameter="page_info")
def items_6(request, **kwargs):
return ITEMS + [kwargs["page_info"]]

Expand Down Expand Up @@ -244,7 +244,7 @@ def test_case3():


def test_case4():
response = client.get("/items_4?page=2").json()
response = client.get("/items_4?page=2&page_size=10").json()
assert response == {"items": ITEMS[10:20], "count": 100}

schema = api.get_openapi_schema()["paths"]["/api/items_4"]["get"]
Expand All @@ -260,12 +260,24 @@ def test_case4():
"type": "integer",
},
"required": False,
}
},
{
"in": "query",
"name": "page_size",
"schema": {
"default": 100,
"maximum": 100,
"minimum": 1,
"title": "Page Size",
"type": "integer",
},
"required": False,
},
]


def test_case5_no_kwargs():
response = client.get("/items_5?page=2").json()
response = client.get("/items_5?page=2&page_size=10").json()
assert response == {"items": ITEMS[10:20], "count": 100}

schema = api.get_openapi_schema()["paths"]["/api/items_5"]["get"]
Expand All @@ -281,14 +293,27 @@ def test_case5_no_kwargs():
"type": "integer",
},
"required": False,
}
},
{
"in": "query",
"name": "page_size",
"schema": {
"default": 100,
"maximum": 100,
"minimum": 1,
"title": "Page Size",
"type": "integer",
},
"required": False,
},
]


def test_case6_pass_param_kwargs():
page = 11
response = client.get(f"/items_6?page={page}").json()
assert response == {"items": [{"page": 11}], "count": 101}
page_size = 10
response = client.get(f"/items_6?page={page}&page_size={page_size}").json()
assert response == {"items": [{"page": page, "page_size": page_size}], "count": 101}

schema = api.get_openapi_schema()["paths"]["/api/items_6"]["get"]

Expand All @@ -303,7 +328,19 @@ def test_case6_pass_param_kwargs():
"type": "integer",
},
"required": False,
}
},
{
"in": "query",
"name": "page_size",
"schema": {
"default": 100,
"maximum": 100,
"minimum": 1,
"title": "Page Size",
"type": "integer",
},
"required": False,
},
]


Expand Down
14 changes: 9 additions & 5 deletions tests/test_pagination_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
from ninja.pagination import (
AsyncPaginationBase,
PageNumberPagination,
PaginationBase,
paginate,
PaginationBase,
)
from ninja.testing import TestAsyncClient

Expand Down Expand Up @@ -113,11 +113,15 @@ async def test_async_page_number():
api = NinjaAPI()

@api.get("/items_page_number", response=List[Any])
@paginate(PageNumberPagination, page_size=10, pass_parameter="page_info")
@paginate(PageNumberPagination, pass_parameter="page_info")
async def items_page_number(request, **kwargs):
return ITEMS + [kwargs["page_info"]]

client = TestAsyncClient(api)

response = await client.get("/items_page_number?page=11")
assert response.json() == {"items": [{"page": 11}], "count": 101}
page = 11
page_size = 10
response = await client.get(f"/items_page_number?page={page}&page_size={page_size}")
assert response.json() == {
"items": [{"page": page, "page_size": page_size}],
"count": 101,
}