diff --git a/docs/module/crud.md b/docs/module/crud.md index 25e786a..5b414b7 100644 --- a/docs/module/crud.md +++ b/docs/module/crud.md @@ -373,6 +373,16 @@ Or via the dependency to narrow which fields are exposed as query parameters: params = UserCrud.offset_paginate_params(search_fields=[Post.title]) ``` +`search_fields`, `facet_fields` and `order_fields` follow the same override rule +everywhere they are accepted — `offset_paginate`, `cursor_paginate`, +`paginate` and the matching `*_paginate_params` dependencies: + +| Passed | Effect | +| --- | --- | +| omitted or `None` | Use the class-level declaration | +| `[]` | Disable this feature for this call | +| `[...]` | Use exactly these fields (the primary key is **not** prepended — that only happens for the class-level `searchable_fields`) | + 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 diff --git a/src/fastapi_toolsets/crud/factory.py b/src/fastapi_toolsets/crud/factory.py index 841c6a4..dcbe9fe 100644 --- a/src/fastapi_toolsets/crud/factory.py +++ b/src/fastapi_toolsets/crud/factory.py @@ -386,13 +386,29 @@ class AsyncCrud(Generic[ModelType]): own_filters=own_filters, ) + @classmethod + def _resolve_search_fields( + cls: type[Self], + search_fields: Sequence[SearchFieldType] | None, + ) -> Sequence[SearchFieldType] | None: + """Return search_fields if given, otherwise fall back to the class-level default.""" + return search_fields if search_fields is not None else cls.searchable_fields + + @classmethod + def _resolve_order_fields( + cls: type[Self], + order_fields: Sequence[OrderFieldType] | None, + ) -> Sequence[OrderFieldType] | None: + """Return order_fields if given, otherwise fall back to the class-level default.""" + return order_fields if order_fields is not None else cls.order_fields + @classmethod def _resolve_search_columns( cls: type[Self], search_fields: Sequence[SearchFieldType] | None, ) -> list[str] | None: """Return search column keys, or None if no searchable fields configured.""" - fields = search_fields if search_fields is not None else cls.searchable_fields + fields = cls._resolve_search_fields(search_fields) if not fields: return None return search_field_keys(fields) @@ -403,7 +419,7 @@ class AsyncCrud(Generic[ModelType]): 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 + fields = cls._resolve_order_fields(order_fields) if not fields: return None return sorted(facet_keys(fields)) @@ -482,9 +498,7 @@ class AsyncCrud(Generic[ModelType]): 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 - ) + resolved_order = cls._resolve_order_fields(order_fields) if resolved_order: keys = facet_keys(resolved_order) order_field_map = dict(zip(keys, resolved_order)) @@ -510,8 +524,21 @@ class AsyncCrud(Generic[ModelType]): ] ) + fixed: dict[str, Any] = { + **pagination_fixed, + "search_fields": (cls._resolve_search_fields(search_fields) or []) + if search + else [], + "facet_fields": (cls._resolve_facet_fields(facet_fields) or []) + if filter + else [], + "order_fields": (cls._resolve_order_fields(order_fields) or []) + if order + else [], + } + async def dependency(**kwargs: Any) -> dict[str, Any]: - result: dict[str, Any] = dict(pagination_fixed) + result: dict[str, Any] = dict(fixed) for name in pagination_param_names: result[name] = kwargs[name] diff --git a/tests/test_crud_search.py b/tests/test_crud_search.py index beb3ae1..7918006 100644 --- a/tests/test_crud_search.py +++ b/tests/test_crud_search.py @@ -2543,6 +2543,16 @@ class TestOrderParamsViaConsolidated: assert len(result.data) == 2 +def _fully_declared_user_crud(): + """A CRUD class declaring all three field sets, as a real app would.""" + return CrudFactory( + User, + searchable_fields=[User.username], + facet_fields=[User.email], + order_fields=[User.username], + ) + + class TestOffsetPaginateParamsSchema: """Tests for AsyncCrud.offset_paginate_params().""" @@ -2612,6 +2622,9 @@ class TestOffsetPaginateParamsSchema: "items_per_page": 10, "include_total": False, "include_facets": True, + "search_fields": [], + "facet_fields": [], + "order_fields": [], } @pytest.mark.anyio @@ -2655,6 +2668,42 @@ class TestOffsetPaginateParamsSchema: assert "search" not in param_names assert "search_column" not in param_names + @pytest.mark.anyio + @pytest.mark.parametrize( + "kwargs", + [ + {"search": False, "filter": False, "order": False}, + {"search_fields": [], "facet_fields": [], "order_fields": []}, + ], + ids=["flags", "empty-overrides"], + ) + async def test_disabled_features_clear_response_metadata( + self, db_session: AsyncSession, kwargs + ): + """Disabling a feature on one endpoint also drops it from the response.""" + await UserCrud.create(db_session, UserCreate(username="bob", email="b@x.io")) + Crud = _fully_declared_user_crud() + dep = Crud.offset_paginate_params(**kwargs) + params = await dep(page=1, items_per_page=10) + result = await Crud.offset_paginate(db_session, **params, schema=UserRead) + assert result.search_columns is None + assert result.order_columns is None + assert result.filter_attributes is None + + @pytest.mark.anyio + async def test_enabled_features_keep_response_metadata( + self, db_session: AsyncSession + ): + """The declared class defaults still reach the response when left enabled.""" + await UserCrud.create(db_session, UserCreate(username="bob", email="b@x.io")) + Crud = _fully_declared_user_crud() + dep = Crud.offset_paginate_params() + params = await dep(page=1, items_per_page=10) + result = await Crud.offset_paginate(db_session, **params, schema=UserRead) + assert result.search_columns == ["id", "username"] + assert result.order_columns == ["username"] + assert result.filter_attributes == {"email": ["b@x.io"]} + def test_filter_enabled_but_no_facet_fields(self): """filter=True with no facet_fields silently skips filter params.""" dep = RoleCrud.offset_paginate_params(search=False, filter=True, order=False) @@ -2727,6 +2776,9 @@ class TestCursorPaginateParamsSchema: "cursor": None, "items_per_page": 5, "include_facets": True, + "search_fields": [], + "facet_fields": [], + "order_fields": [], } @pytest.mark.anyio @@ -2837,6 +2889,9 @@ class TestPaginateParamsSchema: "items_per_page": 10, "include_total": True, "include_facets": True, + "search_fields": [], + "facet_fields": [], + "order_fields": [], } @pytest.mark.anyio