Skip to content

Commit 9f08f2f

Browse files
committed
fix(litestar): normalize sort filter defaults
1 parent 204887d commit 9f08f2f

7 files changed

Lines changed: 198 additions & 38 deletions

File tree

advanced_alchemy/extensions/fastapi/providers.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,13 @@
4848
SQLAlchemyAsyncRepositoryService,
4949
SQLAlchemySyncRepositoryService,
5050
)
51-
from advanced_alchemy.utils.dependencies import DependencyCache, FieldNameType, FilterConfig, make_hashable
51+
from advanced_alchemy.utils.dependencies import (
52+
DependencyCache,
53+
FieldNameType,
54+
FilterConfig,
55+
make_hashable,
56+
normalize_sort_field,
57+
)
5258
from advanced_alchemy.utils.text import camelize
5359

5460
logger = logging.getLogger("advanced_alchemy.extensions.fastapi")
@@ -599,6 +605,7 @@ def provide_search_filter(
599605

600606
# Add sort filter providers
601607
if sort_field := config.get("sort_field"):
608+
sort_field_default = normalize_sort_field(sort_field)
602609
sort_order_default = config.get("sort_order", "desc")
603610

604611
def provide_order_by(
@@ -609,7 +616,7 @@ def provide_order_by(
609616
description="Field to order by.",
610617
required=False,
611618
),
612-
] = sort_field, # type: ignore[assignment]
619+
] = sort_field_default,
613620
sort_order: Annotated[
614621
Optional[SortOrder],
615622
Query(

advanced_alchemy/extensions/litestar/providers.py

Lines changed: 38 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,13 @@
4343
SQLAlchemyAsyncRepositoryService,
4444
SQLAlchemySyncRepositoryService,
4545
)
46-
from advanced_alchemy.utils.dependencies import DependencyCache, FieldNameType, FilterConfig, make_hashable
46+
from advanced_alchemy.utils.dependencies import (
47+
DependencyCache,
48+
FieldNameType,
49+
FilterConfig,
50+
make_hashable,
51+
normalize_sort_field,
52+
)
4753
from advanced_alchemy.utils.singleton import SingletonMeta
4854
from advanced_alchemy.utils.text import camelize
4955

@@ -317,6 +323,33 @@ def create_filter_dependencies(
317323
return deps
318324

319325

326+
def _create_order_by_filter_provider(
327+
sort_field: Union[str, set[str], list[str]],
328+
sort_order_default: SortOrder = "desc",
329+
) -> Callable[..., OrderBy]:
330+
sort_field_default = normalize_sort_field(sort_field)
331+
332+
def provide_order_by(
333+
field_name: Annotated[
334+
StringOrNone,
335+
QueryParameter(
336+
title="Order by field",
337+
name="orderBy",
338+
),
339+
] = sort_field_default,
340+
sort_order: Annotated[
341+
SortOrderOrNone,
342+
QueryParameter(
343+
title="Field to search",
344+
name="sortOrder",
345+
),
346+
] = sort_order_default,
347+
) -> OrderBy:
348+
return OrderBy(field_name=field_name, sort_order=sort_order or sort_order_default) # type: ignore[arg-type]
349+
350+
return provide_order_by
351+
352+
320353
def _create_statement_filters( # noqa: C901
321354
config: FilterConfig, dep_defaults: DependencyDefaults = DEPENDENCY_DEFAULTS
322355
) -> dict[str, Provide]:
@@ -408,26 +441,10 @@ def provide_search_filter(
408441
filters[dep_defaults.SEARCH_FILTER_DEPENDENCY_KEY] = Provide(provide_search_filter, sync_to_thread=False)
409442

410443
if sort_field := config.get("sort_field"):
411-
412-
def provide_order_by(
413-
field_name: Annotated[
414-
StringOrNone,
415-
QueryParameter(
416-
title="Order by field",
417-
name="orderBy",
418-
),
419-
] = sort_field, # type: ignore[assignment]
420-
sort_order: Annotated[
421-
SortOrderOrNone,
422-
QueryParameter(
423-
title="Field to search",
424-
name="sortOrder",
425-
),
426-
] = config.get("sort_order", "desc"),
427-
) -> OrderBy:
428-
return OrderBy(field_name=field_name, sort_order=sort_order) # type: ignore[arg-type]
429-
430-
filters[dep_defaults.ORDER_BY_FILTER_DEPENDENCY_KEY] = Provide(provide_order_by, sync_to_thread=False)
444+
filters[dep_defaults.ORDER_BY_FILTER_DEPENDENCY_KEY] = Provide(
445+
_create_order_by_filter_provider(sort_field, config.get("sort_order", "desc")),
446+
sync_to_thread=False,
447+
)
431448

432449
# Add not_in filter providers
433450
if not_in_fields := config.get("not_in_fields"):

advanced_alchemy/filters.py

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@
6363
if TYPE_CHECKING:
6464
from sqlalchemy.orm import InstrumentedAttribute
6565

66+
FilterFieldName: TypeAlias = Union[str, ColumnElement[Any], InstrumentedAttribute[Any]]
67+
InstrumentedField: TypeAlias = Union[ColumnElement[Any], InstrumentedAttribute[Any]]
68+
else:
69+
FilterFieldName: TypeAlias = Any
70+
InstrumentedField: TypeAlias = Any
71+
6672
__all__ = (
6773
"BeforeAfter",
6874
"CollectionFilter",
@@ -160,9 +166,7 @@ def append_to_statement(
160166
return statement
161167

162168
@staticmethod
163-
def _get_instrumented_attr(
164-
model: Any, key: "Union[str, ColumnElement[Any], InstrumentedAttribute[Any]]"
165-
) -> "Union[ColumnElement[Any], InstrumentedAttribute[Any]]":
169+
def _get_instrumented_attr(model: Any, key: FilterFieldName) -> InstrumentedField:
166170
"""Get SQLAlchemy instrumented attribute from model.
167171
168172
Args:
@@ -193,7 +197,7 @@ class BeforeAfter(StatementFilter):
193197
194198
"""
195199

196-
field_name: "Union[str, ColumnElement[Any], InstrumentedAttribute[Any]]"
200+
field_name: FilterFieldName
197201
"""Field name, model attribute, or func expression."""
198202
before: Optional[datetime.datetime]
199203
"""Filter results where field is earlier than this value."""
@@ -239,7 +243,7 @@ class OnBeforeAfter(StatementFilter):
239243
240244
"""
241245

242-
field_name: "Union[str, ColumnElement[Any], InstrumentedAttribute[Any]]"
246+
field_name: FilterFieldName
243247
"""Field name, model attribute, or func expression."""
244248
on_or_before: Optional[datetime.datetime]
245249
"""Filter results where field is on or earlier than this value."""
@@ -287,7 +291,7 @@ class CollectionFilter(InAnyFilter, Generic[T]):
287291
Use ``prefer_any=True`` in ``append_to_statement`` to use the ``ANY`` operator.
288292
"""
289293

290-
field_name: "Union[str, ColumnElement[Any], InstrumentedAttribute[Any]]"
294+
field_name: FilterFieldName
291295
"""Field name, model attribute, or func expression."""
292296
values: Union[Collection[T], None]
293297
"""Values for the ``IN`` clause. If this is None, no filter is applied.
@@ -346,7 +350,7 @@ class NotInCollectionFilter(InAnyFilter, Generic[T]):
346350
347351
"""
348352

349-
field_name: "Union[str, ColumnElement[Any], InstrumentedAttribute[Any]]"
353+
field_name: FilterFieldName
350354
"""Field name, model attribute, or func expression."""
351355
values: Union[Collection[T], None]
352356
"""Values for the ``NOT IN`` clause. If None or empty, no filter is applied."""
@@ -419,7 +423,7 @@ class NullFilter(StatementFilter):
419423
- :meth:`sqlalchemy.sql.expression.ColumnOperators.is_`: IS NULL operator
420424
"""
421425

422-
field_name: "Union[str, ColumnElement[Any], InstrumentedAttribute[Any]]"
426+
field_name: FilterFieldName
423427
"""Field name, model attribute, or func expression."""
424428

425429
def append_to_statement(self, statement: StatementTypeT, model: type[ModelT]) -> StatementTypeT:
@@ -472,7 +476,7 @@ class NotNullFilter(StatementFilter):
472476
- :meth:`sqlalchemy.sql.expression.ColumnOperators.is_not`: IS NOT NULL operator
473477
"""
474478

475-
field_name: "Union[str, ColumnElement[Any], InstrumentedAttribute[Any]]"
479+
field_name: FilterFieldName
476480
"""Field name, model attribute, or func expression."""
477481

478482
def append_to_statement(self, statement: StatementTypeT, model: type[ModelT]) -> StatementTypeT:
@@ -556,7 +560,7 @@ class OrderBy(StatementFilter):
556560
- :meth:`sqlalchemy.sql.expression.ColumnElement.desc`: Descending order
557561
"""
558562

559-
field_name: "Union[str, ColumnElement[Any], InstrumentedAttribute[Any]]"
563+
field_name: FilterFieldName
560564
"""Field name, model attribute, or func expression (e.g., ``func.random()``)."""
561565
sort_order: Literal["asc", "desc"] = "asc"
562566
"""Sort direction ("asc" or "desc")."""
@@ -728,7 +732,7 @@ class ComparisonFilter(StatementFilter):
728732
ValueError: If an invalid operator is provided
729733
"""
730734

731-
field_name: "Union[str, ColumnElement[Any], InstrumentedAttribute[Any]]"
735+
field_name: FilterFieldName
732736
"""Field name, model attribute, or func expression."""
733737
operator: str
734738
"""Comparison operator to use (one of 'eq', 'ne', 'gt', 'ge', 'lt', 'le')."""

advanced_alchemy/utils/dependencies.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,12 @@
1212

1313
from advanced_alchemy.utils.singleton import SingletonMeta
1414

15-
__all__ = ("DependencyCache", "FieldNameType", "FilterConfig", "make_hashable")
15+
__all__ = ("DependencyCache", "FieldNameType", "FilterConfig", "make_hashable", "normalize_sort_field")
1616

1717
HashableValue = Union[str, int, float, bool, None]
1818
HashableType = Union[HashableValue, tuple[Any, ...], tuple[tuple[str, Any], ...], tuple[HashableValue, ...]]
1919
SortOrder = Literal["asc", "desc"]
20+
SortField = Union[str, set[str], list[str]]
2021

2122

2223
class FieldNameType(NamedTuple):
@@ -39,7 +40,7 @@ class FilterConfig(TypedDict):
3940
"""Enable an id filter using the supplied type."""
4041
id_field: NotRequired[str]
4142
"""Model field storing the primary key or identifier."""
42-
sort_field: NotRequired[Union[str, set[str], list[str]]]
43+
sort_field: NotRequired[SortField]
4344
"""Default field or fields to use for sorting."""
4445
sort_order: NotRequired[SortOrder]
4546
"""Default sort order."""
@@ -121,7 +122,10 @@ def make_hashable(value: Any) -> HashableType:
121122
mapping = cast("Mapping[Any, Any]", value) # type: ignore[redundant-cast]
122123
items = [(str(key), make_hashable(mapping[key])) for key in sorted(mapping.keys(), key=str)]
123124
return tuple(items)
124-
if isinstance(value, (list, set)):
125+
if isinstance(value, list):
126+
values = cast("Iterable[Any]", value)
127+
return tuple(make_hashable(item) for item in values)
128+
if isinstance(value, set):
125129
values = cast("Iterable[Any]", value)
126130
hashable_items = [make_hashable(item) for item in values]
127131
filtered_items = [item for item in hashable_items if item is not None]
@@ -132,3 +136,12 @@ def make_hashable(value: Any) -> HashableType:
132136
if isinstance(value, (str, int, float, bool, type(None))):
133137
return value
134138
return str(value)
139+
140+
141+
def normalize_sort_field(sort_field: SortField) -> str:
142+
"""Return a scalar default field for a configured sort field value."""
143+
if isinstance(sort_field, str):
144+
return sort_field
145+
if isinstance(sort_field, list):
146+
return sort_field[0]
147+
return sorted(sort_field)[0]

tests/unit/test_extensions/test_fastapi/test_providers.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,30 @@ def test_provide_filters_normalizes_list_field_config() -> None:
530530
}
531531

532532

533+
def test_provide_filters_uses_scalar_default_from_list_sort_field() -> None:
534+
"""Test list-based sort fields produce a scalar default order field."""
535+
deps = provide_filters({"sort_field": ["name", "id"]})
536+
537+
app = FastAPI()
538+
539+
@app.get("/items")
540+
async def get_items(filters: Annotated[list[FilterTypes], Depends(deps)]) -> list[dict[str, str]]:
541+
return [
542+
{"field_name": cast(str, filter_.field_name), "sort_order": filter_.sort_order}
543+
for filter_ in filters
544+
if isinstance(filter_, OrderBy)
545+
]
546+
547+
client = TestClient(app)
548+
response = client.get("/items")
549+
overridden_response = client.get("/items?orderBy=id&sortOrder=asc")
550+
551+
assert response.status_code == 200
552+
assert response.json() == [{"field_name": "name", "sort_order": "desc"}]
553+
assert overridden_response.status_code == 200
554+
assert overridden_response.json() == [{"field_name": "id", "sort_order": "asc"}]
555+
556+
533557
def test_openapi_schema_edge_cases() -> None:
534558
"""Test OpenAPI schema generation for edge cases and special configurations."""
535559
# Test with minimal configuration

tests/unit/test_extensions/test_litestar/test_providers.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,83 @@ def test_order_by_filter() -> None:
438438
assert f.sort_order == "desc"
439439

440440

441+
def test_order_by_filter_uses_scalar_default_from_list_sort_field() -> None:
442+
"""Test list-based sort fields produce a scalar default order field."""
443+
deps = _create_statement_filters({"sort_field": ["name", "id"]})
444+
445+
provider_func = deps["order_by_filter"].dependency
446+
f = provider_func()
447+
448+
assert isinstance(f, OrderBy)
449+
assert f.field_name == "name"
450+
f.append_to_statement(select(DITestModel), DITestModel)
451+
452+
453+
def test_order_by_filter_uses_scalar_default_from_set_sort_field() -> None:
454+
"""Test set-based sort fields produce a deterministic scalar default order field."""
455+
deps = _create_statement_filters({"sort_field": {"name", "id"}})
456+
457+
provider_func = deps["order_by_filter"].dependency
458+
f = provider_func()
459+
460+
assert isinstance(f, OrderBy)
461+
assert f.field_name == "id"
462+
f.append_to_statement(select(DITestModel), DITestModel)
463+
464+
465+
def test_individual_order_by_dependency_injection_skips_runtime_field_validation() -> None:
466+
"""Test individual Litestar filter dependencies can be injected directly."""
467+
468+
@get("/", dependencies=create_filter_dependencies({"sort_field": "name"}))
469+
async def handler(order_by_filter: OrderBy) -> dict[str, str]:
470+
return {"field_name": cast(str, order_by_filter.field_name), "sort_order": order_by_filter.sort_order}
471+
472+
with TestClient(Litestar([handler])) as client:
473+
response = client.get("/")
474+
overridden_response = client.get("/?orderBy=id&sortOrder=asc")
475+
476+
assert response.status_code == 200
477+
assert response.json() == {"field_name": "name", "sort_order": "desc"}
478+
assert overridden_response.status_code == 200
479+
assert overridden_response.json() == {"field_name": "id", "sort_order": "asc"}
480+
481+
482+
def test_litestar_openapi_schema_uses_typed_sort_parameters() -> None:
483+
"""Test sort parameters keep concrete OpenAPI schemas for collection defaults."""
484+
filter_dependencies = create_filter_dependencies({"sort_field": ["name", "id"], "sort_order": "asc"})
485+
486+
@get("/test")
487+
async def handler(filters: Annotated[list[FilterTypes], Dependency(skip_validation=True)]) -> list[str]:
488+
return [type(filter_).__name__ for filter_ in filters]
489+
490+
app = Litestar(
491+
route_handlers=[handler],
492+
signature_namespace=signature_namespace_values,
493+
dependencies=filter_dependencies,
494+
openapi_config=OpenAPIConfig(title="Test API", version="1.0.0", path="/schema"),
495+
)
496+
497+
with TestClient(app) as client:
498+
response = client.get("/schema/openapi.json")
499+
500+
assert response.status_code == 200
501+
parameters = response.json()["paths"]["/test"]["get"]["parameters"]
502+
order_by_param = next(param for param in parameters if param["name"] == "orderBy")
503+
sort_order_param = next(param for param in parameters if param["name"] == "sortOrder")
504+
505+
assert order_by_param["schema"] == {
506+
"oneOf": [{"type": "string", "default": "name"}, {"type": "null"}],
507+
"title": "Order by field",
508+
"default": "name",
509+
}
510+
assert sort_order_param["schema"] == {
511+
"type": ["null", "string"],
512+
"enum": ["asc", "desc", None],
513+
"title": "Field to search",
514+
"default": "asc",
515+
}
516+
517+
441518
def test_not_in_filter() -> None:
442519
"""Test creating not_in filter dependency."""
443520
deps = _create_statement_filters({"not_in_fields": ["status"]})

0 commit comments

Comments
 (0)