From e38d8d2d4f19ac452f003b3882cd26f307e7b104 Mon Sep 17 00:00:00 2001 From: d3vyce <44915747+d3vyce@users.noreply.github.com> Date: Sat, 4 Apr 2026 14:46:36 +0200 Subject: [PATCH] feat: expose sort_columns in paginated response (#225) --- src/fastapi_toolsets/crud/factory.py | 25 ++++++++ src/fastapi_toolsets/schemas.py | 1 + tests/test_crud.py | 39 ++++++++++++ tests/test_crud_search.py | 95 ++++++++++++++++++++++++++++ 4 files changed, 160 insertions(+) diff --git a/src/fastapi_toolsets/crud/factory.py b/src/fastapi_toolsets/crud/factory.py index a27f2ae..cc7a0ac 100644 --- a/src/fastapi_toolsets/crud/factory.py +++ b/src/fastapi_toolsets/crud/factory.py @@ -278,6 +278,17 @@ class AsyncCrud(Generic[ModelType]): return None return search_field_keys(fields) + @classmethod + def _resolve_sort_columns( + cls: type[Self], + order_fields: Sequence[QueryableAttribute[Any]] | None, + ) -> list[str] | None: + """Return sort column keys, or None if no order fields configured.""" + fields = order_fields if order_fields is not None else cls.order_fields + if not fields: + return None + return sorted(f.key for f in fields) + @classmethod def _build_paginate_params( cls: type[Self], @@ -1208,6 +1219,7 @@ class AsyncCrud(Generic[ModelType]): search: str | SearchConfig | None = None, search_fields: Sequence[SearchFieldType] | None = None, search_column: str | None = None, + order_fields: Sequence[QueryableAttribute[Any]] | None = None, facet_fields: Sequence[FacetFieldType] | None = None, filter_by: dict[str, Any] | BaseModel | None = None, schema: type[BaseModel], @@ -1228,6 +1240,7 @@ class AsyncCrud(Generic[ModelType]): search: Search query string or SearchConfig object search_fields: Fields to search in (overrides class default) search_column: Restrict search to a single column key. + order_fields: Fields allowed for sorting (overrides class default). facet_fields: Columns to compute distinct values for (overrides class default) filter_by: Dict of {column_key: value} to filter by declared facet fields. Keys must match the column.key of a facet field. Scalar → equality, @@ -1308,6 +1321,7 @@ class AsyncCrud(Generic[ModelType]): session, facet_fields, filters, search_joins ) search_columns = cls._resolve_search_columns(search_fields) + sort_columns = cls._resolve_sort_columns(order_fields) return OffsetPaginatedResponse( data=items, @@ -1319,6 +1333,7 @@ class AsyncCrud(Generic[ModelType]): ), filter_attributes=filter_attributes, search_columns=search_columns, + sort_columns=sort_columns, ) @classmethod @@ -1336,6 +1351,7 @@ class AsyncCrud(Generic[ModelType]): search: str | SearchConfig | None = None, search_fields: Sequence[SearchFieldType] | None = None, search_column: str | None = None, + order_fields: Sequence[QueryableAttribute[Any]] | None = None, facet_fields: Sequence[FacetFieldType] | None = None, filter_by: dict[str, Any] | BaseModel | None = None, schema: type[BaseModel], @@ -1357,6 +1373,7 @@ class AsyncCrud(Generic[ModelType]): search: Search query string or SearchConfig object. search_fields: Fields to search in (overrides class default). search_column: Restrict search to a single column key. + order_fields: Fields allowed for sorting (overrides class default). facet_fields: Columns to compute distinct values for (overrides class default). filter_by: Dict of {column_key: value} to filter by declared facet fields. Keys must match the column.key of a facet field. Scalar → equality, @@ -1468,6 +1485,7 @@ class AsyncCrud(Generic[ModelType]): session, facet_fields, filters, search_joins ) search_columns = cls._resolve_search_columns(search_fields) + sort_columns = cls._resolve_sort_columns(order_fields) return CursorPaginatedResponse( data=items, @@ -1479,6 +1497,7 @@ class AsyncCrud(Generic[ModelType]): ), filter_attributes=filter_attributes, search_columns=search_columns, + sort_columns=sort_columns, ) @overload @@ -1500,6 +1519,7 @@ class AsyncCrud(Generic[ModelType]): search: str | SearchConfig | None = ..., search_fields: Sequence[SearchFieldType] | None = ..., search_column: str | None = ..., + order_fields: Sequence[QueryableAttribute[Any]] | None = ..., facet_fields: Sequence[FacetFieldType] | None = ..., filter_by: dict[str, Any] | BaseModel | None = ..., schema: type[BaseModel], @@ -1524,6 +1544,7 @@ class AsyncCrud(Generic[ModelType]): search: str | SearchConfig | None = ..., search_fields: Sequence[SearchFieldType] | None = ..., search_column: str | None = ..., + order_fields: Sequence[QueryableAttribute[Any]] | None = ..., facet_fields: Sequence[FacetFieldType] | None = ..., filter_by: dict[str, Any] | BaseModel | None = ..., schema: type[BaseModel], @@ -1547,6 +1568,7 @@ class AsyncCrud(Generic[ModelType]): search: str | SearchConfig | None = None, search_fields: Sequence[SearchFieldType] | None = None, search_column: str | None = None, + order_fields: Sequence[QueryableAttribute[Any]] | None = None, facet_fields: Sequence[FacetFieldType] | None = None, filter_by: dict[str, Any] | BaseModel | None = None, schema: type[BaseModel], @@ -1575,6 +1597,7 @@ class AsyncCrud(Generic[ModelType]): search: Search query string or :class:`.SearchConfig` object. search_fields: Fields to search in (overrides class default). search_column: Restrict search to a single column key. + order_fields: Fields allowed for sorting (overrides class default). facet_fields: Columns to compute distinct values for (overrides class default). filter_by: Dict of ``{column_key: value}`` to filter by declared @@ -1604,6 +1627,7 @@ class AsyncCrud(Generic[ModelType]): search=search, search_fields=search_fields, search_column=search_column, + order_fields=order_fields, facet_fields=facet_fields, filter_by=filter_by, schema=schema, @@ -1624,6 +1648,7 @@ class AsyncCrud(Generic[ModelType]): search=search, search_fields=search_fields, search_column=search_column, + order_fields=order_fields, facet_fields=facet_fields, filter_by=filter_by, schema=schema, diff --git a/src/fastapi_toolsets/schemas.py b/src/fastapi_toolsets/schemas.py index 608ebbc..4d9130d 100644 --- a/src/fastapi_toolsets/schemas.py +++ b/src/fastapi_toolsets/schemas.py @@ -163,6 +163,7 @@ class PaginatedResponse(BaseResponse, Generic[DataT]): pagination_type: PaginationType | None = None filter_attributes: dict[str, list[Any]] | None = None search_columns: list[str] | None = None + sort_columns: list[str] | None = None _discriminated_union_cache: ClassVar[dict[Any, Any]] = {} diff --git a/tests/test_crud.py b/tests/test_crud.py index 3c3ffe4..f49e598 100644 --- a/tests/test_crud.py +++ b/tests/test_crud.py @@ -247,6 +247,45 @@ class TestResolveSearchColumns: assert "username" not in result +class TestResolveSortColumns: + """Tests for _resolve_sort_columns logic.""" + + def test_returns_none_when_no_order_fields(self): + """Returns None when cls.order_fields is None and no order_fields passed.""" + + class AbstractCrud(AsyncCrud[User]): + pass + + assert AbstractCrud._resolve_sort_columns(None) is None + + def test_returns_none_when_empty_order_fields_passed(self): + """Returns None when an empty list is passed explicitly.""" + crud = CrudFactory(User) + assert crud._resolve_sort_columns([]) is None + + def test_returns_keys_from_class_order_fields(self): + """Returns sorted column keys from cls.order_fields when no override passed.""" + crud = CrudFactory(User, order_fields=[User.username]) + result = crud._resolve_sort_columns(None) + assert result is not None + assert "username" in result + + def test_order_fields_override_takes_priority(self): + """Explicit order_fields override cls.order_fields.""" + crud = CrudFactory(User, order_fields=[User.username]) + result = crud._resolve_sort_columns([User.email]) + assert result is not None + assert "email" in result + assert "username" not in result + + def test_returns_sorted_keys(self): + """Keys are returned in sorted order.""" + crud = CrudFactory(User, order_fields=[User.email, User.username]) + result = crud._resolve_sort_columns(None) + assert result is not None + assert result == sorted(result) + + class TestDefaultLoadOptionsIntegration: """Integration tests for default_load_options with real DB queries.""" diff --git a/tests/test_crud_search.py b/tests/test_crud_search.py index d0413c5..bc7de14 100644 --- a/tests/test_crud_search.py +++ b/tests/test_crud_search.py @@ -1516,6 +1516,101 @@ class TestSearchColumns: assert result.data[0].username == "bob" +class TestSortColumns: + """Tests for sort_columns in paginated responses.""" + + @pytest.mark.anyio + async def test_sort_columns_returned_in_offset_paginate( + self, db_session: AsyncSession + ): + """offset_paginate response includes sort_columns.""" + UserSortCrud = CrudFactory(User, order_fields=[User.username, User.email]) + await UserCrud.create( + db_session, UserCreate(username="alice", email="a@test.com") + ) + + result = await UserSortCrud.offset_paginate(db_session, schema=UserRead) + + assert result.sort_columns is not None + assert "username" in result.sort_columns + assert "email" in result.sort_columns + + @pytest.mark.anyio + async def test_sort_columns_returned_in_cursor_paginate( + self, db_session: AsyncSession + ): + """cursor_paginate response includes sort_columns.""" + UserSortCursorCrud = CrudFactory( + User, + cursor_column=User.id, + order_fields=[User.username, User.email], + ) + await UserCrud.create( + db_session, UserCreate(username="alice", email="a@test.com") + ) + + result = await UserSortCursorCrud.cursor_paginate(db_session, schema=UserRead) + + assert result.sort_columns is not None + assert "username" in result.sort_columns + assert "email" in result.sort_columns + + @pytest.mark.anyio + async def test_sort_columns_none_when_no_order_fields( + self, db_session: AsyncSession + ): + """sort_columns is None when no order_fields are configured.""" + result = await UserCrud.offset_paginate(db_session, schema=UserRead) + + assert result.sort_columns is None + + @pytest.mark.anyio + async def test_sort_columns_override_in_offset_paginate( + self, db_session: AsyncSession + ): + """order_fields override in offset_paginate is reflected in sort_columns.""" + await UserCrud.create( + db_session, UserCreate(username="alice", email="a@test.com") + ) + + result = await UserCrud.offset_paginate( + db_session, order_fields=[User.email], schema=UserRead + ) + + assert result.sort_columns == ["email"] + + @pytest.mark.anyio + async def test_sort_columns_override_in_cursor_paginate( + self, db_session: AsyncSession + ): + """order_fields override in cursor_paginate is reflected in sort_columns.""" + UserCursorCrud = CrudFactory(User, cursor_column=User.id) + await UserCrud.create( + db_session, UserCreate(username="alice", email="a@test.com") + ) + + result = await UserCursorCrud.cursor_paginate( + db_session, order_fields=[User.username], schema=UserRead + ) + + assert result.sort_columns == ["username"] + + @pytest.mark.anyio + async def test_sort_columns_are_sorted_alphabetically( + self, db_session: AsyncSession + ): + """sort_columns keys are returned in alphabetical order.""" + UserSortCrud = CrudFactory(User, order_fields=[User.email, User.username]) + await UserCrud.create( + db_session, UserCreate(username="alice", email="a@test.com") + ) + + result = await UserSortCrud.offset_paginate(db_session, schema=UserRead) + + assert result.sort_columns is not None + assert result.sort_columns == sorted(result.sort_columns) + + class TestOrderParamsViaConsolidated: """Tests for order params via consolidated offset_paginate_params()."""