Request queries¶
hare.contrib.request_query describes the rows a request asks for once, on a class: the queryset it
starts from, the parameters it filters by, the search, the orderings a request may choose and the
pagination. The class is a pydantic model of the request's parameters; a framework adapter
(Litestar, FastAPI, Robyn) builds it from a request and hands it
to the handler, which runs it:
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()
Install it with the request-query extra (pydantic).
The filters, their lookups and the values they take come from the ORM itself —
get_lookup_info() — so a lookup a project registers with
register_lookup() works like a built-in one, and a declaration that doesn't match the models fails when
the application starts, not on a request.
Parameters¶
A parameter without a marker filters by its own name: author is .filter(author=...),
title__icontains is .filter(title__icontains=...). A parameter whose value is None doesn't filter.
Every key .filter() accepts works:
| Parameter | Filter |
|---|---|
author: int | None |
the relation by the related key (author=3) |
author__in: list[int] | None |
the relation by a list of keys |
author__publisher__country__in: list[int] | None |
a relation at the end of a chain |
author__publisher__country__pk__in: list[int] | None |
the same through pk |
profile__city: str | None |
a one-to-one relation, from either side |
tags__name: str | None |
a many-to-many relation — each row is returned once |
published_at__year__gte: int | None |
a date part |
pk: KeyColumns[int, int] | None |
a composite primary key, ?pk=2,1 |
line: KeyColumns[int, int] | None |
a relation to a model with a composite key |
score__within: tuple[int, int] | None |
a lookup registered with register_lookup() |
From a list of fields: 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) — a field, a relation, a
path through relations, an annotation of Meta.queryset — makes a parameter per lookup: status,
status__in, author, author__in, published_at__gte, published_at__lte, published_at__range,
author__profile__isnull. Lookup.EXACT (or "exact") is the path itself, any other lookup adds
__<lookup>; a lookup registered with register_lookup() goes by its name. parameter= names the
parameters in place of the path, so the API doesn't show the model's structure: city and city__in
filter by author__profile__city. description= replaces the field's description in the API schema.
The type of each parameter is the value its filter takes, as the ORM describes it:
| The filter takes | The parameter |
|---|---|
| one value | T | None |
a list (in, not_in) |
list[T] | None — the parameter repeated, ?status__in=draft&status__in=published |
a range (range) |
tuple[T, T] | None — two values, from and to |
| a composite key | KeyColumns[...] in place of T |
a field with an enum (IntEnumField, CharEnumField), compared by exact, not, in, not_in |
the enum in place of T |
T is the value's type: the field's, the related key's for a relation, int for a date part, bool for
isnull. A filter taking a value whose type is only known when the query runs (an annotation of raw SQL,
any JSON value) is a ConfigurationError — declare its parameter by hand. A parameter Meta.filters
makes that the class declares itself, or that an option adds, is a ConfigurationError too: aliases,
filter methods and special types stay declared by hand, next to Meta.filters.
For an array whose element field has null=True, list-valued lookups also accept NULL elements:
their item type is T | None. An explicit list[None] or list[Literal[None]] parameter is a valid
narrower declaration. Setting the entire optional parameter to None still leaves its filter out.
A parametrized type takes the value of its class: dict[str, str] | None for a filter taking a dict
(HStoreField's contains).
The parameters are added once the models are bound — by Hare.bind_models(), which needs no connection,
or Hare.init() — when a framework adapter reads the parameters for the handlers' signatures
(HarePlugin binds the models itself), when the query is first built, or on prepare_parameters(). A
subclass gets the parameters of the Meta.filters it inherits.
Descriptions¶
A filter parameter without a description of its own is described by its field's description, and a list
or a range by how it is written ("Repeat the parameter for each value."); the API schema shows it. A
parameter with Field(description=...) keeps its own.
Aliases: Filter¶
Filter gives the parameter a name of its own, so the API doesn't show — and doesn't change with — the
model's structure:
Filter(key) takes the whole key (Filter("author__name__iexact")); lookup is appended to it with
__.
Parameters that don't filter: NoFilter¶
The handler reads query.mode; the query ignores it.
Path parameters: InPath¶
A parameter read from the route's path, not from the query string — every framework adapter reads and
documents it so (Litestar's own FromPath works in a Litestar application too).
Filter methods¶
A method filter_<parameter>(self, value) turns the parameter's value into a Q of its own — sync or
async; None adds no condition:
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)
A parameter with a filter method isn't checked against the ORM — its method decides what it does.
The async filter methods of the parameters that have a value run at once (asyncio.gather) — a slow
lookup in one doesn't wait for another; the conditions keep the order the parameters are declared in.
Inside a transaction their queries still use the transaction's connection one at a time.
Types¶
A parameter's annotation must take the value its filter takes, as get_lookup_info() describes it:
- one value (
LookupValueShape.VALUE) — the value's type, a subclass, an enum or aLiteralof such values (strfor a text lookup,intfor a relation to an integer key,boolforisnull,tuple[UUID, int]for a composite key); - a list (
LIST,__in) —list[...],set[...],frozenset[...]ortuple[..., ...]of it; - a range (
RANGE,__range) —tuple[T, T]orlist[T].
bool doesn't count as int. A mismatch is a ConfigurationError naming the class, the parameter, what
the filter takes and what the parameter is annotated with.
Three types read one text value:
KeyColumns[int, int]— the values of a composite key joined with commas (?pk=2,1). In the parameter's decoded text a comma inside a value is%2Cand a percent sign%25— in the URL%252Cand%2525(?pk=a%252Cb,cis the key("a,b", "c")); any other%is kept as written. A list of keys islist[KeyColumns[int, int]]— one parameter per key (?pk__in=2,1&pk__in=3,1).CommaSeparated[int]— a list in one parameter (?ids=1,2,3); repeated parameters are joined, empty items skipped.GenericTarget— the target of aGenericForeignKeyFieldas<branch>:<key>(?target=post:1, a composite key's values joined with commas:article_version:7,2), read as{"type": "post", "id": 1}.Meta.filterstypesFilterField("target")with it andFilterField("target__type")with aLiteralof the branch names.
A type of your own read from one text value carries TextParameter(description, repeats=False) in its
Annotated metadata, next to the validator parsing the text, as these three do — a framework adapter
then passes the raw text through; with repeats=True every value of a repeated parameter comes as a
list of texts.
A value that doesn't fit — of the wrong type, out of range, an ordering the query doesn't allow — raises
InvalidRequestQuery with one error per parameter (loc, msg, type); a framework adapter answers it
as that framework answers invalid parameters itself — 400 Bad Request in Litestar,
422 Unprocessable Content in FastAPI and Robyn.
Bounds¶
The bounds a request gives one field must leave a value between them. Each lower bound (gt, gte) is
paired with each upper bound (lt, lte) of the same field path — declared or from Meta.filters,
under an alias too (published_since with Filter("published_at", lookup="gte")), through a date part
as well (published_at__year__gte): ?published_at__gte=2026-02-01&published_at__lte=2026-01-01 is an
InvalidRequestQuery on the upper bound's parameter (type range). Equal bounds pass, unless one of
them is strict (gt, lt). A range parameter starting after it ends is refused the same way. Values
that don't compare (an aware and a naive datetime) are left to the database.
Order of the parameters¶
The parameters the class declares come first, then those of Meta.filters, then the options' (search,
ordering, fields, include, deleted rows, pagination) — in the model's fields and in the API schema.
Options: Meta¶
| Option | Meaning |
|---|---|
queryset |
The rows the query starts from: Book.objects.all().select_related("author"). A class without one only serves as a base. |
filters |
An iterable of FilterField — the fields a request may filter by and each one's lookups — see Meta.filters. |
search |
A SearchConfig, None for no search. |
ordering |
An OrderingConfig, None when a request can't choose the ordering. |
pagination |
An OffsetPagination, a ScrollPagination or a CursorPagination, None for none. OffsetPagination() by default. |
join_type |
How the parameters' conditions join — Connector.AND (default) or Connector.OR. |
fields |
A FieldsConfig, None when a request can't choose the fields to load. |
include |
An IncludeConfig, None when a request can't ask for relations. |
deleted |
A DeletedConfig, None when a request can't see soft-deleted rows. |
versions |
LatestVersions() to keep only the latest version of each record of a VersionedModel, None for every version. |
api_getter |
An iterable of ApiSource — HTTP APIs of other services the rows are filled from — and ApiValueMapping — values of the row copied to other attributes; see Meta.api_getter. |
A subclass inherits each option it doesn't set from the nearest base that does.
queryset is built when the module is imported, before Hare.init(); each request works on a copy.
get_queryset() can be overridden for rows that depend on the request — the filters are still checked
against Meta.queryset, so a filter on an annotation needs the annotation there. A model with
Meta.tenant_field needs nothing more: the scope of Tenancy.scope() applies when the query runs, not
when Meta.queryset is built.
Each option that adds parameters (search, ordering, fields, include, deleted, pagination) is
a RequestOption; parameter= (limit_parameter=, offset_parameter=, cursor_parameter= for a
pagination) renames its query parameter. A RequestOption names its parameters
(get_parameter_fields(), each a ParameterField(name, annotation, default,
parameter_type=ParameterType.OPTION, allowed_values=None)), checks the values a request gives them
(check_request()) and applies them — an option of your own derives from it the same way.
Search: SearchConfig¶
search = SearchConfig(fields=("title", "author__name"), lookup="icontains", parameter="search", join_type=Connector.OR)
?search=tolk matches rows where any field (Connector.OR) or every field (Connector.AND) matches.
Each field's lookup must take text. A blank search filters nothing.
split_words=True searches word by word: every word of the text must match, each in the fields joined by
join_type — ?search=ursula wizard finds a book by Ursula titled "Wizard".
Ordering: OrderingConfig¶
ordering = OrderingConfig(fields=("title", "published_at", "author__name"), default=("-published_at",))
A request names one or more of fields, comma-separated, - for descending:
?ordering=-published_at,title. A name outside fields or named twice is refused. The rows are ordered
by the handler's order_by(), else the request's ordering, else default, else the queryset's own
ordering, else the model's Meta.ordering — and then by the primary key's fields not ordered by yet, so
rows with equal values keep one order from page to page (every field of a composite key); a model without
a primary key (Meta.primary_key = None) gets no such fields. An ordering through a relation to many
rows is refused — each row would repeat — and so is an encrypted field in fields or default: its
random ciphertext orders nothing, and the class raises ConfigurationError when its declaration is
built, not each request a 500. A NULL sorts where the database puts it — first going up on SQLite, last
on PostgreSQL and ClickHouse (see NULL ordering); a
handler fixing it orders with F(...).asc(nulls_last=True) in its order_by().
Pagination¶
Every pagination derives from Pagination — the page size a request may ask for (default_limit,
max_limit) and its parameter (limit_parameter); each adds how a request says which page and builds
its own type of page (get_page()), so a pagination of your own is a subclass too.
OffsetPagination(default_limit=100, max_limit=1000, limit_parameter="limit", offset_parameter="offset")
pages by ?limit= and ?offset=. page() returns a Page: result, count (all matching rows),
limit, offset, next and previous — the request's address with offset replaced, every other
parameter kept as it was, repeated ones included; previous of an offset smaller than the page size
leads to the first page.
ScrollPagination(default_limit=100, max_limit=1000, limit_parameter="limit", offset_parameter="offset")
pages by ?limit= and ?offset= as well, but without counting the matching rows — for a feed or a long
list that shows only "next" and "previous": the COUNT of a large table is saved, and whether a next
page exists is read from one row fetched past the page. page() returns a ScrollPage: result,
limit, offset, next, previous — it has no count at all. Both offset paginations derive from
BaseOffsetPagination.
CursorPagination(default_limit=100, max_limit=1000, limit_parameter="limit", cursor_parameter="cursor")
pages by a cursor: the ordering values of the row a page starts after or ends before
(after_cursor()/before_cursor()). A page costs the same wherever it is, rows added between requests
don't shift the pages, and there is no count. page() returns a CursorPage: result, limit,
next_cursor, previous_cursor, next, previous. A cursor names the ordering it was made for; one of
another ordering, text that isn't a cursor, or a cursor whose value doesn't fit its field (a text for an
IntField, NULL for a NOT NULL field read on the row itself) is an InvalidRequestQuery. The ordering
of a cursor pagination may not use an annotation or a path inside a field's value (details__pages of a
JSON field): the cursor reads field values off the row; fields through forward relations are loaded with the
rows (select_related()), since the cursor reads them off the last row. A model without a primary key
can't have a cursor pagination: rows equal in the ordering would be skipped.
BinaryField ordering values are preserved as URL-safe Base64 inside the cursor, including bytes
that are not UTF-8 text.
An untyped JSONField keeps its JSON values in the cursor, including scalars; the same applies to
a GeneratedField with that output field. The boundary is compared in the field's JSON ordering.
Container boundaries retain their element types, including UUIDs and binary data, and named
TupleField values retain their named elements when the cursor is read.
Page sizes are checked when the option is created: 1 <= default_limit <= max_limit <= 100000.
Fields and relations: FieldsConfig, IncludeConfig¶
fields = FieldsConfig(fields=("title", "status", "published_at"))
include = IncludeConfig(relations=("author", "author__profile", "tags"))
?fields=title,status loads only the named fields (QuerySet.only()), the primary key and the fields
the ordering reads; reading another field of such a row raises AttributeError. The names must be the
model's own fields — relations are loaded with include — or targets of
Meta.api_getter. ?include=author,tags loads the named
relations with the rows: a relation to one row with a join (select_related()), a relation to many rows
with a query of its own (prefetch_related()). A name the option doesn't list is an
InvalidRequestQuery. Both apply to fetch(), page() and get().
Deleted rows: DeletedConfig¶
For a model with Meta.soft_delete_field: ?deleted=include — every row, ?deleted=only — the deleted
ones, ?deleted=exclude or no value — the rest, as without the option. Asking for deleted rows needs
may_see_deleted() to allow it — otherwise the query raises RequestQueryForbidden and a framework
adapter answers 403 Forbidden:
class StaffBookQuery(BookQuery):
async def may_see_deleted(self) -> bool:
return self.request.user.is_admin
may_see_deleted() returns True by default: declaring the option lets every request see them. A model
without Meta.soft_delete_field is a ConfigurationError.
Latest versions: LatestVersions¶
class CatalogRecordQuery(RequestQuery[CatalogRecord]):
class Meta:
queryset = CatalogRecord.objects.filter(status="published")
versions = LatestVersions()
For a VersionedModel: only the latest version of each record — the rows for which no newer version of
the same id exists, one correlated subquery (NOT EXISTS), not a list of ids. The versions compared
are the rows of Meta.queryset (above: the latest published version) with the deleted rows as the
request asks for them and the access condition — a newer version the request may not see doesn't hide the
one it may. The request's filters apply to the latest versions: a filter matching only an older version
finds nothing. A model that isn't a VersionedModel is a ConfigurationError, and so is a model on a
database running no correlated subqueries — raised by check_declarations()
once the model has a connection.
Values from other services: 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"),
)
The rows are filled with what HTTP APIs of other services answer for the values they hold. An ApiSource
is asked for every value of its key the rows hold — each value once, however many rows hold it — and
each item of the answer goes to the rows holding the value it answers. Needs the http extra.
- The values asked.
keyis a path of names joined by dots ("partner_user_id","partner.user_id"); each part is an attribute or a dict key, and a list on the way is walked item by item; a relation of a row is followed when the rows loaded it (?include=,select_related(),prefetch_related()), and holds no value otherwise.Noneisn't asked for, nor a value no answer item can be matched back to (a dict of a JSON field) — the targets of their rows stay empty. - The requests.
request_modesays how a source is asked: ApiRequestMode.QUERY_MANY(the default) — one request per batch of up tobatch_size(200) values, each value a repetition ofquery_parameter(?ids=1&ids=2). Each answer item is matched to the value it answers by itsmatch_key(the key's last part when not given), read throughkey_type(UUID,int, any function) — a value the type refuses matches nothing. The values are in the URL: a large batch makes a long one, and a server refuses a URL longer than its own limit (often414 URI Too Longbeyond about 8 KB) — keepbatch_sizewithin what the service takes.ApiRequestMode.QUERY_ONE— one request per value,?id=1.ApiRequestMode.PATH— one request per value, the value in the URL, quoted:url="http://users/service/users/{partner_user_id}"names the key's last part.
With these two modes, an item is matched by match_key too when it is given, otherwise the first item
of the answer answers. items_path ("result") is where the items are in the answer — the answer
itself by default; a single item is taken as a list of one.
- Where the values go. Each ApiValueMapping of mappings puts a value of the answer item — at its
key, the whole item without one — into set_key of the object holding the source's key: for
key="partner.user_id" and set_key="partner.user", into the user of each partner. A row holding no
value, and a value without an answer, leave the target None, or its default. Several mappings of one
target settle it by their mode: ApiValueMappingMode.LAST (the default) keeps the last value,
NOT_EMPTY the first one that isn't empty. field=DatetimeField() reads the value through a field of
hare — an ISO text becomes a datetime — and convert= through a function of yours, after the field when
both are given. An ApiValueMapping given in api_getter itself, outside a source, copies a value of
the row itself (key) to another attribute (set_key).
- One run. The sources of a run are asked at the same time, through one HTTP client, and their values
are applied in their order — a target two sources set keeps the later one's value. A source sends at
most concurrency (10) requests at a time, each taking at most timeout_seconds (10). headers is a
dict of ASCII texts on one line, or an async function giving it — called once a run; the headers
are left out of the source's repr().
- Repeats. retry=ApiGetterRetry(attempts=3) repeats a request whose connection broke, or that was
answered with one of status_codes (429, 502, 503, 504), up to attempts times (0 by default):
after a random pause of up to base_delay_seconds * 2 ** repeat (0.5 s), at most max_delay_seconds
(10 s), or as long as the answer's Retry-After asks — in seconds or as an HTTP date — within the same
limit.
- Errors. A source not required leaves the targets empty when it answers 404, an error or no JSON, or
can't be reached. With required=True a source raises instead: ApiDataNotFoundError — 404, or no item
for a value; ApiRequestError — the connection failed every time, an error answer, an answer that isn't
JSON or can't be read (a body that doesn't decode); ApiValueMissingError — the rows hold no value of its
key at all. A mapping with required=True raises ApiValueMissingError for a target left empty, and a
field or convert refusing a value raises ApiValueConversionError. Each is an ApiGetterError
(.source_name); a framework adapter answers it with 502 Bad Gateway, without its message — it names the
service and the values asked.
- When. fetch(), page() and get() fill their rows before after_fetch(); get_for_update()
doesn't — the row is loaded only to be changed. A source loads its values with the rows a request
reads; triggers=ApiGetterTriggers(on_read=True, on_create=False, on_update=False) chooses that, and a
handler fills the rows it created or updated itself — the sources whose on_create/on_update is set:
deal = await Deal.objects.create(**data)
(deal,) = await DealQuery(request=request).load_api_values([deal], ApiGetterTrigger.CREATE)
loads_api_values() returns True; override it to leave the rows of some requests bare — a request of
another service asking for the rows themselves.
- fields. A target — the first part of a set_key — may be listed in FieldsConfig.fields like a
field. A request whose ?fields= names none of a source's targets (nor, through ?include=, the
relation they are on) doesn't ask that source; one naming a target loads the field the source's key
starts with (partner_user_id) though it doesn't name it. Without ?fields= every source is asked.
- What is reported. Each attempt of a request is logged at DEBUG on the hare logger and given to the
observers of ApiRequestExecuted (Observers.observe(ApiRequestExecuted, ...)): source_name, url
(without the query, the fragment and the credentials), status_code (None when the connection failed),
duration_seconds, attempt (0 for the first) and error — the headers aren't part of it.
Settings contradicting each other are refused when they are made — a PATH URL not naming the key, a
query mode without query_parameter, a required mapping with a default — ConfigurationError.
ApiGetter.set(objs, sources, mappings) fills any objects — dicts too — without a request query.
Running a query¶
| Method | Returns |
|---|---|
await query.page() |
A Page, a ScrollPage or a CursorPage, by Meta.pagination. |
await query.fetch() |
Every matching row, ordered, without pagination. |
await query.get() |
The one matching row; DoesNotExist / MultipleObjectsReturned otherwise. Takes does_not_exist_exception and multiple_objects_returned_exception as QuerySet.get() does — get(does_not_exist_exception=None) gives None when no row matches. |
await query.count() / exists() |
How many rows match / whether any does. |
await query.delete() |
Deletes the matching rows and returns how many — None on a database reporting no count (ClickHouse). |
await query.update(**values) |
Updates the matching rows and returns how many — None on a database reporting no count (ClickHouse). |
await query.get_for_update() |
The one matching row, locked until the transaction ends. |
await query.count_by(*names, limit=None) |
How many matching rows have each value of each field. |
await query.get_filtered_queryset() |
The filtered queryset, not ordered — for anything else. |
A condition across a relation to many rows (a reverse FK, a many-to-many relation) makes the query
DISTINCT, so each row is returned and counted once — any condition: a parameter, a filter method's Q,
the search, the handler's where(), get_access_condition().
delete() and update() need a filter from the request or from where() — a request filtering by
nothing raises InvalidRequestQuery — answered as an invalid request by the framework integrations, like
an invalid parameter — instead of changing every row the user may see. A filter counts by the condition
it gives: a parameter whose filter_<name>() method returns None (or an empty Q()) filters by
nothing, and so does an empty text of a lookup it matches every row with (?title__icontains=, of
contains, startswith, endswith, their case-insensitive forms and the regex lookups) — as a blank
search does.
get_for_update() is for a handler that changes the row: call it inside a transaction; it locks the row
with SELECT ... FOR UPDATE. A database that locks no row (Features.supports_select_for_update off:
SQLite, ClickHouse) raises UnSupportedError before any SQL is sent — a row read without its lock could
change under the handler. Use get() there. The fields and include parameters don't apply to it.
Counts by value: 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}}
For the filters of a list, which show how many rows each choice leaves. Each field is counted under every
filter of the request except its own — ?status=draft still counts every status — with the search,
where() and the access condition. Every key of a relation is its own filter (author, author__in,
author_id__in), and so is a date part of a field (published_at__year). A relation counts by the
related key — a tuple for a composite key; a relation to many rows counts a row once per related row;
None counts the rows without a value — of a composite key too, not (None, None). The most frequent
values come first; limit= keeps that many. A filter across a relation to many rows counts each row once
— which needs a primary key: on a model without one it raises QueryError. An encrypted field can't be
counted by — its rows would each be a group of their own — and raises FieldError naming count_by();
so does a sensitive field, whose values the counts would hand out as their keys. A filter, a search or
an ordering declared on a sensitive field is the declaration's choice and works: __icontains, a
search and an ordering let a client guess its values piece by piece, so declare only the lookups that
don't (exact, in) on such a field.
The handler's part¶
query.where(*conditions, **filters)adds conditions of the handler's own, joined withAND.query.order_by(*names)replaces the ordering — names or ordering expressions (Ordering("rating", Order.DESC_NULLS_LAST)).query.select_related(...),prefetch_related(...),annotate(...)change the queryset.query.requestis the request the query was built from;query.cache_key()names the class and its parameter values — the same for the same request — for caching a result together with whatever else it depends on.
Hooks¶
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() returns the rows the request may see at all — it joins every query, delete()
included. after_fetch() runs on the rows of fetch(), page() and get(), once
Meta.api_getter filled them; loads_api_values() decides whether it fills them.
may_see_deleted() decides whether the request may ask for deleted rows (see DeletedConfig).
Without a web framework¶
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)
Builds a query from a URL's query string or from (name, value) pairs — for tests, a CLI, a WebSocket
message or a framework without an adapter. A parameter taking a list gets every value of a repeated name,
any other the last one; a name the query doesn't have is ignored; keyword values (path parameters) win
over the query string; request= passes the request. query.set_request_url(url) sets the request's
whole address for the links of a page when the request's url isn't it.
Dialects¶
RequestQuery checks every filter and search lookup against each dialect a driver connects to, so it
can't use a lookup only one of them runs. A dialect's own request query — PostgresqlRequestQuery of
hare.dialects.postgresql.request_query, SqliteRequestQuery of hare.dialects.sqlite.request_query,
ClickhouseRequestQuery of hare.dialects.clickhouse.request_query —
checks against that dialect only, and requires its model's connection to be of it:
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")
A PostgreSQL request query may use full-text search, the trigram_* lookups, array, range and JSON
container lookups, and lookups registered for PostgreSQL only. A lookup that needs an extension
(pg_trgm) needs it installed in the database.
Checking declarations¶
RequestQuery.check_declarations() checks every request query class whose queryset's model is registered
in the current HareContext, and raises one ConfigurationError listing every wrong one: a parameter
naming no filter of the model, a filter a dialect doesn't run, an annotation that doesn't take the
filter's value, a search field that doesn't take text, an ordering the model lacks or that crosses a
relation to many rows, a cursor ordering by an annotation or a path inside a field, a Meta option
missing a required method or attribute. A class of a model the context doesn't have belongs to another
application and is skipped. Every framework adapter (Litestar, FastAPI, Robyn)
calls it at startup; without one, a class is checked on its first use (get_declaration()). The
declaration holds the ORM's descriptions of the class's filters and orderings — an ordering the class
meets later (the queryset's own, a handler's order_by()) is described the first time — so a request
reads none of them again.
Options are checked by the structure their consumers use: a compatible object need not inherit the
built-in option class. filters and api_getter accept lists and other iterables as well as tuples;
their items are checked as the declaration is built. Use a reusable iterable when declarations may be
rebuilt after model registration changes. The checks do not call option methods to guess their results.
Models registered while the application runs¶
A model registered with Hare.register_live_models() — a table whose columns the application's users
configure, a table opened in a
database browser — gets its request queries the same way. RequestQuery.for_model() builds a class for
it, checked at once:
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()
The model is given by its class or its "app.Model" label (RequestQuery.for_model("content.Notice")),
looked up in the current context. It takes each Meta option as a keyword argument — queryset
(model.objects by default), filters,
search, ordering, pagination (an OffsetPagination() by default), fields, include, deleted,
versions, join_type — and name, the class's name (<Model>RequestQuery by default). A wrong
declaration raises ConfigurationError right there, as check_declarations() would find it. Called on a
dialect's request query (PostgresqlRequestQuery.for_model()), the class is of that dialect. The class
works wherever a declared one does — RequestQueryDIPlugin.provide(),
RequestQueryDependency.provide(), a Robyn handler's parameter, PageSchema[...], HareDTO — and is
held weakly: once nothing holds it, it leaves the declarations and get_concrete_subclasses().
A request query class forgets its declaration and its parameters with every model they read — its
queryset's model, and each model its filters, search, orderings, fields or include cross. That
happens when such a model is unregistered or registered again (Hare.unregister_live_models(),
register_live_models()), when a relation is added to it, and after any registration in Registries (a
lookup, a renderer, a type mapping, a QuerySet method, a driver). The next use builds them again from the
models as they are now: a parameter of Meta.filters gets the type its field has now, a field that is
gone is a ConfigurationError. A class whose Meta.queryset is of an unregistered model refuses to run
with a ConfigurationError — build the class again for the model registered in its place.
check_declaration() rebuilds and checks one class.
Describing the parameters: describe_parameters()¶
describe_parameters() says what each parameter is — what a filter panel, a relation picker or a sort
menu needs beyond the JSON schema. It needs the models bound, not the connections, so it works after
Hare.bind_models():
for parameter in BookQuery.describe_parameters():
print(parameter.name, parameter.parameter_type, parameter.filter_key, parameter.relation)
Each ParameterDescription has:
| Attribute | What it is |
|---|---|
name, parameter_type |
The parameter and what it does — ParameterType.FILTER, FILTER_METHOD (a filter_<name> method), NO_FILTER, SEARCH, ORDERING, LIMIT, OFFSET, CURSOR, FIELDS, INCLUDE, DELETED, OPTION (an option of your own). |
annotation, description, default, required |
Its type, its description, its default. |
takes_many_values |
It takes every value of a repeated parameter, or a CommaSeparated list — a multi-select. |
in_path |
It is read from the route's path (InPath()). |
choices |
The members of the enum it takes, as ParameterChoice(value, label) — the label is the member's name. |
allowed_values |
For an option's parameter, the names a request may give: the orderings, the fields, the include relations, the deleted modes. |
filter_key, path, lookup |
A filter's .filter() key, the key without its lookup, and the lookup. |
value_shape, value_type |
Whether a filter takes one value, a list or a range, and the type of a value — a tuple of types for a composite key. |
nullable |
The value the filter compares can be missing — a nullable field, a nullable or to-many relation — so an "empty" choice means something. |
relation |
The last relation a filter crosses, as RelationDescription(path, model, key_fields, to_many, compares_key) — compares_key when the value is the related row's key (author, tags__in), a relation picker; key_fields has every field of a composite key. |
bound, paired_parameters |
Which bound of its path a filter gives (BoundSide.LOWER, UPPER, RANGE) and the parameters giving the other one (published_at__gte pairs with published_at__lte). |
A class forgotten with its models describes them as they are now. Fields have no choices of their own
besides an enum: a field of CharEnumField/IntEnumField gives its enum's members.