Coverage for fastapi_restly / query / _impl.py: 97%
365 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-09 18:41 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-09 18:41 +0000
1import collections.abc as _abc
2import datetime as _dt
3import decimal as _decimal
4import functools
5import uuid as _uuid
6import weakref
7from collections import defaultdict
8from typing import (
9 Annotated,
10 Any,
11 Callable,
12 Iterator,
13 Optional,
14 cast,
15 get_args,
16 get_origin,
17)
19import pydantic
20import sqlalchemy
21from pydantic import Field
22from pydantic.fields import FieldInfo
23from pydantic_core import SchemaValidator, core_schema
24from sqlalchemy import ColumnElement, Select
25from sqlalchemy.orm import DeclarativeBase, aliased
26from sqlalchemy.orm.attributes import InstrumentedAttribute
27from sqlalchemy.orm.properties import ColumnProperty
28from sqlalchemy.orm.util import AliasedClass
29from starlette.datastructures import QueryParams
31from .._pagination import (
32 _DEFAULT_PAGINATION,
33 _SORT_QUERY_PARAM,
34 NoPagination,
35 NumberedPagination,
36)
37from ..exc import BadQueryParam
38from ..schemas._base import (
39 IDRef,
40 IDSchema,
41 _schema_role_name,
42 _unwrap_optional_annotation,
43)
44from ._shared import _append_pk_tiebreak, _escape_like_value
46SchemaType = type[pydantic.BaseModel]
47_JoinAliases = dict[tuple[str, ...], AliasedClass[Any]]
49_AnyPagination = NumberedPagination | NoPagination | None
51#: The pagination that each generated list params model was created with, so
52#: :func:`apply_list_params` and view registration can read it back.
53_CREATED_WITH: "weakref.WeakKeyDictionary[type, _AnyPagination]" = (
54 weakref.WeakKeyDictionary()
55)
58class _FromParams:
59 """The default of :func:`apply_list_params`'s ``pagination``: the one its
60 params model was created with."""
62 def __repr__(self) -> str:
63 # the default as the API reference shows it; the docstring explains it
64 return "..."
67_FROM_PARAMS = _FromParams()
68_UNKNOWN = object()
71def _created_with(params_model: type) -> Any:
72 """The pagination that generated list params were created with, or
73 ``_UNKNOWN`` for hand-written ones."""
74 return _CREATED_WITH.get(params_model, _UNKNOWN)
77def _query_settings(pagination: _AnyPagination) -> tuple[Any, ...] | None:
78 """What the pagination fields of list params depend on; ``None`` for
79 no pagination."""
80 if not isinstance(pagination, NumberedPagination):
81 return None
82 return (
83 pagination.page_query_param,
84 pagination.page_size_query_param,
85 pagination.default_page_size,
86 pagination.max_page_size,
87 pagination.max_page,
88 )
91#: Largest pagination offset accepted by supported SQL databases. SQLite and
92#: PostgreSQL bind ``OFFSET`` as a signed 64-bit integer.
93_MAX_PAGINATION_OFFSET = 2**63 - 1
96def _reserved_names(pagination: _AnyPagination) -> frozenset[str]:
97 """Query parameters the schema produces besides filters.
99 A filter column whose public name is one of these would shadow pagination
100 or sort, which would silently break the endpoint contract. Treated as a
101 hard error.
102 """
103 names = {_SORT_QUERY_PARAM}
104 if isinstance(pagination, NumberedPagination):
105 names |= {pagination.page_query_param, pagination.page_size_query_param}
106 return frozenset(names)
109# Types that support SQL ``<``/``<=``/``>``/``>=`` comparisons. Booleans are
110# excluded: ordering booleans is rarely meaningful, and ``WHERE active >= true``
111# raises ``sqlalchemy.exc.ArgumentError`` at query time, which would otherwise
112# surface to the client as a 500.
113_ORDERABLE_TYPES: tuple[type, ...] = (
114 int,
115 float,
116 _decimal.Decimal,
117 _dt.date,
118 _dt.datetime,
119 _dt.time,
120 _dt.timedelta,
121 str,
122)
125def _is_string_field(field: FieldInfo) -> bool:
126 annotation = _unwrap_optional_annotation(field.annotation)
127 return annotation is str
130def _is_idref_field(field: FieldInfo) -> bool:
131 """True for a scalar ``IDRef[T]`` FK field (e.g. ``post_id: IDRef[Post]``).
133 That ``*_id``-named spelling is discouraged -- the misuse lint steers it to
134 ``fr.MustExist[int, Post]`` -- but the query layer still handles it here so
135 the field keeps its filter param.
137 ``IDRef`` is a ``BaseModel`` subclass, so without this it would be recursed
138 into as a nested schema and yield only ``post_id.id`` -- which never resolves
139 (``post_id`` is a scalar column, not a relationship), leaving the FK with no
140 filter param at all. Treated as a leaf, it filters on its own public name.
141 Targets ``IDRef`` specifically, not ``IDSchema``: a nested *resource* schema
142 that embeds its ``id`` also subclasses ``IDSchema`` and must keep its dotted
143 traversal. ``list[IDRef[T]]`` (to-many) is unaffected -- its annotation is a
144 list, so it is never a nested schema here.
145 """
146 annotation = _unwrap_optional_annotation(field.annotation)
147 return isinstance(annotation, type) and issubclass(annotation, IDRef)
150def _supports_scalar_operators(field: FieldInfo) -> bool:
151 """False for collection-typed fields (``dict``, ``list[str]``, ``Sequence``,
152 ...) that no query-string value can coerce into: ``eq``/``__in``/``__ne``
153 (and range) filters on such a field would always 400 at request time, so
154 only ``__isnull`` is generated -- the same advertise-only-what-works
155 principle applied to non-column fields.
157 The check mirrors what the request path's ``_parse_value`` (full Pydantic
158 field validation) will accept. ``Annotated`` wrappers are unwrapped even
159 inside ``Optional`` (``conlist(...)`` produces those), and a
160 ``pydantic.Json[...]`` field counts as scalar: its validation parses the
161 query string as JSON, so its filters work. ``str``/``bytes`` are
162 collections in the abc sense but coerce fine, so they stay scalar.
163 """
164 annotation = field.annotation
165 metadata: list[Any] = list(field.metadata)
166 while True:
167 annotation = _unwrap_optional_annotation(annotation)
168 if get_origin(annotation) is Annotated:
169 annotation, *extra = get_args(annotation)
170 metadata.extend(extra)
171 continue
172 break
173 # ``pydantic.Json`` is a real class at runtime; the type stubs model it as
174 # an ``Annotated`` factory, hence the cast.
175 json_marker = cast(type, pydantic.Json)
176 if any(isinstance(item, json_marker) for item in metadata):
177 return True
178 origin = get_origin(annotation) or annotation
179 return not (
180 isinstance(origin, type)
181 and issubclass(origin, _abc.Collection)
182 and not issubclass(origin, (str, bytes))
183 )
186def _supports_range_operators(field: FieldInfo) -> bool:
187 annotation = _unwrap_optional_annotation(field.annotation)
188 if annotation is bool:
189 return False
190 if not isinstance(annotation, type):
191 return True
192 if issubclass(annotation, bool): 192 ↛ 193line 192 didn't jump to line 193 because the condition on line 192 was never true
193 return False
194 if issubclass(annotation, _ORDERABLE_TYPES):
195 return True
196 if issubclass(annotation, _uuid.UUID):
197 return False
198 return False
201def _pagination_offset_validator(pagination: NumberedPagination) -> Any:
202 """A model validator that rejects a page whose SQL offset would not fit in
203 a signed 64-bit integer."""
204 page_param = pagination.page_query_param
205 page_size_param = pagination.page_size_query_param
207 @pydantic.model_validator(mode="after")
208 def _validate_pagination_offset(params: pydantic.BaseModel) -> pydantic.BaseModel:
209 page = cast(int, getattr(params, page_param))
210 page_size = cast(Optional[int], getattr(params, page_size_param))
211 if page_size is None: 211 ↛ 212line 211 didn't jump to line 212 because the condition on line 211 was never true
212 return params
213 if page - 1 > _MAX_PAGINATION_OFFSET // page_size:
214 raise ValueError(
215 f"{page_param} and {page_size_param} produce an offset above "
216 f"{_MAX_PAGINATION_OFFSET}"
217 )
218 return params
220 return _validate_pagination_offset
223def derive_schema_list_params(
224 schema_response: SchemaType,
225 model: type[DeclarativeBase],
226 *,
227 pagination: _AnyPagination = _DEFAULT_PAGINATION,
228) -> SchemaType:
229 """
230 Build the list params: a Pydantic model that describes and validates the
231 URL query parameters of a list endpoint. A view keeps it in
232 :attr:`~fastapi_restly.views.BaseRestView.schema_list_params`.
234 The generated model accepts pagination (``page``, ``page_size``), sorting
235 (``sort``), and one filter parameter per field of ``schema_response`` that maps to
236 a filterable column on ``model`` -- with optional ``__in``/``__ne``/``__gte``/
237 ``__lte``/``__gt``/``__lt``/``__isnull``/``__contains``/``__icontains``
238 suffixes. Fields that do not resolve to a column (relationship/collection
239 fields, or reference traversals the request path would reject) get no filter
240 params, so the generated schema -- and the OpenAPI it produces -- no longer
241 advertises filters for fields that are not filterable at all. WriteOnly
242 fields get none either, since a filter on one would leak its value. Fields
243 whose type is a collection (``dict``/``list``, e.g. ``JSON`` or ``ARRAY``
244 columns) generate only ``__isnull``: a query-string value cannot coerce into
245 them, so every other operator would fail at request time.
247 With a ``pagination`` (the default is ``page`` and ``page_size``), its two
248 query parameters are added under its names and validated by Pydantic with
249 bounds: the page at least 1 and at most ``max_page`` when set, the page
250 size from 1 to ``max_page_size``. The resulting SQL offset must also fit in
251 a signed 64-bit integer. Out-of-range values produce a standard 422
252 response from FastAPI. With a :class:`~fastapi_restly.views.NoPagination`
253 or ``None``, no pagination parameters are emitted at all -- the endpoint
254 returns every matching row -- while sorting and filtering stay available.
256 :param schema_response: The response class whose fields decide the
257 available filter parameters. A view passes its ``schema_response``, so
258 a client can filter only on what it can see.
259 :param model: The SQLAlchemy model the list endpoint queries. Used to verify
260 each field resolves to a filterable column; non-column fields are
261 omitted from the generated params.
262 :param pagination: The view's pagination settings. A ``NoPagination`` or
263 ``None`` is an unpaginated view, which returns the full result set.
264 """
265 fields: dict[str, Any] = {}
266 if isinstance(pagination, NumberedPagination):
267 page_description = "1-based page number."
268 if pagination.max_page is not None:
269 page_description += f" At most {pagination.max_page}."
270 fields[pagination.page_query_param] = (
271 Annotated[
272 int, Field(ge=1, le=pagination.max_page, description=page_description)
273 ],
274 1,
275 )
276 fields[pagination.page_size_query_param] = (
277 Annotated[
278 Optional[int],
279 Field(
280 ge=1,
281 le=pagination.max_page_size,
282 description=(
283 f"Number of items per page (1–{pagination.max_page_size}). "
284 f"Defaults to {pagination.default_page_size}."
285 ),
286 ),
287 ],
288 pagination.default_page_size,
289 )
290 fields[_SORT_QUERY_PARAM] = (
291 Annotated[
292 Optional[str],
293 Field(
294 description=(
295 "Comma-separated list of fields to sort by. Prefix a "
296 "field with ``-`` for descending order. Example: "
297 "``-created_at,name``."
298 )
299 ),
300 ],
301 None,
302 )
303 reserved = _reserved_names(pagination)
304 for name, field in _iter_fields_including_nested(schema_response):
305 if name in reserved:
306 raise ValueError(
307 f"List params for {_written_name(schema_response)!r} cannot expose "
308 f"field {name!r}: it collides with a reserved pagination/sort "
309 "parameter. Add a Pydantic alias to expose it as a filter, or "
310 "rename the pagination parameter."
311 )
313 # Only emit params for fields that resolve to a filterable column on the
314 # model -- using the very predicate the request path applies. A
315 # relationship/collection field (e.g. ``books: list[BookRef]``) or a
316 # reference traversal that does not resolve would otherwise advertise
317 # filters in OpenAPI that always 400 at request time.
318 try:
319 _resolve_column(model, name, schema_response)
320 except BadQueryParam:
321 continue
323 # Type filter parameters as ``Optional[list[str]]`` instead of the
324 # column's true type) so FastAPI/Starlette preserve repeated query
325 # parameters as a list and downstream ``_parse_value`` can perform
326 # field-type coercion. ``__isnull`` stays a scalar bool because
327 # repeating it makes no sense.
328 # Collection/JSON-typed fields (``dict``, ``list[str]``) resolve to
329 # real columns, but no query-string value can coerce into them, so
330 # every operator except ``__isnull`` would 400 at request time.
331 supports_scalar = _supports_scalar_operators(field)
332 if supports_scalar:
333 eq_desc = (
334 f"Filter by ``{name}``. Comma-separated values are OR-combined "
335 "(SQL ``IN``). Repeat the parameter to AND multiple predicates."
336 )
337 ne_desc = (
338 f"Exclude rows where ``{name}`` matches. Comma-separated values "
339 "are AND-combined (SQL ``NOT IN``)."
340 )
341 in_desc = (
342 f"Filter by ``{name}`` with explicit SQL ``IN`` semantics. "
343 "Provide comma-separated values."
344 )
345 fields[name] = (
346 Annotated[Optional[list[str]], Field(description=eq_desc)],
347 None,
348 )
349 fields[f"{name}__in"] = (
350 Annotated[Optional[list[str]], Field(description=in_desc)],
351 None,
352 )
353 fields[f"{name}__ne"] = (
354 Annotated[Optional[list[str]], Field(description=ne_desc)],
355 None,
356 )
357 fields[f"{name}__isnull"] = (
358 Annotated[
359 Optional[bool],
360 Field(
361 description=(
362 f"``true`` matches rows where ``{name}`` IS NULL; "
363 f"``false`` matches IS NOT NULL."
364 )
365 ),
366 ],
367 None,
368 )
370 if supports_scalar and _supports_range_operators(field):
371 for suffix, sql in (
372 ("__gte", ">="),
373 ("__lte", "<="),
374 ("__gt", ">"),
375 ("__lt", "<"),
376 ):
377 fields[f"{name}{suffix}"] = (
378 Annotated[
379 Optional[list[str]],
380 Field(description=f"``{name} {sql} value``."),
381 ],
382 None,
383 )
385 if _is_string_field(field):
386 fields[f"{name}__contains"] = (
387 Annotated[
388 Optional[list[str]],
389 Field(
390 description=(
391 f"Case-sensitive substring search on "
392 f"``{name}``. Repeat the parameter to AND "
393 "multiple terms; whitespace inside one value is "
394 "also AND-split as a convenience."
395 )
396 ),
397 ],
398 None,
399 )
400 fields[f"{name}__icontains"] = (
401 Annotated[
402 Optional[list[str]],
403 Field(
404 description=(
405 f"Case-insensitive substring search on "
406 f"``{name}``. Repeat the parameter to AND "
407 "multiple terms; whitespace inside one value is "
408 "also AND-split as a convenience."
409 )
410 ),
411 ],
412 None,
413 )
415 schema_name = _schema_role_name(schema_response, "ListParams")
416 validators = (
417 {"_validate_pagination_offset": _pagination_offset_validator(pagination)}
418 if isinstance(pagination, NumberedPagination)
419 else {}
420 )
421 params_model = pydantic.create_model( # type: ignore[call-overload]
422 schema_name, __validators__=validators, **fields
423 )
424 _CREATED_WITH[params_model] = pagination
425 return params_model
428def apply_list_params(
429 query: Select[Any],
430 list_params: pydantic.BaseModel | QueryParams,
431 model: type[DeclarativeBase],
432 schema_response: SchemaType,
433 *,
434 pagination: _AnyPagination = cast(Any, _FROM_PARAMS),
435) -> Select[Any]:
436 """
437 Apply pagination, sorting, and filtering to ``query`` using validated
438 list params.
440 ``list_params`` is normally an instance of the model returned by
441 :func:`derive_schema_list_params`. The default list endpoints always pass a
442 validated instance, so pagination/filter bounds have already been checked.
443 ``schema_response`` decides the fields a client can filter and sort on.
444 A view passes its response class.
446 The arguments match the view method
447 :meth:`~fastapi_restly.views.RestView.apply_list_params`, which calls this
448 function with the view's model, response class and pagination.
450 A raw :class:`~starlette.datastructures.QueryParams` is also accepted
451 for callers that build the query parameters programmatically.
452 **Raw inputs bypass schema validation** — the caller is responsible
453 for verifying ``page``/``page_size`` ranges and any per-view bounds
454 (``max_page_size``); this function only performs the minimum coercion
455 needed to apply the SQL clauses.
457 ``pagination`` names the page and page-size parameters to read. By default
458 it is the pagination that :func:`derive_schema_list_params` created the
459 list params model with, and ``page`` and ``page_size`` for raw
460 ``QueryParams`` or a hand-written model. A ``NoPagination`` or ``None``
461 applies no ``LIMIT``/``OFFSET``, and so does a missing page-size key.
463 Examples::
465 # Pagination
466 page=2&page_size=50
468 # Sorting
469 sort=name,-created_at
471 # Filtering
472 name=Bob&status=active&created_at__gte=2024-01-01
474 # Contains (string fields)
475 name__contains=John&email__icontains=example
476 """
477 if isinstance(pagination, _FromParams):
478 pagination = (
479 _CREATED_WITH.get(type(list_params), _DEFAULT_PAGINATION)
480 if isinstance(list_params, pydantic.BaseModel)
481 else _DEFAULT_PAGINATION
482 )
483 query_params = _coerce_to_query_params(list_params)
484 aliases: _JoinAliases = {}
485 query = _apply_filtering(
486 query_params,
487 query,
488 model,
489 schema_response,
490 aliases=aliases,
491 pagination=pagination,
492 )
493 query = _apply_sorting(query_params, query, model, schema_response, aliases=aliases)
494 if isinstance(pagination, NumberedPagination):
495 query = _apply_pagination(query_params, query, pagination)
496 return query
499def _coerce_to_query_params(
500 list_params: pydantic.BaseModel | QueryParams,
501) -> QueryParams:
502 """Normalise validated list params or raw QueryParams to QueryParams.
504 When a dumped field is a list (e.g. a repeated ``name__contains``), each
505 element is expanded to its own ``(key, value)`` tuple so that
506 ``QueryParams.multi_items()`` later returns the original repeated values.
507 """
508 if isinstance(list_params, QueryParams):
509 return list_params
510 if isinstance(list_params, pydantic.BaseModel):
511 dumped = list_params.model_dump(exclude_none=True, by_alias=True, mode="json")
512 items: list[tuple[str, str]] = []
513 for key, value in dumped.items():
514 if isinstance(value, list):
515 items.extend((key, str(item)) for item in value)
516 else:
517 items.append((key, str(value)))
518 return QueryParams(items)
519 return QueryParams(list_params)
522def _apply_pagination(
523 query_params: QueryParams, select_query: Select[Any], pagination: NumberedPagination
524) -> Select[Any]:
525 page_size = _get_int(query_params, pagination.page_size_query_param)
526 if page_size is None:
527 return select_query
528 page = _get_int(query_params, pagination.page_query_param) or 1
529 offset = (page - 1) * page_size
530 return select_query.limit(page_size).offset(offset)
533def _get_int(query_params: QueryParams, param_name: str) -> Optional[int]:
534 value = query_params.get(param_name)
535 if not value:
536 return None
537 try:
538 return int(value)
539 except ValueError:
540 raise BadQueryParam(
541 f"Invalid value for URL query parameter {param_name}: "
542 f"{value} is not an integer"
543 )
546def _apply_sorting(
547 query_params: QueryParams,
548 select_query: Select[Any],
549 model: type[DeclarativeBase],
550 schema_cls: SchemaType,
551 *,
552 aliases: _JoinAliases | None = None,
553) -> Select[Any]:
554 if aliases is None:
555 aliases = {}
556 sorted_on: list[InstrumentedAttribute[Any]] = []
557 for column_name in (query_params.get("sort") or "").split(","):
558 if not column_name:
559 continue
560 order = sqlalchemy.asc
561 if column_name.startswith("-"):
562 order = sqlalchemy.desc
563 column_name = column_name[1:]
564 select_query, column = _join_column(
565 select_query, model, column_name, schema_cls, aliases
566 )
567 select_query = select_query.order_by(order(column))
568 sorted_on.append(column)
569 return _append_pk_tiebreak(select_query, model, sorted_on)
572def _written_name(schema_cls: SchemaType) -> str:
573 """The name of the class that the user wrote: for a response class that
574 Restly derived, the view's schema."""
575 return schema_cls.__dict__.get("__restly_response_of__", schema_cls).__name__
578def _iter_fields_including_nested(
579 schema_cls: SchemaType, prefix: str = ""
580) -> Iterator[tuple[str, FieldInfo]]:
581 for name, field in schema_cls.model_fields.items():
582 # A WriteOnly field, or one with ``exclude=True``, never goes out,
583 # so it is never a filter or sort key.
584 if field.exclude is True:
585 continue
586 public_name = field.alias or name
587 # Each segment of the public dotted path becomes part of the URL
588 # grammar. ``__`` is reserved for operator suffixes (``__gte``,
589 # ``__contains``, ...) and ``.`` is reserved for relation traversal,
590 # so a segment containing either character would create an
591 # ambiguous URL key. Reject at schema-generation time so the
592 # collision surfaces during view registration, not at request time.
593 if "__" in public_name:
594 raise ValueError(
595 f"List params for {_written_name(schema_cls)!r} cannot "
596 f"expose field {public_name!r}: ``__`` is reserved for "
597 "operator suffixes. Choose a different Pydantic alias."
598 )
599 if "." in public_name:
600 raise ValueError(
601 f"List params for {_written_name(schema_cls)!r} cannot "
602 f"expose field {public_name!r}: ``.`` is reserved for "
603 "relation traversal. Choose a different Pydantic alias."
604 )
605 full_name = f"{prefix}.{public_name}" if prefix else public_name
606 nested = _get_nested_schema(field)
607 if nested and not _is_idref_field(field):
608 yield from _iter_fields_including_nested(nested, full_name)
609 else:
610 yield full_name, field
613def _resolve_field_name(schema_cls: SchemaType, public_name: str) -> str | None:
614 """Return the Python field name for a public URL field name.
616 The public name is the field's alias when one is declared, otherwise the
617 field name itself. Aliased fields are *only* reachable by their alias —
618 Python field names are never part of the public URL contract, even when
619 the schema has ``populate_by_name=True`` (which only affects how Pydantic
620 parses input bodies, not the generated list params).
622 A field that never goes out, WriteOnly or with ``exclude=True``, never
623 resolves: filtering or sorting on it would let a client read back a value
624 that responses leave out.
625 """
626 resolved: str | None = None
627 for field_name, field in schema_cls.model_fields.items():
628 if field.alias == public_name:
629 resolved = field_name
630 break
631 else:
632 field = schema_cls.model_fields.get(public_name)
633 if field is not None and field.alias is None:
634 resolved = public_name
635 if resolved is not None and schema_cls.model_fields[resolved].exclude is True:
636 return None
637 return resolved
640def _join_column(
641 select_query: Select[Any],
642 model: type[DeclarativeBase],
643 column_path: str,
644 schema_cls: SchemaType,
645 aliases: _JoinAliases,
646) -> tuple[Select[Any], InstrumentedAttribute[Any]]:
647 """Join each relationship path once and return its aliased leaf column."""
648 relationships, column = _resolve_column(model, column_path, schema_cls)
649 source: type[DeclarativeBase] | AliasedClass[Any] = model
650 path: tuple[str, ...] = ()
651 for relationship in relationships:
652 # The full path distinguishes home_city.country from work_city.country
653 # and successive hops through a self-referential relationship.
654 path += (relationship.key,)
655 target = aliases.get(path)
656 if target is None:
657 target = aliased(relationship.property.mapper)
658 select_query = select_query.join_from(
659 source, getattr(source, relationship.key).of_type(target)
660 )
661 aliases[path] = target
662 source = target
663 return select_query, getattr(source, column.key)
666def _resolve_column(
667 model: type[DeclarativeBase], column_path: str, schema_cls: SchemaType
668) -> tuple[list[InstrumentedAttribute[Any]], InstrumentedAttribute[Any]]:
669 """Resolve a (possibly dotted) public column path to its SQLAlchemy column,
670 plus the relationship attributes that need to be joined.
672 Strict: every path segment must resolve through the schema's public name
673 (alias when set, Python field name otherwise). Falling back to a raw
674 model attribute lookup would let URLs reach columns the schema didn't
675 expose — for example, a Python field name on an aliased schema field —
676 and silently bypass the public-name contract.
677 """
678 joins: list[InstrumentedAttribute[Any]] = []
679 current_model = model
680 current_schema: SchemaType | None = schema_cls
681 name = column_path
682 while "." in name:
683 relation, _, name = name.partition(".")
684 if current_schema is None: 684 ↛ 685line 684 didn't jump to line 685 because the condition on line 684 was never true
685 raise BadQueryParam(f"Invalid attribute in URL query: {column_path}")
686 field_name = _resolve_field_name(current_schema, relation)
687 if field_name is None:
688 raise BadQueryParam(f"Invalid attribute in URL query: {column_path}")
689 rel = getattr(current_model, field_name, None)
690 if not isinstance(rel, InstrumentedAttribute) or not hasattr(
691 rel.property, "mapper"
692 ):
693 raise BadQueryParam(f"Invalid attribute in URL query: {column_path}")
694 joins.append(rel)
695 current_model = rel.property.mapper.class_
696 current_schema = _get_nested_schema(current_schema.model_fields[field_name])
698 if current_schema is None: 698 ↛ 699line 698 didn't jump to line 699 because the condition on line 698 was never true
699 raise BadQueryParam(f"Invalid attribute in URL query: {column_path}")
700 field_name = _resolve_field_name(current_schema, name)
701 if field_name is None:
702 raise BadQueryParam(f"Invalid attribute in URL query: {column_path}")
703 column = getattr(current_model, field_name, None)
704 if (
705 column is None
706 or not isinstance(column, InstrumentedAttribute)
707 or not isinstance(column.property, ColumnProperty)
708 ):
709 raise BadQueryParam(f"Invalid attribute in URL query: {column_path}")
710 return joins, cast(InstrumentedAttribute[Any], column)
713def _apply_filtering(
714 query_params: QueryParams,
715 select_query: Select[Any],
716 model: type[DeclarativeBase],
717 schema_cls: SchemaType,
718 *,
719 aliases: _JoinAliases | None = None,
720 pagination: _AnyPagination = _DEFAULT_PAGINATION,
721) -> Select[Any]:
722 """Apply ``key=value`` and ``key__op=value`` filters to ``select_query``.
724 Multiple filters on the same column are AND-combined. Comma-separated
725 values within one parameter are OR-combined for ``eq`` (the default),
726 mapped to SQL ``IN`` for ``in``, and AND-combined for ``ne`` (so
727 ``status__ne=a,b`` means NOT IN (a, b)). For ``contains``/``icontains``
728 values are split on whitespace and AND-combined.
729 """
730 if aliases is None:
731 aliases = {}
732 reserved = _reserved_names(pagination)
733 filters: dict[InstrumentedAttribute[Any], list[ColumnElement[Any]]] = defaultdict(
734 list
735 )
737 for key, raw_value in query_params.multi_items():
738 if key in reserved:
739 continue
741 if "__" in key:
742 column_name, op = key.split("__", 1)
743 else:
744 column_name, op = key, "eq"
746 select_query, column = _join_column(
747 select_query, model, column_name, schema_cls, aliases
748 )
749 parser = functools.partial(_parse_value, schema_cls, column_name)
751 if op == "isnull":
752 try:
753 value = pydantic.TypeAdapter(bool).validate_python(raw_value)
754 except pydantic.ValidationError as exc:
755 raise BadQueryParam(
756 f"Invalid value for URL query parameter {key}"
757 ) from exc
758 filters[column].append(column.is_(None) if value else column.isnot(None))
759 continue
761 clause = _build_clause(column, raw_value, op, parser)
762 if clause is not None:
763 filters[column].append(clause)
765 for column, clauses in filters.items():
766 and_clause = clauses[0] if len(clauses) == 1 else sqlalchemy.and_(*clauses)
767 select_query = select_query.where(and_clause)
768 return select_query
771def _build_clause(
772 column: InstrumentedAttribute[Any],
773 raw_value: str,
774 op: str,
775 parser: Callable[[str], Any],
776) -> ColumnElement[Any] | None:
777 """Combine multiple values within one parameter according to ``op`` semantics."""
778 if op in {"contains", "icontains"}:
779 values = [v for v in raw_value.split() if v]
780 if not values:
781 return None
782 clauses = [_make_where_clause(column, v, op, parser) for v in values]
783 return clauses[0] if len(clauses) == 1 else sqlalchemy.and_(*clauses)
785 values = raw_value.split(",")
786 if not values: 786 ↛ 787line 786 didn't jump to line 787 because the condition on line 786 was never true
787 return None
788 if op == "in":
789 return column.in_([_parse_filter_value(column, v, parser) for v in values])
790 clauses = [_make_where_clause(column, v, op, parser) for v in values]
791 if len(clauses) == 1:
792 return clauses[0]
793 # ``ne`` with multiple values means NOT IN (...) — AND-combine, not OR.
794 if op == "ne":
795 return sqlalchemy.and_(*clauses)
796 return sqlalchemy.or_(*clauses)
799def _parse_value(schema_cls: SchemaType, column_name: str, value: str) -> Any:
800 if "." in column_name:
801 relation, _, column_part = column_name.partition(".")
802 relation_field_name = _resolve_field_name(schema_cls, relation) or relation
803 field = schema_cls.model_fields.get(relation_field_name)
804 nested = _get_nested_schema(field)
805 if nested is None: 805 ↛ 806line 805 didn't jump to line 806 because the condition on line 805 was never true
806 raise BadQueryParam(f"Invalid attribute in URL query: {column_name}")
807 return _parse_value(nested, column_part, value)
809 field_name = _resolve_field_name(schema_cls, column_name)
810 if field_name is None:
811 raise BadQueryParam(f"Invalid attribute in URL query: {column_name}")
813 try:
814 values, _, _ = _field_validator(schema_cls, field_name).validate_python(
815 {field_name: value}
816 )
817 result = values[field_name]
818 except Exception:
819 raise BadQueryParam(f"Invalid attribute in URL query: {column_name}")
820 # An IDRef[T] FK field validates to an IDRef object; the SQL bind value
821 # is its scalar id, not the reference wrapper (which cannot bind).
822 if isinstance(result, IDSchema):
823 return result.id
824 return result
827@functools.cache
828def _field_validator(schema_cls: SchemaType, field_name: str) -> SchemaValidator:
829 """A validator for a one-field mapping, cut from the model's core schema.
831 The named field supplies ``ValidationInfo.field_name``. No other fields
832 are validated, so ``ValidationInfo.data`` is empty.
834 Field validators, constraints and the model config apply; model validators
835 do not. A filter value is one column, so a cross-field rule has nothing to
836 compare against and would only reject legal filters (as validating through
837 ``validate_assignment`` on a ``model_construct()`` skeleton did).
838 """
839 core: Any = schema_cls.__pydantic_core_schema__
840 definitions = core.get("definitions") if core.get("type") == "definitions" else None
841 definitions_by_ref = {
842 definition["ref"]: definition for definition in definitions or ()
843 }
844 config = None
845 node: Any = core
846 while node is not None and node.get("type") != "model-fields":
847 # Recursive models keep their model schema in the definitions.
848 if node.get("type") == "definition-ref":
849 node = definitions_by_ref[node["schema_ref"]]
850 continue
851 if node.get("type") == "model" and config is None:
852 config = node.get("config")
853 node = node.get("schema")
854 if node is None: 854 ↛ 855line 854 didn't jump to line 855 because the condition on line 854 was never true
855 raise LookupError(f"{schema_cls.__name__} has no model-fields core schema")
856 field_schema: Any = core_schema.model_fields_schema(
857 {field_name: core_schema.model_field(node["fields"][field_name]["schema"])}
858 )
859 if definitions:
860 # A field typed with a class the schema uses more than once is a
861 # definition-ref; it validates only next to the definitions.
862 field_schema = {
863 "type": "definitions",
864 "schema": field_schema,
865 "definitions": definitions,
866 }
867 return SchemaValidator(field_schema, config)
870def _get_nested_schema(field: FieldInfo | None) -> SchemaType | None:
871 if field is None: 871 ↛ 872line 871 didn't jump to line 872 because the condition on line 871 was never true
872 return None
873 annotation = _unwrap_optional_annotation(field.annotation)
874 if isinstance(annotation, type) and issubclass(annotation, pydantic.BaseModel):
875 return annotation
876 return None
879def _make_where_clause(
880 column: InstrumentedAttribute[Any],
881 filter_value: str,
882 op: str,
883 parser: Callable[[str], Any],
884) -> ColumnElement[Any]:
885 if op == "contains":
886 return column.like(f"%{_escape_like_value(filter_value)}%", escape="\\")
887 if op == "icontains":
888 return column.ilike(f"%{_escape_like_value(filter_value)}%", escape="\\")
890 value = _parse_filter_value(column, filter_value, parser)
891 if op == "gte":
892 return column >= value
893 if op == "lte":
894 return column <= value
895 if op == "gt":
896 return column > value
897 if op == "lt":
898 return column < value
899 if op == "ne":
900 return column != value
901 if op == "eq":
902 return column == value
903 raise BadQueryParam(f"Unsupported filter operator: {op!r}")
906def _parse_filter_value(
907 column: InstrumentedAttribute[Any], raw_value: str, parser: Callable[[str], Any]
908) -> Any:
909 value = parser(raw_value)
910 if (
911 isinstance(value, _dt.datetime)
912 and value.utcoffset() is None
913 and getattr(column.type, "timezone", False)
914 ):
915 return value.replace(tzinfo=_dt.timezone.utc)
916 return value