diff --git a/docs/examples/pagination-search.md b/docs/examples/pagination-search.md index f288d09..ca9ceed 100644 --- a/docs/examples/pagination-search.md +++ b/docs/examples/pagination-search.md @@ -1,6 +1,6 @@ # 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 @@ -16,7 +16,7 @@ This example builds an articles listing endpoint that supports **offset paginati ## 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" --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. -```python title="routes.py:1:27" ---8<-- "docs_src/examples/pagination_search/routes.py:1:27" +```python title="routes.py:1:36" +--8<-- "docs_src/examples/pagination_search/routes.py:1:36" ``` **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** @@ -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. -```python title="routes.py:30:45" ---8<-- "docs_src/examples/pagination_search/routes.py:30:45" +```python title="routes.py:39:59" +--8<-- "docs_src/examples/pagination_search/routes.py:39:59" ``` **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** diff --git a/docs/module/crud.md b/docs/module/crud.md index aa793c8..e21176f 100644 --- a/docs/module/crud.md +++ b/docs/module/crud.md @@ -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: ```python +from typing import Annotated + from fastapi import Depends UserCrud = CrudFactory( @@ -306,7 +308,7 @@ UserCrud = CrudFactory( async def list_users( session: SessionDep, 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]: return await UserCrud.offset_paginate( 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) ``` +## 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 !!! info "Added in `v1.1`" diff --git a/docs_src/examples/pagination_search/app.py b/docs_src/examples/pagination_search/app.py index 9dc7d6d..8a6348f 100644 --- a/docs_src/examples/pagination_search/app.py +++ b/docs_src/examples/pagination_search/app.py @@ -1,6 +1,9 @@ from fastapi import FastAPI +from fastapi_toolsets.exceptions import init_exceptions_handlers + from .routes import router app = FastAPI() +init_exceptions_handlers(app=app) app.include_router(router=router) diff --git a/docs_src/examples/pagination_search/crud.py b/docs_src/examples/pagination_search/crud.py index a05b712..44f44b6 100644 --- a/docs_src/examples/pagination_search/crud.py +++ b/docs_src/examples/pagination_search/crud.py @@ -14,6 +14,8 @@ ArticleCrud = CrudFactory( Article.status, (Article.category, Category.name), ], + sort_fields=[ # fields exposed for client-driven sorting + Article.title, + Article.created_at, + ], ) - -ArticleFilters = ArticleCrud.filter_params() diff --git a/docs_src/examples/pagination_search/routes.py b/docs_src/examples/pagination_search/routes.py index e4b6de0..94e10f6 100644 --- a/docs_src/examples/pagination_search/routes.py +++ b/docs_src/examples/pagination_search/routes.py @@ -1,9 +1,13 @@ +from typing import Annotated + from fastapi import APIRouter, Depends, Query +from fastapi_toolsets.crud import OrderByClause from fastapi_toolsets.schemas import PaginatedResponse from .crud import ArticleCrud from .db import SessionDep +from .models import Article from .schemas import ArticleRead router = APIRouter(prefix="/articles") @@ -12,10 +16,14 @@ router = APIRouter(prefix="/articles") @router.get("/offset") async def list_articles_offset( 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), items_per_page: int = Query(20, ge=1, le=100), search: str | None = None, - filter_by: dict[str, list[str]] = Depends(ArticleCrud.filter_params()), ) -> PaginatedResponse[ArticleRead]: return await ArticleCrud.offset_paginate( session=session, @@ -23,6 +31,7 @@ async def list_articles_offset( items_per_page=items_per_page, search=search, filter_by=filter_by or None, + order_by=order_by, schema=ArticleRead, ) @@ -30,10 +39,14 @@ async def list_articles_offset( @router.get("/cursor") async def list_articles_cursor( 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, items_per_page: int = Query(20, ge=1, le=100), search: str | None = None, - filter_by: dict[str, list[str]] = Depends(ArticleCrud.filter_params()), ) -> PaginatedResponse[ArticleRead]: return await ArticleCrud.cursor_paginate( session=session, @@ -41,5 +54,6 @@ async def list_articles_cursor( items_per_page=items_per_page, search=search, filter_by=filter_by or None, + order_by=order_by, schema=ArticleRead, ) diff --git a/tests/test_example_pagination_search.py b/tests/test_example_pagination_search.py index 33d59b4..43f2c4e 100644 --- a/tests/test_example_pagination_search.py +++ b/tests/test_example_pagination_search.py @@ -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.models import Article, Base, Category from docs_src.examples.pagination_search.routes import router +from fastapi_toolsets.exceptions import init_exceptions_handlers from .conftest import DATABASE_URL def build_app(session: AsyncSession) -> FastAPI: app = FastAPI() + init_exceptions_handlers(app) async def override_get_db(): yield session @@ -269,3 +271,123 @@ class TestCursorPagination: body = resp.json() assert len(body["data"]) == 1 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"