diff --git a/docs/module/crud.md b/docs/module/crud.md index fe12508..69b627d 100644 --- a/docs/module/crud.md +++ b/docs/module/crud.md @@ -324,6 +324,12 @@ result = await UserCrud.offset_paginate( ) ``` +Or via the dependency to narrow which fields are exposed as query parameters: + +```python +params = UserCrud.offset_paginate_params(search_fields=[Post.title]) +``` + This allows searching with both [`offset_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.offset_paginate) and [`cursor_paginate`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud.cursor_paginate): ```python @@ -344,13 +350,37 @@ async def get_users( return await UserCrud.cursor_paginate(session=session, **params, schema=UserRead) ``` +The dependency adds two query parameters to the endpoint: + +| Parameter | Type | +| --------------- | ------------- | +| `search` | `str \| null` | +| `search_column` | `str \| null` | + +``` +GET /posts?search=hello → search all configured columns +GET /posts?search=hello&search_column=title → search only Post.title +``` + +The available search column keys are returned in the `search_columns` field of [`PaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse). Use them to populate a column picker in the UI, or to validate `search_column` values on the client side: + +```json +{ + "status": "SUCCESS", + "data": ["..."], + "pagination": { "..." }, + "search_columns": ["content", "author__username", "title"] +} +``` + +!!! info "Key format uses `__` as a separator for relationship chains." + A direct column `Post.title` produces `"title"`. A relationship tuple `(Post.author, User.username)` produces `"author__username"`. An unknown `search_column` value raises [`InvalidSearchColumnError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidSearchColumnError) (HTTP 422). + ### Faceted search !!! info "Added in `v1.2`" -Declare `facet_fields` on the CRUD class to return distinct column values alongside paginated results. This is useful for populating filter dropdowns or building faceted search UIs. - -Facet fields use the same syntax as `searchable_fields` — direct columns or relationship tuples: +Declare `facet_fields` on the CRUD class to return distinct column values alongside paginated results. This is useful for populating filter dropdowns or building faceted search UIs. Relationship traversal is supported via tuples, using the same syntax as `searchable_fields`: ```python UserCrud = CrudFactory( @@ -372,7 +402,47 @@ result = await UserCrud.offset_paginate( ) ``` -The distinct values are returned in the `filter_attributes` field of [`PaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse): +Or via the dependency to narrow which fields are exposed as query parameters: + +```python +params = UserCrud.offset_paginate_params(facet_fields=[User.country]) +``` + +Facet filtering is built into the consolidated params dependencies. When `filter=True` (the default), each facet field is exposed as a query parameter and values are collected into `filter_by` automatically: + +```python +from typing import Annotated + +from fastapi import Depends + +@router.get("", response_model_exclude_none=True) +async def list_users( + session: SessionDep, + params: Annotated[dict, Depends(UserCrud.offset_paginate_params())], +) -> OffsetPaginatedResponse[UserRead]: + return await UserCrud.offset_paginate(session=session, **params, schema=UserRead) +``` + +```python +@router.get("", response_model_exclude_none=True) +async def list_users( + session: SessionDep, + params: Annotated[dict, Depends(UserCrud.cursor_paginate_params())], +) -> CursorPaginatedResponse[UserRead]: + return await UserCrud.cursor_paginate(session=session, **params, schema=UserRead) +``` + +Both single-value and multi-value query parameters work: + +``` +GET /users?status=active → filter_by={"status": ["active"]} +GET /users?status=active&country=FR → filter_by={"status": ["active"], "country": ["FR"]} +GET /users?role__name=admin&role__name=editor → filter_by={"role__name": ["admin", "editor"]} (IN clause) +``` + +`filter_by` and `filters` can be combined — both are applied with AND logic. + +The distinct values for each facet field are returned in the `filter_attributes` field of [`PaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse). Use them to populate filter dropdowns in the UI, or to validate `filter_by` keys on the client side: ```json { @@ -387,50 +457,14 @@ The distinct values are returned in the `filter_attributes` field of [`Paginated } ``` -Use `filter_by` to pass the client's chosen filter values directly — no need to build SQLAlchemy conditions by hand. Any unknown key raises [`InvalidFacetFilterError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidFacetFilterError). - -!!! info "The keys in `filter_by` are the same keys the client received in `filter_attributes`." - Keys use `__` as a separator for the full relationship chain. A direct column `User.status` produces `"status"`. A relationship tuple `(User.role, Role.name)` produces `"role__name"`. A deeper chain `(User.role, Role.permission, Permission.name)` produces `"role__permission__name"`. - -`filter_by` and `filters` can be combined — both are applied with AND logic. - -Facet filtering is built into the consolidated params dependencies. When `filter=True` (the default), facet fields are exposed as query parameters and collected into `filter_by` automatically: - -```python -from typing import Annotated - -from fastapi import Depends - -UserCrud = CrudFactory( - model=User, - facet_fields=[User.status, User.country, (User.role, Role.name)], -) - -@router.get("", response_model_exclude_none=True) -async def list_users( - session: SessionDep, - params: Annotated[dict, Depends(UserCrud.offset_paginate_params())], -) -> OffsetPaginatedResponse[UserRead]: - return await UserCrud.offset_paginate( - session=session, - **params, - schema=UserRead, - ) -``` - -Both single-value and multi-value query parameters work: - -``` -GET /users?status=active → filter_by={"status": ["active"]} -GET /users?status=active&country=FR → filter_by={"status": ["active"], "country": ["FR"]} -GET /users?role__name=admin&role__name=editor → filter_by={"role__name": ["admin", "editor"]} (IN clause) -``` +!!! info "Key format uses `__` as a separator for relationship chains." + A direct column `User.status` produces `"status"`. A relationship tuple `(User.role, Role.name)` produces `"role__name"`. A deeper chain `(User.role, Role.permission, Permission.name)` produces `"role__permission__name"`. An unknown `filter_by` key raises [`InvalidFacetFilterError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidFacetFilterError) (HTTP 422). ## Sorting !!! info "Added in `v1.3`" -Declare `order_fields` on the CRUD class to expose client-driven column ordering via `order_by` and `order` query parameters. +Declare `order_fields` on the CRUD class. Relationship traversal is supported via tuples, using the same syntax as `searchable_fields` and `facet_fields`: ```python UserCrud = CrudFactory( @@ -438,11 +472,27 @@ UserCrud = CrudFactory( order_fields=[ User.name, User.created_at, + (User.role, Role.name), # sort by a related model column ], ) ``` -Ordering is built into the consolidated params dependencies. When `order=True` (the default), `order_by` and `order` query parameters are exposed and resolved into an `OrderByClause` automatically: +You can override `order_fields` per call: + +```python +result = await UserCrud.offset_paginate( + session=session, + order_fields=[User.name], +) +``` + +Or via the dependency to narrow which fields are exposed as query parameters: + +```python +params = UserCrud.offset_paginate_params(order_fields=[User.name]) +``` + +Sorting is built into the consolidated params dependencies. When `order=True` (the default), `order_by` and `order` query parameters are exposed and resolved into an `OrderByClause` automatically: ```python from typing import Annotated @@ -452,33 +502,50 @@ from fastapi import Depends @router.get("") async def list_users( session: SessionDep, - params: Annotated[dict, Depends(UserCrud.offset_paginate_params( - default_order_field=User.created_at, - ))], + params: Annotated[dict, Depends(UserCrud.offset_paginate_params())], ) -> OffsetPaginatedResponse[UserRead]: return await UserCrud.offset_paginate(session=session, **params, schema=UserRead) ``` +```python +@router.get("") +async def list_users( + session: SessionDep, + params: Annotated[dict, Depends(UserCrud.cursor_paginate_params())], +) -> CursorPaginatedResponse[UserRead]: + return await UserCrud.cursor_paginate(session=session, **params, schema=UserRead) +``` + The dependency adds two query parameters to the endpoint: | Parameter | Type | | ---------- | --------------- | -| `order_by` | `str | null` | +| `order_by` | `str \| null` | | `order` | `asc` or `desc` | ``` -GET /users?order_by=name&order=asc → ORDER BY users.name ASC -GET /users?order_by=name&order=desc → ORDER BY users.name DESC +GET /users?order_by=name&order=asc → ORDER BY users.name ASC +GET /users?order_by=role__name&order=desc → LEFT JOIN roles ON ... ORDER BY roles.name DESC ``` -An unknown `order_by` value raises [`InvalidOrderFieldError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidOrderFieldError) (HTTP 422). +!!! info "Relationship tuples are joined automatically." + When a relation field is selected, the related table is LEFT OUTER JOINed automatically. An unknown `order_by` value raises [`InvalidOrderFieldError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidOrderFieldError) (HTTP 422). -You can also pass `order_fields` directly to override the class-level defaults: -```python -params = UserCrud.offset_paginate_params(order_fields=[User.name]) +The available sort keys are returned in the `order_columns` field of [`PaginatedResponse`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse). Use them to populate a sort picker in the UI, or to validate `order_by` values on the client side: + +```json +{ + "status": "SUCCESS", + "data": ["..."], + "pagination": { "..." }, + "order_columns": ["created_at", "name", "role__name"] +} ``` +!!! info "Key format uses `__` as a separator for relationship chains." + A direct column `User.name` produces `"name"`. A relationship tuple `(User.role, Role.name)` produces `"role__name"`. + ## Relationship loading !!! info "Added in `v1.1`" diff --git a/src/fastapi_toolsets/crud/__init__.py b/src/fastapi_toolsets/crud/__init__.py index 23f7d97..95bf390 100644 --- a/src/fastapi_toolsets/crud/__init__.py +++ b/src/fastapi_toolsets/crud/__init__.py @@ -12,6 +12,7 @@ from ..types import ( JoinType, M2MFieldType, OrderByClause, + OrderFieldType, SearchFieldType, ) from .factory import AsyncCrud, CrudFactory @@ -28,6 +29,7 @@ __all__ = [ "M2MFieldType", "NoSearchableFieldsError", "OrderByClause", + "OrderFieldType", "PaginationType", "SearchConfig", "SearchFieldType", diff --git a/src/fastapi_toolsets/crud/factory.py b/src/fastapi_toolsets/crud/factory.py index cc7a0ac..1c87b0a 100644 --- a/src/fastapi_toolsets/crud/factory.py +++ b/src/fastapi_toolsets/crud/factory.py @@ -38,6 +38,7 @@ from ..types import ( M2MFieldType, ModelType, OrderByClause, + OrderFieldType, SchemaType, SearchFieldType, ) @@ -134,7 +135,7 @@ class AsyncCrud(Generic[ModelType]): model: ClassVar[type[DeclarativeBase]] searchable_fields: ClassVar[Sequence[SearchFieldType] | None] = None facet_fields: ClassVar[Sequence[FacetFieldType] | None] = None - order_fields: ClassVar[Sequence[QueryableAttribute[Any]] | None] = None + order_fields: ClassVar[Sequence[OrderFieldType] | None] = None m2m_fields: ClassVar[M2MFieldType | None] = None default_load_options: ClassVar[Sequence[ExecutableOption] | None] = None cursor_column: ClassVar[Any | None] = None @@ -279,15 +280,15 @@ class AsyncCrud(Generic[ModelType]): return search_field_keys(fields) @classmethod - def _resolve_sort_columns( + def _resolve_order_columns( cls: type[Self], - order_fields: Sequence[QueryableAttribute[Any]] | None, + order_fields: Sequence[OrderFieldType] | 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) + return sorted(facet_keys(fields)) @classmethod def _build_paginate_params( @@ -301,7 +302,7 @@ class AsyncCrud(Generic[ModelType]): order: bool, search_fields: Sequence[SearchFieldType] | None, facet_fields: Sequence[FacetFieldType] | None, - order_fields: Sequence[QueryableAttribute[Any]] | None, + order_fields: Sequence[OrderFieldType] | None, default_order_field: QueryableAttribute[Any] | None, default_order: Literal["asc", "desc"], ) -> Callable[..., Awaitable[dict[str, Any]]]: @@ -360,14 +361,15 @@ class AsyncCrud(Generic[ModelType]): ) reserved_names.update(filter_keys) - order_field_map: dict[str, QueryableAttribute[Any]] | None = None + order_field_map: dict[str, OrderFieldType] | None = None order_valid_keys: list[str] | None = None if order: resolved_order = ( order_fields if order_fields is not None else cls.order_fields ) if resolved_order: - order_field_map = {f.key: f for f in resolved_order} + keys = facet_keys(resolved_order) + order_field_map = dict(zip(keys, resolved_order)) order_valid_keys = sorted(order_field_map.keys()) all_params.extend( [ @@ -419,9 +421,16 @@ class AsyncCrud(Generic[ModelType]): else: field = order_field_map[order_by_val] if field is not None: - result["order_by"] = ( - field.asc() if order_dir == "asc" else field.desc() - ) + if isinstance(field, tuple): + col = field[-1] + result["order_by"] = ( + col.asc() if order_dir == "asc" else col.desc() + ) + result["order_joins"] = list(field[:-1]) + else: + result["order_by"] = ( + field.asc() if order_dir == "asc" else field.desc() + ) else: result["order_by"] = None @@ -445,7 +454,7 @@ class AsyncCrud(Generic[ModelType]): order: bool = True, search_fields: Sequence[SearchFieldType] | None = None, facet_fields: Sequence[FacetFieldType] | None = None, - order_fields: Sequence[QueryableAttribute[Any]] | None = None, + order_fields: Sequence[OrderFieldType] | None = None, default_order_field: QueryableAttribute[Any] | None = None, default_order: Literal["asc", "desc"] = "asc", ) -> Callable[..., Awaitable[dict[str, Any]]]: @@ -507,7 +516,7 @@ class AsyncCrud(Generic[ModelType]): order: bool = True, search_fields: Sequence[SearchFieldType] | None = None, facet_fields: Sequence[FacetFieldType] | None = None, - order_fields: Sequence[QueryableAttribute[Any]] | None = None, + order_fields: Sequence[OrderFieldType] | None = None, default_order_field: QueryableAttribute[Any] | None = None, default_order: Literal["asc", "desc"] = "asc", ) -> Callable[..., Awaitable[dict[str, Any]]]: @@ -572,7 +581,7 @@ class AsyncCrud(Generic[ModelType]): order: bool = True, search_fields: Sequence[SearchFieldType] | None = None, facet_fields: Sequence[FacetFieldType] | None = None, - order_fields: Sequence[QueryableAttribute[Any]] | None = None, + order_fields: Sequence[OrderFieldType] | None = None, default_order_field: QueryableAttribute[Any] | None = None, default_order: Literal["asc", "desc"] = "asc", ) -> Callable[..., Awaitable[dict[str, Any]]]: @@ -1213,13 +1222,14 @@ class AsyncCrud(Generic[ModelType]): outer_join: bool = False, load_options: Sequence[ExecutableOption] | None = None, order_by: OrderByClause | None = None, + order_joins: list[Any] | None = None, page: int = 1, items_per_page: int = 20, include_total: bool = True, search: str | SearchConfig | None = None, search_fields: Sequence[SearchFieldType] | None = None, search_column: str | None = None, - order_fields: Sequence[QueryableAttribute[Any]] | None = None, + order_fields: Sequence[OrderFieldType] | None = None, facet_fields: Sequence[FacetFieldType] | None = None, filter_by: dict[str, Any] | BaseModel | None = None, schema: type[BaseModel], @@ -1277,6 +1287,10 @@ class AsyncCrud(Generic[ModelType]): # Apply search joins (always outer joins for search) q = _apply_search_joins(q, search_joins) + # Apply order joins (relation joins required for order_by field) + if order_joins: + q = _apply_search_joins(q, order_joins) + if filters: q = q.where(and_(*filters)) if resolved := cls._resolve_load_options(load_options): @@ -1321,7 +1335,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) + order_columns = cls._resolve_order_columns(order_fields) return OffsetPaginatedResponse( data=items, @@ -1333,7 +1347,7 @@ class AsyncCrud(Generic[ModelType]): ), filter_attributes=filter_attributes, search_columns=search_columns, - sort_columns=sort_columns, + order_columns=order_columns, ) @classmethod @@ -1347,11 +1361,12 @@ class AsyncCrud(Generic[ModelType]): outer_join: bool = False, load_options: Sequence[ExecutableOption] | None = None, order_by: OrderByClause | None = None, + order_joins: list[Any] | None = None, items_per_page: int = 20, search: str | SearchConfig | None = None, search_fields: Sequence[SearchFieldType] | None = None, search_column: str | None = None, - order_fields: Sequence[QueryableAttribute[Any]] | None = None, + order_fields: Sequence[OrderFieldType] | None = None, facet_fields: Sequence[FacetFieldType] | None = None, filter_by: dict[str, Any] | BaseModel | None = None, schema: type[BaseModel], @@ -1427,6 +1442,10 @@ class AsyncCrud(Generic[ModelType]): # Apply search joins (always outer joins) q = _apply_search_joins(q, search_joins) + # Apply order joins (relation joins required for order_by field) + if order_joins: + q = _apply_search_joins(q, order_joins) + if filters: q = q.where(and_(*filters)) if resolved := cls._resolve_load_options(load_options): @@ -1485,7 +1504,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) + order_columns = cls._resolve_order_columns(order_fields) return CursorPaginatedResponse( data=items, @@ -1497,7 +1516,7 @@ class AsyncCrud(Generic[ModelType]): ), filter_attributes=filter_attributes, search_columns=search_columns, - sort_columns=sort_columns, + order_columns=order_columns, ) @overload @@ -1512,6 +1531,7 @@ class AsyncCrud(Generic[ModelType]): outer_join: bool = ..., load_options: Sequence[ExecutableOption] | None = ..., order_by: OrderByClause | None = ..., + order_joins: list[Any] | None = ..., page: int = ..., cursor: str | None = ..., items_per_page: int = ..., @@ -1519,7 +1539,7 @@ class AsyncCrud(Generic[ModelType]): search: str | SearchConfig | None = ..., search_fields: Sequence[SearchFieldType] | None = ..., search_column: str | None = ..., - order_fields: Sequence[QueryableAttribute[Any]] | None = ..., + order_fields: Sequence[OrderFieldType] | None = ..., facet_fields: Sequence[FacetFieldType] | None = ..., filter_by: dict[str, Any] | BaseModel | None = ..., schema: type[BaseModel], @@ -1537,6 +1557,7 @@ class AsyncCrud(Generic[ModelType]): outer_join: bool = ..., load_options: Sequence[ExecutableOption] | None = ..., order_by: OrderByClause | None = ..., + order_joins: list[Any] | None = ..., page: int = ..., cursor: str | None = ..., items_per_page: int = ..., @@ -1544,7 +1565,7 @@ class AsyncCrud(Generic[ModelType]): search: str | SearchConfig | None = ..., search_fields: Sequence[SearchFieldType] | None = ..., search_column: str | None = ..., - order_fields: Sequence[QueryableAttribute[Any]] | None = ..., + order_fields: Sequence[OrderFieldType] | None = ..., facet_fields: Sequence[FacetFieldType] | None = ..., filter_by: dict[str, Any] | BaseModel | None = ..., schema: type[BaseModel], @@ -1561,6 +1582,7 @@ class AsyncCrud(Generic[ModelType]): outer_join: bool = False, load_options: Sequence[ExecutableOption] | None = None, order_by: OrderByClause | None = None, + order_joins: list[Any] | None = None, page: int = 1, cursor: str | None = None, items_per_page: int = 20, @@ -1568,7 +1590,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, + order_fields: Sequence[OrderFieldType] | None = None, facet_fields: Sequence[FacetFieldType] | None = None, filter_by: dict[str, Any] | BaseModel | None = None, schema: type[BaseModel], @@ -1623,6 +1645,7 @@ class AsyncCrud(Generic[ModelType]): outer_join=outer_join, load_options=load_options, order_by=order_by, + order_joins=order_joins, items_per_page=items_per_page, search=search, search_fields=search_fields, @@ -1642,6 +1665,7 @@ class AsyncCrud(Generic[ModelType]): outer_join=outer_join, load_options=load_options, order_by=order_by, + order_joins=order_joins, page=page, items_per_page=items_per_page, include_total=include_total, @@ -1663,7 +1687,7 @@ def CrudFactory( base_class: type[AsyncCrud[Any]] = AsyncCrud, searchable_fields: Sequence[SearchFieldType] | None = None, facet_fields: Sequence[FacetFieldType] | None = None, - order_fields: Sequence[QueryableAttribute[Any]] | None = None, + order_fields: Sequence[OrderFieldType] | None = None, m2m_fields: M2MFieldType | None = None, default_load_options: Sequence[ExecutableOption] | None = None, cursor_column: Any | None = None, diff --git a/src/fastapi_toolsets/schemas.py b/src/fastapi_toolsets/schemas.py index 4d9130d..2169124 100644 --- a/src/fastapi_toolsets/schemas.py +++ b/src/fastapi_toolsets/schemas.py @@ -163,7 +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 + order_columns: list[str] | None = None _discriminated_union_cache: ClassVar[dict[Any, Any]] = {} diff --git a/src/fastapi_toolsets/types.py b/src/fastapi_toolsets/types.py index ed43164..517a3e5 100644 --- a/src/fastapi_toolsets/types.py +++ b/src/fastapi_toolsets/types.py @@ -19,9 +19,10 @@ JoinType = list[tuple[type[DeclarativeBase] | Any, Any]] M2MFieldType = Mapping[str, QueryableAttribute[Any]] OrderByClause = ColumnElement[Any] | QueryableAttribute[Any] -# Search / facet type aliases +# Search / facet / order type aliases SearchFieldType = InstrumentedAttribute[Any] | tuple[InstrumentedAttribute[Any], ...] FacetFieldType = SearchFieldType +OrderFieldType = SearchFieldType # Dependency type aliases SessionDependency = Callable[[], AsyncGenerator[AsyncSession, None]] | Any diff --git a/tests/test_crud.py b/tests/test_crud.py index f49e598..3667a08 100644 --- a/tests/test_crud.py +++ b/tests/test_crud.py @@ -247,8 +247,8 @@ class TestResolveSearchColumns: assert "username" not in result -class TestResolveSortColumns: - """Tests for _resolve_sort_columns logic.""" +class TestResolveOrderColumns: + """Tests for _resolve_order_columns logic.""" def test_returns_none_when_no_order_fields(self): """Returns None when cls.order_fields is None and no order_fields passed.""" @@ -256,24 +256,24 @@ class TestResolveSortColumns: class AbstractCrud(AsyncCrud[User]): pass - assert AbstractCrud._resolve_sort_columns(None) is None + assert AbstractCrud._resolve_order_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 + assert crud._resolve_order_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) + result = crud._resolve_order_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]) + result = crud._resolve_order_columns([User.email]) assert result is not None assert "email" in result assert "username" not in result @@ -281,10 +281,25 @@ class TestResolveSortColumns: 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) + result = crud._resolve_order_columns(None) assert result is not None assert result == sorted(result) + def test_relation_tuple_produces_dunder_key(self): + """A (rel, column) tuple produces a 'rel__column' key.""" + crud = CrudFactory(User, order_fields=[(User.role, Role.name)]) + result = crud._resolve_order_columns(None) + assert result == ["role__name"] + + def test_mixed_flat_and_relation_fields(self): + """Flat and relation fields can be mixed; keys are sorted.""" + crud = CrudFactory(User, order_fields=[User.username, (User.role, Role.name)]) + result = crud._resolve_order_columns(None) + assert result is not None + assert "username" in result + assert "role__name" in result + 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 bc7de14..7760037 100644 --- a/tests/test_crud_search.py +++ b/tests/test_crud_search.py @@ -1516,14 +1516,14 @@ class TestSearchColumns: assert result.data[0].username == "bob" -class TestSortColumns: - """Tests for sort_columns in paginated responses.""" +class TestOrderColumns: + """Tests for order_columns in paginated responses.""" @pytest.mark.anyio - async def test_sort_columns_returned_in_offset_paginate( + async def test_order_columns_returned_in_offset_paginate( self, db_session: AsyncSession ): - """offset_paginate response includes sort_columns.""" + """offset_paginate response includes order_columns.""" UserSortCrud = CrudFactory(User, order_fields=[User.username, User.email]) await UserCrud.create( db_session, UserCreate(username="alice", email="a@test.com") @@ -1531,15 +1531,15 @@ class TestSortColumns: 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 + assert result.order_columns is not None + assert "username" in result.order_columns + assert "email" in result.order_columns @pytest.mark.anyio - async def test_sort_columns_returned_in_cursor_paginate( + async def test_order_columns_returned_in_cursor_paginate( self, db_session: AsyncSession ): - """cursor_paginate response includes sort_columns.""" + """cursor_paginate response includes order_columns.""" UserSortCursorCrud = CrudFactory( User, cursor_column=User.id, @@ -1551,24 +1551,24 @@ class TestSortColumns: 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 + assert result.order_columns is not None + assert "username" in result.order_columns + assert "email" in result.order_columns @pytest.mark.anyio - async def test_sort_columns_none_when_no_order_fields( + async def test_order_columns_none_when_no_order_fields( self, db_session: AsyncSession ): - """sort_columns is None when no order_fields are configured.""" + """order_columns is None when no order_fields are configured.""" result = await UserCrud.offset_paginate(db_session, schema=UserRead) - assert result.sort_columns is None + assert result.order_columns is None @pytest.mark.anyio - async def test_sort_columns_override_in_offset_paginate( + async def test_order_columns_override_in_offset_paginate( self, db_session: AsyncSession ): - """order_fields override in offset_paginate is reflected in sort_columns.""" + """order_fields override in offset_paginate is reflected in order_columns.""" await UserCrud.create( db_session, UserCreate(username="alice", email="a@test.com") ) @@ -1577,13 +1577,13 @@ class TestSortColumns: db_session, order_fields=[User.email], schema=UserRead ) - assert result.sort_columns == ["email"] + assert result.order_columns == ["email"] @pytest.mark.anyio - async def test_sort_columns_override_in_cursor_paginate( + async def test_order_columns_override_in_cursor_paginate( self, db_session: AsyncSession ): - """order_fields override in cursor_paginate is reflected in sort_columns.""" + """order_fields override in cursor_paginate is reflected in order_columns.""" UserCursorCrud = CrudFactory(User, cursor_column=User.id) await UserCrud.create( db_session, UserCreate(username="alice", email="a@test.com") @@ -1593,13 +1593,13 @@ class TestSortColumns: db_session, order_fields=[User.username], schema=UserRead ) - assert result.sort_columns == ["username"] + assert result.order_columns == ["username"] @pytest.mark.anyio - async def test_sort_columns_are_sorted_alphabetically( + async def test_order_columns_are_sorted_alphabetically( self, db_session: AsyncSession ): - """sort_columns keys are returned in alphabetical order.""" + """order_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") @@ -1607,8 +1607,18 @@ class TestSortColumns: result = await UserSortCrud.offset_paginate(db_session, schema=UserRead) - assert result.sort_columns is not None - assert result.sort_columns == sorted(result.sort_columns) + assert result.order_columns is not None + assert result.order_columns == sorted(result.order_columns) + + @pytest.mark.anyio + async def test_relation_order_field_in_order_columns( + self, db_session: AsyncSession + ): + """A relation tuple order field produces 'rel__column' key in order_columns.""" + UserSortCrud = CrudFactory(User, order_fields=[(User.role, Role.name)]) + result = await UserSortCrud.offset_paginate(db_session, schema=UserRead) + + assert result.order_columns == ["role__name"] class TestOrderParamsViaConsolidated: @@ -1765,6 +1775,92 @@ class TestOrderParamsViaConsolidated: assert result.data[0].username == "alice" assert result.data[1].username == "charlie" + def test_relation_order_field_key_in_enum(self): + """A relation tuple field produces a 'rel__column' key in the order_by enum.""" + UserOrderCrud = CrudFactory(User, order_fields=[(User.role, Role.name)]) + dep = UserOrderCrud.offset_paginate_params(search=False, filter=False) + + sig = inspect.signature(dep) + description = sig.parameters["order_by"].default.description + assert "role__name" in description + + @pytest.mark.anyio + async def test_relation_order_field_produces_order_joins(self): + """Selecting a relation order field emits order_by and order_joins.""" + UserOrderCrud = CrudFactory(User, order_fields=[(User.role, Role.name)]) + dep = UserOrderCrud.offset_paginate_params(search=False, filter=False) + result = await dep( + page=1, items_per_page=20, order_by="role__name", order="asc" + ) + + assert "order_by" in result + assert "order_joins" in result + assert result["order_joins"] == [User.role] + + @pytest.mark.anyio + async def test_relation_order_integrates_with_offset_paginate( + self, db_session: AsyncSession + ): + """Relation order field joins the related table and sorts correctly.""" + UserOrderCrud = CrudFactory(User, order_fields=[(User.role, Role.name)]) + role_b = await RoleCrud.create(db_session, RoleCreate(name="beta")) + role_a = await RoleCrud.create(db_session, RoleCreate(name="alpha")) + await UserCrud.create( + db_session, + UserCreate(username="u1", email="u1@test.com", role_id=role_b.id), + ) + await UserCrud.create( + db_session, + UserCreate(username="u2", email="u2@test.com", role_id=role_a.id), + ) + await UserCrud.create( + db_session, UserCreate(username="u3", email="u3@test.com") + ) + + dep = UserOrderCrud.offset_paginate_params(search=False, filter=False) + params = await dep( + page=1, items_per_page=20, order_by="role__name", order="asc" + ) + result = await UserOrderCrud.offset_paginate( + db_session, **params, schema=UserRead + ) + + usernames = [u.username for u in result.data] + # u2 (alpha) before u1 (beta); u3 (no role, NULL) comes last or first depending on DB + assert usernames.index("u2") < usernames.index("u1") + + @pytest.mark.anyio + async def test_relation_order_integrates_with_cursor_paginate( + self, db_session: AsyncSession + ): + """Relation order field works with cursor_paginate (order_joins applied).""" + UserOrderCrud = CrudFactory( + User, + order_fields=[(User.role, Role.name)], + cursor_column=User.id, + ) + role_b = await RoleCrud.create(db_session, RoleCreate(name="zeta")) + role_a = await RoleCrud.create(db_session, RoleCreate(name="alpha")) + await UserCrud.create( + db_session, + UserCreate(username="cx1", email="cx1@test.com", role_id=role_b.id), + ) + await UserCrud.create( + db_session, + UserCreate(username="cx2", email="cx2@test.com", role_id=role_a.id), + ) + + dep = UserOrderCrud.cursor_paginate_params(search=False, filter=False) + params = await dep( + cursor=None, items_per_page=20, order_by="role__name", order="asc" + ) + result = await UserOrderCrud.cursor_paginate( + db_session, **params, schema=UserRead + ) + + assert result.data is not None + assert len(result.data) == 2 + class TestOffsetPaginateParamsSchema: """Tests for AsyncCrud.offset_paginate_params()."""