From 58a7c475914142469517c179218bd95763e0b353 Mon Sep 17 00:00:00 2001 From: Hadrien David Date: Sun, 2 Aug 2026 08:58:48 -0400 Subject: [PATCH] feat: rename pagination default page-size option --- README.md | 20 +++-- docs/troubleshooting.md | 2 +- .../skills/fastsqla-pagination/SKILL.md | 39 +++++----- src/fastsqla.py | 52 ++++++++++++- tests/integration/test_pagination.py | 20 ++++- tests/unit/test_new_pagination.py | 75 +++++++++++++++++++ 6 files changed, 178 insertions(+), 30 deletions(-) create mode 100644 tests/unit/test_new_pagination.py diff --git a/README.md b/README.md index 940a733..884836d 100644 --- a/README.md +++ b/README.md @@ -106,17 +106,23 @@ following [`SQLAlchemy`'s best practices](https://docs.sqlalchemy.org/en/20/orm/ ``` * Pagination customization: + ```python - ... - from fastapi import Page, new_pagination - ... + from typing import Annotated - Paginate = new_pagination(min_page_size=5, max_page_size=500) + from fastapi import Depends + from fastsqla import Page, PaginateType, new_pagination - @app.get("/heros", response_model=Page[HeroModel]) - async def get_heros(paginate:Paginate): - return paginate(select(Hero)) + CustomPaginate = Annotated[ + PaginateType[HeroModel], + Depends(new_pagination(default_page_size=5, max_page_size=500)), + ] + + @app.get("/heroes", response_model=Page[HeroModel]) + async def get_heroes(paginate: CustomPaginate): + return await paginate(select(Hero)) ``` + * Session lifecycle management: session is commited on request success or rollback on failure. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index a4da0c8..d9bed9e 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -118,7 +118,7 @@ from fastsqla import PaginateType, new_pagination LargePage = Annotated[ PaginateType[HeroModel], - Depends(new_pagination(min_page_size=10, max_page_size=250)), + Depends(new_pagination(default_page_size=10, max_page_size=250)), ] ``` diff --git a/plugin/fastsqla/skills/fastsqla-pagination/SKILL.md b/plugin/fastsqla/skills/fastsqla-pagination/SKILL.md index 064b777..ad90ed0 100644 --- a/plugin/fastsqla/skills/fastsqla-pagination/SKILL.md +++ b/plugin/fastsqla/skills/fastsqla-pagination/SKILL.md @@ -105,14 +105,17 @@ async def list_heroes( ## The `new_pagination()` Factory -For custom pagination behavior, use `new_pagination()` to create a new dependency. It accepts four parameters: +Use `new_pagination()` to create a dependency with custom pagination behavior: -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `min_page_size` | `int` | `10` | Default `limit` value | -| `max_page_size` | `int` | `100` | Maximum allowed `limit` value | -| `query_count_dependency` | `Callable[..., Awaitable[int]] \| None` | `None` | FastAPI dependency returning total item count. When `None`, uses `SELECT COUNT(*) FROM (subquery)`. | -| `result_processor` | `Callable[[Result], Iterable]` | `lambda r: iter(r.unique().scalars())` | Transforms the SQLAlchemy `Result` into an iterable of items | +| Parameter | Type | Default | Description | +|--------------------------|----------------------------------------------|----------------------------------------------|--------------------------------------------------------------------------------------------------------------| +| `default_page_size` | `int` | `10` | Default `limit` value | +| `max_page_size` | `int` | `100` | Maximum allowed `limit` value | +| `query_count_dependency` | `Callable[..., Awaitable[int]] \| None` | `None` | FastAPI dependency returning the total item count; uses `SELECT COUNT(*) FROM (subquery)` when omitted | +| `result_processor` | `Callable[[Result], Iterable]` | `lambda r: iter(r.unique().scalars())` | Transforms the SQLAlchemy `Result` into an iterable of items | + +Use `default_page_size` only for the default. The accepted `limit` range always starts at +`1`. Treat `min_page_size` as a deprecated compatibility alias for `default_page_size`. The return value is a FastAPI dependency. Use it with `Annotated` and `Depends`: @@ -141,7 +144,7 @@ from fastsqla import Page, PaginateType, new_pagination SmallPagePaginate = Annotated[ PaginateType[HeroModel], - Depends(new_pagination(min_page_size=5, max_page_size=25)), + Depends(new_pagination(default_page_size=5, max_page_size=25)), ] @app.get("/heroes") @@ -309,13 +312,13 @@ async def list_heroes(paginate: Paginate[Hero]) -> Page[Hero]: ## Quick Reference -| What you need | What to use | -|---|---| -| Standard pagination (offset/limit) | `Paginate[T]` | -| Custom page sizes | `Annotated[PaginateType[T], Depends(new_pagination(min_page_size=..., max_page_size=...))]` | -| Custom count for joins | `new_pagination(query_count_dependency=my_count_dep)` | -| Multi-column select results | `new_pagination(result_processor=lambda r: iter(r.mappings()))` | -| Type annotation for paginate callable | `PaginateType[T]` | -| Paginated response | `Page[T]` (data + meta) | -| Unpaginated list response | `Collection[T]` (data only) | -| Single item response | `Item[T]` (data only) | +| What you need | What to use | +|----------------------------------------|---------------------------------------------------------------------------------------------------| +| Standard pagination (offset/limit) | `Paginate[T]` | +| Custom page sizes | `Annotated[PaginateType[T], Depends(new_pagination(default_page_size=..., max_page_size=...))]` | +| Custom count for joins | `new_pagination(query_count_dependency=my_count_dep)` | +| Multi-column select results | `new_pagination(result_processor=lambda r: iter(r.mappings()))` | +| Type annotation for paginate callable | `PaginateType[T]` | +| Paginated response | `Page[T]` (data + meta) | +| Unpaginated list response | `Collection[T]` (data only) | +| Single item response | `Item[T]` (data only) | diff --git a/src/fastsqla.py b/src/fastsqla.py index dd84036..d9fa3ab 100644 --- a/src/fastsqla.py +++ b/src/fastsqla.py @@ -1,5 +1,7 @@ +import functools import math import os +import warnings from collections.abc import AsyncGenerator, Awaitable, Callable, Iterable from contextlib import _AsyncGeneratorContextManager, asynccontextmanager from typing import Annotated, TypedDict, TypeVar @@ -397,18 +399,62 @@ async def _paginate( ) +def _accept_deprecated_page_size_option[**P, R]( + function: Callable[P, R], +) -> Callable[P, R]: + @functools.wraps(function) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + if "min_page_size" in kwargs: + if args or "default_page_size" in kwargs: + raise TypeError( + "new_pagination() cannot receive both default_page_size and " + "min_page_size" + ) + warnings.warn( + "min_page_size is deprecated; use default_page_size instead", + DeprecationWarning, + stacklevel=2, + ) + return function(*args, **kwargs) + + return wrapper + + +@_accept_deprecated_page_size_option def new_pagination( - min_page_size: int = 10, + default_page_size: int = 10, max_page_size: int = 100, query_count_dependency: Callable[..., Awaitable[int]] | None = None, result_processor: Callable[[Result], Iterable] = lambda result: iter( result.unique().scalars() ), + *, + min_page_size: int | None = None, ): + """Create a FastAPI pagination dependency. + + Args: + default_page_size: Default value of the `limit` query parameter. + max_page_size: Maximum accepted value of the `limit` query parameter. + query_count_dependency: Optional dependency that returns the total item count. + result_processor: Function that transforms the SQLAlchemy result into page data. + min_page_size: Deprecated alias for `default_page_size`. + + Raises: + TypeError: Both page-size parameter names are supplied. + ValueError: The page-size configuration is invalid. + """ + if min_page_size is not None: + default_page_size = min_page_size + if max_page_size < 1: + raise ValueError("max_page_size must be at least 1") + if not 1 <= default_page_size <= max_page_size: + raise ValueError("default_page_size must be between 1 and max_page_size") + def default_dependency( session: Session, offset: int = Query(0, ge=0), - limit: int = Query(min_page_size, ge=1, le=max_page_size), + limit: int = Query(default_page_size, ge=1, le=max_page_size), ) -> PaginateType[T]: async def paginate(stmt: Select) -> Page: total_items = await _query_count(session, stmt) @@ -421,7 +467,7 @@ async def paginate(stmt: Select) -> Page: def dependency( session: Session, offset: int = Query(0, ge=0), - limit: int = Query(min_page_size, ge=1, le=max_page_size), + limit: int = Query(default_page_size, ge=1, le=max_page_size), total_items: int = Depends(query_count_dependency), ) -> PaginateType[T]: async def paginate(stmt: Select) -> Page: diff --git a/tests/integration/test_pagination.py b/tests/integration/test_pagination.py index 89b143a..5ebd1d2 100644 --- a/tests/integration/test_pagination.py +++ b/tests/integration/test_pagination.py @@ -2,7 +2,7 @@ from fastapi import Depends from pydantic import EmailStr -from pytest import fixture +from pytest import fixture, mark from sqlalchemy import ForeignKey, MetaData, String, Table, func, select, text TOTAL_USERS = 42 @@ -91,6 +91,14 @@ class StickyModel(BaseModel): async def list_users(paginate: Paginate[UserModel]) -> Page[UserModel]: return await paginate(select(User)) + SmallPagePaginate = Annotated[ + PaginateType[UserModel], Depends(new_pagination(default_page_size=5)) + ] + + @app.get("/small-pagination") + async def list_small_page(paginate: SmallPagePaginate) -> Page[UserModel]: + return await paginate(select(User)) + async def query_count(session: Session) -> int: stmt = select(func.count()).select_from(Sticky) result = await session.execute(stmt) @@ -135,6 +143,16 @@ async def test_it_with_out_of_the_box_dependency(client): assert meta["total_items"] == TOTAL_USERS +@mark.parametrize(("query_string", "expected_items"), [("", 5), ("?limit=1", 1)]) +async def test_it_with_custom_default_page_size(client, query_string, expected_items): + res = await client.get(f"/small-pagination{query_string}") + assert res.status_code == 200, (res.status_code, res.content) + + assert len(res.json()["data"]) == expected_items, ( + "Configured default must not change the accepted lower limit" + ) + + async def test_it_with_custom_result_processor(client): res = await client.get("/custom-pagination") assert res.status_code == 200, (res.status_code, res.content) diff --git a/tests/unit/test_new_pagination.py b/tests/unit/test_new_pagination.py new file mode 100644 index 0000000..cac2a27 --- /dev/null +++ b/tests/unit/test_new_pagination.py @@ -0,0 +1,75 @@ +import inspect +from collections.abc import Callable +from typing import cast + +from pytest import mark, raises, warns + + +def _default_limit(dependency: Callable[..., object]) -> int: + parameter = inspect.signature(dependency).parameters["limit"] + return cast(int, parameter.default.default) + + +def test_it_uses_default_page_size(): + from fastsqla import new_pagination + + dependency = new_pagination(default_page_size=5) + + assert _default_limit(dependency) == 5, ( + "Configured page size must be the default limit" + ) + + +def test_it_preserves_positional_default_page_size(): + from fastsqla import new_pagination + + dependency = new_pagination(5) + + assert _default_limit(dependency) == 5, ( + "First positional argument must keep its meaning" + ) + + +def test_it_accepts_deprecated_min_page_size(): + from fastsqla import new_pagination + + with warns(DeprecationWarning, match="use default_page_size instead") as captured: + dependency = new_pagination(min_page_size=5) + + assert captured[0].filename == __file__, "Warning must identify the caller" + assert _default_limit(dependency) == 5, ( + "Deprecated name must preserve configured default" + ) + + +@mark.parametrize( + ("args", "kwargs"), + [ + ((5,), {"min_page_size": 5}), + ((), {"default_page_size": 5, "min_page_size": 5}), + ], +) +def test_it_rejects_both_page_size_names(args, kwargs): + from fastsqla import new_pagination + + with raises( + TypeError, match="cannot receive both default_page_size and min_page_size" + ): + new_pagination(*args, **kwargs) + + +@mark.parametrize( + ("default_page_size", "max_page_size", "message"), + [ + (0, 100, "default_page_size must be between 1 and max_page_size"), + (101, 100, "default_page_size must be between 1 and max_page_size"), + (10, 0, "max_page_size must be at least 1"), + ], +) +def test_it_rejects_invalid_page_size_configuration( + default_page_size, max_page_size, message +): + from fastsqla import new_pagination + + with raises(ValueError, match=message): + new_pagination(default_page_size=default_page_size, max_page_size=max_page_size)