Запросы из параметров HTTP-запроса¶
hare.contrib.request_query один раз, в классе, описывает строки, которые запрашивает клиент API: с
какого набора строк начинать, по каким параметрам фильтровать, где искать, какие сортировки разрешены и
как делить результат на страницы. Класс — это модель pydantic для параметров запроса; адаптер фреймворка
(Litestar, FastAPI, Robyn) создаёт его из запроса и
передаёт обработчику, а тот его выполняет:
from typing import Annotated
from hare.contrib.request_query import Filter, OffsetPagination, OrderingConfig, RequestQuery, SearchConfig
class BookQuery(RequestQuery[Book]):
author: int | None = None
title__icontains: str | None = None
genre_ids: Annotated[list[int] | None, Filter("genres", lookup="in")] = None
class Meta:
queryset = Book.objects.all().select_related("author")
search = SearchConfig(fields=("title", "author__name"))
ordering = OrderingConfig(fields=("title", "published_at"), default=("-published_at",))
pagination = OffsetPagination(default_limit=50, max_limit=200)
page = await BookQuery(author=3, ordering="title").page()
Ставится вместе с дополнительным пакетом request-query (pydantic).
Фильтры, их операторы и значения, которые они принимают, берутся из самого ORM — из
get_lookup_info(), — поэтому оператор, который проект
зарегистрировал через register_lookup(), работает как встроенный, а объявление, которое не
соответствует моделям, даёт ошибку при запуске приложения, а не на запросе.
Параметры¶
Параметр без пометки фильтрует по своему имени: author — это .filter(author=...), title__icontains
— .filter(title__icontains=...). Параметр со значением None не фильтрует. Работает любой ключ,
который принимает .filter():
| Параметр | Фильтр |
|---|---|
author: int | None |
связь по ключу связанной строки (author=3) |
author__in: list[int] | None |
связь по списку ключей |
author__publisher__country__in: list[int] | None |
связь в конце цепочки |
author__publisher__country__pk__in: list[int] | None |
то же через pk |
profile__city: str | None |
связь «один-к-одному», с любой стороны |
tags__name: str | None |
связь «многие-ко-многим» — каждая строка возвращается один раз |
published_at__year__gte: int | None |
часть даты |
pk: KeyColumns[int, int] | None |
составной первичный ключ, ?pk=2,1 |
line: KeyColumns[int, int] | None |
связь с моделью с составным ключом |
score__within: tuple[int, int] | None |
оператор, зарегистрированный через register_lookup() |
Из списка полей: Meta.filters¶
from hare.contrib.request_query import FilterField
from hare.query.enums import Lookup
class BookQuery(RequestQuery[Book]):
class Meta:
queryset = Book.objects.all()
filters = (
FilterField("status", lookups=(Lookup.EXACT, Lookup.IN)),
FilterField("author", lookups=(Lookup.EXACT, Lookup.IN)),
FilterField("published_at", lookups=(Lookup.GTE, Lookup.LTE, Lookup.RANGE)),
FilterField("author__profile", lookups=(Lookup.ISNULL,)),
FilterField("author__profile__city", lookups=(Lookup.EXACT, Lookup.IN), parameter="city"),
)
FilterField(path, lookups=(Lookup.EXACT,), parameter=None, description=None) — поле, связь, путь через
связи или вычисляемое значение Meta.queryset — создаёт по параметру на каждый оператор: status,
status__in, author, author__in, published_at__gte, published_at__lte, published_at__range,
author__profile__isnull. Lookup.EXACT (или "exact") — это сам путь, любой другой оператор добавляет
__<lookup>; оператор, зарегистрированный через register_lookup(), называется своим именем.
parameter= задаёт имя параметров вместо пути, чтобы API не показывал устройство модели: city и
city__in фильтруют по author__profile__city. description= заменяет описание поля в схеме API.
Тип каждого параметра — это значение, которое принимает его фильтр, как его описывает ORM:
| Фильтр принимает | Параметр |
|---|---|
| одно значение | T | None |
список (in, not_in) |
list[T] | None — параметр повторяется, ?status__in=draft&status__in=published |
диапазон (range) |
tuple[T, T] | None — два значения, «от» и «до» |
| составной ключ | KeyColumns[...] вместо T |
поле с перечислением (IntEnumField, CharEnumField) и сравнение через exact, not, in, not_in |
перечисление вместо T |
T — тип значения: тип поля, тип ключа связанной модели для связи, int для части даты, bool для
isnull. Фильтр, тип значения которого становится известен только при выполнении запроса (вычисляемое
значение из готового SQL, любое значение JSON), даёт ConfigurationError — объявите его параметр
вручную. Параметр, который создал бы Meta.filters, но который класс уже объявляет сам или добавляет
какая-то опция, — тоже ConfigurationError: псевдонимы, методы фильтров и особые типы объявляются
вручную рядом с Meta.filters.
У массива, поле элементов которого имеет null=True, списочные фильтры также принимают элементы NULL:
тип элемента — T | None. Явный параметр list[None] или list[Literal[None]] — допустимое сужение типа.
Значение None всего необязательного параметра по-прежнему отключает его фильтр.
Параметризованный тип принимает значение своего класса: dict[str, str] | None — для фильтра, который
берёт dict (contains у HStoreField).
Параметры добавляются, когда модели привязаны — через Hare.bind_models() (подключение не нужно) или
Hare.init(): когда адаптер фреймворка читает параметры для сигнатур обработчиков (HarePlugin сам
привязывает модели), когда запрос строится первый раз или при prepare_parameters(). Подкласс получает
параметры унаследованного Meta.filters.
Описания¶
Параметр фильтра без собственного описания описывается description своего поля, а список или диапазон —
тем, как он записывается («Повторите параметр для каждого значения»); это показывает схема API. Параметр
с Field(description=...) сохраняет своё описание.
Псевдонимы: Filter¶
Filter даёт параметру собственное имя, чтобы API не показывал устройство модели — и не менялся вместе с
ним:
Filter(key) принимает ключ целиком (Filter("author__name__iexact")); lookup добавляется к нему
через __.
Параметры, которые не фильтруют: NoFilter¶
Обработчик читает query.mode; запрос его не учитывает.
Параметры пути: InPath¶
Параметр, который читается из пути маршрута, а не из строки запроса, — так его читает и описывает любой
адаптер фреймворка (в приложении Litestar работает и собственный FromPath Litestar).
Методы фильтров¶
Метод filter_<parameter>(self, value) превращает значение параметра в собственный Q — синхронно или
асинхронно; None не добавляет условия:
class BookQuery(RequestQuery[Book]):
rated_at_least: int | None = None
def filter_rated_at_least(self, value: int) -> Q:
return Q(author__rating__gte=value)
Параметр с методом фильтра не сверяется с ORM — что он делает, решает его метод.
Асинхронные методы фильтров у параметров со значением выполняются одновременно (asyncio.gather):
медленный запрос в одном не ждёт другой. Условия идут в порядке объявления параметров. Внутри транзакции
их запросы по-прежнему выполняются на её соединении по очереди.
Типы¶
Аннотация параметра должна принимать значение, которое принимает его фильтр, как его описывает
get_lookup_info():
- одно значение (
LookupValueShape.VALUE) — тип значения, его подкласс, перечисление илиLiteralтаких значений (strдля текстового оператора,intдля связи с целочисленным ключом,boolдляisnull,tuple[UUID, int]для составного ключа); - список (
LIST,__in) —list[...],set[...],frozenset[...]илиtuple[..., ...]из него; - диапазон (
RANGE,__range) —tuple[T, T]илиlist[T].
bool не считается int. Несоответствие даёт ConfigurationError с именем класса, параметром, тем, что
принимает фильтр, и тем, какая у параметра аннотация.
Три типа читают одно текстовое значение:
KeyColumns[int, int]— значения составного ключа через запятую (?pk=2,1). В раскодированном тексте параметра запятая внутри значения —%2C, знак процента —%25; в URL это%252Cи%2525(?pk=a%252Cb,c— ключ("a,b", "c")). Любой другой%остаётся как написан. Список ключей —list[KeyColumns[int, int]], по параметру на ключ (?pk__in=2,1&pk__in=3,1).CommaSeparated[int]— список в одном параметре (?ids=1,2,3); повторённые параметры объединяются, пустые элементы пропускаются.GenericTarget— цельGenericForeignKeyFieldв виде<branch>:<key>(?target=post:1, значения составного ключа через запятую:article_version:7,2), читается как{"type": "post", "id": 1}.Meta.filtersтипизирует имFilterField("target"), аFilterField("target__type")—Literalиз имён веток.
Свой тип, который читается из одного текстового значения, несёт TextParameter(description, repeats=False)
в метаданных Annotated рядом с валидатором, разбирающим текст, как эти три, — тогда адаптер
фреймворка передаёт текст как есть; с repeats=True каждое значение повторённого параметра приходит
списком текстов.
Значение, которое не подходит — неверного типа, вне диапазона, сортировка, которую запрос не разрешает, —
даёт InvalidRequestQuery с одной ошибкой на параметр (loc, msg, type); адаптер фреймворка
отвечает на неё так же, как сам фреймворк отвечает на неверные параметры: 400 Bad Request в Litestar,
422 Unprocessable Content в FastAPI и Robyn.
Границы¶
Границы, которые запрос задаёт одному полю, должны оставлять между собой хотя бы одно значение. Каждая
нижняя граница (gt, gte) сопоставляется с каждой верхней (lt, lte) того же пути поля — и
объявленной, и из Meta.filters, в том числе под псевдонимом (published_since с
Filter("published_at", lookup="gte")) и через часть даты (published_at__year__gte):
?published_at__gte=2026-02-01&published_at__lte=2026-01-01 — это InvalidRequestQuery на параметре
верхней границы (type range). Равные границы допустимы, если ни одна из них не строгая (gt, lt).
Параметр range, который начинается позже, чем заканчивается, отклоняется так же. Значения, которые
нельзя сравнить (дата-время с поясом и без), оставляются базе.
Порядок параметров¶
Сначала идут параметры, которые объявляет сам класс, затем параметры Meta.filters, затем параметры
опций (поиск, сортировка, поля, связи, удалённые строки, страницы) — и в полях модели, и в схеме API.
Опции: Meta¶
| Опция | Что задаёт |
|---|---|
queryset |
Строки, с которых начинается запрос: Book.objects.all().select_related("author"). Класс без него служит только основой для других. |
filters |
Итерируемый объект с FilterField — поля, по которым запрос может фильтровать, и операторы каждого — см. Meta.filters. |
search |
SearchConfig, None — без поиска. |
ordering |
OrderingConfig, None — запрос не может выбрать сортировку. |
pagination |
OffsetPagination, ScrollPagination или CursorPagination, None — без страниц. По умолчанию OffsetPagination(). |
join_type |
Как объединяются условия параметров — Connector.AND (по умолчанию) или Connector.OR. |
fields |
FieldsConfig, None — запрос не может выбрать загружаемые поля. |
include |
IncludeConfig, None — запрос не может попросить связи. |
deleted |
DeletedConfig, None — запрос не может видеть мягко удалённые строки. |
versions |
LatestVersions() — оставлять только последнюю версию каждой записи VersionedModel; None — все версии. |
api_getter |
Итерируемый объект с ApiSource — HTTP API других сервисов, которыми дополняются строки, — и ApiValueMapping — значения строки, копируемые в другие атрибуты; см. Meta.api_getter. |
Подкласс наследует каждую опцию, которую не задаёт сам, от ближайшего базового класса, который её задаёт.
queryset строится при импорте модуля, до Hare.init(); каждый запрос работает со своей копией.
get_queryset() можно переопределить для строк, зависящих от запроса, — фильтры всё равно сверяются с
Meta.queryset, поэтому фильтру по вычисляемому значению нужно это значение там. Модели с
Meta.tenant_field больше ничего не нужно: область из Tenancy.scope() применяется при выполнении
запроса, а не при построении Meta.queryset.
Каждая опция, добавляющая параметры (search, ordering, fields, include, deleted, pagination),
— это RequestOption; parameter= (у пагинации — limit_parameter=, offset_parameter=,
cursor_parameter=) переименовывает её параметр запроса. RequestOption называет свои параметры
(get_parameter_fields(), каждый — ParameterField(name, annotation, default,
parameter_type=ParameterType.OPTION, allowed_values=None)), проверяет значения, которые им даёт запрос
(check_request()), и применяет их; своя опция наследуется от неё так же.
Поиск: SearchConfig¶
search = SearchConfig(fields=("title", "author__name"), lookup="icontains", parameter="search", join_type=Connector.OR)
?search=tolk находит строки, где подходит любое поле (Connector.OR) или каждое поле
(Connector.AND). Оператор каждого поля должен принимать текст. Пустой поиск ничего не фильтрует.
split_words=True ищет по словам: каждое слово текста должно найтись, каждое — в полях, объединённых
через join_type; ?search=ursula wizard найдёт книгу Урсулы с названием «Wizard».
Сортировка: OrderingConfig¶
ordering = OrderingConfig(fields=("title", "published_at", "author__name"), default=("-published_at",))
Запрос называет одно или несколько полей из fields через запятую, - — по убыванию:
?ordering=-published_at,title. Имя вне fields или названное дважды отклоняется. Строки сортируются по
order_by() обработчика, иначе по сортировке из запроса, иначе по default, иначе по собственной
сортировке набора строк, иначе по Meta.ordering модели — и затем по полям первичного ключа, по которым
ещё нет сортировки, чтобы строки с равными значениями сохраняли один порядок от страницы к странице (все
поля составного ключа); модель без первичного ключа (Meta.primary_key = None) таких полей не получает.
Сортировка через связь «ко многим» отклоняется — каждая строка повторялась бы, — как и зашифрованное поле
в fields или default: его случайный шифротекст ничего не упорядочивает, и класс даёт
ConfigurationError при построении объявления, а не ошибку 500 на каждом запросе. NULL встаёт туда,
куда его ставит база, — первым по возрастанию в SQLite, последним в PostgreSQL и ClickHouse (см.
Порядок NULL); обработчик закрепляет его место через
F(...).asc(nulls_last=True) в своём order_by().
Страницы¶
Каждый способ деления на страницы наследуется от Pagination — размер страницы, который может запросить
клиент (default_limit, max_limit), и его параметр (limit_parameter); каждый добавляет то, как
запрос указывает страницу, и строит свой вид страницы (get_page()), поэтому свой способ — тоже
подкласс.
OffsetPagination(default_limit=100, max_limit=1000, limit_parameter="limit", offset_parameter="offset")
делит на страницы через ?limit= и ?offset=. page() возвращает Page: result, count (все
подходящие строки), limit, offset, next и previous — адрес запроса с заменённым offset, где все
остальные параметры сохранены как были, в том числе повторённые; previous при смещении меньше размера
страницы ведёт на первую страницу.
ScrollPagination(default_limit=100, max_limit=1000, limit_parameter="limit", offset_parameter="offset")
тоже делит через ?limit= и ?offset=, но не считает подходящие строки — для ленты или длинного списка,
где показываются только «дальше» и «назад»: не нужен COUNT большой таблицы, а есть ли следующая
страница, определяется по одной строке, прочитанной сверх страницы. page() возвращает ScrollPage:
result, limit, offset, next, previous — счётчика нет вовсе. Оба способа со смещением
наследуются от BaseOffsetPagination.
CursorPagination(default_limit=100, max_limit=1000, limit_parameter="limit", cursor_parameter="cursor")
делит по курсору: значения сортировки строки, после которой начинается или до которой заканчивается
страница (after_cursor()/before_cursor()). Страница стоит одинаково, где бы она ни была, строки,
добавленные между запросами, не сдвигают страницы, счётчика нет. page() возвращает CursorPage:
result, limit, next_cursor, previous_cursor, next, previous. Курсор помнит сортировку, для
которой создан; курсор другой сортировки, текст, который не является курсором, или курсор со значением,
которое не подходит полю (текст для IntField, NULL для поля NOT NULL самой строки), — это
InvalidRequestQuery. Сортировка при делении по курсору не может использовать вычисляемое значение или
путь внутри значения поля (details__pages у JSON-поля): курсор читает значения полей из строки; поля
через прямые связи загружаются вместе со строками (select_related()), потому что курсор читает их из
последней строки. У модели без первичного ключа деления по курсору быть не может: строки, равные по
сортировке, пропускались бы.
Значения сортировки BinaryField сохраняются внутри курсора как URL-safe Base64, в том числе байты,
которые не являются текстом UTF-8.
Нетипизированное JSONField сохраняет в курсоре JSON-значения, включая скаляры; это относится и к
GeneratedField с таким выходным полем. Граница сравнивается так же, как база сравнивает значения
JSON-поля.
Границы по контейнерам сохраняют типы элементов, включая UUID и байты; именованный TupleField
сохраняет именованные элементы при чтении курсора.
Размеры страниц проверяются при создании опции: 1 <= default_limit <= max_limit <= 100000.
Поля и связи: FieldsConfig, IncludeConfig¶
fields = FieldsConfig(fields=("title", "status", "published_at"))
include = IncludeConfig(relations=("author", "author__profile", "tags"))
?fields=title,status загружает только названные поля (QuerySet.only()), первичный ключ и поля,
которые читает сортировка; обращение к другому полю такой строки даёт AttributeError. Имена должны быть
собственными полями модели — связи загружаются через include — или целями
Meta.api_getter. ?include=author,tags загружает
названные связи вместе со строками: связь с одной строкой — через соединение таблиц (select_related()),
связь со многими строками — отдельным запросом (prefetch_related()). Имя, которого нет в опции, — это
InvalidRequestQuery. Обе опции действуют в fetch(), page() и get().
Удалённые строки: DeletedConfig¶
Для модели с Meta.soft_delete_field: ?deleted=include — все строки, ?deleted=only — только
удалённые, ?deleted=exclude или без значения — остальные, как без опции. Чтобы запросить удалённые
строки, это должен разрешить may_see_deleted() — иначе запрос даёт RequestQueryForbidden, и адаптер
фреймворка отвечает 403 Forbidden:
class StaffBookQuery(BookQuery):
async def may_see_deleted(self) -> bool:
return self.request.user.is_admin
may_see_deleted() по умолчанию возвращает True: объявление опции позволяет видеть удалённые строки
любому запросу. Модель без Meta.soft_delete_field даёт ConfigurationError.
Последние версии: LatestVersions¶
class CatalogRecordQuery(RequestQuery[CatalogRecord]):
class Meta:
queryset = CatalogRecord.objects.filter(status="published")
versions = LatestVersions()
Для VersionedModel: только последняя версия каждой записи — строки, для которых нет более новой версии
с тем же id; это один связанный подзапрос (NOT EXISTS), а не список идентификаторов. Сравниваются
версии из строк Meta.queryset (выше — последняя опубликованная версия) с учётом того, какие удалённые
строки просит запрос, и условия доступа: более новая версия, которую запросу видеть нельзя, не скрывает
ту, которую можно. Фильтры запроса применяются к последним версиям: фильтр, подходящий только под более
старую версию, ничего не находит. Модель, которая не является VersionedModel, даёт
ConfigurationError, как и модель на базе, которая не выполняет связанные подзапросы, — это сообщает
check_declarations(), когда у модели есть подключение.
Значения из других сервисов: Meta.api_getter¶
from uuid import UUID
from hare.contrib.request_query import ApiGetterRetry, ApiSource, ApiValueMapping
async def get_service_headers() -> dict[str, str]:
return {"X-Service-Token": await service_tokens.get()}
class DealQuery(RequestQuery[Deal]):
class Meta:
queryset = Deal.objects.all()
fields = FieldsConfig(fields=("title", "status", "partner_user", "label"))
api_getter = (
ApiSource(
url="http://users/service/users",
key="partner_user_id",
mappings=(ApiValueMapping(set_key="partner_user"),),
name="users",
query_parameter="ids",
match_key="id",
key_type=UUID,
headers=get_service_headers,
retry=ApiGetterRetry(attempts=3),
),
ApiValueMapping(set_key="label", key="title"),
)
Строки дополняются тем, что HTTP API других сервисов отвечают на хранящиеся в них значения. У ApiSource
запрашивается каждое значение его key, которое есть в строках, — каждое один раз, сколько бы строк его
ни содержало, — и каждый элемент ответа попадает в строки со значением, на которое он отвечает. Нужен
extra http.
- Запрашиваемые значения.
key— путь из имён через точку ("partner_user_id","partner.user_id"); каждая часть — атрибут или ключ словаря, список на пути обходится поэлементно; по связи строки путь идёт, если строки её загрузили (?include=,select_related(),prefetch_related()), иначе значения у неё нет.Noneне запрашивается, как и значение, с которым не сопоставить элемент ответа (словарь JSON-поля), — цели их строк остаются пустыми. - Запросы.
request_modeзадаёт, как спрашивается источник: ApiRequestMode.QUERY_MANY(по умолчанию) — один запрос на пачку доbatch_size(200) значений, каждое значение — повторquery_parameter(?ids=1&ids=2). Элемент ответа сопоставляется со значением по своемуmatch_key(по умолчанию — последняя часть ключа), прочитанному черезkey_type(UUID,int, любая функция); значение, которое тип не принимает, ни с чем не совпадает. Значения идут в URL: большая пачка делает его длинным, а сервер отказывает в URL длиннее своего предела (часто414 URI Too Longпосле примерно 8 КБ) — держитеbatch_sizeв пределах того, что принимает сервис.ApiRequestMode.QUERY_ONE— один запрос на значение,?id=1.ApiRequestMode.PATH— один запрос на значение, значение в URL, экранированное:url="http://users/service/users/{partner_user_id}"называет последнюю часть ключа.
В этих двух режимах элемент тоже сопоставляется по match_key, если он задан, иначе отвечает первый
элемент ответа. items_path ("result") — где в ответе элементы; по умолчанию — сам ответ; одиночный
элемент считается списком из одного.
- Куда попадают значения. Каждый ApiValueMapping из mappings кладёт значение элемента ответа — по
его key, без него весь элемент — в set_key объекта, содержащего ключ источника: для
key="partner.user_id" и set_key="partner.user" — в user каждого partner. Строка без значения и
значение без ответа оставляют цель None или её default. Несколько сопоставлений одной цели решают по
своему mode: ApiValueMappingMode.LAST (по умолчанию) оставляет последнее значение, NOT_EMPTY —
первое непустое. field=DatetimeField() читает значение через поле hare — ISO-текст становится
datetime, — а convert= через вашу функцию, после поля, если заданы оба. ApiValueMapping, заданный в
самом api_getter, вне источника, копирует значение самой строки (key) в другой атрибут (set_key).
- Один прогон. Источники прогона спрашиваются одновременно, через один HTTP-клиент, а их значения
применяются в их порядке — цель, которую задают два источника, получает значение более позднего.
Источник шлёт не больше concurrency (10) запросов одновременно, каждый не дольше timeout_seconds
(10). headers — словарь ASCII-текстов без переносов строк или async-функция, которая его
возвращает, — вызывается один раз за прогон; в repr() источника заголовков нет.
- Повторы. retry=ApiGetterRetry(attempts=3) повторяет запрос, у которого оборвалось соединение или
ответ пришёл с одним из status_codes (429, 502, 503, 504), до attempts раз (по умолчанию 0):
после случайной паузы до base_delay_seconds * 2 ** повтор (0,5 с), не больше max_delay_seconds
(10 с), или столько, сколько просит Retry-After ответа — в секундах или HTTP-датой, — в том же пределе.
- Ошибки. Источник без required оставляет цели пустыми, если отвечает 404, ошибкой или не JSON либо
недоступен. С required=True источник вместо этого бросает: ApiDataNotFoundError — 404 или нет элемента
для значения; ApiRequestError — соединение не удалось ни разу, ответ-ошибка, ответ не JSON или его нельзя
прочитать (тело не распаковывается); ApiValueMissingError — в строках вообще нет значений его ключа.
Сопоставление с required=True бросает ApiValueMissingError для оставшейся пустой цели, а field или
convert, отвергнувшие значение, — ApiValueConversionError. Все они — ApiGetterError (.source_name);
адаптер веб-фреймворка отвечает на неё 502 Bad Gateway без её текста — он называет сервис и запрошенные
значения.
- Когда. fetch(), page() и get() дополняют свои строки до after_fetch(); get_for_update() —
нет: строка загружается только для изменения. Источник загружает значения со строками, которые читает
запрос; это выбирает triggers=ApiGetterTriggers(on_read=True, on_create=False, on_update=False), а
строки, которые обработчик создал или изменил, он дополняет сам — источниками с on_create/on_update:
deal = await Deal.objects.create(**data)
(deal,) = await DealQuery(request=request).load_api_values([deal], ApiGetterTrigger.CREATE)
loads_api_values() возвращает True; переопределите его, чтобы оставить строки некоторых запросов
как есть, — например, для запроса другого сервиса, которому нужны сами строки.
- fields. Цель — первую часть set_key — можно указать в FieldsConfig.fields, как поле. Запрос,
чей ?fields= не называет ни одной цели источника (ни, через ?include=, связи, на которой они лежат),
этот источник не спрашивает; запрос, называющий цель, загружает поле, с которого начинается ключ
источника (partner_user_id), даже если его не назвал. Без ?fields= спрашиваются все источники.
- Что сообщается. Каждая попытка запроса пишется в лог hare на уровне DEBUG и передаётся наблюдателям
ApiRequestExecuted (Observers.observe(ApiRequestExecuted, ...)): source_name, url (без параметров
запроса, фрагмента и учётных данных), status_code (None, если соединение не удалось),
duration_seconds, attempt (0 — первая) и error; заголовков в событии нет.
Противоречащие друг другу настройки отклоняются при создании — URL режима PATH без ключа, режим
запроса без query_parameter, обязательное отображение (required) с default — ConfigurationError.
ApiGetter.set(objs, sources, mappings) дополняет любые объекты — и словари — без запроса.
Выполнение запроса¶
| Метод | Возвращает |
|---|---|
await query.page() |
Page, ScrollPage или CursorPage — по Meta.pagination. |
await query.fetch() |
Все подходящие строки, отсортированные, без деления на страницы. |
await query.get() |
Единственную подходящую строку; иначе DoesNotExist / MultipleObjectsReturned. Принимает does_not_exist_exception и multiple_objects_returned_exception, как QuerySet.get(), — get(does_not_exist_exception=None) возвращает None, если подходящей строки нет. |
await query.count() / exists() |
Сколько строк подходит / подходит ли хотя бы одна. |
await query.delete() |
Удаляет подходящие строки и возвращает их число — None на базе, которая его не сообщает (ClickHouse). |
await query.update(**values) |
Обновляет подходящие строки и возвращает их число — None на базе, которая его не сообщает (ClickHouse). |
await query.get_for_update() |
Единственную подходящую строку, заблокированную до конца транзакции. |
await query.count_by(*names, limit=None) |
Сколько подходящих строк имеет каждое значение каждого поля. |
await query.get_filtered_queryset() |
Отфильтрованный набор строк без сортировки — для всего остального. |
Условие через связь «ко многим» (обратный внешний ключ, «многие-ко-многим») делает запрос DISTINCT,
чтобы каждая строка возвращалась и считалась один раз, — любое условие: параметр, Q метода фильтра,
поиск, where() обработчика, get_access_condition().
delete() и update() требуют фильтра из запроса или из where() — запрос, который ничего не фильтрует,
даёт InvalidRequestQuery — интеграции с фреймворками отвечают на него как на недопустимый запрос, так же как
на неверный параметр, — а не меняет все строки, которые видит пользователь. Фильтр считается по условию,
которое он даёт: параметр, чей метод filter_<name>() вернул None (или пустой Q()), ничего не фильтрует,
как и пустой текст оператора фильтра, которому подходит любая строка (?title__icontains= у contains,
startswith, endswith, их вариантов без учёта регистра и операторов фильтра регулярных выражений), — так
же, как пустой поиск.
get_for_update() нужен обработчику, который меняет строку: вызывайте его внутри транзакции; он
блокирует строку через SELECT ... FOR UPDATE. База, которая не блокирует строк
(Features.supports_select_for_update выключен: SQLite, ClickHouse), даёт UnSupportedError до отправки
SQL — строка, прочитанная без блокировки, могла бы измениться под обработчиком. Там используйте get().
Параметры полей и связей на него не действуют.
Количество по значениям: count_by()¶
counts = await query.count_by("status", "author", "tags")
# {"status": {"published": 3, "draft": 2}, "author": {1: 2, 2: 2, 3: 1}, "tags": {1: 3, 2: 2, None: 1}}
Для фильтров списка, которые показывают, сколько строк оставит каждый вариант. Каждое поле считается при
всех фильтрах запроса, кроме его собственного, — ?status=draft всё равно считает все статусы, — с
поиском, where() и условием доступа. Каждый ключ связи — свой фильтр (author, author__in,
author_id__in), как и часть даты поля (published_at__year). Связь считается по ключу связанной строки
(для составного ключа — кортеж); связь «ко многим» считает строку по разу на каждую связанную строку;
None считает строки без значения — и для составного ключа, а не (None, None). Самые частые значения
идут первыми; limit= оставляет столько. Фильтр через связь «ко многим» считает каждую строку один раз —
для этого нужен первичный ключ: на модели без него это даёт QueryError. Считать по зашифрованному полю
нельзя — каждая строка была бы отдельной группой, — это даёт FieldError с упоминанием count_by(); так
же и с полем sensitive, чьи значения подсчёт отдал бы как свои ключи. Фильтр, поиск или сортировка,
объявленные на поле sensitive, — выбор объявления, и они работают: __icontains, поиск и сортировка
позволяют клиенту угадывать его значения по частям, поэтому на таком поле объявляют только те
операторы фильтра, которые этого не дают (exact, in).
Что добавляет обработчик¶
query.where(*conditions, **filters)добавляет собственные условия обработчика черезAND.query.order_by(*names)заменяет сортировку — имена или выражения сортировки (Ordering("rating", Order.DESC_NULLS_LAST)).query.select_related(...),prefetch_related(...),annotate(...)меняют набор строк.query.request— запрос, из которого построен объект;query.cache_key()называет класс и значения его параметров (одинаково для одинакового запроса) — чтобы кэшировать результат вместе со всем, от чего он ещё зависит.
Точки расширения¶
class StaffBookQuery(BookQuery):
async def get_access_condition(self) -> Q | None:
return Q(branch=self.request.user.branch_id)
async def after_fetch(self, items: list[Book]) -> list[Book]:
await attach_prices(items)
return items
get_access_condition() возвращает строки, которые запросу вообще можно видеть, — это условие
добавляется к каждому запросу, в том числе к delete(). after_fetch() вызывается для строк fetch(),
page() и get() после того, как их дополнил Meta.api_getter; loads_api_values()
решает, дополнять ли их. may_see_deleted() решает, может ли запрос просить удалённые строки (см.
DeletedConfig).
Без веб-фреймворка¶
query = BookQuery.from_query_string("status=draft&tag_ids=1&tag_ids=2&ordering=-title")
query = BookQuery.from_query_parameters([("status", "draft"), ("tag_ids", "1")], id=3)
Строит запрос из строки параметров адреса или из пар (name, value) — для тестов, командной строки,
сообщения WebSocket или фреймворка без адаптера. Параметр, принимающий список, получает все значения
повторённого имени, любой другой — последнее; имя, которого у запроса нет, не учитывается; значения из
именованных аргументов (параметры пути) важнее строки параметров; request= передаёт сам запрос.
query.set_request_url(url) задаёт полный адрес запроса для ссылок страницы, если url запроса им не
является.
Диалекты¶
RequestQuery проверяет каждый оператор фильтра и поиска на каждом диалекте, к которому подключается
драйвер, поэтому не может использовать оператор, который выполняет только один из них. Собственный запрос
диалекта — PostgresqlRequestQuery из hare.dialects.postgresql.request_query, SqliteRequestQuery из
hare.dialects.sqlite.request_query, ClickhouseRequestQuery из hare.dialects.clickhouse.request_query —
проверяет только на своём диалекте и требует, чтобы подключение
его модели было этого диалекта:
from hare.contrib.request_query import SearchConfig
from hare.dialects.postgresql.request_query import PostgresqlRequestQuery
class BookSearchQuery(PostgresqlRequestQuery[Book]):
keywords__overlap: list[str] | None = None
class Meta:
queryset = Book.objects.all()
search = SearchConfig(fields=("title", "description"), lookup="search")
Запрос для PostgreSQL может использовать полнотекстовый search, операторы trigram_*, операторы
вложенности для массивов, диапазонов и JSON и операторы, зарегистрированные только для PostgreSQL.
Оператору, которому нужно расширение (pg_trgm), нужно, чтобы оно было установлено в базе.
Проверка объявлений¶
RequestQuery.check_declarations() проверяет каждый класс запроса, модель набора строк которого
зарегистрирована в текущем HareContext, и даёт одну ConfigurationError со списком всех ошибочных:
параметр, не называющий ни одного фильтра модели; фильтр, который не выполняет диалект; вычисляемое
значение, не принимающее значения фильтра; поле поиска, не принимающее текст; сортировка, которой нет у
модели или которая идёт через связь «ко многим»; сортировка курсора по вычисляемому значению или пути
внутри поля; опция Meta без нужного метода или атрибута. Класс модели, которой нет в контексте,
принадлежит другому приложению и пропускается. Каждый адаптер фреймворка (Litestar,
FastAPI, Robyn) вызывает это при запуске; без адаптера класс проверяется
при первом использовании (get_declaration()). Объявление хранит описания ORM для фильтров и сортировок
класса — сортировка, которую класс встречает позже (собственная сортировка набора строк, order_by()
обработчика), описывается при первой встрече, — поэтому запрос ни одно из них не читает заново.
У опций проверяется структура, которую используют их потребители: совместимому объекту не требуется
наследование от встроенного класса опции. filters и api_getter принимают списки и другие итерируемые
объекты наряду с кортежами; их элементы проверяются при построении объявления. Если объявление может
перестраиваться после изменения регистрации моделей, используйте повторно итерируемый объект.
Проверки не вызывают методы опций, чтобы угадать их результат.
Модели, зарегистрированные во время работы¶
Модель, зарегистрированная через Hare.register_live_models() — таблица, колонки которой настраивают
пользователи приложения, таблица,
открытая в обозревателе базы, — получает запросы так же. RequestQuery.for_model() строит для неё класс
и сразу его проверяет:
from hare.contrib.request_query import FilterField, OrderingConfig, RequestQuery
from hare.query.enums import Lookup
ContentQuery = RequestQuery.for_model(
content_model,
filters=(FilterField("status", lookups=(Lookup.EXACT, Lookup.IN)),),
ordering=OrderingConfig(fields=("title",), default=("title",)),
)
page = await ContentQuery.from_query_string("status__in=draft&status__in=published").page()
Модель задаётся классом или меткой "app.Model" (RequestQuery.for_model("content.Notice")), которая
ищется в текущем контексте. Он принимает каждую опцию Meta именованным аргументом — queryset (по
умолчанию model.objects),
filters, search, ordering, pagination (по умолчанию OffsetPagination()), fields, include,
deleted, versions, join_type, — а также name, имя класса (по умолчанию <Model>RequestQuery).
Ошибочное объявление сразу даёт ConfigurationError, как его нашёл бы check_declarations(). При вызове
у запроса диалекта (PostgresqlRequestQuery.for_model()) класс получается этого диалекта. Класс работает
везде, где работает объявленный вручную, — RequestQueryDIPlugin.provide(),
RequestQueryDependency.provide(), параметр обработчика Robyn, PageSchema[...], HareDTO — и хранится
по слабой ссылке: когда на него никто не ссылается, он исчезает из объявлений и из
get_concrete_subclasses().
Класс запроса забывает своё объявление и параметры вместе с каждой моделью, которую они читают: моделью
своего набора строк и каждой моделью, через которую проходят его фильтры, поиск, сортировки, fields или
include. Это происходит, когда такая модель снимается с регистрации или регистрируется заново
(Hare.unregister_live_models(), register_live_models()), когда к ней добавляется связь, и после любой
регистрации в Registries (оператора фильтра, способа вывода выражения, записи типа, метода QuerySet,
драйвера). При следующем использовании всё строится заново по моделям в их нынешнем виде: параметр
Meta.filters получает тип, который у его поля сейчас, а исчезнувшее поле — это ConfigurationError.
Класс, у которого Meta.queryset относится к снятой с регистрации модели, отказывается работать с
ConfigurationError — постройте класс заново для модели, зарегистрированной на её месте.
check_declaration() перестраивает и проверяет один класс.
Описание параметров: describe_parameters()¶
describe_parameters() сообщает, что представляет собой каждый параметр, — то, что нужно панели
фильтров, выбору связанной записи или меню сортировки сверх схемы JSON. Ему нужны привязанные модели, а
не подключения, поэтому он работает после Hare.bind_models():
for parameter in BookQuery.describe_parameters():
print(parameter.name, parameter.parameter_type, parameter.filter_key, parameter.relation)
У каждого ParameterDescription есть:
| Атрибут | Что это |
|---|---|
name, parameter_type |
Параметр и что он делает — ParameterType.FILTER, FILTER_METHOD (метод filter_<name>), NO_FILTER, SEARCH, ORDERING, LIMIT, OFFSET, CURSOR, FIELDS, INCLUDE, DELETED, OPTION (ваша собственная опция). |
annotation, description, default, required |
Его тип, описание, значение по умолчанию и обязательность. |
takes_many_values |
Принимает все значения повторённого параметра или список CommaSeparated — множественный выбор. |
in_path |
Читается из пути маршрута (InPath()). |
choices |
Элементы перечисления, которое он принимает, в виде ParameterChoice(value, label) — подпись — это имя элемента. |
allowed_values |
Для параметра опции — имена, которые может передать запрос: сортировки, поля fields, связи include, режимы deleted. |
filter_key, path, lookup |
Ключ .filter() у фильтра, ключ без оператора и сам оператор. |
value_shape, value_type |
Принимает ли фильтр одно значение, список или диапазон, и тип значения — кортеж типов для составного ключа. |
nullable |
Сравниваемого значения может не быть — поле с NULL, связь с NULL или связь «ко многим», — поэтому вариант «пусто» имеет смысл. |
relation |
Последняя связь, через которую проходит фильтр, в виде RelationDescription(path, model, key_fields, to_many, compares_key): compares_key, если значение — ключ связанной строки (author, tags__in), то есть это выбор связанной записи; key_fields содержит все поля составного ключа. |
bound, paired_parameters |
Какую границу своего пути задаёт фильтр (BoundSide.LOWER, UPPER, RANGE), и параметры, задающие другую (published_at__gte в паре с published_at__lte). |
Класс, забывший описание вместе со своими моделями, описывает их в нынешнем виде. Своих вариантов выбора,
кроме перечислений, у полей нет: поле CharEnumField/IntEnumField даёт элементы своего перечисления.