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.
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/PKG-INFO +1 -1
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/pyproject.toml +1 -1
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/pyproject.toml.orig +1 -1
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/__init__.py +1 -1
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/_orm.py +6 -1
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/crud/factory.py +120 -51
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/crud/search.py +45 -7
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/dependencies.py +11 -3
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/models/watched.py +18 -10
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/LICENSE +0 -0
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/README.md +0 -0
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/_imports.py +0 -0
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/cli/__init__.py +0 -0
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/cli/app.py +0 -0
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/cli/commands/__init__.py +0 -0
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/cli/commands/fixtures.py +0 -0
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/cli/config.py +0 -0
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/cli/pyproject.py +0 -0
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/cli/utils.py +0 -0
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/crud/__init__.py +0 -0
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/db/__init__.py +0 -0
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/db/core.py +0 -0
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/db/locks.py +0 -0
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/db/m2m.py +0 -0
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/db/testing.py +0 -0
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/db/watch.py +0 -0
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/exceptions/__init__.py +0 -0
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/exceptions/exceptions.py +0 -0
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/exceptions/handler.py +0 -0
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/fixtures/__init__.py +0 -0
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/fixtures/enum.py +0 -0
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/fixtures/registry.py +0 -0
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/fixtures/utils.py +0 -0
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/logger.py +0 -0
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/metrics/__init__.py +0 -0
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/metrics/handler.py +0 -0
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/metrics/registry.py +0 -0
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/models/__init__.py +0 -0
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/models/columns.py +0 -0
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/py.typed +0 -0
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/pytest/__init__.py +0 -0
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/pytest/plugin.py +0 -0
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/pytest/utils.py +0 -0
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/schemas.py +0 -0
- {fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/types.py +0 -0
|
@@ -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,
|
|
96
|
+
value: Any,
|
|
97
|
+
*,
|
|
98
|
+
direction: _CursorDirection = _CursorDirection.NEXT,
|
|
99
|
+
key: Sequence[Any] = (),
|
|
93
100
|
) -> str:
|
|
94
|
-
"""Encode a cursor
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
173
|
-
"""
|
|
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
|
-
|
|
248
|
-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
1820
|
+
plan.filters.append(bound < cursor_val)
|
|
1749
1821
|
else:
|
|
1750
|
-
plan.filters.append(
|
|
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
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
173
|
-
|
|
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 =
|
|
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
|
|
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:
|
|
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
|
|
292
|
+
"""Transaction that dispatches on a real commit, on block exit or ``commit()``."""
|
|
293
293
|
|
|
294
294
|
__slots__ = ()
|
|
295
295
|
|
|
296
|
-
|
|
296
|
+
def _committing_session(self) -> "EventSession | None":
|
|
297
|
+
"""The EventSession a commit of this transaction would really commit."""
|
|
297
298
|
session = self.session
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
)
|
|
304
|
-
preloaded =
|
|
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
|
|
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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/cli/commands/__init__.py
RENAMED
|
File without changes
|
{fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/cli/commands/fixtures.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/exceptions/__init__.py
RENAMED
|
File without changes
|
{fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/exceptions/exceptions.py
RENAMED
|
File without changes
|
{fastapi_toolsets-5.1.6 → fastapi_toolsets-5.1.7}/src/fastapi_toolsets/exceptions/handler.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|