Skip to content

Commit d85ace0

Browse files
authored
feat: filter & service corrections (#431)
## Summary Closes a cluster of filter-correctness issues filed 2026-04-26 and adds the first-party service base classes that downstream consumers have been re-implementing. - Closes #424 — `StatementFilter`s now emit qualified columns; dotted `field_name="users.name"` works without `AmbiguousColumnError` on joined SELECTs and the count-query path. - Closes #425 — `SearchFilter` / `NotInSearchFilter` now raise `TypeError` for unsupported `field_name` types instead of silently dropping the WHERE clause, and accept `exp.Expression` (e.g. `LOWER(name)`) as a sort/search key — finishing the type widening started for #427. - Closes #426 — `QueryBuilder.order_by(sql.raw("COL DESC"))` correctly emits `ORDER BY ... DESC` instead of treating `DESC` as a column alias. - Closes #427 — `OrderByFilter.field_name` (and `SearchFilter.field_name`) accept `exp.Expression` for computed-column ordering/search; the wire surface (`?orderBy=`) stays `str`-only. - Closes #428 — exposes `SearchFilter.like_pattern` and adds `SearchFilter.escape_like_value()` so consumers bypassing the standard filter pipeline can opt into safe `%`/`_` escaping. - Closes #429 — adds first-party `SQLSpecAsyncService` / `SQLSpecSyncService` in `sqlspec/service.py` (`paginate`, `get_or_404`, `exists`, `begin`/`commit`/`rollback`, `begin_transaction`) and re-exports them from every framework extension package. Flask re-exports the sync class only.
1 parent 258cb64 commit d85ace0

22 files changed

Lines changed: 1302 additions & 187 deletions

File tree

docs/recipes/service_layer.rst

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ Base Service
3939
4040
4141
class SQLSpecAsyncService(Generic[AsyncDriverT]):
42-
"""Base async service with pagination, get-or-404, and transactions."""
42+
"""Base async service with pagination, get-one, and transactions."""
4343
4444
def __init__(self, driver: AsyncDriverT) -> None:
4545
self.driver = driver
@@ -88,7 +88,7 @@ Base Service
8888
total=total,
8989
)
9090
91-
async def get_or_404(
91+
async def get_one(
9292
self,
9393
statement: Statement | QueryBuilder,
9494
/,
@@ -160,7 +160,7 @@ Base Service
160160
161161
162162
class SQLSpecSyncService(Generic[SyncDriverT]):
163-
"""Base sync service with pagination, get-or-404, and transactions."""
163+
"""Base sync service with pagination, get-one, and transactions."""
164164
165165
def __init__(self, driver: SyncDriverT) -> None:
166166
self.driver = driver
@@ -209,7 +209,7 @@ Base Service
209209
total=total,
210210
)
211211
212-
def get_or_404(
212+
def get_one(
213213
self,
214214
statement: Statement | QueryBuilder,
215215
/,
@@ -294,7 +294,7 @@ Filters from Litestar dependencies flow straight through ``*filters`` to the dri
294294
)
295295
296296
async def get_user(self, user_id: UUID) -> User:
297-
return await self.get_or_404(
297+
return await self.get_one(
298298
sql.select("id", "email", "name").from_("users").where_eq("id", user_id),
299299
schema_type=User,
300300
error_message=f"User {user_id} not found",
@@ -338,7 +338,7 @@ Filters from Litestar dependencies flow straight through ``*filters`` to the dri
338338
)
339339
340340
def get_user(self, user_id: UUID) -> User:
341-
return self.get_or_404(
341+
return self.get_one(
342342
sql.select("id", "email", "name").from_("users").where_eq("id", user_id),
343343
schema_type=User,
344344
error_message=f"User {user_id} not found",

docs/usage/filtering.rst

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,23 +17,59 @@ to get both the page data and the total matching count.
1717
:end-before: # end-example
1818
:dedent: 4
1919
:no-upgrade:
20-
2120
Core Filter Types
2221
-----------------
2322

2423
SQLSpec defines filter types in ``sqlspec.core`` that can be used independently
2524
or with framework integrations:
2625

2726
- ``LimitOffsetFilter(limit, offset)`` -- pagination
28-
- ``OrderByFilter(field_name, sort_order)`` -- sorting
27+
- ``OrderByFilter(field_name, sort_order)`` -- sorting (supports expression mode)
2928
- ``SearchFilter(field_name, value, ignore_case)`` -- text search
3029
- ``BeforeAfterFilter(field_name, before, after)`` -- date range
3130
- ``InCollectionFilter(field_name, values)`` -- set membership
3231
- ``NotInCollectionFilter(field_name, values)`` -- set exclusion
3332
- ``NullFilter(field_name)`` -- IS NULL check
3433
- ``NotNullFilter(field_name)`` -- IS NOT NULL check
3534

35+
Qualified Field Names
36+
~~~~~~~~~~~~~~~~~~~~~
37+
38+
Every field-name-bearing filter supports table-qualified field names (e.g. ``p.name``).
39+
SQLSpec correctly parses these into qualified SQLGlot column references and sanitizes
40+
generated parameter names (e.g. ``p_name_search``), making filters safe to use in
41+
joined queries.
42+
43+
.. code-block:: python
44+
45+
# Disambiguate columns in a JOIN
46+
query = sql.select("p.name", "c.name").from_("parent p").join("child c", "p.id = c.parent_id")
47+
filter_obj = SearchFilter(field_name="p.name", value="alice")
48+
# Results in: WHERE p.name LIKE :p_name_search
49+
50+
Expression Mode
51+
~~~~~~~~~~~~~~~
52+
53+
Filters like ``OrderByFilter`` support passing a SQLGlot expression instead of a
54+
string field name. This allows complex sorting and filtering logic:
55+
56+
.. code-block:: python
57+
58+
from sqlglot import exp
59+
60+
# Sort by COALESCE(lines, 0)
61+
expr = exp.func("COALESCE", exp.column("lines"), exp.Literal.number(0))
62+
filter_obj = OrderByFilter(field_name=expr, sort_order="desc")
63+
64+
Search Patterns
65+
~~~~~~~~~~~~~~~
66+
67+
``SearchFilter`` and ``NotInSearchFilter`` expose a ``like_pattern`` property
68+
that returns the percent-wrapped search value (e.g. ``%alice%``). This is useful
69+
when you need to use the pattern construction logic outside of the filter system.
70+
3671
Litestar Filter Dependencies
72+
3773
-----------------------------
3874

3975
When using the Litestar extension, ``create_filter_dependencies()`` auto-generates
@@ -76,6 +112,27 @@ Using filters in a Litestar handler:
76112
The generated dependencies automatically handle query parameters for configured fields like
77113
``?currentPage=2&pageSize=10&searchString=alice&orderBy=name&sortOrder=asc``.
78114

115+
Service Layer
116+
-------------
117+
118+
For common database operations and pagination in application services, SQLSpec provides
119+
base classes ``SQLSpecAsyncService`` and ``SQLSpecSyncService`` in ``sqlspec.service``.
120+
121+
.. code-block:: python
122+
123+
from sqlspec.service import SQLSpecAsyncService
124+
from sqlspec.core.filters import LimitOffsetFilter
125+
126+
class UserService(SQLSpecAsyncService):
127+
async def list_users(self, filters: list[StatementFilter]) -> OffsetPagination[User]:
128+
query = sql.select("*").from_("users")
129+
return await self.paginate(query, *filters, schema_type=User)
130+
131+
async def some_handler(db_session: AsyncDriver, filters: list[StatementFilter]):
132+
service = UserService(db_session)
133+
page = await service.list_users(filters)
134+
return page # Returns OffsetPagination container
135+
79136
Related Guides
80137
--------------
81138

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.44.0"
27+
version = "0.45.0"
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.44.0"
267+
current_version = "0.45.0"
268268
ignore_missing_files = false
269269
ignore_missing_version = false
270270
message = "chore(release): bump to v{new_version}"

sqlspec/builder/_select.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -425,7 +425,13 @@ def order_by(self, *items: Union[str, exp.Ordered, "Column"], desc: bool = False
425425
order_item = order_item.desc()
426426
else:
427427
extracted_item = extract_expression(item)
428-
order_item = extracted_item.desc() if desc and not isinstance(item, exp.Ordered) else extracted_item
428+
if isinstance(extracted_item, exp.Alias):
429+
alias_name = (extracted_item.alias or "").lower()
430+
if alias_name in {"asc", "desc"}:
431+
extracted_item = exp.Ordered(this=extracted_item.this, desc=alias_name == "desc")
432+
order_item = (
433+
extracted_item.desc() if desc and not isinstance(extracted_item, exp.Ordered) else extracted_item
434+
)
429435
current_expr = current_expr.order_by(order_item, copy=False)
430436
builder.set_expression(current_expr)
431437
return cast("Self", builder)

0 commit comments

Comments
 (0)