Skip to content

Commit b756cd6

Browse files
authored
fix: wire-mode filter normalization (#439)
Corrects an issue where the frontend generates camel case schemas and there is a mismatch between allowed columns and configured filter dependency settings.
1 parent ea191bd commit b756cd6

17 files changed

Lines changed: 833 additions & 60 deletions

File tree

docs/changelog.rst

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,41 @@ SQLSpec Changelog
1010
Recent Updates
1111
==============
1212

13-
schema_dump wire_format Opt-Out (Unreleased)
13+
v0.46.2 - Framework Filter ``orderBy`` Aliases (Unreleased)
14+
-----------------------------------------------------------
15+
16+
**Fixed:**
17+
18+
* Litestar ``create_filter_dependencies()`` and FastAPI ``provide_filters()``
19+
now accept camel-case API-facing sort aliases for configured ``orderBy``
20+
fields by default. Endpoints can accept values such as
21+
``orderBy=uploadedCollections`` while producing an ``OrderByFilter`` for the
22+
SQL-facing field ``uploaded_collections``. Raw configured values remain
23+
accepted for compatibility, explicit aliases can still be added with
24+
``sort_field_aliases``, and ``sort_field_camelize=False`` restores
25+
snake_case-only validation. (`#438
26+
<https://github.com/litestar-org/sqlspec/issues/438>`_)
27+
28+
**Safety:**
29+
30+
* Alias normalization is closed over the configured ``sort_field`` allowlist.
31+
Unknown aliases and aliases targeting fields outside ``sort_field`` are
32+
rejected before SQL construction, preserving the existing identifier
33+
allowlist.
34+
35+
v0.46.1 - Litestar Filter Provider Binding Fix
36+
----------------------------------------------
37+
38+
**Fixed:**
39+
40+
* Litestar generated filter providers now use unique dependency parameter names
41+
for sibling ``IN``, ``NOT IN``, null, not-null, and range filters. This stops
42+
sibling providers in the same filter family from cross-binding values while
43+
preserving the existing query parameter aliases. (`#435
44+
<https://github.com/litestar-org/sqlspec/issues/435>`_, `#436
45+
<https://github.com/litestar-org/sqlspec/pull/436>`_)
46+
47+
v0.46.0 - ``schema_dump`` Wire-Format Opt-Out
1448
---------------------------------------------
1549

1650
**Added:**
@@ -27,8 +61,8 @@ schema_dump wire_format Opt-Out (Unreleased)
2761
* The internal serializer cache key now includes ``wire_format`` so that
2862
``True`` and ``False`` calls for the same Struct type cannot collide.
2963

30-
Schema Wire Correctness (Unreleased)
31-
-------------------------------------
64+
v0.44.0 - Schema Wire Correctness
65+
---------------------------------
3266

3367
**Fixed:**
3468

docs/examples/patterns/filter_dependencies.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ def show_filter_dependencies() -> None:
1111
user_filters: FilterConfig = {
1212
"id_filter": str, # Filter by user IDs
1313
"id_field": "id", # Column name for ID filter
14-
"sort_field": ["created_at", "name"], # Allowed sort columns
14+
"sort_field": ["created_at", "uploaded_collections", "name"], # Allowed sort columns
15+
# orderBy accepts camel aliases like uploadedCollections by default
16+
"sort_field_aliases": {"lastUpload": "uploaded_collections"}, # Optional non-mechanical aliases
1517
"sort_order": "desc", # Default sort direction
1618
"pagination_type": "limit_offset", # Enable pagination
1719
"pagination_size": 20, # Default page size

docs/recipes/service_layer.rst

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,7 @@ forwarded through the service to the driver:
371371
dependencies = create_filter_dependencies({
372372
"pagination_type": "limit_offset",
373373
"pagination_size": 20,
374-
"sort_field": ["created_at", "name"],
374+
"sort_field": ["created_at", "uploaded_collections", "name"],
375375
"sort_order": "desc",
376376
"search": "name,email",
377377
})
@@ -384,6 +384,13 @@ forwarded through the service to the driver:
384384
) -> OffsetPagination[User]:
385385
return await users_service.list_with_count(*filters)
386386
387+
``sort_field`` remains the SQL-facing allowlist. Clients can request
388+
``?orderBy=uploadedCollections`` by default while the service receives an
389+
``OrderByFilter`` for ``uploaded_collections``. Existing snake_case values, such
390+
as ``?orderBy=uploaded_collections``, remain valid for compatibility. Set
391+
``sort_field_camelize=False`` when an endpoint must accept only raw configured
392+
``orderBy`` values.
393+
387394
.. seealso::
388395

389396
- :doc:`/usage/drivers_and_querying` for the driver methods used here

docs/reference/extensions/fastapi.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ Plugin
1515
Dependency Helpers
1616
==================
1717

18+
``provide_filters()`` supports the same ``orderBy`` alias contract as the
19+
Litestar provider. Camel-case query values are accepted by default for
20+
configured ``sort_field`` values. Use ``sort_field_aliases`` for explicit API
21+
names, or set ``sort_field_camelize=False`` to accept only raw configured
22+
values. Alias values normalize to fields from ``sort_field`` before
23+
``OrderByFilter`` is created, so the SQL-facing sort allowlist remains strict.
24+
1825
.. autofunction:: sqlspec.extensions.fastapi.provide_filters
1926

2027
.. autoclass:: sqlspec.extensions.fastapi.DependencyDefaults

docs/reference/extensions/litestar.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ Store
3636
Providers
3737
=========
3838

39+
``create_filter_dependencies()`` accepts camel-case aliases for configured
40+
``orderBy`` fields by default. Use ``sort_field_aliases`` to map explicit API
41+
names to configured SQL-facing fields, or set ``sort_field_camelize=False`` when
42+
an endpoint must accept only raw configured values. Alias values are normalized
43+
before ``OrderByFilter`` is created, and unknown aliases cannot bypass the
44+
``sort_field`` allowlist.
45+
3946
.. autoclass:: sqlspec.extensions.litestar.providers.DependencyDefaults
4047
:members:
4148
:show-inheritance:

docs/usage/filtering.rst

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ Filtering & Pagination
22
======================
33

44
SQLSpec provides filter types and pagination helpers that work with any driver.
5-
The Litestar extension includes auto-generated filter dependencies for REST APIs.
5+
The Litestar and FastAPI extensions include auto-generated filter dependencies
6+
for REST APIs.
67

78
Pagination with SQL Objects
89
---------------------------
@@ -68,13 +69,14 @@ Search Patterns
6869
that returns the percent-wrapped search value (e.g. ``%alice%``). This is useful
6970
when you need to use the pattern construction logic outside of the filter system.
7071

71-
Litestar Filter Dependencies
72-
72+
Framework Filter Dependencies
7373
-----------------------------
7474

7575
When using the Litestar extension, ``create_filter_dependencies()`` auto-generates
76-
Litestar dependency providers from a declarative configuration. These providers
77-
parse query parameters from incoming requests and produce filter objects.
76+
Litestar dependency providers from a declarative configuration. FastAPI provides
77+
the same filter contract through ``SQLSpecPlugin.provide_filters()`` for use with
78+
``Depends()``. These providers parse query parameters from incoming requests and
79+
produce filter objects.
7880

7981
.. literalinclude:: /examples/patterns/filter_dependencies.py
8082
:language: python
@@ -95,7 +97,7 @@ Using filters in a Litestar handler:
9597
user_filter_deps = create_filter_dependencies({
9698
"pagination_type": "limit_offset",
9799
"pagination_size": 20,
98-
"sort_field": ["created_at", "name"],
100+
"sort_field": ["created_at", "uploaded_collections", "name"],
99101
"sort_order": "desc",
100102
"search": "name,email",
101103
})
@@ -110,7 +112,46 @@ Using filters in a Litestar handler:
110112
return {"data": data, "total": total}
111113
112114
The generated dependencies automatically handle query parameters for configured fields like
113-
``?currentPage=2&pageSize=10&searchString=alice&orderBy=name&sortOrder=asc``.
115+
``?currentPage=2&pageSize=10&searchString=alice&orderBy=uploadedCollections&sortOrder=asc``.
116+
Camelized ``orderBy`` values are accepted by default for every configured
117+
``sort_field`` value, so ``orderBy=uploadedCollections`` is normalized to the
118+
SQL-facing field ``uploaded_collections`` before the ``OrderByFilter`` is
119+
created. Raw configured values such as ``orderBy=uploaded_collections`` also
120+
remain accepted for compatibility.
121+
122+
Sort aliases are closed over the configured ``sort_field`` allowlist. Use
123+
``sort_field_aliases`` when the public API name is not a mechanical camel-case
124+
conversion, or set ``sort_field_camelize=False`` to require raw configured
125+
``orderBy`` values only:
126+
127+
.. code-block:: python
128+
129+
user_filter_deps = create_filter_dependencies({
130+
"sort_field": ["created_at", "uploaded_collections"],
131+
"sort_field_aliases": {"lastUpload": "uploaded_collections"},
132+
})
133+
134+
snake_case_only_filter_deps = create_filter_dependencies({
135+
"sort_field": ["created_at", "uploaded_collections"],
136+
"sort_field_camelize": False,
137+
})
138+
139+
``orderBy=lastUpload`` is accepted, but aliases that target fields outside
140+
``sort_field`` are rejected when the provider is created. Unknown ``orderBy``
141+
values still fail validation before reaching SQL construction.
142+
143+
For FastAPI, use the same configuration with ``Depends()``:
144+
145+
.. code-block:: python
146+
147+
filters = Depends(
148+
db_ext.provide_filters({
149+
"sort_field": ["created_at", "uploaded_collections"],
150+
})
151+
)
152+
153+
SQLSpec does not ship generated filter providers for Flask, Starlette, or Sanic;
154+
their integrations do not have a runtime ``orderBy`` alias surface.
114155

115156
Service Layer
116157
-------------

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ maintainers = [{ name = "Litestar Developers", email = "hello@litestar.dev" }]
2424
name = "sqlspec"
2525
readme = "README.md"
2626
requires-python = ">=3.10, <4.0"
27-
version = "0.46.1"
27+
version = "0.46.2"
2828

2929
[project.urls]
3030
Discord = "https://discord.gg/litestar"
@@ -264,7 +264,7 @@ opt_level = "3" # Maximum optimization (0-3)
264264
allow_dirty = true
265265
commit = false
266266
commit_args = "--no-verify"
267-
current_version = "0.46.1"
267+
current_version = "0.46.2"
268268
ignore_missing_files = false
269269
ignore_missing_version = false
270270
message = "chore(release): bump to v{new_version}"
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""Shared filter alias helpers for framework extensions."""
2+
3+
from collections.abc import Mapping
4+
from typing import NamedTuple
5+
6+
from sqlspec.utils.text import camelize
7+
8+
__all__ = ("SortField", "SortFieldResolution", "resolve_sort_field_aliases")
9+
10+
SortField = str | set[str] | list[str]
11+
12+
13+
class SortFieldResolution(NamedTuple):
14+
"""Resolved sort-field alias metadata.
15+
16+
Args:
17+
default_field: Internal SQL-facing field used when no query value is supplied.
18+
default_query_value: API-facing default value exposed on the query parameter.
19+
allowed_fields: Internal SQL-facing allowlist.
20+
inbound_aliases: API-facing query values mapped to internal field names.
21+
field_display_names: Internal field names mapped to their preferred API-facing display names.
22+
allowed_display_names: Display names ordered to match the configured sort fields.
23+
"""
24+
25+
default_field: str
26+
default_query_value: str
27+
allowed_fields: frozenset[str]
28+
inbound_aliases: dict[str, str]
29+
field_display_names: dict[str, str]
30+
allowed_display_names: tuple[str, ...]
31+
32+
def normalize(self, value: str | None) -> str | None:
33+
"""Normalize a query value to an internal field name.
34+
35+
Args:
36+
value: API-facing query value, or ``None`` to request the default field.
37+
38+
Returns:
39+
The internal field name if the value is configured, otherwise ``None``.
40+
"""
41+
if value is None:
42+
return self.default_field
43+
return self.inbound_aliases.get(value)
44+
45+
46+
def resolve_sort_field_aliases(
47+
sort_field: SortField, sort_field_aliases: Mapping[str, str] | None = None, sort_field_camelize: bool = True
48+
) -> SortFieldResolution:
49+
"""Resolve sort-field aliases to a closed allowlist map.
50+
51+
Args:
52+
sort_field: Configured SQL-facing sort field or fields.
53+
sort_field_aliases: Optional API-facing alias to SQL-facing field mapping.
54+
sort_field_camelize: Whether to generate camel-case aliases for configured fields. Defaults to ``True``.
55+
56+
Returns:
57+
Precomputed alias metadata for framework filter providers.
58+
59+
Raises:
60+
ValueError: If an alias targets an unknown field or collides with a different field.
61+
"""
62+
fields = _coerce_fields(sort_field)
63+
allowed_fields = frozenset(fields)
64+
inbound_aliases: dict[str, str] = {}
65+
field_display_names = {field: field for field in fields}
66+
67+
for field in fields:
68+
_add_alias(inbound_aliases, alias=field, field=field)
69+
70+
if sort_field_camelize:
71+
for field in fields:
72+
alias = camelize(field)
73+
_add_alias(inbound_aliases, alias=alias, field=field)
74+
field_display_names[field] = alias
75+
76+
if sort_field_aliases:
77+
for alias, field in sort_field_aliases.items():
78+
if field not in allowed_fields:
79+
msg = f"sort field alias '{alias}' targets unknown sort field '{field}'"
80+
raise ValueError(msg)
81+
_add_alias(inbound_aliases, alias=alias, field=field)
82+
field_display_names[field] = alias
83+
84+
allowed_display_names = tuple(field_display_names[field] for field in fields)
85+
return SortFieldResolution(
86+
default_field=fields[0],
87+
default_query_value=field_display_names[fields[0]],
88+
allowed_fields=allowed_fields,
89+
inbound_aliases=inbound_aliases,
90+
field_display_names=field_display_names,
91+
allowed_display_names=allowed_display_names,
92+
)
93+
94+
95+
def _coerce_fields(sort_field: SortField) -> tuple[str, ...]:
96+
if isinstance(sort_field, str):
97+
return (sort_field,)
98+
fields = tuple(sorted(sort_field)) if isinstance(sort_field, set) else tuple(sort_field)
99+
if not fields:
100+
msg = "sort_field must include at least one field"
101+
raise ValueError(msg)
102+
return fields
103+
104+
105+
def _add_alias(inbound_aliases: dict[str, str], *, alias: str, field: str) -> None:
106+
existing_field = inbound_aliases.get(alias)
107+
if existing_field is None or existing_field == field:
108+
inbound_aliases[alias] = field
109+
return
110+
111+
msg = f"ambiguous sort field alias '{alias}' maps to both '{existing_field}' and '{field}'"
112+
raise ValueError(msg)

sqlspec/extensions/fastapi/extension.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -374,7 +374,7 @@ async def list_users(
374374
"search": "name,email",
375375
"search_ignore_case": True,
376376
"pagination_type": "limit_offset",
377-
"sort_field": "created_at",
377+
"sort_field": ["created_at", "uploaded_collections"],
378378
})
379379
),
380380
):

0 commit comments

Comments
 (0)