feat: expose sort_columns in paginated response (#225)

This commit is contained in:
d3vyce
2026-04-04 14:46:36 +02:00
committed by GitHub
parent 9b74f162ab
commit e38d8d2d4f
4 changed files with 160 additions and 0 deletions

View File

@@ -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,

View File

@@ -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]] = {}

View File

@@ -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."""

View File

@@ -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()."""