Compare commits

...
2 Commits
Author SHA1 Message Date
d3vyce e09b911277 Merge pull request #393 from d3vyce/392-searchfalsefilterfalseorderfalse-dont-clear-the-response-metadata
fix: search=False/filter=False/order=False don't clear the response metadata
2026-09-01 19:46:46 +02:00
d3vyce f7ecb76e8d fix: search=False/filter=False/order=False don't clear the response metadata 2026-09-01 13:43:33 -04:00
3 changed files with 98 additions and 6 deletions
+10
View File
@@ -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]) 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): 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 ```python
+33 -6
View File
@@ -386,13 +386,29 @@ class AsyncCrud(Generic[ModelType]):
own_filters=own_filters, 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 @classmethod
def _resolve_search_columns( def _resolve_search_columns(
cls: type[Self], cls: type[Self],
search_fields: Sequence[SearchFieldType] | None, search_fields: Sequence[SearchFieldType] | None,
) -> list[str] | None: ) -> list[str] | None:
"""Return search column keys, or None if no searchable fields configured.""" """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: if not fields:
return None return None
return search_field_keys(fields) return search_field_keys(fields)
@@ -403,7 +419,7 @@ class AsyncCrud(Generic[ModelType]):
order_fields: Sequence[OrderFieldType] | None, order_fields: Sequence[OrderFieldType] | None,
) -> list[str] | None: ) -> list[str] | None:
"""Return sort column keys, or None if no order fields configured.""" """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: if not fields:
return None return None
return sorted(facet_keys(fields)) return sorted(facet_keys(fields))
@@ -482,9 +498,7 @@ class AsyncCrud(Generic[ModelType]):
order_field_map: dict[str, OrderFieldType] | None = None order_field_map: dict[str, OrderFieldType] | None = None
order_valid_keys: list[str] | None = None order_valid_keys: list[str] | None = None
if order: if order:
resolved_order = ( resolved_order = cls._resolve_order_fields(order_fields)
order_fields if order_fields is not None else cls.order_fields
)
if resolved_order: if resolved_order:
keys = facet_keys(resolved_order) keys = facet_keys(resolved_order)
order_field_map = dict(zip(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]: 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: for name in pagination_param_names:
result[name] = kwargs[name] result[name] = kwargs[name]
+55
View File
@@ -2543,6 +2543,16 @@ class TestOrderParamsViaConsolidated:
assert len(result.data) == 2 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: class TestOffsetPaginateParamsSchema:
"""Tests for AsyncCrud.offset_paginate_params().""" """Tests for AsyncCrud.offset_paginate_params()."""
@@ -2612,6 +2622,9 @@ class TestOffsetPaginateParamsSchema:
"items_per_page": 10, "items_per_page": 10,
"include_total": False, "include_total": False,
"include_facets": True, "include_facets": True,
"search_fields": [],
"facet_fields": [],
"order_fields": [],
} }
@pytest.mark.anyio @pytest.mark.anyio
@@ -2655,6 +2668,42 @@ class TestOffsetPaginateParamsSchema:
assert "search" not in param_names assert "search" not in param_names
assert "search_column" 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): def test_filter_enabled_but_no_facet_fields(self):
"""filter=True with no facet_fields silently skips filter params.""" """filter=True with no facet_fields silently skips filter params."""
dep = RoleCrud.offset_paginate_params(search=False, filter=True, order=False) dep = RoleCrud.offset_paginate_params(search=False, filter=True, order=False)
@@ -2727,6 +2776,9 @@ class TestCursorPaginateParamsSchema:
"cursor": None, "cursor": None,
"items_per_page": 5, "items_per_page": 5,
"include_facets": True, "include_facets": True,
"search_fields": [],
"facet_fields": [],
"order_fields": [],
} }
@pytest.mark.anyio @pytest.mark.anyio
@@ -2837,6 +2889,9 @@ class TestPaginateParamsSchema:
"items_per_page": 10, "items_per_page": 10,
"include_total": True, "include_total": True,
"include_facets": True, "include_facets": True,
"search_fields": [],
"facet_fields": [],
"order_fields": [],
} }
@pytest.mark.anyio @pytest.mark.anyio