Skip to content
Merged
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
20 changes: 13 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
]
```

Expand Down
39 changes: 21 additions & 18 deletions plugin/fastsqla/skills/fastsqla-pagination/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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) |
52 changes: 49 additions & 3 deletions src/fastsqla.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down
20 changes: 19 additions & 1 deletion tests/integration/test_pagination.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
75 changes: 75 additions & 0 deletions tests/unit/test_new_pagination.py
Original file line number Diff line number Diff line change
@@ -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)
Loading