fastapi-toolsets 5.1.6__tar.gz → 5.1.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/PKG-INFO +1 -1
  2. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/pyproject.toml +1 -1
  3. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/pyproject.toml.orig +1 -1
  4. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/__init__.py +1 -1
  5. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/_orm.py +6 -1
  6. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/crud/factory.py +120 -51
  7. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/crud/search.py +45 -7
  8. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/dependencies.py +11 -3
  9. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/models/watched.py +18 -10
  10. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/LICENSE +0 -0
  11. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/README.md +0 -0
  12. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/_imports.py +0 -0
  13. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/cli/__init__.py +0 -0
  14. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/cli/app.py +0 -0
  15. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/cli/commands/__init__.py +0 -0
  16. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/cli/commands/fixtures.py +0 -0
  17. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/cli/config.py +0 -0
  18. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/cli/pyproject.py +0 -0
  19. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/cli/utils.py +0 -0
  20. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/crud/__init__.py +0 -0
  21. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/db/__init__.py +0 -0
  22. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/db/core.py +0 -0
  23. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/db/locks.py +0 -0
  24. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/db/m2m.py +0 -0
  25. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/db/testing.py +0 -0
  26. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/db/watch.py +0 -0
  27. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/exceptions/__init__.py +0 -0
  28. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/exceptions/exceptions.py +0 -0
  29. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/exceptions/handler.py +0 -0
  30. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/fixtures/__init__.py +0 -0
  31. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/fixtures/enum.py +0 -0
  32. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/fixtures/registry.py +0 -0
  33. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/fixtures/utils.py +0 -0
  34. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/logger.py +0 -0
  35. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/metrics/__init__.py +0 -0
  36. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/metrics/handler.py +0 -0
  37. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/metrics/registry.py +0 -0
  38. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/models/__init__.py +0 -0
  39. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/models/columns.py +0 -0
  40. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/py.typed +0 -0
  41. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/pytest/__init__.py +0 -0
  42. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/pytest/plugin.py +0 -0
  43. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/pytest/utils.py +0 -0
  44. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/schemas.py +0 -0
  45. {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-toolsets
3
- Version: 5.1.6
3
+ Version: 5.1.7
4
4
  Summary: Production-ready utilities for FastAPI applications
5
5
  Keywords: fastapi,sqlalchemy,postgresql
6
6
  Author: d3vyce
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fastapi-toolsets"
3
- version = "5.1.6"
3
+ version = "5.1.7"
4
4
  description = "Production-ready utilities for FastAPI applications"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fastapi-toolsets"
3
- version = "5.1.6"
3
+ version = "5.1.7"
4
4
  description = "Production-ready utilities for FastAPI applications"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -24,4 +24,4 @@ Example usage:
24
24
  return Response(data={"user": user.username}, message="Success")
25
25
  """
26
26
 
27
- __version__ = "5.1.6"
27
+ __version__ = "5.1.7"
@@ -1,6 +1,6 @@
1
1
  """SQLAlchemy state and expression helpers."""
2
2
 
3
- from collections.abc import Sequence
3
+ from collections.abc import Iterable, Sequence
4
4
  from typing import Any
5
5
 
6
6
  from sqlalchemy import inspect as sa_inspect
@@ -12,6 +12,11 @@ def key_expr(columns: Sequence[Any]) -> Any:
12
12
  return columns[0] if len(columns) == 1 else tuple_(*columns)
13
13
 
14
14
 
15
+ def is_to_many(rels: Iterable[Any]) -> bool:
16
+ """True if the relationship path *rels* crosses a collection."""
17
+ return any(rel.property.uselist for rel in rels)
18
+
19
+
15
20
  def is_expired(obj: Any) -> bool:
16
21
  """True when *obj* or any of its attributes must be re-read from the database."""
17
22
  state = sa_inspect(obj)
@@ -21,10 +21,14 @@ from sqlalchemy import (
21
21
  Float,
22
22
  Integer,
23
23
  Numeric,
24
+ PrimaryKeyConstraint,
25
+ String,
26
+ UniqueConstraint,
24
27
  Uuid,
25
28
  and_,
26
29
  func,
27
30
  select,
31
+ tuple_,
28
32
  )
29
33
  from sqlalchemy.dialects.postgresql import insert
30
34
  from sqlalchemy.exc import NoResultFound
@@ -37,7 +41,7 @@ from sqlalchemy.sql.elements import UnaryExpression
37
41
  from sqlalchemy.sql.roles import WhereHavingRole
38
42
  from sqlalchemy.sql.util import ClauseAdapter
39
43
 
40
- from .._orm import is_expired, key_expr, loaded_relationships
44
+ from .._orm import is_expired, is_to_many, key_expr, loaded_relationships
41
45
  from ..db import transaction
42
46
  from ..exceptions import InvalidOrderFieldError, NotFoundError
43
47
  from ..schemas import (
@@ -89,23 +93,23 @@ class _CursorDirection(str, Enum):
89
93
 
90
94
 
91
95
  def _encode_cursor(
92
- value: Any, *, direction: _CursorDirection = _CursorDirection.NEXT
96
+ value: Any,
97
+ *,
98
+ direction: _CursorDirection = _CursorDirection.NEXT,
99
+ key: Sequence[Any] = (),
93
100
  ) -> str:
94
- """Encode a cursor column value and navigation direction as a URL-safe base64 string."""
95
- return (
96
- base64.urlsafe_b64encode(
97
- json.dumps({"val": str(value), "dir": direction}).encode()
98
- )
99
- .decode()
100
- .rstrip("=")
101
- )
101
+ """Encode a cursor value, its row's tiebreak key and direction as URL-safe base64."""
102
+ payload: dict[str, Any] = {"val": str(value), "dir": direction}
103
+ if key:
104
+ payload["key"] = [str(v) for v in key]
105
+ return base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=")
102
106
 
103
107
 
104
- def _decode_cursor(cursor: str) -> tuple[str, _CursorDirection]:
105
- """Decode a URL-safe base64 cursor string into ``(raw_value, direction)``."""
108
+ def _decode_cursor(cursor: str) -> tuple[str, list[str] | None, _CursorDirection]:
109
+ """Decode a URL-safe base64 cursor string into ``(raw_value, raw_key, direction)``."""
106
110
  padded = cursor + "=" * (-len(cursor) % 4)
107
111
  payload = json.loads(base64.urlsafe_b64decode(padded).decode())
108
- return payload["val"], _CursorDirection(payload["dir"])
112
+ return payload["val"], payload.get("key"), _CursorDirection(payload["dir"])
109
113
 
110
114
 
111
115
  def _page_size_query(default: int, max_size: int) -> int:
@@ -137,6 +141,27 @@ def _parse_cursor_value(raw_val: str, col_type: Any) -> Any:
137
141
  )
138
142
 
139
143
 
144
+ def _parse_key_value(raw_val: str, col_type: Any) -> Any:
145
+ """Parse a raw tiebreak key value, which unlike a cursor value may be a string."""
146
+ if isinstance(col_type, String):
147
+ return raw_val
148
+ return _parse_cursor_value(raw_val, col_type)
149
+
150
+
151
+ def _is_unique(column: Any) -> bool:
152
+ """True if a primary key, constraint or index makes *column* alone unique."""
153
+ if column.unique:
154
+ return True
155
+ table = column.table
156
+ keys = [
157
+ c.columns
158
+ for c in table.constraints
159
+ if isinstance(c, (PrimaryKeyConstraint, UniqueConstraint))
160
+ ]
161
+ keys += [i.columns for i in table.indexes if i.unique]
162
+ return any(len(cols) == 1 and next(iter(cols)) is column for cols in keys)
163
+
164
+
140
165
  def _apply_joins(q: Any, joins: JoinType | None, outer_join: bool) -> Any:
141
166
  """Apply a list of (model, condition) joins to a SQLAlchemy select query."""
142
167
  if not joins:
@@ -152,9 +177,7 @@ def _repeats_rows(joins: JoinType | None, *rel_lists: Sequence[Any] | None) -> b
152
177
  A to-many relationship join yields one row per related row. A raw
153
178
  join's cardinality cannot be inspected, so it counts as repeating.
154
179
  """
155
- return bool(joins) or any(
156
- rel.property.uselist for rels in rel_lists for rel in (rels or ())
157
- )
180
+ return bool(joins) or any(is_to_many(rels or ()) for rels in rel_lists)
158
181
 
159
182
 
160
183
  @dataclass
@@ -169,10 +192,16 @@ class _QueryPlan:
169
192
  fb_joins: list[Any]
170
193
 
171
194
 
172
- def _grouped_order(clause: Any, table: Any) -> Any:
173
- """Recast an order clause for a query grouped by the entity's key."""
195
+ def _order_expr(clause: Any) -> tuple[Any, Any]:
196
+ """Split an order clause into its sorted operand and that operand's SQL expression."""
174
197
  inner = clause.element if isinstance(clause, UnaryExpression) else clause
175
198
  expr = inner.__clause_element__() if hasattr(inner, "__clause_element__") else inner
199
+ return inner, expr
200
+
201
+
202
+ def _grouped_order(clause: Any, table: Any) -> Any:
203
+ """Recast an order clause for a query grouped by the entity's key."""
204
+ inner, expr = _order_expr(clause)
176
205
  tables = {
177
206
  t
178
207
  for c in getattr(expr, "base_columns", ())
@@ -229,6 +258,26 @@ class AsyncCrud(Generic[ModelType]):
229
258
  for col in cls.model.__mapper__.primary_key
230
259
  ]
231
260
 
261
+ @classmethod
262
+ def _key_tiebreak(cls: type[Self], order_clauses: Sequence[Any]) -> list[Any]:
263
+ """The primary key columns *order_clauses* leave out, so ties keep one order."""
264
+ ordered = [_order_expr(c)[1] for c in order_clauses]
265
+ return [
266
+ attr
267
+ for attr, col in zip(cls._pk_attrs(), cls.model.__mapper__.primary_key)
268
+ if not any(e.compare(col) for e in ordered)
269
+ ]
270
+
271
+ @classmethod
272
+ def _tiebreak_attrs(
273
+ cls: type[Self], cursor_column: Any
274
+ ) -> list[QueryableAttribute[Any]]:
275
+ """The primary key columns ordering rows that share a *cursor_column* value."""
276
+ column = cursor_column.property.columns[0]
277
+ if _is_unique(column):
278
+ return []
279
+ return [a for a in cls._pk_attrs() if a.property.columns[0] is not column]
280
+
232
281
  @classmethod
233
282
  async def _page_entities(
234
283
  cls: type[Self],
@@ -236,16 +285,18 @@ class AsyncCrud(Generic[ModelType]):
236
285
  q: Any,
237
286
  *,
238
287
  order_clauses: Sequence[Any],
239
- limit: int,
288
+ limit: int | None,
240
289
  offset: int | None = None,
241
290
  load_options: Sequence[ExecutableOption] | None = None,
242
291
  with_for_update: _ForUpdateMode = False,
243
292
  ) -> list[ModelType]:
244
- """Return up to *limit* entities, paging over distinct primary keys."""
293
+ """Return up to *limit* (or all) entities, paging over distinct primary keys."""
245
294
  pk_attrs = cls._pk_attrs()
246
295
  table = cls.model.__table__
247
- # Fall back to the primary key so the page boundary is deterministic.
248
- grouped = [_grouped_order(c, table) for c in order_clauses] or [pk_attrs[0]]
296
+ grouped = [
297
+ *(_grouped_order(c, table) for c in order_clauses),
298
+ *cls._key_tiebreak(order_clauses),
299
+ ]
249
300
  id_q = (
250
301
  q.order_by(None)
251
302
  .with_only_columns(*pk_attrs)
@@ -284,14 +335,14 @@ class AsyncCrud(Generic[ModelType]):
284
335
  *,
285
336
  repeats: bool,
286
337
  order_clauses: Sequence[Any],
287
- limit: int,
338
+ limit: int | None,
288
339
  offset: int | None = None,
289
340
  load_options: Sequence[ExecutableOption] | None = None,
290
341
  with_for_update: _ForUpdateMode = False,
291
342
  ) -> list[ModelType]:
292
343
  """Return up to *limit* entities of *q*, by key when a join *repeats* rows."""
293
344
  if repeats:
294
- # LIMIT would slice joined rows and `.unique()` shrink the page.
345
+ # LIMIT and OFFSET would count joined rows, not entities.
295
346
  return await cls._page_entities(
296
347
  session,
297
348
  q,
@@ -301,6 +352,7 @@ class AsyncCrud(Generic[ModelType]):
301
352
  load_options=load_options,
302
353
  with_for_update=with_for_update,
303
354
  )
355
+ q = q.order_by(*cls._key_tiebreak(order_clauses))
304
356
  q = _apply_for_update(q, with_for_update)
305
357
  if offset:
306
358
  q = q.offset(offset)
@@ -1184,6 +1236,7 @@ class AsyncCrud(Generic[ModelType]):
1184
1236
  outer_join: bool = False,
1185
1237
  with_for_update: _ForUpdateMode = False,
1186
1238
  load_options: Sequence[ExecutableOption] | None = None,
1239
+ order_by: OrderByClause | None = None,
1187
1240
  schema: type[SchemaType],
1188
1241
  ) -> Response[SchemaType] | None: ...
1189
1242
 
@@ -1198,6 +1251,7 @@ class AsyncCrud(Generic[ModelType]):
1198
1251
  outer_join: bool = False,
1199
1252
  with_for_update: _ForUpdateMode = False,
1200
1253
  load_options: Sequence[ExecutableOption] | None = None,
1254
+ order_by: OrderByClause | None = None,
1201
1255
  schema: None = ...,
1202
1256
  ) -> ModelType | None: ...
1203
1257
 
@@ -1211,6 +1265,7 @@ class AsyncCrud(Generic[ModelType]):
1211
1265
  outer_join: bool = False,
1212
1266
  with_for_update: _ForUpdateMode = False,
1213
1267
  load_options: Sequence[ExecutableOption] | None = None,
1268
+ order_by: OrderByClause | None = None,
1214
1269
  schema: type[BaseModel] | None = None,
1215
1270
  ) -> ModelType | Response[Any] | None:
1216
1271
  """Get the first matching record, or None.
@@ -1222,6 +1277,8 @@ class AsyncCrud(Generic[ModelType]):
1222
1277
  outer_join: Use LEFT OUTER JOIN instead of INNER JOIN
1223
1278
  with_for_update: Lock the row for update
1224
1279
  load_options: SQLAlchemy loader options (e.g., selectinload)
1280
+ order_by: Column or expression deciding which match comes first.
1281
+ Without it, any matching row may be returned.
1225
1282
  schema: Pydantic schema to serialize the result into. When provided,
1226
1283
  the result is automatically wrapped in a ``Response[schema]``.
1227
1284
 
@@ -1235,6 +1292,8 @@ class AsyncCrud(Generic[ModelType]):
1235
1292
  q = q.where(and_(*filters))
1236
1293
  if resolved := cls._resolve_load_options(load_options):
1237
1294
  q = q.options(*resolved)
1295
+ if order_by is not None:
1296
+ q = q.order_by(order_by)
1238
1297
  q = _apply_for_update(q, with_for_update)
1239
1298
  result = await session.execute(q.limit(1))
1240
1299
  item = result.unique().scalars().first()
@@ -1270,7 +1329,7 @@ class AsyncCrud(Generic[ModelType]):
1270
1329
  ``"nowait"`` for ``FOR UPDATE NOWAIT``, ``"skip_locked"`` for
1271
1330
  ``FOR UPDATE SKIP LOCKED``.
1272
1331
  load_options: SQLAlchemy loader options
1273
- order_by: Column or list of columns to order by
1332
+ order_by: Column or expression to order by
1274
1333
  limit: Max number of rows to return
1275
1334
  offset: Rows to skip
1276
1335
 
@@ -1286,7 +1345,7 @@ class AsyncCrud(Generic[ModelType]):
1286
1345
  if order_by is not None:
1287
1346
  q = q.order_by(order_by)
1288
1347
 
1289
- if limit is not None:
1348
+ if limit is not None or offset is not None:
1290
1349
  return await cls._fetch_page(
1291
1350
  session,
1292
1351
  q,
@@ -1299,8 +1358,6 @@ class AsyncCrud(Generic[ModelType]):
1299
1358
  )
1300
1359
 
1301
1360
  q = _apply_for_update(q, with_for_update)
1302
- if offset is not None:
1303
- q = q.offset(offset)
1304
1361
  result = await session.execute(q)
1305
1362
  return cast(Sequence[ModelType], result.unique().scalars().all())
1306
1363
 
@@ -1578,7 +1635,7 @@ class AsyncCrud(Generic[ModelType]):
1578
1635
  joins: List of (model, condition) tuples for joining related tables
1579
1636
  outer_join: Use LEFT OUTER JOIN instead of INNER JOIN
1580
1637
  load_options: SQLAlchemy loader options
1581
- order_by: Column or list of columns to order by
1638
+ order_by: Column or expression to order by
1582
1639
  page: Page number (1-indexed)
1583
1640
  items_per_page: Number of items per page
1584
1641
  include_total: When ``False``, skip the ``COUNT`` query;
@@ -1705,7 +1762,8 @@ class AsyncCrud(Generic[ModelType]):
1705
1762
  outer_join: Use LEFT OUTER JOIN instead of INNER JOIN.
1706
1763
  load_options: SQLAlchemy loader options. Falls back to
1707
1764
  ``default_load_options`` when not provided.
1708
- order_by: Additional ordering applied after the cursor column.
1765
+ order_by: Additional ordering applied after the cursor column and
1766
+ its primary key tiebreak, which already order rows fully.
1709
1767
  items_per_page: Number of items per page (default 20).
1710
1768
  search: Search query string or SearchConfig object.
1711
1769
  search_fields: Fields to search in (overrides class default).
@@ -1738,16 +1796,30 @@ class AsyncCrud(Generic[ModelType]):
1738
1796
  filter_by=filter_by,
1739
1797
  facet_fields=facet_fields,
1740
1798
  )
1799
+ # Rows sharing a cursor value are ordered, and resumed, by primary key.
1800
+ tiebreak = cls._tiebreak_attrs(cursor_column)
1801
+
1741
1802
  # The cursor only positions the page: facets describe the whole result.
1742
1803
  direction = _CursorDirection.NEXT
1743
1804
  if cursor is not None:
1744
- raw_val, direction = _decode_cursor(cursor)
1805
+ raw_val, raw_key, direction = _decode_cursor(cursor)
1745
1806
  col_type = cursor_column.property.columns[0].type
1746
1807
  cursor_val: Any = _parse_cursor_value(raw_val, col_type)
1808
+ # Without a key (unique column, or a cursor from before keys) resume on the value.
1809
+ bound: Any = cursor_column
1810
+ if tiebreak and raw_key is not None:
1811
+ bound = tuple_(cursor_column, *tiebreak)
1812
+ cursor_val = (
1813
+ cursor_val,
1814
+ *(
1815
+ _parse_key_value(v, a.property.columns[0].type)
1816
+ for v, a in zip(raw_key, tiebreak, strict=True)
1817
+ ),
1818
+ )
1747
1819
  if direction is _CursorDirection.PREV:
1748
- plan.filters.append(cursor_column < cursor_val)
1820
+ plan.filters.append(bound < cursor_val)
1749
1821
  else:
1750
- plan.filters.append(cursor_column > cursor_val)
1822
+ plan.filters.append(bound > cursor_val)
1751
1823
 
1752
1824
  q = select(cls.model)
1753
1825
  q = _apply_joins(q, joins, outer_join)
@@ -1757,13 +1829,11 @@ class AsyncCrud(Generic[ModelType]):
1757
1829
  if resolved := cls._resolve_load_options(load_options):
1758
1830
  q = q.options(*resolved)
1759
1831
 
1760
- # Cursor column is always the primary sort; reverse direction for prev traversal
1761
- cursor_clause = (
1762
- cursor_column.desc()
1763
- if direction is _CursorDirection.PREV
1764
- else cursor_column
1765
- )
1766
- order_clauses: list[Any] = [cursor_clause]
1832
+ # Cursor column then key is always the primary sort; reverse direction for prev traversal
1833
+ order_clauses: list[Any] = [
1834
+ col.desc() if direction is _CursorDirection.PREV else col
1835
+ for col in (cursor_column, *tiebreak)
1836
+ ]
1767
1837
  if order_by is not None:
1768
1838
  order_clauses.append(order_by)
1769
1839
  q = q.order_by(*order_clauses)
@@ -1784,21 +1854,22 @@ class AsyncCrud(Generic[ModelType]):
1784
1854
  if direction is _CursorDirection.PREV:
1785
1855
  items_page = list(reversed(items_page))
1786
1856
 
1857
+ def _cursor_at(item: Any, to: _CursorDirection) -> str:
1858
+ return _encode_cursor(
1859
+ getattr(item, cursor_col_name),
1860
+ direction=to,
1861
+ key=[getattr(item, a.key) for a in tiebreak],
1862
+ )
1863
+
1787
1864
  # next_cursor: points past the last item in ascending order
1788
1865
  next_cursor: str | None = None
1789
1866
  if direction is _CursorDirection.NEXT:
1790
1867
  if has_more and items_page:
1791
- next_cursor = _encode_cursor(
1792
- getattr(items_page[-1], cursor_col_name),
1793
- direction=_CursorDirection.NEXT,
1794
- )
1868
+ next_cursor = _cursor_at(items_page[-1], _CursorDirection.NEXT)
1795
1869
  else:
1796
1870
  # Going backward: always provide a next_cursor to allow returning forward
1797
1871
  if items_page:
1798
- next_cursor = _encode_cursor(
1799
- getattr(items_page[-1], cursor_col_name),
1800
- direction=_CursorDirection.NEXT,
1801
- )
1872
+ next_cursor = _cursor_at(items_page[-1], _CursorDirection.NEXT)
1802
1873
 
1803
1874
  # prev_cursor: points before the first item in ascending order
1804
1875
  prev_cursor: str | None = None
@@ -1806,9 +1877,7 @@ class AsyncCrud(Generic[ModelType]):
1806
1877
  (direction is _CursorDirection.NEXT and cursor is not None)
1807
1878
  or (direction is _CursorDirection.PREV and has_more)
1808
1879
  ):
1809
- prev_cursor = _encode_cursor(
1810
- getattr(items_page[0], cursor_col_name), direction=_CursorDirection.PREV
1811
- )
1880
+ prev_cursor = _cursor_at(items_page[0], _CursorDirection.PREV)
1812
1881
 
1813
1882
  items: list[Any] = [schema.model_validate(item) for item in items_page]
1814
1883
 
@@ -11,6 +11,7 @@ from sqlalchemy import (
11
11
  Table,
12
12
  and_,
13
13
  any_,
14
+ false,
14
15
  func,
15
16
  or_,
16
17
  select,
@@ -32,7 +33,7 @@ from sqlalchemy.types import (
32
33
  Uuid,
33
34
  )
34
35
 
35
- from .._orm import key_expr
36
+ from .._orm import is_to_many, key_expr
36
37
  from ..exceptions import (
37
38
  InvalidFacetFilterError,
38
39
  InvalidSearchColumnError,
@@ -120,6 +121,11 @@ class SearchPlan:
120
121
 
121
122
 
122
123
  _EMPTY_PLAN = SearchPlan([], [], [], [])
124
+ _NO_MATCH_PLAN = SearchPlan([false()], [], [false()], [])
125
+
126
+ _INTEGER_CHARS = frozenset("0123456789-")
127
+ _UUID_CHARS = frozenset("0123456789abcdef-")
128
+ _LIKE_SPECIAL = frozenset("%_\\")
123
129
 
124
130
  _Entry = tuple[tuple[Any, ...], "ColumnElement[bool]"]
125
131
 
@@ -165,18 +171,44 @@ def build_search_plan(
165
171
  raise InvalidSearchColumnError(search_column, sorted(index))
166
172
  fields = [index[search_column]]
167
173
 
168
- entries = [(_field_rels(f), _search_condition(f, query, config)) for f in fields]
174
+ chars = set(query if config.case_sensitive else query.lower())
175
+ possible = [f for f in fields if _can_contain(_field_column(f), chars)]
176
+ if not possible or (config.match_mode == "all" and len(possible) < len(fields)):
177
+ return _NO_MATCH_PLAN
178
+
179
+ entries = [(_field_rels(f), _search_condition(f, query, config)) for f in possible]
169
180
  page = _render_search(entries, [], config.match_mode)
170
- to_many = [e for e in entries if any(r.property.uselist for r in e[0])]
181
+ related = [e for e in entries if e[0]]
182
+ to_many = [e for e in related if is_to_many(e[0])]
171
183
  # All or nothing, so the aggregate form matches the page form exactly.
172
- if to_many and all(_semi_joinable(r) for rels, _ in to_many for r in rels):
173
- joined = [e for e in entries if e not in to_many]
184
+ if related and _all_semi_joinable(related):
185
+ agg = _render_search([], entries, config.match_mode)
186
+ elif to_many and _all_semi_joinable(to_many):
187
+ # Not `e not in to_many`: == on relationship attributes builds SQL.
188
+ joined = [e for e in entries if not is_to_many(e[0])]
174
189
  agg = _render_search(joined, to_many, config.match_mode)
175
190
  else:
176
191
  agg = page
177
192
  return SearchPlan(*page, *agg)
178
193
 
179
194
 
195
+ def _can_contain(column: Any, chars: set[str]) -> bool:
196
+ """False when the text of an integer or UUID column can never contain *chars*."""
197
+ if chars & _LIKE_SPECIAL:
198
+ return True
199
+ col_type = column.type
200
+ if isinstance(col_type, Integer):
201
+ return chars <= _INTEGER_CHARS
202
+ if isinstance(col_type, Uuid):
203
+ return chars <= _UUID_CHARS
204
+ return True
205
+
206
+
207
+ def _all_semi_joinable(entries: Sequence[_Entry]) -> bool:
208
+ """True if every relationship of *entries* can be filtered with a subquery."""
209
+ return all(_semi_joinable(rel) for rels, _ in entries for rel in rels)
210
+
211
+
180
212
  def _render_search(
181
213
  joined: Sequence[_Entry], subqueries: Sequence[_Entry], match_mode: str
182
214
  ) -> tuple[list["ColumnElement[bool]"], list[InstrumentedAttribute[Any]]]:
@@ -221,11 +253,16 @@ def _field_rels(field: SearchFieldType) -> tuple[Any, ...]:
221
253
  return tuple(field[:-1]) if isinstance(field, tuple) else ()
222
254
 
223
255
 
256
+ def _field_column(field: SearchFieldType) -> Any:
257
+ """Column a search field reads, at the end of its relationship path."""
258
+ return field[-1] if isinstance(field, tuple) else field
259
+
260
+
224
261
  def _search_condition(
225
262
  field: SearchFieldType, query: str, config: SearchConfig
226
263
  ) -> "ColumnElement[bool]":
227
264
  """LIKE/ILIKE condition on the field's column."""
228
- column = field[-1] if isinstance(field, tuple) else field
265
+ column = _field_column(field)
229
266
  # Cast to String only when needed, to preserve pg_trgm GIN index
230
267
  # usability on already-String columns.
231
268
  column_as_string = (
@@ -252,6 +289,7 @@ def _is_plain_join(condition: Any, pairs: Sequence[tuple[Any, Any]]) -> bool:
252
289
  return found == expected
253
290
 
254
291
 
292
+ @functools.lru_cache(maxsize=128)
255
293
  def _semi_joinable(rel: Any) -> bool:
256
294
  """True if *rel* can be filtered with an `IN (subquery)` instead of a join."""
257
295
  prop = rel.property
@@ -470,7 +508,7 @@ def _facet_rows(
470
508
  prefiltered
471
509
  or not all(_semi_joinable(rel) for rel in rels)
472
510
  or any(rel.property.uselist and str(rel) in joined for rel in rels)
473
- or (base_joins and not any(rel.property.uselist for rel in rels))
511
+ or (base_joins and not is_to_many(rels))
474
512
  )
475
513
  joins = [*base_joins, *rels] if keep_join else base_joins
476
514
  rows = apply_search_joins(select(model), joins)
@@ -5,7 +5,7 @@ import typing
5
5
  from collections.abc import Callable, Sequence
6
6
  from typing import Any, cast
7
7
 
8
- from fastapi import Depends
8
+ from fastapi import Body, Depends, Path
9
9
  from fastapi.params import Depends as DependsClass
10
10
  from sqlalchemy.ext.asyncio import AsyncSession
11
11
  from sqlalchemy.sql.base import ExecutableOption
@@ -31,10 +31,14 @@ def _fetch_dependency(
31
31
  *,
32
32
  session_dep: SessionDependency,
33
33
  param_name: str,
34
+ source: Any,
34
35
  crud: type[AsyncCrud[ModelType]] | None,
35
36
  load_options: Sequence[ExecutableOption] | None,
36
37
  ) -> ModelType:
37
- """Build a Depends() that fetches one row by ``field == <param_name>``."""
38
+ """Build a Depends() that fetches one row by ``field == <param_name>``.
39
+
40
+ *source* (``Path()`` or ``Body()``) tells FastAPI where the value comes from.
41
+ """
38
42
  session_callable = _unwrap_session_dep(session_dep)
39
43
  if crud is not None and crud.model is not model:
40
44
  raise ValueError(
@@ -57,6 +61,7 @@ def _fetch_dependency(
57
61
  param_name,
58
62
  inspect.Parameter.KEYWORD_ONLY,
59
63
  annotation=field.type.python_type,
64
+ default=source,
60
65
  ),
61
66
  inspect.Parameter(
62
67
  "session",
@@ -112,6 +117,7 @@ def PathDependency(
112
117
  field,
113
118
  session_dep=session_dep,
114
119
  param_name=param_name or f"{model.__name__.lower()}_{field.key}",
120
+ source=Path(),
115
121
  crud=crud,
116
122
  load_options=load_options,
117
123
  )
@@ -132,7 +138,8 @@ def BodyDependency(
132
138
  model: SQLAlchemy model class
133
139
  field: Model field to filter by (e.g., User.id)
134
140
  session_dep: Session dependency function (e.g., get_db)
135
- body_field: Name of the field in the request body
141
+ body_field: Key of the JSON request body holding the value. Other body
142
+ parameters of the endpoint then sit under their own names.
136
143
  crud: Existing CRUD class to fetch with, so its ``default_load_options``
137
144
  apply. Defaults to a bare ``CrudFactory(model)``.
138
145
  load_options: SQLAlchemy loader options for the fetch. Overrides the CRUD's
@@ -161,6 +168,7 @@ def BodyDependency(
161
168
  field,
162
169
  session_dep=session_dep,
163
170
  param_name=body_field,
171
+ source=Body(embed=True),
164
172
  crud=crud,
165
173
  load_options=load_options,
166
174
  )
@@ -289,21 +289,29 @@ async def _batch_reload(
289
289
 
290
290
 
291
291
  class _EventSessionTransaction(AsyncSessionTransaction):
292
- """Transaction context manager that dispatches on a real commit."""
292
+ """Transaction that dispatches on a real commit, on block exit or ``commit()``."""
293
293
 
294
294
  __slots__ = ()
295
295
 
296
- async def __aexit__(self, type_: object, value: object, traceback: object) -> None:
296
+ def _committing_session(self) -> "EventSession | None":
297
+ """The EventSession a commit of this transaction would really commit."""
297
298
  session = self.session
298
- commits = (
299
- type_ is None
300
- and not self.nested
301
- and isinstance(session, EventSession)
302
- and self.is_active
303
- )
304
- preloaded = _snapshot_loaded_relationships(session) if commits else {}
299
+ if self.nested or not self.is_active or not isinstance(session, EventSession):
300
+ return None
301
+ return session
302
+
303
+ async def __aexit__(self, type_: object, value: object, traceback: object) -> None:
304
+ session = self._committing_session() if type_ is None else None
305
+ preloaded = {} if session is None else _snapshot_loaded_relationships(session)
305
306
  await super().__aexit__(type_, value, traceback)
306
- if commits:
307
+ if session is not None:
308
+ await session._dispatch_pending(preloaded)
309
+
310
+ async def commit(self) -> None:
311
+ session = self._committing_session()
312
+ preloaded = {} if session is None else _snapshot_loaded_relationships(session)
313
+ await super().commit()
314
+ if session is not None:
307
315
  await session._dispatch_pending(preloaded)
308
316
 
309
317