From 69fadf2926d26e7b4a135fa2cac1e6cc6748eb9e Mon Sep 17 00:00:00 2001 From: Wellington Date: Fri, 6 Dec 2024 23:16:05 -0300 Subject: [PATCH 1/2] fix: async pagination evaluate fix for queryset object --- ninja/conf.py | 1 + ninja/pagination.py | 29 +++++++++------- tests/test_pagination.py | 63 +++++++++++++++++++++++++++------- tests/test_pagination_async.py | 14 +++++--- 4 files changed, 77 insertions(+), 30 deletions(-) diff --git a/ninja/conf.py b/ninja/conf.py index 1ae570e2d..7ae7bc0ca 100644 --- a/ninja/conf.py +++ b/ninja/conf.py @@ -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 diff --git a/ninja/pagination.py b/ninja/pagination.py index 48ad1e21f..b2e2300bd 100644 --- a/ninja/pagination.py +++ b/ninja/pagination.py @@ -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 @@ -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) @@ -108,7 +111,7 @@ 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 @@ -116,11 +119,11 @@ async def apaginate_queryset( 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( @@ -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 @@ -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 diff --git a/tests/test_pagination.py b/tests/test_pagination.py index 9cc11b33a..30b2e0b70 100644 --- a/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -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() @@ -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"]] @@ -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"] @@ -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"] @@ -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"] @@ -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, + }, ] diff --git a/tests/test_pagination_async.py b/tests/test_pagination_async.py index 4c5679c9f..2bb8cc2e2 100644 --- a/tests/test_pagination_async.py +++ b/tests/test_pagination_async.py @@ -9,8 +9,8 @@ from ninja.pagination import ( AsyncPaginationBase, PageNumberPagination, - PaginationBase, paginate, + PaginationBase, ) from ninja.testing import TestAsyncClient @@ -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, + } From 46704021857d1b358553a51bdf175d22c6db96e6 Mon Sep 17 00:00:00 2001 From: Wellington Date: Fri, 6 Dec 2024 23:56:10 -0300 Subject: [PATCH 2/2] docs: adjust pagination docs --- docs/docs/guides/response/pagination.md | 52 +++++++++++-------------- 1 file changed, 22 insertions(+), 30 deletions(-) diff --git a/docs/docs/guides/response/pagination.md b/docs/docs/guides/response/pagination.md index 1e79181fc..8691ecde9 100644 --- a/docs/docs/guides/response/pagination.md +++ b/docs/docs/guides/response/pagination.md @@ -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" @@ -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 @@ -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) @@ -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 @@ -63,24 +61,24 @@ 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 @@ -88,25 +86,21 @@ If you need access to `Input` parameters used for pagination in your view functi 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: @@ -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 @@ -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 @@ -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())