fastapi-restly 0.5.0__py3-none-any.whl
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_restly/__init__.py +106 -0
- fastapi_restly/_exception_handlers.py +194 -0
- fastapi_restly/_pytest_fixtures.py +256 -0
- fastapi_restly/db/__init__.py +31 -0
- fastapi_restly/db/_globals.py +76 -0
- fastapi_restly/db/_proxy.py +42 -0
- fastapi_restly/db/_session.py +275 -0
- fastapi_restly/exceptions.py +12 -0
- fastapi_restly/models/__init__.py +18 -0
- fastapi_restly/models/_base.py +84 -0
- fastapi_restly/objects.py +144 -0
- fastapi_restly/py.typed +0 -0
- fastapi_restly/pytest_fixtures.py +24 -0
- fastapi_restly/query/__init__.py +13 -0
- fastapi_restly/query/_impl.py +594 -0
- fastapi_restly/query/_shared.py +22 -0
- fastapi_restly/schemas/__init__.py +29 -0
- fastapi_restly/schemas/_base.py +518 -0
- fastapi_restly/schemas/_generator.py +382 -0
- fastapi_restly/testing/__init__.py +20 -0
- fastapi_restly/testing/_client.py +98 -0
- fastapi_restly/testing/_fixtures.py +20 -0
- fastapi_restly/views/__init__.py +40 -0
- fastapi_restly/views/_async.py +216 -0
- fastapi_restly/views/_base.py +1294 -0
- fastapi_restly/views/_openapi.py +206 -0
- fastapi_restly/views/_react_admin.py +393 -0
- fastapi_restly/views/_sync.py +213 -0
- fastapi_restly-0.5.0.dist-info/METADATA +407 -0
- fastapi_restly-0.5.0.dist-info/RECORD +34 -0
- fastapi_restly-0.5.0.dist-info/WHEEL +5 -0
- fastapi_restly-0.5.0.dist-info/entry_points.txt +2 -0
- fastapi_restly-0.5.0.dist-info/licenses/LICENSE +21 -0
- fastapi_restly-0.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
import datetime as _dt
|
|
2
|
+
import decimal as _decimal
|
|
3
|
+
import functools
|
|
4
|
+
import uuid as _uuid
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from typing import Annotated, Any, Callable, Iterator, Optional, cast
|
|
7
|
+
|
|
8
|
+
import pydantic
|
|
9
|
+
import sqlalchemy
|
|
10
|
+
from fastapi import HTTPException
|
|
11
|
+
from pydantic import Field
|
|
12
|
+
from pydantic.fields import FieldInfo
|
|
13
|
+
from sqlalchemy import ColumnElement, Select
|
|
14
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
15
|
+
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
|
16
|
+
from sqlalchemy.orm.properties import ColumnProperty
|
|
17
|
+
from starlette.datastructures import QueryParams
|
|
18
|
+
|
|
19
|
+
from ._shared import _escape_like_value, _unwrap_optional_annotation
|
|
20
|
+
|
|
21
|
+
SchemaType = type[pydantic.BaseModel]
|
|
22
|
+
|
|
23
|
+
#: Default ``page_size`` applied to list endpoints when the client does not
|
|
24
|
+
#: send one. ``None`` disables the implicit cap (lists return every matching
|
|
25
|
+
#: row and ``page`` is ignored). Override per-view via
|
|
26
|
+
#: :attr:`BaseRestView.default_page_size`.
|
|
27
|
+
DEFAULT_PAGE_SIZE: int | None = None
|
|
28
|
+
|
|
29
|
+
#: Maximum ``page_size`` accepted by list endpoints. Values above this are
|
|
30
|
+
#: rejected with a 422 by the FastAPI Pydantic-Query validation layer.
|
|
31
|
+
#: Override per-view via :attr:`BaseRestView.max_page_size`.
|
|
32
|
+
MAX_PAGE_SIZE = 1000
|
|
33
|
+
|
|
34
|
+
#: Reserved query-parameter names produced by the schema. Filter columns
|
|
35
|
+
#: literally named one of these would shadow pagination/sort, which would
|
|
36
|
+
#: silently break the endpoint contract. Treated as a hard error.
|
|
37
|
+
_RESERVED_NAMES = frozenset({"page", "page_size", "sort"})
|
|
38
|
+
|
|
39
|
+
# Types that support SQL ``<``/``<=``/``>``/``>=`` comparisons. Booleans
|
|
40
|
+
# deliberately don't — ordering booleans is rarely meaningful and emitting
|
|
41
|
+
# ``WHERE active >= true`` raises ``sqlalchemy.exc.ArgumentError`` at query
|
|
42
|
+
# time, which would otherwise surface to the client as a 500.
|
|
43
|
+
_ORDERABLE_TYPES: tuple[type, ...] = (
|
|
44
|
+
int,
|
|
45
|
+
float,
|
|
46
|
+
_decimal.Decimal,
|
|
47
|
+
_dt.date,
|
|
48
|
+
_dt.datetime,
|
|
49
|
+
_dt.time,
|
|
50
|
+
_dt.timedelta,
|
|
51
|
+
str,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _is_string_field(field: FieldInfo) -> bool:
|
|
56
|
+
annotation = _unwrap_optional_annotation(field.annotation)
|
|
57
|
+
return annotation is str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _supports_range_operators(field: FieldInfo) -> bool:
|
|
61
|
+
annotation = _unwrap_optional_annotation(field.annotation)
|
|
62
|
+
if annotation is bool:
|
|
63
|
+
return False
|
|
64
|
+
if not isinstance(annotation, type):
|
|
65
|
+
return True
|
|
66
|
+
if issubclass(annotation, bool):
|
|
67
|
+
return False
|
|
68
|
+
if issubclass(annotation, _ORDERABLE_TYPES):
|
|
69
|
+
return True
|
|
70
|
+
if issubclass(annotation, _uuid.UUID):
|
|
71
|
+
return False
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def create_list_params_schema(
|
|
76
|
+
schema_cls: SchemaType,
|
|
77
|
+
*,
|
|
78
|
+
default_page_size: int | None = DEFAULT_PAGE_SIZE,
|
|
79
|
+
max_page_size: int = MAX_PAGE_SIZE,
|
|
80
|
+
) -> SchemaType:
|
|
81
|
+
"""
|
|
82
|
+
Create a Pydantic model that describes and validates URL query parameters
|
|
83
|
+
for list endpoints.
|
|
84
|
+
|
|
85
|
+
The generated model accepts pagination (``page``, ``page_size``), sorting
|
|
86
|
+
(``sort``), and one filter parameter per response-schema field with
|
|
87
|
+
optional ``__in``/``__ne``/``__gte``/``__lte``/``__gt``/``__lt``/``__isnull``/
|
|
88
|
+
``__contains``/``__icontains`` suffixes.
|
|
89
|
+
|
|
90
|
+
``page`` and ``page_size`` are validated by Pydantic with bounds
|
|
91
|
+
(``page >= 1``, ``1 <= page_size <= max_page_size``); out-of-range values
|
|
92
|
+
produce a standard 422 response from FastAPI.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
schema_cls: The response schema whose fields drive the available
|
|
96
|
+
filter parameters.
|
|
97
|
+
default_page_size: Default value for the ``page_size`` parameter.
|
|
98
|
+
``None`` (the default) means "no implicit page size" — omitting
|
|
99
|
+
``page_size`` returns every matching row and ``page`` is ignored.
|
|
100
|
+
max_page_size: Upper bound (inclusive) for the ``page_size``
|
|
101
|
+
parameter. Defaults to :data:`MAX_PAGE_SIZE`.
|
|
102
|
+
"""
|
|
103
|
+
fields: dict[str, Any] = {
|
|
104
|
+
"page": (
|
|
105
|
+
Annotated[
|
|
106
|
+
int,
|
|
107
|
+
Field(
|
|
108
|
+
ge=1,
|
|
109
|
+
description=(
|
|
110
|
+
"1-based page number. Only takes effect when "
|
|
111
|
+
"``page_size`` is also set."
|
|
112
|
+
),
|
|
113
|
+
),
|
|
114
|
+
],
|
|
115
|
+
1,
|
|
116
|
+
),
|
|
117
|
+
"page_size": (
|
|
118
|
+
Annotated[
|
|
119
|
+
Optional[int],
|
|
120
|
+
Field(
|
|
121
|
+
ge=1,
|
|
122
|
+
le=max_page_size,
|
|
123
|
+
description=(
|
|
124
|
+
f"Number of items per page (1–{max_page_size}). "
|
|
125
|
+
"Omit to return every matching row (no implicit cap)."
|
|
126
|
+
),
|
|
127
|
+
),
|
|
128
|
+
],
|
|
129
|
+
default_page_size,
|
|
130
|
+
),
|
|
131
|
+
"sort": (
|
|
132
|
+
Annotated[
|
|
133
|
+
Optional[str],
|
|
134
|
+
Field(
|
|
135
|
+
description=(
|
|
136
|
+
"Comma-separated list of fields to sort by. Prefix a "
|
|
137
|
+
"field with ``-`` for descending order. Example: "
|
|
138
|
+
"``-created_at,name``."
|
|
139
|
+
)
|
|
140
|
+
),
|
|
141
|
+
],
|
|
142
|
+
None,
|
|
143
|
+
),
|
|
144
|
+
}
|
|
145
|
+
for name, field in _iter_fields_including_nested(schema_cls):
|
|
146
|
+
if name in _RESERVED_NAMES:
|
|
147
|
+
raise ValueError(
|
|
148
|
+
f"List-params schema for {schema_cls.__name__!r} cannot expose "
|
|
149
|
+
f"field {name!r}: it collides with a reserved pagination/sort "
|
|
150
|
+
"parameter. Add a Pydantic alias to expose it as a filter."
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# Type filter parameters as ``Optional[list[str]]`` (rather than the
|
|
154
|
+
# column's true type) so FastAPI/Starlette preserve repeated query
|
|
155
|
+
# parameters as a list and downstream ``_parse_value`` can perform
|
|
156
|
+
# the actual type coercion. ``__isnull`` stays a scalar bool because
|
|
157
|
+
# repeating it makes no sense.
|
|
158
|
+
eq_desc = (
|
|
159
|
+
f"Filter by ``{name}``. Comma-separated values are OR-combined "
|
|
160
|
+
"(SQL ``IN``). Repeat the parameter to AND multiple predicates."
|
|
161
|
+
)
|
|
162
|
+
ne_desc = (
|
|
163
|
+
f"Exclude rows where ``{name}`` matches. Comma-separated values "
|
|
164
|
+
"are AND-combined (SQL ``NOT IN``)."
|
|
165
|
+
)
|
|
166
|
+
in_desc = (
|
|
167
|
+
f"Filter by ``{name}`` with explicit SQL ``IN`` semantics. "
|
|
168
|
+
"Provide comma-separated values."
|
|
169
|
+
)
|
|
170
|
+
fields[name] = (
|
|
171
|
+
Annotated[Optional[list[str]], Field(description=eq_desc)],
|
|
172
|
+
None,
|
|
173
|
+
)
|
|
174
|
+
fields[f"{name}__in"] = (
|
|
175
|
+
Annotated[Optional[list[str]], Field(description=in_desc)],
|
|
176
|
+
None,
|
|
177
|
+
)
|
|
178
|
+
fields[f"{name}__ne"] = (
|
|
179
|
+
Annotated[Optional[list[str]], Field(description=ne_desc)],
|
|
180
|
+
None,
|
|
181
|
+
)
|
|
182
|
+
fields[f"{name}__isnull"] = (
|
|
183
|
+
Annotated[
|
|
184
|
+
Optional[bool],
|
|
185
|
+
Field(
|
|
186
|
+
description=(
|
|
187
|
+
f"``true`` matches rows where ``{name}`` IS NULL; "
|
|
188
|
+
f"``false`` matches IS NOT NULL."
|
|
189
|
+
)
|
|
190
|
+
),
|
|
191
|
+
],
|
|
192
|
+
None,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
if _supports_range_operators(field):
|
|
196
|
+
for suffix, sql in (
|
|
197
|
+
("__gte", ">="),
|
|
198
|
+
("__lte", "<="),
|
|
199
|
+
("__gt", ">"),
|
|
200
|
+
("__lt", "<"),
|
|
201
|
+
):
|
|
202
|
+
fields[f"{name}{suffix}"] = (
|
|
203
|
+
Annotated[
|
|
204
|
+
Optional[list[str]],
|
|
205
|
+
Field(description=f"``{name} {sql} value``."),
|
|
206
|
+
],
|
|
207
|
+
None,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
if _is_string_field(field):
|
|
211
|
+
fields[f"{name}__contains"] = (
|
|
212
|
+
Annotated[
|
|
213
|
+
Optional[list[str]],
|
|
214
|
+
Field(
|
|
215
|
+
description=(
|
|
216
|
+
f"Case-sensitive substring search on "
|
|
217
|
+
f"``{name}``. Repeat the parameter to AND "
|
|
218
|
+
"multiple terms; whitespace inside one value is "
|
|
219
|
+
"also AND-split as a convenience."
|
|
220
|
+
)
|
|
221
|
+
),
|
|
222
|
+
],
|
|
223
|
+
None,
|
|
224
|
+
)
|
|
225
|
+
fields[f"{name}__icontains"] = (
|
|
226
|
+
Annotated[
|
|
227
|
+
Optional[list[str]],
|
|
228
|
+
Field(
|
|
229
|
+
description=(
|
|
230
|
+
f"Case-insensitive substring search on "
|
|
231
|
+
f"``{name}``. Repeat the parameter to AND "
|
|
232
|
+
"multiple terms; whitespace inside one value is "
|
|
233
|
+
"also AND-split as a convenience."
|
|
234
|
+
)
|
|
235
|
+
),
|
|
236
|
+
],
|
|
237
|
+
None,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
schema_name = "ListParams" + schema_cls.__name__
|
|
241
|
+
return pydantic.create_model(schema_name, **fields) # type: ignore[call-overload]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def apply_list_params(
|
|
245
|
+
params: pydantic.BaseModel | QueryParams,
|
|
246
|
+
select_query: Select[Any],
|
|
247
|
+
model: type[DeclarativeBase],
|
|
248
|
+
schema_cls: SchemaType,
|
|
249
|
+
) -> Select[Any]:
|
|
250
|
+
"""
|
|
251
|
+
Apply pagination, sorting, and filtering on a SQL query using validated
|
|
252
|
+
list-endpoint query parameters.
|
|
253
|
+
|
|
254
|
+
``params`` is normally an instance of the schema returned by
|
|
255
|
+
:func:`create_list_params_schema`. The generated FastAPI endpoints
|
|
256
|
+
always pass a validated instance, so pagination/filter bounds have
|
|
257
|
+
already been checked.
|
|
258
|
+
|
|
259
|
+
A raw :class:`~starlette.datastructures.QueryParams` is also accepted
|
|
260
|
+
for callers that build the query parameters programmatically.
|
|
261
|
+
**Raw inputs bypass schema validation** — the caller is responsible
|
|
262
|
+
for verifying ``page``/``page_size`` ranges and any per-view bounds
|
|
263
|
+
(``max_page_size``); this function only performs the minimum coercion
|
|
264
|
+
needed to apply the SQL clauses.
|
|
265
|
+
|
|
266
|
+
Examples::
|
|
267
|
+
|
|
268
|
+
# Pagination
|
|
269
|
+
page=2&page_size=50
|
|
270
|
+
|
|
271
|
+
# Sorting
|
|
272
|
+
sort=name,-created_at
|
|
273
|
+
|
|
274
|
+
# Filtering
|
|
275
|
+
name=Bob&status=active&created_at__gte=2024-01-01
|
|
276
|
+
|
|
277
|
+
# Contains (string fields)
|
|
278
|
+
name__contains=John&email__icontains=example
|
|
279
|
+
"""
|
|
280
|
+
query_params = _coerce_to_query_params(params)
|
|
281
|
+
select_query = _apply_filtering(query_params, select_query, model, schema_cls)
|
|
282
|
+
select_query = _apply_sorting(query_params, select_query, model, schema_cls)
|
|
283
|
+
select_query = _apply_pagination(query_params, select_query)
|
|
284
|
+
return select_query
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _coerce_to_query_params(params: pydantic.BaseModel | QueryParams) -> QueryParams:
|
|
288
|
+
"""Normalise a validated Pydantic model or raw QueryParams to QueryParams.
|
|
289
|
+
|
|
290
|
+
When a dumped field is a list (e.g. a repeated ``name__contains``), each
|
|
291
|
+
element is expanded to its own ``(key, value)`` tuple so that
|
|
292
|
+
``QueryParams.multi_items()`` later returns the original repeated values.
|
|
293
|
+
"""
|
|
294
|
+
if isinstance(params, QueryParams):
|
|
295
|
+
return params
|
|
296
|
+
if isinstance(params, pydantic.BaseModel):
|
|
297
|
+
dumped = params.model_dump(exclude_none=True, by_alias=True, mode="json")
|
|
298
|
+
items: list[tuple[str, str]] = []
|
|
299
|
+
for key, value in dumped.items():
|
|
300
|
+
if isinstance(value, list):
|
|
301
|
+
items.extend((key, str(item)) for item in value)
|
|
302
|
+
else:
|
|
303
|
+
items.append((key, str(value)))
|
|
304
|
+
return QueryParams(items)
|
|
305
|
+
return QueryParams(params)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _apply_pagination(
|
|
309
|
+
query_params: QueryParams, select_query: Select[Any]
|
|
310
|
+
) -> Select[Any]:
|
|
311
|
+
page_size = _get_int(query_params, "page_size")
|
|
312
|
+
if page_size is None:
|
|
313
|
+
return select_query
|
|
314
|
+
page = _get_int(query_params, "page") or 1
|
|
315
|
+
offset = (page - 1) * page_size
|
|
316
|
+
return select_query.limit(page_size).offset(offset)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _get_int(query_params: QueryParams, param_name: str) -> Optional[int]:
|
|
320
|
+
value = query_params.get(param_name)
|
|
321
|
+
if not value:
|
|
322
|
+
return None
|
|
323
|
+
try:
|
|
324
|
+
return int(value)
|
|
325
|
+
except ValueError:
|
|
326
|
+
raise HTTPException(
|
|
327
|
+
400,
|
|
328
|
+
f"Invalid value for URL query parameter {param_name}: "
|
|
329
|
+
f"{value} is not an integer",
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _apply_sorting(
|
|
334
|
+
query_params: QueryParams,
|
|
335
|
+
select_query: Select[Any],
|
|
336
|
+
model: type[DeclarativeBase],
|
|
337
|
+
schema_cls: SchemaType,
|
|
338
|
+
) -> Select[Any]:
|
|
339
|
+
sort_string = query_params.get("sort")
|
|
340
|
+
if not sort_string:
|
|
341
|
+
id_column = getattr(model, "id", None)
|
|
342
|
+
if id_column is not None:
|
|
343
|
+
return select_query.order_by(id_column)
|
|
344
|
+
return select_query
|
|
345
|
+
|
|
346
|
+
for column_name in sort_string.split(","):
|
|
347
|
+
order = sqlalchemy.asc
|
|
348
|
+
if column_name.startswith("-"):
|
|
349
|
+
order = sqlalchemy.desc
|
|
350
|
+
column_name = column_name[1:]
|
|
351
|
+
joins, column = _resolve_column(model, column_name, schema_cls)
|
|
352
|
+
for join in joins:
|
|
353
|
+
select_query = select_query.join(join)
|
|
354
|
+
select_query = select_query.order_by(order(column))
|
|
355
|
+
return select_query
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _iter_fields_including_nested(
|
|
359
|
+
schema_cls: SchemaType, prefix: str = ""
|
|
360
|
+
) -> Iterator[tuple[str, FieldInfo]]:
|
|
361
|
+
for name, field in schema_cls.model_fields.items():
|
|
362
|
+
public_name = field.alias or name
|
|
363
|
+
# Each segment of the public dotted path becomes part of the URL
|
|
364
|
+
# grammar. ``__`` is reserved for operator suffixes (``__gte``,
|
|
365
|
+
# ``__contains``, ...) and ``.`` is reserved for relation traversal,
|
|
366
|
+
# so a segment containing either character would create an
|
|
367
|
+
# ambiguous URL key. Reject at schema-generation time so the
|
|
368
|
+
# collision surfaces during view registration, not at request time.
|
|
369
|
+
if "__" in public_name:
|
|
370
|
+
raise ValueError(
|
|
371
|
+
f"List-params schema for {schema_cls.__name__!r} cannot "
|
|
372
|
+
f"expose field {public_name!r}: ``__`` is reserved for "
|
|
373
|
+
"operator suffixes. Choose a different Pydantic alias."
|
|
374
|
+
)
|
|
375
|
+
if "." in public_name:
|
|
376
|
+
raise ValueError(
|
|
377
|
+
f"List-params schema for {schema_cls.__name__!r} cannot "
|
|
378
|
+
f"expose field {public_name!r}: ``.`` is reserved for "
|
|
379
|
+
"relation traversal. Choose a different Pydantic alias."
|
|
380
|
+
)
|
|
381
|
+
full_name = f"{prefix}.{public_name}" if prefix else public_name
|
|
382
|
+
nested = _get_nested_schema(field)
|
|
383
|
+
if nested:
|
|
384
|
+
yield from _iter_fields_including_nested(nested, full_name)
|
|
385
|
+
else:
|
|
386
|
+
yield full_name, field
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _resolve_field_name(schema_cls: SchemaType, public_name: str) -> str | None:
|
|
390
|
+
"""Given a schema and a public (URL-facing) name, return the canonical
|
|
391
|
+
Python field name to use with the model.
|
|
392
|
+
|
|
393
|
+
The public name is the field's alias when one is declared, otherwise the
|
|
394
|
+
field name itself. Aliased fields are *only* reachable by their alias —
|
|
395
|
+
Python field names are never part of the public URL contract, even when
|
|
396
|
+
the schema has ``populate_by_name=True`` (which only affects how Pydantic
|
|
397
|
+
parses input bodies, not the generated list-params query schema).
|
|
398
|
+
"""
|
|
399
|
+
for field_name, field in schema_cls.model_fields.items():
|
|
400
|
+
if field.alias == public_name:
|
|
401
|
+
return field_name
|
|
402
|
+
|
|
403
|
+
if public_name in schema_cls.model_fields:
|
|
404
|
+
field = schema_cls.model_fields[public_name]
|
|
405
|
+
if field.alias is None:
|
|
406
|
+
return public_name
|
|
407
|
+
return None
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _resolve_column(
|
|
411
|
+
model: type[DeclarativeBase], column_path: str, schema_cls: SchemaType
|
|
412
|
+
) -> tuple[list[InstrumentedAttribute[Any]], InstrumentedAttribute[Any]]:
|
|
413
|
+
"""Resolve a (possibly dotted) public column path to its SQLAlchemy column,
|
|
414
|
+
plus the relationship attributes that need to be joined.
|
|
415
|
+
|
|
416
|
+
Strict: every path segment must resolve through the schema's public name
|
|
417
|
+
(alias when set, Python field name otherwise). Falling back to a raw
|
|
418
|
+
model attribute lookup would let URLs reach columns the schema didn't
|
|
419
|
+
expose — for example, a Python field name on an aliased schema field —
|
|
420
|
+
and silently bypass the public-name contract.
|
|
421
|
+
"""
|
|
422
|
+
joins: list[InstrumentedAttribute[Any]] = []
|
|
423
|
+
current_model = model
|
|
424
|
+
current_schema: SchemaType | None = schema_cls
|
|
425
|
+
name = column_path
|
|
426
|
+
while "." in name:
|
|
427
|
+
relation, _, name = name.partition(".")
|
|
428
|
+
if current_schema is None:
|
|
429
|
+
raise HTTPException(400, f"Invalid attribute in URL query: {column_path}")
|
|
430
|
+
field_name = _resolve_field_name(current_schema, relation)
|
|
431
|
+
if field_name is None:
|
|
432
|
+
raise HTTPException(400, f"Invalid attribute in URL query: {column_path}")
|
|
433
|
+
rel = getattr(current_model, field_name, None)
|
|
434
|
+
if not isinstance(rel, InstrumentedAttribute) or not hasattr(
|
|
435
|
+
rel.property, "mapper"
|
|
436
|
+
):
|
|
437
|
+
raise HTTPException(400, f"Invalid attribute in URL query: {column_path}")
|
|
438
|
+
joins.append(rel)
|
|
439
|
+
current_model = rel.property.mapper.class_
|
|
440
|
+
current_schema = _get_nested_schema(current_schema.model_fields[field_name])
|
|
441
|
+
|
|
442
|
+
if current_schema is None:
|
|
443
|
+
raise HTTPException(400, f"Invalid attribute in URL query: {column_path}")
|
|
444
|
+
field_name = _resolve_field_name(current_schema, name)
|
|
445
|
+
if field_name is None:
|
|
446
|
+
raise HTTPException(400, f"Invalid attribute in URL query: {column_path}")
|
|
447
|
+
column = getattr(current_model, field_name, None)
|
|
448
|
+
if (
|
|
449
|
+
column is None
|
|
450
|
+
or not isinstance(column, InstrumentedAttribute)
|
|
451
|
+
or not isinstance(column.property, ColumnProperty)
|
|
452
|
+
):
|
|
453
|
+
raise HTTPException(400, f"Invalid attribute in URL query: {column_path}")
|
|
454
|
+
return joins, cast(InstrumentedAttribute[Any], column)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _apply_filtering(
|
|
458
|
+
query_params: QueryParams,
|
|
459
|
+
select_query: Select[Any],
|
|
460
|
+
model: type[DeclarativeBase],
|
|
461
|
+
schema_cls: SchemaType,
|
|
462
|
+
) -> Select[Any]:
|
|
463
|
+
"""Apply ``key=value`` and ``key__op=value`` filters to ``select_query``.
|
|
464
|
+
|
|
465
|
+
Multiple filters on the same column are AND-combined. Comma-separated
|
|
466
|
+
values within one parameter are OR-combined for ``eq`` (the default),
|
|
467
|
+
mapped to SQL ``IN`` for ``in``, and AND-combined for ``ne`` (so
|
|
468
|
+
``status__ne=a,b`` means NOT IN (a, b)). For ``contains``/``icontains``
|
|
469
|
+
values are split on whitespace and AND-combined.
|
|
470
|
+
"""
|
|
471
|
+
filters: dict[InstrumentedAttribute[Any], list[ColumnElement[Any]]] = defaultdict(
|
|
472
|
+
list
|
|
473
|
+
)
|
|
474
|
+
joins: set[InstrumentedAttribute[Any]] = set()
|
|
475
|
+
|
|
476
|
+
for key, raw_value in query_params.multi_items():
|
|
477
|
+
if key in _RESERVED_NAMES:
|
|
478
|
+
continue
|
|
479
|
+
|
|
480
|
+
if "__" in key:
|
|
481
|
+
column_name, op = key.split("__", 1)
|
|
482
|
+
else:
|
|
483
|
+
column_name, op = key, "eq"
|
|
484
|
+
|
|
485
|
+
column_joins, column = _resolve_column(model, column_name, schema_cls)
|
|
486
|
+
joins.update(column_joins)
|
|
487
|
+
parser = functools.partial(_parse_value, schema_cls, column_name)
|
|
488
|
+
|
|
489
|
+
if op == "isnull":
|
|
490
|
+
try:
|
|
491
|
+
value = pydantic.TypeAdapter(bool).validate_python(raw_value)
|
|
492
|
+
except pydantic.ValidationError as exc:
|
|
493
|
+
raise HTTPException(
|
|
494
|
+
400, f"Invalid value for URL query parameter {key}"
|
|
495
|
+
) from exc
|
|
496
|
+
filters[column].append(column.is_(None) if value else column.isnot(None))
|
|
497
|
+
continue
|
|
498
|
+
|
|
499
|
+
clause = _build_clause(column, raw_value, op, parser)
|
|
500
|
+
if clause is not None:
|
|
501
|
+
filters[column].append(clause)
|
|
502
|
+
|
|
503
|
+
for join in joins:
|
|
504
|
+
select_query = select_query.join(join)
|
|
505
|
+
|
|
506
|
+
for column, clauses in filters.items():
|
|
507
|
+
and_clause = clauses[0] if len(clauses) == 1 else sqlalchemy.and_(*clauses)
|
|
508
|
+
select_query = select_query.where(and_clause)
|
|
509
|
+
return select_query
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _build_clause(
|
|
513
|
+
column: InstrumentedAttribute[Any],
|
|
514
|
+
raw_value: str,
|
|
515
|
+
op: str,
|
|
516
|
+
parser: Callable[[str], Any],
|
|
517
|
+
) -> ColumnElement[Any] | None:
|
|
518
|
+
"""Combine multiple values within one parameter according to ``op`` semantics."""
|
|
519
|
+
if op in {"contains", "icontains"}:
|
|
520
|
+
values = [v for v in raw_value.split() if v]
|
|
521
|
+
if not values:
|
|
522
|
+
return None
|
|
523
|
+
clauses = [_make_where_clause(column, v, op, parser) for v in values]
|
|
524
|
+
return clauses[0] if len(clauses) == 1 else sqlalchemy.and_(*clauses)
|
|
525
|
+
|
|
526
|
+
values = raw_value.split(",")
|
|
527
|
+
if not values:
|
|
528
|
+
return None
|
|
529
|
+
if op == "in":
|
|
530
|
+
return column.in_([parser(v) for v in values])
|
|
531
|
+
clauses = [_make_where_clause(column, v, op, parser) for v in values]
|
|
532
|
+
if len(clauses) == 1:
|
|
533
|
+
return clauses[0]
|
|
534
|
+
# ``ne`` with multiple values means NOT IN (...) — AND-combine, not OR.
|
|
535
|
+
if op == "ne":
|
|
536
|
+
return sqlalchemy.and_(*clauses)
|
|
537
|
+
return sqlalchemy.or_(*clauses)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _parse_value(schema_cls: SchemaType, column_name: str, value: str) -> Any:
|
|
541
|
+
if "." in column_name:
|
|
542
|
+
relation, _, column_part = column_name.partition(".")
|
|
543
|
+
relation_field_name = _resolve_field_name(schema_cls, relation) or relation
|
|
544
|
+
field = schema_cls.model_fields.get(relation_field_name)
|
|
545
|
+
nested = _get_nested_schema(field)
|
|
546
|
+
if nested is None:
|
|
547
|
+
raise HTTPException(400, f"Invalid attribute in URL query: {column_name}")
|
|
548
|
+
return _parse_value(nested, column_part, value)
|
|
549
|
+
|
|
550
|
+
field_name = _resolve_field_name(schema_cls, column_name)
|
|
551
|
+
if field_name is None:
|
|
552
|
+
raise HTTPException(400, f"Invalid attribute in URL query: {column_name}")
|
|
553
|
+
|
|
554
|
+
try:
|
|
555
|
+
obj = schema_cls.__pydantic_validator__.validate_assignment(
|
|
556
|
+
schema_cls.model_construct(), field_name, value
|
|
557
|
+
)
|
|
558
|
+
return getattr(obj, field_name)
|
|
559
|
+
except Exception:
|
|
560
|
+
raise HTTPException(400, f"Invalid attribute in URL query: {column_name}")
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _get_nested_schema(field: FieldInfo | None) -> SchemaType | None:
|
|
564
|
+
if field is None:
|
|
565
|
+
return None
|
|
566
|
+
annotation = _unwrap_optional_annotation(field.annotation)
|
|
567
|
+
if isinstance(annotation, type) and issubclass(annotation, pydantic.BaseModel):
|
|
568
|
+
return annotation
|
|
569
|
+
return None
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _make_where_clause(
|
|
573
|
+
column: InstrumentedAttribute[Any],
|
|
574
|
+
filter_value: str,
|
|
575
|
+
op: str,
|
|
576
|
+
parser: Callable[[str], Any],
|
|
577
|
+
) -> ColumnElement[Any]:
|
|
578
|
+
if op == "gte":
|
|
579
|
+
return column >= parser(filter_value)
|
|
580
|
+
if op == "lte":
|
|
581
|
+
return column <= parser(filter_value)
|
|
582
|
+
if op == "gt":
|
|
583
|
+
return column > parser(filter_value)
|
|
584
|
+
if op == "lt":
|
|
585
|
+
return column < parser(filter_value)
|
|
586
|
+
if op == "ne":
|
|
587
|
+
return column != parser(filter_value)
|
|
588
|
+
if op == "contains":
|
|
589
|
+
return column.like(f"%{_escape_like_value(filter_value)}%", escape="\\")
|
|
590
|
+
if op == "icontains":
|
|
591
|
+
return column.ilike(f"%{_escape_like_value(filter_value)}%", escape="\\")
|
|
592
|
+
if op == "eq":
|
|
593
|
+
return column == parser(filter_value)
|
|
594
|
+
raise HTTPException(400, f"Unsupported filter operator: {op!r}")
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import types
|
|
2
|
+
from typing import Any, Union, get_args, get_origin
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _escape_like_value(value: str) -> str:
|
|
6
|
+
"""Escape SQL LIKE wildcard characters for literal substring matching."""
|
|
7
|
+
escaped = value.replace("\\", "\\\\")
|
|
8
|
+
escaped = escaped.replace("%", "\\%")
|
|
9
|
+
escaped = escaped.replace("_", "\\_")
|
|
10
|
+
return escaped
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _unwrap_optional_annotation(annotation: Any) -> Any:
|
|
14
|
+
"""Unwrap Optional[X] or X | None to X. Returns annotation unchanged otherwise."""
|
|
15
|
+
origin = get_origin(annotation)
|
|
16
|
+
if origin not in (types.UnionType, Union):
|
|
17
|
+
return annotation
|
|
18
|
+
|
|
19
|
+
non_none_args = [arg for arg in get_args(annotation) if arg is not type(None)]
|
|
20
|
+
if len(non_none_args) == 1:
|
|
21
|
+
return non_none_args[0]
|
|
22
|
+
return annotation
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from ._base import (
|
|
2
|
+
BaseSchema,
|
|
3
|
+
IDRef,
|
|
4
|
+
IDSchema,
|
|
5
|
+
ReadOnly,
|
|
6
|
+
TimestampsSchemaMixin,
|
|
7
|
+
WriteOnly,
|
|
8
|
+
)
|
|
9
|
+
from ._generator import create_schema_from_model
|
|
10
|
+
|
|
11
|
+
# Public API for ``fastapi_restly.schemas``.
|
|
12
|
+
#
|
|
13
|
+
# Framework internals (``OmitReadOnlyMixin``, ``PatchMixin``,
|
|
14
|
+
# ``create_model_with_optional_fields``, ``create_model_without_read_only_fields``,
|
|
15
|
+
# ``readonly_marker``, ``writeonly_marker``, ``getattrs``,
|
|
16
|
+
# ``rebase_with_model_config``, ``set_schema_title``, ``get_writable_inputs``,
|
|
17
|
+
# ``get_read_only_fields``, ``get_write_only_fields``, ``SQLAlchemyModel``,
|
|
18
|
+
# the ``_generator`` introspection helpers, etc.) live in
|
|
19
|
+
# ``fastapi_restly.schemas._base`` / ``._generator`` and are not re-exported
|
|
20
|
+
# here. They may move or change without notice.
|
|
21
|
+
__all__ = [
|
|
22
|
+
"BaseSchema",
|
|
23
|
+
"IDRef",
|
|
24
|
+
"IDSchema",
|
|
25
|
+
"ReadOnly",
|
|
26
|
+
"TimestampsSchemaMixin",
|
|
27
|
+
"WriteOnly",
|
|
28
|
+
"create_schema_from_model",
|
|
29
|
+
]
|