docs: add sorting

This commit is contained in:
2026-02-28 14:56:42 -05:00
parent 32ac3dc127
commit 0c39c3255e
6 changed files with 208 additions and 13 deletions

View File

@@ -1,6 +1,6 @@
# Pagination & search # Pagination & search
This example builds an articles listing endpoint that supports **offset pagination**, **cursor pagination**, **full-text search**, and **faceted filtering** — all from a single `CrudFactory` definition. This example builds an articles listing endpoint that supports **offset pagination**, **cursor pagination**, **full-text search**, **faceted filtering**, and **sorting** — all from a single `CrudFactory` definition.
## Models ## Models
@@ -16,7 +16,7 @@ This example builds an articles listing endpoint that supports **offset paginati
## Crud ## Crud
Declare `facet_fields` and `searchable_fields` once on [`CrudFactory`](../reference/crud.md#fastapi_toolsets.crud.factory.CrudFactory). All endpoints built from this class share the same defaults and can override them per call. Declare `searchable_fields`, `facet_fields`, and `sort_fields` once on [`CrudFactory`](../reference/crud.md#fastapi_toolsets.crud.factory.CrudFactory). All endpoints built from this class share the same defaults and can override them per call.
```python title="crud.py" ```python title="crud.py"
--8<-- "docs_src/examples/pagination_search/crud.py" --8<-- "docs_src/examples/pagination_search/crud.py"
@@ -46,14 +46,14 @@ Declare `facet_fields` and `searchable_fields` once on [`CrudFactory`](../refere
Best for admin panels or any UI that needs a total item count and numbered pages. Best for admin panels or any UI that needs a total item count and numbered pages.
```python title="routes.py:1:27" ```python title="routes.py:1:36"
--8<-- "docs_src/examples/pagination_search/routes.py:1:27" --8<-- "docs_src/examples/pagination_search/routes.py:1:36"
``` ```
**Example request** **Example request**
``` ```
GET /articles/offset?page=2&items_per_page=10&search=fastapi&status=published GET /articles/offset?page=2&items_per_page=10&search=fastapi&status=published&sort_by=title&sort_order=asc
``` ```
**Example response** **Example response**
@@ -83,14 +83,14 @@ GET /articles/offset?page=2&items_per_page=10&search=fastapi&status=published
Best for feeds, infinite scroll, or any high-throughput API where offset performance degrades. Best for feeds, infinite scroll, or any high-throughput API where offset performance degrades.
```python title="routes.py:30:45" ```python title="routes.py:39:59"
--8<-- "docs_src/examples/pagination_search/routes.py:30:45" --8<-- "docs_src/examples/pagination_search/routes.py:39:59"
``` ```
**Example request** **Example request**
``` ```
GET /articles/cursor?items_per_page=10&status=published GET /articles/cursor?items_per_page=10&status=published&sort_by=created_at&sort_order=desc
``` ```
**Example response** **Example response**

View File

@@ -295,6 +295,8 @@ Use `filter_by` to pass the client's chosen filter values directly — no need t
Use [`filter_params()`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.filter_params) to generate a dict with the facet filter values from the query parameters: Use [`filter_params()`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.filter_params) to generate a dict with the facet filter values from the query parameters:
```python ```python
from typing import Annotated
from fastapi import Depends from fastapi import Depends
UserCrud = CrudFactory( UserCrud = CrudFactory(
@@ -306,7 +308,7 @@ UserCrud = CrudFactory(
async def list_users( async def list_users(
session: SessionDep, session: SessionDep,
page: int = 1, page: int = 1,
filter_by: dict[str, list[str]] = Depends(UserCrud.filter_params()), filter_by: Annotated[dict[str, list[str]], Depends(UserCrud.filter_params())],
) -> PaginatedResponse[UserRead]: ) -> PaginatedResponse[UserRead]:
return await UserCrud.offset_paginate( return await UserCrud.offset_paginate(
session=session, session=session,
@@ -323,6 +325,58 @@ GET /users?status=active&country=FR → filter_by={"status": ["active"], "coun
GET /users?role=admin&role=editor → filter_by={"role": ["admin", "editor"]} (IN clause) GET /users?role=admin&role=editor → filter_by={"role": ["admin", "editor"]} (IN clause)
``` ```
## Sorting
!!! info "Added in `v1.3`"
Declare `sort_fields` on the CRUD class to expose client-driven column ordering via `sort_by` and `sort_order` query parameters.
```python
UserCrud = CrudFactory(
model=User,
sort_fields=[
User.name,
User.created_at,
],
)
```
Call [`sort_params()`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.sort_params) to generate a FastAPI dependency that maps the query parameters to an [`OrderByClause`](../reference/crud.md#fastapi_toolsets.crud.factory.OrderByClause) expression:
```python
from typing import Annotated
from fastapi import Depends
from fastapi_toolsets.crud import OrderByClause
@router.get("")
async def list_users(
session: SessionDep,
order_by: Annotated[OrderByClause | None, Depends(UserCrud.sort_params())],
) -> PaginatedResponse[UserRead]:
return await UserCrud.offset_paginate(session=session, order_by=order_by)
```
The dependency adds two query parameters to the endpoint:
| Parameter | Type |
| ------------ | ------------------ |
| `sort_by` | `str | null` |
| `sort_order` | `asc` or `desc` |
```
GET /users?sort_by=name&sort_order=asc → ORDER BY users.name ASC
GET /users?sort_by=name&sort_order=desc → ORDER BY users.name DESC
```
An unknown `sort_by` value raises [`InvalidSortFieldError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidSortFieldError) (HTTP 422).
You can also pass `sort_fields` directly to `sort_params()` to override the class-level defaults without modifying them:
```python
UserSortParams = UserCrud.sort_params(sort_fields=[User.name])
```
## Relationship loading ## Relationship loading
!!! info "Added in `v1.1`" !!! info "Added in `v1.1`"

View File

@@ -1,6 +1,9 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi_toolsets.exceptions import init_exceptions_handlers
from .routes import router from .routes import router
app = FastAPI() app = FastAPI()
init_exceptions_handlers(app=app)
app.include_router(router=router) app.include_router(router=router)

View File

@@ -14,6 +14,8 @@ ArticleCrud = CrudFactory(
Article.status, Article.status,
(Article.category, Category.name), (Article.category, Category.name),
], ],
sort_fields=[ # fields exposed for client-driven sorting
Article.title,
Article.created_at,
],
) )
ArticleFilters = ArticleCrud.filter_params()

View File

@@ -1,9 +1,13 @@
from typing import Annotated
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from fastapi_toolsets.crud import OrderByClause
from fastapi_toolsets.schemas import PaginatedResponse from fastapi_toolsets.schemas import PaginatedResponse
from .crud import ArticleCrud from .crud import ArticleCrud
from .db import SessionDep from .db import SessionDep
from .models import Article
from .schemas import ArticleRead from .schemas import ArticleRead
router = APIRouter(prefix="/articles") router = APIRouter(prefix="/articles")
@@ -12,10 +16,14 @@ router = APIRouter(prefix="/articles")
@router.get("/offset") @router.get("/offset")
async def list_articles_offset( async def list_articles_offset(
session: SessionDep, session: SessionDep,
filter_by: Annotated[dict[str, list[str]], Depends(ArticleCrud.filter_params())],
order_by: Annotated[
OrderByClause | None,
Depends(ArticleCrud.sort_params(default_field=Article.created_at)),
],
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
items_per_page: int = Query(20, ge=1, le=100), items_per_page: int = Query(20, ge=1, le=100),
search: str | None = None, search: str | None = None,
filter_by: dict[str, list[str]] = Depends(ArticleCrud.filter_params()),
) -> PaginatedResponse[ArticleRead]: ) -> PaginatedResponse[ArticleRead]:
return await ArticleCrud.offset_paginate( return await ArticleCrud.offset_paginate(
session=session, session=session,
@@ -23,6 +31,7 @@ async def list_articles_offset(
items_per_page=items_per_page, items_per_page=items_per_page,
search=search, search=search,
filter_by=filter_by or None, filter_by=filter_by or None,
order_by=order_by,
schema=ArticleRead, schema=ArticleRead,
) )
@@ -30,10 +39,14 @@ async def list_articles_offset(
@router.get("/cursor") @router.get("/cursor")
async def list_articles_cursor( async def list_articles_cursor(
session: SessionDep, session: SessionDep,
filter_by: Annotated[dict[str, list[str]], Depends(ArticleCrud.filter_params())],
order_by: Annotated[
OrderByClause | None,
Depends(ArticleCrud.sort_params(default_field=Article.created_at)),
],
cursor: str | None = None, cursor: str | None = None,
items_per_page: int = Query(20, ge=1, le=100), items_per_page: int = Query(20, ge=1, le=100),
search: str | None = None, search: str | None = None,
filter_by: dict[str, list[str]] = Depends(ArticleCrud.filter_params()),
) -> PaginatedResponse[ArticleRead]: ) -> PaginatedResponse[ArticleRead]:
return await ArticleCrud.cursor_paginate( return await ArticleCrud.cursor_paginate(
session=session, session=session,
@@ -41,5 +54,6 @@ async def list_articles_cursor(
items_per_page=items_per_page, items_per_page=items_per_page,
search=search, search=search,
filter_by=filter_by or None, filter_by=filter_by or None,
order_by=order_by,
schema=ArticleRead, schema=ArticleRead,
) )

View File

@@ -15,12 +15,14 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
from docs_src.examples.pagination_search.db import get_db from docs_src.examples.pagination_search.db import get_db
from docs_src.examples.pagination_search.models import Article, Base, Category from docs_src.examples.pagination_search.models import Article, Base, Category
from docs_src.examples.pagination_search.routes import router from docs_src.examples.pagination_search.routes import router
from fastapi_toolsets.exceptions import init_exceptions_handlers
from .conftest import DATABASE_URL from .conftest import DATABASE_URL
def build_app(session: AsyncSession) -> FastAPI: def build_app(session: AsyncSession) -> FastAPI:
app = FastAPI() app = FastAPI()
init_exceptions_handlers(app)
async def override_get_db(): async def override_get_db():
yield session yield session
@@ -269,3 +271,123 @@ class TestCursorPagination:
body = resp.json() body = resp.json()
assert len(body["data"]) == 1 assert len(body["data"]) == 1
assert body["data"][0]["title"] == "SQLAlchemy async" assert body["data"][0]["title"] == "SQLAlchemy async"
class TestOffsetSorting:
"""Tests for sort_by / sort_order query parameters on the offset endpoint."""
@pytest.mark.anyio
async def test_default_order_uses_created_at_asc(
self, client: AsyncClient, ex_db_session
):
"""No sort_by → default field (created_at) ASC."""
await seed(ex_db_session)
resp = await client.get("/articles/offset")
assert resp.status_code == 200
titles = [a["title"] for a in resp.json()["data"]]
assert titles == ["FastAPI tips", "SQLAlchemy async", "Draft notes"]
@pytest.mark.anyio
async def test_sort_by_title_asc(self, client: AsyncClient, ex_db_session):
"""sort_by=title&sort_order=asc returns alphabetical order."""
await seed(ex_db_session)
resp = await client.get("/articles/offset?sort_by=title&sort_order=asc")
assert resp.status_code == 200
titles = [a["title"] for a in resp.json()["data"]]
assert titles == ["Draft notes", "FastAPI tips", "SQLAlchemy async"]
@pytest.mark.anyio
async def test_sort_by_title_desc(self, client: AsyncClient, ex_db_session):
"""sort_by=title&sort_order=desc returns reverse alphabetical order."""
await seed(ex_db_session)
resp = await client.get("/articles/offset?sort_by=title&sort_order=desc")
assert resp.status_code == 200
titles = [a["title"] for a in resp.json()["data"]]
assert titles == ["SQLAlchemy async", "FastAPI tips", "Draft notes"]
@pytest.mark.anyio
async def test_sort_by_created_at_desc(self, client: AsyncClient, ex_db_session):
"""sort_by=created_at&sort_order=desc returns newest-first."""
await seed(ex_db_session)
resp = await client.get("/articles/offset?sort_by=created_at&sort_order=desc")
assert resp.status_code == 200
titles = [a["title"] for a in resp.json()["data"]]
assert titles == ["Draft notes", "SQLAlchemy async", "FastAPI tips"]
@pytest.mark.anyio
async def test_invalid_sort_by_returns_422(
self, client: AsyncClient, ex_db_session
):
"""Unknown sort_by field returns 422 with SORT-422 error code."""
resp = await client.get("/articles/offset?sort_by=nonexistent_field")
assert resp.status_code == 422
body = resp.json()
assert body["error_code"] == "SORT-422"
assert body["status"] == "FAIL"
class TestCursorSorting:
"""Tests for sort_by / sort_order query parameters on the cursor endpoint.
In cursor_paginate the cursor_column is always the primary sort; order_by
acts as a secondary tiebreaker. With the seeded articles (all having unique
created_at values) the overall ordering is always created_at ASC regardless
of the sort_by value — only the valid/invalid field check and the response
shape are meaningful here.
"""
@pytest.mark.anyio
async def test_default_order_uses_created_at_asc(
self, client: AsyncClient, ex_db_session
):
"""No sort_by → default field (created_at) ASC."""
await seed(ex_db_session)
resp = await client.get("/articles/cursor")
assert resp.status_code == 200
titles = [a["title"] for a in resp.json()["data"]]
assert titles == ["FastAPI tips", "SQLAlchemy async", "Draft notes"]
@pytest.mark.anyio
async def test_sort_by_title_asc_accepted(self, client: AsyncClient, ex_db_session):
"""sort_by=title is a valid field — request succeeds and returns all articles."""
await seed(ex_db_session)
resp = await client.get("/articles/cursor?sort_by=title&sort_order=asc")
assert resp.status_code == 200
assert len(resp.json()["data"]) == 3
@pytest.mark.anyio
async def test_sort_by_title_desc_accepted(
self, client: AsyncClient, ex_db_session
):
"""sort_by=title&sort_order=desc is valid — request succeeds and returns all articles."""
await seed(ex_db_session)
resp = await client.get("/articles/cursor?sort_by=title&sort_order=desc")
assert resp.status_code == 200
assert len(resp.json()["data"]) == 3
@pytest.mark.anyio
async def test_invalid_sort_by_returns_422(
self, client: AsyncClient, ex_db_session
):
"""Unknown sort_by field returns 422 with SORT-422 error code."""
resp = await client.get("/articles/cursor?sort_by=nonexistent_field")
assert resp.status_code == 422
body = resp.json()
assert body["error_code"] == "SORT-422"
assert body["status"] == "FAIL"