mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-09-19 03:09:56 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
089b2a1437 | ||
|
|
e09b911277 | ||
|
|
f7ecb76e8d
|
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -22,15 +22,15 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyio"
|
name = "anyio"
|
||||||
version = "4.12.1"
|
version = "4.14.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "idna" },
|
{ name = "idna" },
|
||||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
|
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
Reference in New Issue
Block a user