Filtering and Ordering Semantics
Filtering and ordering pass through four places. The Canonical Viewset declares the filters and ordering fields. Model Info publishes those declarations. list and retrieve endpoints reject query parameters outside them. The client builds its filter and sort controls from the published metadata.
This page describes what the server accepts, rejects, and publishes at each step, and how the client uses it. Configuration Surface and Defaults lists the Wire Query Parameters, and Declare List Ordering gives the steps for setting up a list's ordering.
Contract Boundary and Authority
Model info reads filterset_class, ordering, and ordering_fields from the canonical viewset. A model registered with a serializer only (Serializer-Only Registration) has an empty model_filtering section, because it has no filterset. Its model_ordering section still reports the model's own Meta.ordering.
A serializer field is filterable only when the viewset's filterset declares a filter for it. Sorting follows DRF: OrderingFilter. When the viewset declares no ordering_fields, every readable serializer field is sortable under its source. A field renamed with source="name" sorts as name. A field sourced from a Python model property is not sortable, because the database has no column for it. When the viewset declares ordering_fields, only the fields that it names, plus the default ordering's fields, are sortable.
Metadata Projection for Ordering and Filtering
Ordering metadata
model_ordering has two keys:
defaultlists the names of the fields or queryset annotations that the rows are sorted by when a request sends noo. A path that ends in a transform is listed under its dotted name, such ascreated.year. It comes from the viewset'sorderingwhen that is declared, and from the model'sMeta.orderingotherwise. The two are never merged.fieldslists every field that a request may name ino, each with anameand a semantictype. A field that the default ordering names also carriesascending.
fields follows the viewset's ordering_fields. An explicit list reports its entries, and "__all__" reports the model's fields and the annotations that get_queryset adds. With no ordering_fields, fields reports the serializer's readable fields by source. Every field that the default ordering reads also appears in fields. VuedaOrderingFilter accepts an explicit o for each of them.
Every name in model_ordering is the public name that a client sends back in o. Django's "pk" alias is reported as the field behind it: id, or each field of a Composite Primary Key. The ordering backend accepts both o=id and o=pk.
get_model_ordering documents each rule in full. core.ordering documents how an ordering term is read.
A default ordering is reported whole or not at all. default is empty when any of its terms cannot be reported under one name. Two kinds of term cause that:
- A term that resolves to no field and no queryset annotation, such as a field renamed without updating
ordering. Everylistrequest that falls back to it fails with Django'sFieldError. - A term that reads more than one column, or none, such as
Concat("first_name", "last_name")or"?". The rows are sorted, and nothing describes the sort.
A viewset ordering that fails to resolve does not fall back to the model's Meta.ordering. DRF passes the viewset's ordering to order_by() as written, so the request fails.
The vueda_info.E006 system check reports a viewset ordering or ordering_fields entry that names no field. Django's models.E015 reports the same drift in Meta.ordering. E006 follows the same resolution as the metadata, so a "pk" alias, a formatted_name reached through a lookup expression, a path that ends in a transform such as created__year, and an annotation that get_queryset adds are all accepted. For a path that doesn't resolve, E006 quotes Django's own FieldError message. E006 builds the viewset's queryset to learn its annotations. When get_queryset raises without a request, for example because it reads self.request, E006 skips that viewset.
An ordering_fields entry that names no field is left out of fields, and the other entries stay. The ordering backend still accepts o for that entry, because it validates against the declared names. A request that names it fails in order_by() with a server error.
Queryset Annotations
An annotation that the viewset's get_queryset adds is orderable. It appears in fields when ordering_fields names it or is "__all__", or when the default ordering names it. Its type comes from the annotation's output_field. The type is alpha when Django cannot resolve one.
A default ordering that names an annotation is reported under the annotation's name, in default and in fields. A transform on an annotation, such as launched__year, is reported the same way, under its dotted name launched.year, and typed from the transform's output. The metadata builds the viewset's queryset without a request, so a get_queryset that reads self.request fails the model-info request with a server error. A multi-column function becomes reportable once it is annotated and the ordering names the annotation, or once a Django: GeneratedField or a database view column holds it. Declare List Ordering shows each option.
Database Functions in a Default Ordering
A default ordering term can be any expression that order_by() accepts. That includes scalar functions such as Django: Lower and Django: Coalesce. VUEDA reads the columns that a term uses from Django's expression tree, so any Django: Func subclass works. Window functions need a Django: Window, as Django requires.
A term that reads one column is reported under that column. Its type describes the column, and ascending follows the term's direction. Lower("name").desc() is reported as name, type alpha, with ascending: false.
A term that reads more than one column leaves default empty. Each column still appears in fields without ascending, so a client can offer each as a sort. A term that reads no column, such as "?" or Django: Now, leaves default empty. It adds nothing to fields. A viewset can list "?" in ordering_fields to accept o=?, and fields does not report it.
An explicit o sorts by the column itself. o=name against a Lower("name") default is a case-sensitive sort. The VUEDA client does not send the default ordering back, so a list that the reader has not sorted keeps the function.
formatted_name as an ordering and filter target
A model's Formatted Name is orderable when the database can sort it: a formatted_name column, or a path named by formatted_name_lookup_expression. Metadata reports either as formatted_name, typed from the column that the path reaches. Create a CRUD Surface describes how a model configures its formatted name.
A formatted_name computed by get_formatted_name() has no column. It is left out of fields, and a default ordering that names it is not reported. The vueda_info.E005 system check reports ordering declared on a computed formatted_name.
A model's Meta.ordering may name its own lookup-expression formatted_name when its default manager is a FormattedNameManager. Django's models.E015 skips that one term. A related path such as customer__formatted_name in Meta.ordering still fails models.E015.
A viewset's ordering and filters can name a related model's formatted_name at any depth, such as customer__formatted_name. A client sends the dotted form, o=customer.formatted_name. On the server, VuedaOrderingFilter and the filterset rewrite the path to the column behind it, such as customer__data__formatted_name. Metadata and the accepted query names use the declared path. A request that names the rewritten path is rejected.
Two related shapes are left unrewritten, as core.formatted_name.resolve_formatted_name_path describes. One is a related formatted_name computed by get_formatted_name(). The other is a path through a reverse foreign key or many-to-many, which would multiply rows. In ordering, E006 reports both, and metadata leaves them out.
A related formatted_name that is a real column is an ordinary field path. Through a many-valued relation it is neither rewritten nor reported, and it multiplies rows like any other such path.
The Filterset Half of the Rewrite
A filter builds its query from its field_name, so the rewrite happens on the filterset. FormattedNamePathFilterSetMixin rewrites field_name on each filterset instance's copy of its filters. The class declaration keeps the declared path, and the generated filter label comes from it.
Both VUEDA filterset bases include the mixin: VuedaFilterSet and VuedaCompositePrimaryKeyFilterSet. A filterset built on django-filter: FilterSet directly gets no rewrite. A filter on customer__formatted_name there raises FieldError, a server error, on each request that sends it. Declare List Ordering shows how to add the mixin.
No system check reads a filter's field_name. A filter on one of the two unrewritten shapes stays in model_filtering, and a request that sends it raises FieldError.
VuedaCompositePrimaryKeyFilterSet declares no default filters, because a composite primary key model has no id field. Set Up CRUD for a Composite Primary Key Model covers its filters.
A transform may follow formatted_name in a filter's field_name. The rewrite keeps it: customer__formatted_name__unaccent queries customer__data__formatted_name__unaccent, and formatted_name__unaccent on the model's own annotation is left for Django to apply. See Transforms in an Ordering Path for which transforms Django accepts.
class CartFilterSet(VuedaFilterSet):
customer_name = rest_framework.CharFilter(
field_name="customer__formatted_name__unaccent",
lookup_expr="icontains",
)Whether a transform also applies to the value that a client sends depends on the transform:
field_name | Lookup | Applied to | Client sends | Matches "José Smith" |
|---|---|---|---|---|
customer__formatted_name__unaccent | icontains | the column and the client's value | jose | Yes |
customer__formatted_name__lower | exact | the column only | josé smith | Yes |
customer__formatted_name__lower | exact | the column only | José Smith | No |
customer__formatted_name | iexact | the column and the client's value | JOSÉ SMITH | Yes |
Django marks unaccent as bilateral, which is what makes it apply to both sides. It needs django.contrib.postgres in INSTALLED_APPS and the unaccent database extension. lower and upper are database functions that a project registers as transforms itself, with CharField.register_lookup(Lower), and they apply to the column alone. For a case-insensitive match, use the iexact or icontains lookup on customer__formatted_name instead of a lower transform.
django-filter can't build a label from such a path, because formatted_name on a model that reaches it through a lookup expression is not a model field. The label falls back to the filter's own name and lookup, so customer_name above is labelled "Customer Name Contains". Declare a label when that doesn't read well to the person using the list (see Filter labels).
Filtering metadata
model_filtering maps each filter's public name to an entry. Every entry carries hidden, label, lookup_exprs, required, type_db, type_model, type_filter, choices, and input_type. Optional keys, such as suffixes, appear when they apply.
lookup_exprs is a list with one element. A django-filter filter has one lookup expression. A Meta.fields entry with several lookups becomes one filter per lookup.
A filter whose form field is disabled is left out. A negated filter (exclude=True) is reported like any other filter.
choices takes one of three forms:
- A list of
{label, value}entries for a static choice set. Values are strings, and the blank placeholder option is left out. true, withapp_label,model, andfilterset_name, for a filter backed by a related model's queryset. The client loads these choices from the filter-choices endpoint.true, with the same keys, for a value-derived filter such as django-filter:AllValuesFilter. Its options are the values that the column holds now, so it is reported this way even when the column is empty.
Public Filter Names
PublicFilterAliasMixin renames every filter to a public name by turning each __ into .. Both VUEDA filterset bases include it. A Meta.fields entry of {"customer__formatted_name": ["exact", "icontains"]} produces customer.formatted_name and customer.formatted_name.icontains. A filter declared under an attribute name with no __, such as customer_name, keeps that name.
Only the public name is accepted. A request that sends the declared name, such as customer__formatted_name=, gets a 400.
A dotted public name does not say whether its last segment is a lookup or a relation. customer.formatted_name.icontains is the icontains lookup on customer.formatted_name. Clients read the exact names from model_filtering.
A multi-widget filter adds its suffix after an underscore. django-filter: RangeFilter declared as distributor__id accepts distributor.id_min and distributor.id_max. The client finds the suffix by splitting the key on the last underscore, which works because paths use ..
The vueda_info.E012 system check reports two filters that accept the same query parameter. Both filters below accept distributor.id_min:
class ProductFilterSet(VuedaFilterSet):
distributor__id = filters.RangeFilter(field_name="distributor__id")
distributor__id_min = filters.NumberFilter(field_name="distributor__id", lookup_expr="gte")The check's hint says to rename or remove one of them. It also covers names generated from Meta.fields.
Filter labels
model_filtering reports a label for every filter, and the client shows it in the filter menu and on the filter's chip. The label is decided in this order:
- A declared
labelis used as written:rest_framework.CharFilter(field_name="customer__formatted_name", label="Customer")is labelled "Customer". - Otherwise, django-filter builds one from the declared path. It joins the verbose names of the model fields that the path names, adds the lookup expression in words, and VUEDA title-cases the result.
field_name="customer__user__email", lookup_expr="icontains"is labelled "Customer User Email Address Contains", because theemailfield's verbose name is "email address". - When django-filter can't follow the path, the filter's own name is used instead. Django labels a form field that declares no label the same way: underscores become spaces, and VUEDA title-cases the result. A dot in a public filter name reads as a space too. The words django-filter would add stay: "Exclude" in front of a negated filter, and the lookup expression in words after the name. A lookup that the name already ends with is taken off the name first, so it isn't repeated.
The third step applies to a path that names something other than a model field. That covers formatted_name on a model that reaches it through formatted_name_lookup_expression, and a path that ends in a transform, such as customer__formatted_name__lower. django-filter's own label for such a path would be [invalid name].
model_filtering reports django-filter's label unless it is [invalid name], and the Django fallback in that case. Customer reaches its formatted_name through a lookup expression in these examples, and both columns are title-cased the way model_filtering reports them:
| Filter name | field_name | Lookup | django-filter | Django fallback |
|---|---|---|---|---|
customer_email | customer__user__email | exact | Customer User Email Address | Not used |
customer_formatted_name | customer__formatted_name | exact | [Invalid Name] | Customer Formatted Name |
customer_name_lower | customer__formatted_name__lower | exact | [Invalid Name] | Customer Name Lower |
customer.formatted_name | customer__formatted_name | exact | [Invalid Name] | Customer Formatted Name |
customer_formatted_name_icontains | customer__formatted_name | icontains | [Invalid Name] | Customer Formatted Name Contains |
customer_name | customer__formatted_name | icontains, exclude=True | [Invalid Name] | Exclude Customer Name Contains |
A declared label replaces both columns.
The Django fallback applies to model_filtering, which the VUEDA client reads. django-filter's own label on the filter is still [invalid name], and DRF's browsable API shows that one. Declare a label on such a filter to fix both.
A generated label describes the path, not what the reader is looking for. A filter named formatted_name on an invoice list is labelled "Formatted Name", which says nothing about invoices to someone filtering the list. Name a filter after what it means to the reader, such as customer_name, or declare a label.
Queryset Ordering
An order_by() on the viewset's queryset attribute, inside get_queryset, or in the model's default manager sorts rows that no declaration describes. OrderingFilter applies only the viewset's ordering or the request's o. With neither, it leaves the queryset's order in place. The rows arrive in the queryset's order, while default reports the model's Meta.ordering or nothing.
When the viewset declares ordering, that ordering replaces the queryset's own on every list request. The metadata is then accurate, and the queryset's order_by() has no effect.
A model's Meta.ordering is applied by Django, and the ordering backend applies nothing when the viewset declares no ordering. This matters for nulls placement, described in the next section.
Three Rules for Queryset Ordering
The vueda_info.E010 and vueda_info.E015 system checks hold an ordering in a queryset to three rules. E010 checks each viewset's list queryset. E015 checks each registered model's default manager.
Rule 1: the metadata must not lie (E010, per viewset). Each list arrives in one order, and model_ordering.default must not claim a different one:
Viewset ordering declared? | The rows arrive in | default reports |
|---|---|---|
| Yes | The viewset's ordering, because DRF applies it | The viewset's ordering, so the two always agree |
| No, and the queryset carries an ordering | The queryset's ordering, from get_queryset() or the manager | Meta.ordering, or nothing |
| No, and the queryset carries none | Meta.ordering | Meta.ordering, so the two always agree |
So the metadata can lie in the middle row, when Meta.ordering names a different order. An empty default makes no claim, so it can't lie. Fix it by declaring the order as the viewset's ordering. For an ordering that names an annotation, removing Meta.ordering also works.
The third row has one exception. Django leaves Meta.ordering out of a query that has a GROUP BY, such as a queryset that annotates an aggregate like Count("items"). With no viewset ordering, the rows of such a list arrive unordered while default reports Meta.ordering, so E010 reports it too. Declare the order as the viewset's ordering. DRF applies it with order_by(), and Django keeps an explicit ordering on a grouped query. Removing Meta.ordering also clears the error, because an empty default makes no claim.
Rule 2: declare what can be declared (E015 for a manager, E010 for get_queryset() and the queryset attribute). An ordering made only of fields belongs in a declaration: Meta.ordering for a manager, and the viewset's ordering for get_queryset() or the queryset attribute. An ordering that names an annotation is exempt, because a declaration can't always hold it. Meta.ordering can't name an annotation, and an aggregate such as Count("items") has to be annotated before anything can sort by it. So a manager or get_queryset() may sort by an annotation, however complex it is. A manager ordering that sorts the same way as Meta.ordering passes, because default already describes it.
Rule 3: no dead order_by() (E010). When the viewset declares ordering, DRF replaces the queryset's ordering on every list request, so an order_by() in get_queryset() or on the queryset attribute does nothing. Remove it, or make it match. An ordering from the default manager is exempt, because it still sorts every other query of the model.
The rules give these results:
- An annotation ordering with no
Meta.orderingpasses, in a manager or inget_queryset(), as long as the viewset declares noorderingthat it contradicts (rule 3). - An annotation ordering with a different
Meta.orderingand no viewsetorderingbreaks rule 1. E010 reports each viewset that starts from it. - A fields-only ordering in a manager, in
get_queryset(), or on thequerysetattribute breaks rule 2, unless it matches the declared default:Meta.orderingfor a manager, and the default thatmodel_ordering.defaultreports for a viewset. E015 reports a manager once for the model, so E010 doesn't repeat it for each viewset. - Any ordering in
get_queryset()that differs from a declared viewsetorderingbreaks rule 3.
A .reverse() in a Sortable List
One case falls outside the three rules, because it breaks the sorts that a client asks for, not only the default. Django keeps a .reverse() on the query through a later order_by(). DRF's ordering backend calls order_by() with the viewset's ordering or the request's o, so on a reversed list queryset every sort comes back in the opposite order. A client that asks for name ascending gets it descending. E010 reports a .reverse() in a viewset's list queryset, from get_queryset(), the queryset attribute, or the default manager, whenever the viewset has an order to get backwards: it declares ordering, or a list request can sort by at least one name. Without ordering_fields, DRF lets o sort by any readable serializer field, so a viewset can't be sorted only when it sets ordering_fields = [] and no default ordering applies. A .reverse() on such a viewset gives a list in one fixed order, and the three rules judge it like any other ordering.
How the Checks Read an Ordering
E010 builds the viewset without a request and calls get_queryset(). When get_queryset() raises without a request, the check reads the queryset attribute instead. E015 builds a queryset from the default manager. Both read the terms on the query, not the call that put them there. So an order_by() in get_queryset(), an order_by() on the queryset attribute, and a custom QuerySet method that calls order_by() are all read the same way. On a viewset that can't be sorted, a .reverse() is read as part of the ordering: each term is flipped, and a .reverse() with no order_by() reads as the reversed Meta.ordering, because that is the order that Django runs.
A query that Django rejects is reported with Django's own FieldError message, because every query of it would raise the same error. That covers a misspelled field, F() name, or transform. It also covers an aggregate written straight into order_by(), which Django rejects with a message to annotate it first. E015 compiles a manager's query only when the manager orders, because the check is about ordering. E010 doesn't repeat a rejected default manager query for each viewset.
Two orderings agree when they sort by the same terms, in the same order. Direction counts: ordering = ["queued"] disagrees with a queryset ordered by -queued. Nulls placement counts too. A queryset ordered by F("due_date").asc(nulls_first=True) disagrees with ordering = ["due_date"], because PostgreSQL puts nulls last in an ascending sort. The check applies the viewset's nulls_ordering to its ordering first, the same way a list request does, so nulls_ordering = {"due_date": "first"} makes the two agree. A function over a column counts as its own term, so Lower("name") disagrees with "name". Two function terms agree when Django compiles them to the same SQL, so Collate("name", "C") disagrees with Collate("name", "de"), although a message quotes both as Collate(F(name)). A "pk" alias and the field behind it count as the same term. A formatted_name and the column that its formatted_name_lookup_expression names also count as the same term.
A message about an ordering, a .reverse(), or a grouping names where it was written: the queryset attribute, get_queryset(), or the default manager. A message about a query that Django rejects while it compiles names the viewset's list queryset as a whole.
Manager Ordering
A model's default manager sorts every query that names no other manager, such as the admin, related lookups, and the expanded children that a VuedaViewSet loads. That makes its ordering a model-wide default, and Meta.ordering is where Django, DRF, and model_ordering.default read the model-wide default. The default manager is the one that Meta.default_manager_name names. Without that setting, it is the first manager declared on the model or its bases.
A manager other than the default orders only the code that names it, such as Order.unfulfilled.all(). E015 does not check it. A viewset whose queryset starts from that manager carries its ordering, and E010 applies the three rules to it like any other.
A Meta.ordering that differs from a default manager's annotation ordering is still correct for the model. It applies wherever code goes around the default manager, such as a second manager or the base manager that Django uses internally. Rule 1 judges only the viewsets that report it as their default.
What the Checks Cannot See
E010 and E015 do not see these cases:
- An ordering that varies by request. When
get_queryset()needs the request, E010 reads only thequerysetattribute. - An ordering that depends on
self.action, such as anorder_by()thatget_queryset()applies only whenself.action == "list". E010 callsget_queryset()with no action set, so it never sees that ordering. Setting the action first does not help, becauseVuedaViewSet.get_querysetthen builds the list serializer, and that serializer needs the request. - A viewset whose
filter_backendsleaves out the ordering backend. Nothing applies itsorderingor a client'so, so E010 applies none of the ordering rules to it. It still reports a query that Django rejects. - A bare
order_by(), which clears the model's default ordering.queryset_explicit_orderingdescribes this gap. - An ordering that names no single column, such as
"?"orConcat("first_name", "last_name"). The checks have nothing to compare it with.
The checks show only that the ordering that a viewset's queryset carries, when it is built without a request, follows the three rules. They do not show the order that any request returns.
Nulls Placement for Client-Requested Ordering
Stock DRF passes an explicit o to the database as a plain field name. The database's default nulls placement then applies, even when the default ordering placed nulls first.
nulls_ordering maps a field to "first" or "last". VuedaOrderingFilter applies that placement in both directions. A field also listed in nulls_ordering_flip swaps the placement when sorted descending.
The placement applies wherever the field is sorted by name: an explicit o, and a viewset ordering written as strings. A term written as an expression, such as F("due_date").asc(nulls_first=True), keeps its own placement. A model's Meta.ordering gets no placement when the viewset declares no ordering, because the backend applies nothing then.
A nulls_ordering key and a nulls_ordering_flip entry are __-joined paths, as in ordering, even though o is dotted. The backend translates o=customer.user.email to customer__user__email before it looks up a placement. So {"customer__user__email": "first"} applies, and {"customer.user.email": "first"} matches nothing.
A key must also name a field that a list request can sort by. That is a field that ordering_fields offers or that the default ordering names. A placement on any other field is never applied.
A key that ends in the "pk" alias and a key that names the field behind it are the same key. {"customer__pk": "first"} also applies to o=customer.id, which is the spelling that model_ordering advertises. This holds for a primary key of one column.
A composite primary key needs a placement on each of its columns. model_ordering advertises the key one column at a time, so a client sorts by one column, and a key on the pk applies only when the list is sorted by the whole key. Declare the placement on each column as well. Django allows a relation to reach a composite primary key model only from the reverse side. For example, an order list whose order_items reverse relation leads to a model with pk = models.CompositePrimaryKey("order_id", "product_id") declares {"order_items__pk": "first", "order_items__order": "first", "order_items__product": "first"}. An order with no items has nulls in those columns, so the placement decides where it sorts.
A value other than "first" or "last" is ignored at request time, and the rows use the database's default placement. The vueda_info.E007 system check reports such a value. It also reports a nulls_ordering that is not a dict, and a nulls_ordering_flip entry that has no placement to flip. It reports a key or entry that names no field a list request can sort by, and its hint gives the __-joined spelling of a dotted key. A key that resolves as written but contains a . or a ,, such as a JSONField key specs__v1.2, has no o name, so the hint says to annotate the value under a plain name instead. It reports a composite pk key when the same attribute leaves out any of the key's columns. It reports every problem in both attributes in one run.
To read the names a list request can sort by, the check builds the viewset without a request. When the viewset's get_queryset, or the serializer that DRF reads default ordering fields from, needs the request, the check skips the keys that no request can sort by.
model_ordering does not report nulls placement.
Transforms in an Ordering Path
A declared ordering path may end in a transform. A transform is a lookup that turns a field's value into another value, such as __year on a date or __len on an array. Django accepts these paths in order_by() and in Meta.ordering, and VUEDA accepts them in a viewset's ordering, ordering_fields, and nulls_ordering.
class InvoiceViewSet(VuedaViewSet):
queryset = Invoice.objects.all()
serializer_class = InvoiceSerializer
ordering = ["created__year", "-created__month"]
ordering_fields = ["created__year", "created__date__year", "details__priority"]
# model_ordering.default == ["created.year", "created.month"]
# model_ordering.fields includes {"name": "created.year", "type": "numeric", "ascending": true}
# `?o=created.year` and `?o=-details.priority` are accepted.VUEDA looks each transform up in Django's own lookup registry, the same way a query does. So any transform that Django resolves for the field works:
- a built-in transform, such as
year,month, ordate; - a chain of transforms, such as
created__date__year; - a transform that a project registers on a field class itself;
- a key on a
JSONField, such asdetails__priority.
A misspelled transform, such as created__yaer, names nothing. vueda_info.E006 reports it the same way it reports a misspelled field.
A key on a JSONField can contain any character, including a . or a ,. That is a limitation, described under Names that contain a dot or a comma.
model_ordering reports the path under its dotted name, which is the name that a client sends back in ?o=. The type comes from the transform's output, not from the column. created.year is numeric, because the database sorts the year as a number. A key on a JSONField is alpha, the same as the JSONField itself.
This differs from a database function. ExtractYear("created") sorts by the same value as created__year, but it is an expression around the column. So it is reported under the column's own name, created, and typed from the column (see Database Functions in a Default Ordering). Write the path form when a client should be able to name the transformed value in ?o=.
A path does not have to start with a model field. Two other starting points accept transforms too:
- A queryset annotation.
ordering_fields = ["launched__year"]works whenget_querysetadds alaunchedannotation. Django applies the transform to the annotation, andmodel_ordering.fieldsreportslaunched.year, typed from the transform's output. The annotation may come from.annotate()or.alias(). A plain string in Django'sorder_by()can't apply a transform to an.alias()name, soVuedaOrderingFilterorders by such a path through anF()expression, which can. A default ordering that names such a path is reported inmodel_ordering.defaultas well (see Queryset annotations). formatted_name.formatted_name__unaccentandcustomer__formatted_name__unaccentboth work, including on a model that reachesformatted_namethroughformatted_name_lookup_expression.VuedaOrderingFilterrewrites the part up toformatted_nameto the lookup expression behind it and keeps the transforms, so the database applies them to the column that the lookup expression lands on. The model's ownformatted_nameis left on its annotation, and Django applies the transforms there.
Some transforms are not built in. Lower and Ceil, for example, are database functions. A path such as name__lower works only after a project registers the function as a transform, with CharField.register_lookup(Lower). Until then, vueda_info.E006 reports the path as naming nothing, because Django would refuse it too.
A column_totals path may end in a transform as well. See Total Over a Transform.
Names that contain a dot or a comma
A client can't sort by a name that contains a . or a ,. This is a known limitation of the ?o= parameter, and no setting lifts it.
?o= takes dotted names, and the client sends every ordering term that way. VUEDA turns each . in a term back into __ to find the ORM path. So a . that belongs to the name itself is read as a separator. DRF splits a ?o= value on commas, so a , splits the name into two terms.
Django model fields can't contain either character. Two kinds of name can:
- A key on a
JSONField.ordering_fields = ["specs__v1.2"]names the keyv1.2. Its dotted name,specs.v1.2, reads back asspecs__v1__2, a different key. - An annotation name built from a dictionary.
.annotate(**{"v1.2": F("name")})is valid Django. Its dotted name,v1.2, reads back asv1__2.
The query itself sorts by such a name without trouble. A viewset's ordering that names one still orders every list. What fails is naming it from a client:
vueda_info.E006reports the declaration, somanage.py checkfails until it changes.model_orderingleaves the name out offields. A default ordering that names one is not reported indefaulteither.?o=rejects the dotted name with a400, rather than sorting by the different path that it reads back as.
To make such a value sortable, annotate it under a name without a dot or a comma in the viewset's own get_queryset, and order by that name:
from django.db.models.fields.json import KeyTextTransform
class ReleaseViewSet(VuedaViewSet):
ordering_fields = ["spec_v1_2"]
def get_queryset(self):
return super().get_queryset().annotate(spec_v1_2=KeyTextTransform("v1.2", "specs"))Query Namespace and Validation Boundary
NoExtraFieldsForViewSetMixin rejects any query parameter that the endpoint does not recognize. This is Query Parameter Validation.
A list request accepts:
- Each filter's public name, and for a multi-widget filter, each suffixed name such as
distributor.id_min. p,ps,e,f,om,s, ando.ct, the column totals parameter, when the viewset includes ListRowLevelViewSetMixin.
A viewset with no filterset_class accepts only the second and third groups. A retrieve request accepts only e, f, and om.
An unrecognized key gets a 400 with one entry per unknown key. The message starts with Invalid query parameter. and then names the accepted filters. When the viewset has no filterset, it names the accepted parameters. The client's list adapters classify this response as a ListFilterError, as CRUD Adapter Layer describes. Field and Expand Semantics describes how f and e values are checked.
DRF ignores unknown query parameters by default. DRF Ecosystem Compatibility Boundaries describes this departure.
remove_invalid_fields checks every comma-separated o term before applying any. If one term is invalid, the request gets a 400 keyed o. The message names every invalid term and lists the valid ones. Empty terms, as in o=name,, are dropped.
A term is valid when it is the public name of a field in get_valid_fields. That set holds every declared ordering_fields name, each field that the default ordering reads, and the fields behind "pk".
Search Contract Surface
VuedaSearchFilterBackend handles s. It extends DRF: SearchFilter with three search_fields prefixes:
#for trigram similarity.~for trigram word similarity.V:for ranked search.
DRF's own prefixes, ^, =, @, and $, still work. With no #, ~, or V: field, the backend behaves as SearchFilter. The exception is a field that reaches through a many-valued relation, which Search Through a Many-Valued Relation describes.
Plain, #, and ~ fields filter rows, and each group must match. # and ~ fields join the search terms into one term. V: fields rank the rows that remain.
A search is ranked when it has a V: field, or a plain field alongside # or ~ fields. The rank, combined_rank, adds full-text rank, trigram similarity, a whole-word match score, and 10 per plain match. Rows below search_threshold (0.2) are dropped. A search that uses only # or ~ fields is filtered and not ranked.
A ranked search is sorted by combined_rank, best first, unless the request sends o. Any nonempty o replaces the rank order. An o with an invalid term gets the 400 described above.
Search Through a Many-Valued Relation
A search field can reach through a reverse foreign key or a many-to-many, such as V:cart_items__product_option__name on a Cart. A join on that relation returns one row per matching related row. The backend keeps those joins inside an Exists subquery and narrows the viewset's queryset to the objects that match. The list returns each object once, whatever the prefix.
In a ranked search, an object matches when at least one of its related rows reaches search_threshold. The object ranks by its best matching row, and the primary key breaks ties. When the request sends o, the list keeps the order that it has without a search, and the primary key breaks ties.
The subquery reads the viewset's search queryset, which get_search_queryset returns. It is the result of the viewset's own get_search_queryset() when the viewset defines one. Otherwise, it is the model's default manager.
The viewset's queryset applies its ordering, annotations, aggregates, and filters outside the subquery, so they see every related row. A viewset that lists only carts with at least three items keeps such a cart when two of its items match the search:
class CartViewSet(VuedaViewSet):
search_fields = ["V:cart_items__product_option__name"]
def get_queryset(self):
return super().get_queryset().annotate(item_count=Count("cart_items")).filter(item_count__gte=3)A filter through the searched relation and the search are separate conditions, as two filter() calls on a many-valued relation are in Django. Take a viewset queryset filtered by cart_items__quantity__gte=24. It keeps a cart with one item of quantity 24 and another item that matches the search.
A search whose fields follow only foreign keys joins at most one row per object. So does a search whose fields are all annotations of the viewset's queryset. Such a search matches against the viewset's queryset directly and never reads the search queryset.
When a Viewset Needs get_search_queryset()
A viewset defines get_search_queryset() in two cases. In the first, its search_fields name an annotation that only its get_queryset() adds, alongside a field through a many-valued relation. The default manager lacks the annotation, so every search request fails with FieldError: Cannot resolve keyword 'customer_name', a server error:
class CartViewSet(VuedaViewSet):
search_fields = ["V:customer_name", "V:cart_items__product_option__name"]
def get_queryset(self):
return super().get_queryset().annotate(customer_name=F("customer__user__name"))
def get_search_queryset(self):
return Cart.objects.annotate(customer_name=F("customer__user__name"))An annotation that the default manager already adds needs no get_search_queryset(). One example is the formatted_name that a FormattedNameManager adds.
In the second case, the model's default manager hides rows that the viewset lists. In this example, the default manager leaves out archived widgets, and the viewset lists them through a second manager:
class WidgetManager(FormattedNameManager):
def get_queryset(self):
return super().get_queryset().filter(archived=False)
class Widget(VuedaModel):
objects = WidgetManager()
all_objects = FormattedNameManager()
class WidgetViewSet(VuedaViewSet):
queryset = Widget.all_objects.all()
search_fields = ["V:parts__name"]
def get_search_queryset(self):
return Widget.all_objects.all()Without get_search_queryset(), the subquery reads Widget.objects, so every search leaves out the archived widgets. The request returns a 200, and no system check reports it.
A search queryset follows four rules:
- It carries every annotation that
search_fieldsname. - It includes every row that the viewset lists.
- It applies no filters. Inside the subquery, a filter on an aggregate or a window function sees only the matching related rows. A filter through the searched relation limits a ranked search to the related rows that pass it.
- It is a new queryset. Returning
self.get_queryset()brings the viewset's filters into the subquery.
The vueda_info.E014 system check reads the search queryset of each registered viewset whose search reaches through a many-valued relation. It reports a search_fields entry that names neither a model field nor an annotation of the search queryset. It also reports a get_search_queryset() that filters on an aggregate or a window function. It skips a get_search_queryset() that cannot run outside a request, for example because it reads self.request.
Filter Choices and Permission Surfaces
Choices for a queryset-backed or value-derived filter change with the data, so model info does not include them. The client loads them from List filterset field choices. It requests a page size of 200 and loads only the first page (#381). ModelInfoFilterSetChoicesViewSet describes that endpoint.
The endpoint returns:
404for a filter name that the filterset does not declare. The message lists the valid names.404for a model with no viewset, or a viewset with no filterset.403when the user lacks the mappedreadpermission on the model. A queryset-backed filter also needs the mappedlistpermission on the related model. Without it, the list works and that filter's dropdown stays empty.500withFieldErrorwhen the related model has no label path. That model has noformatted_namecolumn, noformatted_name_lookup_expression, and noget_formatted_name().
The 500 case gets no startup report for a model outside FormattedNameBaseModel, such as the user model. The vueda_info.E001 system check covers only a FormattedNameBaseModel that sets formatted_name = None with neither alternative.
Choices come only from the rows that the registered viewset's list view reads. The endpoint starts from the viewset's get_queryset(), called as a list request with empty URL keyword arguments. filter_rows_for_user then keeps the rows that pass the same row-level and workflow state rules that the list view applies. The request's other filters then narrow those rows. A value-derived filter offers only the values that those rows hold. A queryset-backed filter offers only related rows that the user may also list, by the same rules on the related model. A related row that the user may not list is left out, even when a visible row references it. Static choices read no rows, so these rules do not change them.
The endpoint does not call the viewset's get_queryset. A dropdown can therefore offer a value that appears only on rows that get_queryset excludes from the list (#459). Row-Level Permission Filtering describes the row-level rules.
Client Normalization and Cache Semantics
storeModelInfo fetches model info once per app.model and camelCases its keys. storeModelConfig derives from it:
sortables, the names inordering.fields.sorted, theordering.defaultnames with theirascendingdirections.filterablesandfilterableDetails, fromfiltering.
useFilterables merges the config's filterables with a view's overrides. use/useViewList resolves that list once for ViewList and passes it to FilterGroup. useFilter picks each filter's component and widget. use/useFilterForm converts a filter value to and from its URL form, using the filter's one lookup expression. useViewList writes the reader's sort to o. storeModelChoices and useModelChoices load filter choices.
The add-filter menu, FilterMenu, offers only filters that the client can render an input for. useViewList's validFilterables keeps a visible filter when its type has value handling and components to mount. The components come from the filter field mapping, or from the view config's fieldComponents and widgetComponents. A range filter needs both boundary components. A widget that resolves to WidgetUnmapped does not count, because it shows a diagnostic. A filter that fails this check is left out of the menu, and its URL value is not restored. A console warning names the app, model, and filter once per list visit.
mergeFilterFieldMapping registers a custom type's components and value handling together. Filter Input Types lists the built-in types.
A filter marked hidden, such as the id filter that IdInFilterSet declares, has no input, whatever its type. Its type needs no mapping. Its URL value still reaches the list request and shows as a List Scope, a chip with a clear control. Open a Scoped List describes scopes, the scopes prop, and clearing.
storeModelInfo caches a failed fetch. Later requests for that model reject with the cached error, so its filter and sort controls stay unavailable until the cache clears. Reactive Data Flow describes when that happens.
Filter Input Types
Each model_filtering entry reports a type_filter, which the client reads as typeFilter. For most filters, it names the Django form field class that the filter builds. AllValuesFilter and django-filter: AllValuesMultipleFilter report AllValuesChoiceField and AllValuesMultipleChoiceField. Their choices are the values that a column holds, and the client loads them from the filter-choices endpoint.
The client picks a filter's input and value shape from its type. Two tables in utils/fieldMappings describe each type:
FilterFieldMappingsholds value handling.initialValueis the empty value that the filter form starts from.arraymarks a list value.repeatedKeymarks a list that thelistrequest sends as one key per value.rangemarks a range.filterFieldMappingholds the components: the field component and widget. For a range, it holds the field set and each boundary's component and widget.
Built-in Types
typeFilter | Input | URL value |
|---|---|---|
CharField | WidgetTextInput | The text |
BooleanField | WidgetToggle | true or false |
NullBooleanField | WidgetSelectDropdown with the metadata's choices | The chosen value |
ChoiceField, TypedChoiceField | WidgetSelectDropdown with the metadata's choices | The chosen value |
ModelChoiceField | WidgetModel, single select | The chosen primary key |
MultipleChoiceField | WidgetCombobox, multi-select, with the metadata's choices | One value per choice |
ModelChoiceInField, ModelMultipleChoiceInField, ModelMultipleChoiceField | WidgetModel, multi-select | One primary key per choice |
AllValuesChoiceField | WidgetModel, single select | The chosen stored value |
AllValuesMultipleChoiceField | WidgetModel, multi-select | One stored value per choice |
DateField | WidgetDateField | YYYY-MM-DD |
DateTimeField, IsoDateTimeField | WidgetDateField with minute granularity | YYYY-MM-DDTHH:MM:SS |
TimeField | WidgetTimeField | HH:MM:SS |
DurationField | WidgetDuration with days, hours, and minutes | [D ]HH:MM:SS, such as 1 02:30:00 |
DecimalField, PositiveDecimalField, FloatField | WidgetNumberInput | The number |
DecimalInField | WidgetTagsInput, numbers only | One number per entry |
RangeField | Two WidgetNumberInput boundaries | One key per suffix |
DateRangeField | Two WidgetDateField boundaries | One key per suffix |
DateTimeRangeField | Two WidgetDateField boundaries with minute granularity | One key per suffix |
In the page URL, a list value repeats its key once per value, such as ?condition=new&condition=used. A range writes one <filter>_<suffix> key per boundary, with the two suffixes from its metadata.
The list request sends a list value in the form that the filter's Django widget reads:
MultipleChoiceField,ModelMultipleChoiceField, andAllValuesMultipleChoiceFieldsetrepeatedKey. The request repeats the key once per value, such as?condition=new&condition=used. TheirSelectMultiplewidget reads each key as one value, so a stored value with a comma, such asAcme, Inc., arrives intact.ModelChoiceInField,ModelMultipleChoiceInField, andDecimalInFieldsend one comma-separated value, such as?price.in=1,2.5. Their django-filter CSV widget reads one key and splits its value.
ChoiceField, TypedChoiceField, and MultipleChoiceField filters show the choices that their metadata lists. The ModelChoice* and AllValues* types report choices: true. WidgetModel loads their choices from the filter-choices endpoint when the input gets focus or already holds a value.
The add-filter menu leaves out a visible filter whose type is missing from either table, such as a UUIDField filter. Client Normalization and Cache Semantics describes that check.
Custom Filter Types
mergeFilterFieldMapping registers a custom type or changes a built-in one. An entry's initialValue, array, repeatedKey, and range keys go to FilterFieldMappings. Its other keys go to filterFieldMapping.
import WidgetColor from "./WidgetColor.vue";
import { mergeFilterFieldMapping } from "@vueda/utils/fieldMappings.js";
mergeFilterFieldMapping({
ColorField: {
component: "FormField",
fieldProps: { hidden: true },
widget: WidgetColor,
initialValue: null,
},
});A type needs entries in both tables. Without value handling, the filter form cannot build an empty or URL-restored value. Without components, it has nothing to render.
- The widget's value is what the request parameter carries: a string, or an array of strings for a type with
array. - A list type sets
array: trueandinitialValue: []. A single URL value then restores as a list with one entry. A list type whose Django widget reads repeated keys, such asSelectMultiple, also setsrepeatedKey: true. Without it, thelistrequest sends the values as one comma-separated value. - A range type sets
range: trueand aninitialValueobject. It providescomponent: "FieldSetRange",boundaryComponent, andboundaryWidget. The filter's metadata supplies the two suffixes. fieldProps: { hidden: true }makesFormFieldrender the widget without its own label row, because the filter form already shows the filter's label. It does not hide the filter.
Observable Failure Modes
A field sorts under its source name. With no ordering_fields, a serializer field that renames Product.name to title sorts as name. A request for o=title gets a 400, as does o on a property-backed field. Both columns look sortable in the UI, and sorting one returns the 400.
The sort indicator disagrees with the rows. A queryset order_by() sorts the rows while default reports another order, or none. The response is a 200. The client's sort indicator is wrong, and its reset restores an order the server never applied. vueda_info.E010 reports it, whether the order_by() is on the queryset attribute or in get_queryset(). An ordering that depends on the request goes unreported.
A sorted list shows no sort. A default ordering on a multi-column function or a column-less term sorts the rows without a default entry. No check reports it, and the client shows no sort indicator.
A search leaves out rows that the default manager hides. A viewset lists rows that its model's default manager filters out, such as archived rows listed through a second manager. Every search through a many-valued relation leaves those rows out, with a 200. No check reports it. When a Viewset Needs get_search_queryset() gives the fix.
A search on an annotation raises FieldError. A viewset's search_fields name an annotation that only its get_queryset() adds, alongside a field through a many-valued relation. Every search request fails with a server error. vueda_info.E014 reports the viewset at startup.
A filter dropdown stays empty. The filter-choices endpoint answers 403 or 500 while the rest of the list works. The request that fails is the choices request, and the list request succeeds.