02 — Type & Operator System¶
This is the technical heart of the product: which Python/Pydantic types we support, which operators each exposes, how compound types behave, and how a user controls all of it.
Mental model¶
Two registries drive everything:
- Type → operator profile. Each supported type has a default profile: the
set of operators it exposes out of the box. Profiles are tiered (
safe,full) so we can keep dangerous operators off by default. - Operator → semantics. Each operator defines its value arity (single / list / range), the value's relationship to the field type (same type / bool / int), and how each backend adapter compiles it.
Resolving a model field is then: field type → profile → operator set, with
per-field overrides layered on top.
Scalar types¶
| Python / Pydantic type | Default operators (safe) |
Additional in full |
|---|---|---|
str |
eq, ne, in, nin, contains, startswith, endswith |
icontains, istartswith, iendswith, regex, text_search |
int, float, Decimal |
eq, ne, gt, gte, lt, lte, in, nin |
between |
bool |
eq |
ne |
datetime, date, time |
eq, ne, gt, gte, lt, lte |
between |
UUID |
eq, ne, in, nin |
|
Enum / Literal[...] |
eq, ne, in, nin |
|
bytes |
(not filterable by default) | eq |
Notes:
regexisfull-only and additionally gated by a config flag, because it is a ReDoS and full-scan risk. See Safety below.contains/startswith/endswithvalues are alwaysre.escape()d before being compiled to Mongo$regex(anchored for the*withvariants). The user value is a literal substring, never a pattern — without this guarantee,containswould silently be the regex operator, ReDoS included. Pattern matching is exclusively the job of the explicit, gatedregexoperator.text_search(full; requires the backend capability) compiles to a real text query — Mongo$textover a text index,matchin Elasticsearch — instead of a scanning regex. Adapters that lack it reject it at registration.- Case-insensitive variants (
i*) are separate operators rather than a flag, so they appear explicitly in the docs and compile to explicit backend forms. eqis implicit: a barefield=is treated asfield__eq=. This keeps the 90% case (?status=active) clean.
Optionals and nullability¶
Optional[T] / T | None exposes everything T does plus:
isnull→field__isnull=true|false(compiles to{field: None}/{field: {$ne: None}}, orIS NULLin SQL).exists(Mongo-flavored;fullonly) →{field: {$exists: bool}}. For SQL adaptersexistsaliases toisnullsemantics or is rejected — adapters declare which operators they support (doc 03/04).
Compound types¶
This is where the design earns its keep. The user explicitly called out
list[str]; here is the full treatment.
list[T] / set[T] (arrays of scalars)¶
An array field needs operators about membership and shape, which are distinct from scalar operators. We expose a curated set:
| Operator | Meaning | Mongo compilation |
|---|---|---|
has |
array contains this element | {tags: "x"} (Mongo matches scalar against array) |
has_any |
contains any of these | {tags: {$in: [...]}} |
has_all |
contains all of these | {tags: {$all: [...]}} |
len (len__gte, etc.) |
array length comparison | {tags: {$size: n}} / $expr for ranges |
empty |
is empty / non-empty | see precise spec below |
Query forms:
?tags__has=python
?tags__has_any=python,rust # any-of
?tags__has_all=python,rust # all-of
?tags__len__gte=2
?tags__empty=false
Precise empty semantics (empty-vs-missing is a classic Mongo trap, so we pin
it down):
?tags__empty=true→ the field exists and is the empty array:{tags: {"$eq": []}}(equivalent to$size: 0, but index-friendlier).?tags__empty=false→ the field exists and has at least one element:{"tags.0": {"$exists": true}}.- A missing field matches neither. Use
isnull/existsto reason about presence;emptyreasons only about shape. This distinction is documented on the operator itself.
Design choice: we do not silently apply scalar string operators (
contains) tolist[str]—tags__containswould be ambiguous (substring of an element? membership?). Array fields get array operators. Element-level substring matching is an explicit, named,full-tier operator (tags__has_substr) so the intent is unmistakable.
Nested Pydantic models (embedded documents)¶
Nested fields are reachable by dotted path, which maps cleanly to Mongo's dot notation:
Parameter matching (precise — there is no request-time parsing)¶
Because the entire parameter surface is generated from the model at
registration time, the library never splits incoming parameter names. Each
generated parameter carries its own (field_path, operator) pair; an incoming
name is matched exactly against the generated set. address__city__contains
works not because a parser split it correctly, but because registration emitted
a parameter with that exact name bound to (("address", "city"), contains).
This makes otherwise-nasty cases non-issues by construction:
- Multi-token operators (
len__gte,elem) — the full spelling is just part of the generated name. - A field literally named with
__in it, or a nested field that shares a name with an operator (address.in) — the generated name is whatever it is; collisions between two generated names are detected at registration and raised as a config error naming both sources. - Unknown incoming parameters are never mis-parsed — they simply don't match,
and are handled per the
ignore/strictsetting (doc 01).
Generation notes:
- Bare
address__city(no operator suffix) is emitted as the implicit-eqparameter foraddress.city. - Recursion depth is bounded (default 2 levels) and configurable, to keep the generated parameter surface finite and the docs readable.
- Cycles (self-referential models) are detected and truncated at the depth limit.
list[NestedModel] (arrays of embedded documents)¶
These need element-match semantics ("a user with an order over $100 that is
also refunded"). Mongo expresses this with $elemMatch. We expose it explicitly:
The elem token groups conditions that must hold for the same array element,
compiling to a single $elemMatch. Without elem, conditions on different
parameters are independent (Mongo's default array-matching semantics) — we
document this difference loudly because it surprises people. v1 may ship
elem as full-tier given the subtlety.
dict[str, T] / free-form maps¶
Free-form maps clash with a core promise: every parameter is pre-generated, typed, and documented in OpenAPI — which is impossible when the key set is unknown. So support is deliberately narrow:
?metadata__has_key=region— key presence. This is generatable (one parameter, value typedstr) and always available when the field is enabled.- Value-at-key filtering is available only for keys enumerated in config:
generates metadata__region, metadata__tier (typed as T), and nothing
else. No enumeration → no value filtering; we do not accept arbitrary
metadata__<anything> at request time, because those params would be
undocumented and untyped.
Default: not filterable unless explicitly enabled.
Union[A, B] (non-optional unions)¶
Discouraged for filtering — the operator set is ambiguous. Default: not
filterable; the library logs a one-time warning naming the field and how to make
it explicit (annotate it, or pick a concrete type via FilterSet).
Field → DB-name mapping and aliases¶
- Pydantic aliases are respected: an alias (e.g.
alias="userName") defaults both names at once — the query parameter is named after the alias and the compiled query targets the alias too.Filterable(param=...)andFilterable(source=...)each override one side independently, so the resolution order is: public name =param→ alias → field name; source name =source→ alias → field name. - Explicit source override for when the Mongo field differs from the model:
?age__gte=21 → {"ageYears": {"$gte": 21}}.
- Custom parameter name (decouple URL from field):
Per-field configurability — the core question you asked¶
Yes — it must be configurable which operators are exposed per field, and the design provides four layers, from coarse to fine, each overriding the previous:
-
Global default profile.
FilterConfig(default_profile="safe")— applies to every field by type. This is the zero-config behavior. -
Per-type override. "All strings in this app expose
icontainsbut neverregex."
- Per-field inline (
Annotated). Wins over type-level.
email: Annotated[str, Filterable(ops=["eq"])] # exact-match only
bio: Annotated[str, Filterable(ops=ops.NONE)] # explicitly unfilterable
score: Annotated[int, Filterable(ops=ops.ALL)] # everything int supports
- Per-field on the route. Wins over everything, including the field's own
Filterable(ops=...)— the route has the final say over its own surface. The shipped spelling is the route-level mapping, keyed by public parameter name:
A FilterSet's fields mapping (Stage 3) occupies this same layer, and is
also where custom/computed filters live:
class UserFilter(FilterSet):
class Meta:
model = User
fields = {"name": ["contains"], "age": ["gte", "lte"]}
# custom filter not derivable from a single field:
active_since = DateFilter(field="last_login", op="gte")
Two rules pin down how the ladder interacts with the safety gates (settled by the implementation, now normative):
ops.NONEandsortable=Falseare absolute. They sit outside the ladder: a config that tries to override them — naming anops.NONEfield inFilterConfig.operators, or asortable=Falsefield inFilterConfig.sortable— raisesConfigurationErrorat registration. Route-level config can never quietly resurrect a field the model opted out; a model-level opt-out is a security posture, not a default.- Explicit operator lists bypass the
allow_regexgate. Listing"regex"inFilterConfig.operators, inFilterable(ops=[...]), or in atype_profilesentry is the eyes-open opt-in and needs no extra flag. The tier profiles remain gated:ops.ALLand thesafe/fulldefault profile emitregexonly withFilterConfig(allow_regex=True).
Allow-list vs deny-list semantics¶
- In Option C (zero-config) the model is an implicit allow-list by type:
filterable types in, sensitive types (
bytes,dict, bareUnion) out by default, plus a globalexclude=[...]for named fields. - In
FilterSetthefieldsmapping is a strict allow-list: if it's not listed, it's not filterable. This is the safe default for public APIs — you opt fields in, never accidentally leak a new field by adding it to the model.
This allow-list-by-default-in-FilterSet property is a deliberate security posture, not an accident.
Operator value parsing¶
- Single-value ops (
eq,gte,contains): the value is coerced to the field's type by Pydantic (soage__gte=21yields anint, andage__gte=xxyields a clean 422). - List-value ops (
in,nin,has_any,has_all): accept both - repeated keys:
?status__in=a&status__in=b, and - comma-joined:
?status__in=a,b
Each element is coerced to the field type. A max_list_length guard applies
(default 100) to prevent giant $in clauses.
- Range ops (between): two values, ?age__between=21,65 → {$gte:21,$lte:65}.
Sugar over gte+lte; we keep both spellings, between is just nicer to read.
- Bool-valued ops (isnull, empty, exists): parse true/false/1/0.
Safety & performance (defaults that protect users)¶
Filtering APIs are an unbounded attack/footgun surface. Defaults are conservative:
regexoff by default (ReDoS + collection-scan risk). Enable per-field or globally with eyes open; when enabled we anchor/length-cap patterns and document the risk.contains/icontainsvalues arere.escape()d — always, not as an option — so user input is a literal substring, never a pattern. They still compile to unanchored regex in Mongo → collection scans on unindexed fields. We expose this honestly in docs and offer thetext_searchoperator, which uses a real Mongo text index where one exists.max_list_lengthcaps$in/$allblowups.max_filterscaps the number of simultaneous filters per request.max_limit/default_limiton pagination; an unboundedlimitis never allowed.- Sortable-field allow-list (default = filterable set) prevents sorting on unindexed fields by surprise.
- Field allow-list in FilterSet prevents accidental field exposure.
All guards are config knobs with safe defaults — the library is safe out of the box and tunable when you know your indexes.
Continue to 03 — Architecture.