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, tiered like everything else:
| Operator | Meaning | Tier | Mongo compilation |
|---|---|---|---|
has |
array contains this element | safe |
{tags: "x"} (Mongo matches scalar against array) |
has_any |
contains any of these | safe |
{tags: {$in: [...]}} |
has_all |
contains all of these | safe |
{tags: {$all: [...]}} |
len__eq |
array length equals | safe |
{tags: {$size: n}} |
empty |
is empty / non-empty | safe |
see precise spec below |
len__ne, len__gt, len__gte, len__lt, len__lte |
array length comparison | full |
guarded $expr (below) |
len__eq is safe because it compiles to a plain $size match, which can use
an index. The other five len comparisons compile to $expr — no index
support, same collection-scan reasoning that gates other full operators —
so they are full-tier.
Query forms:
?tags__has=python
?tags__has_any=python,rust # any-of
?tags__has_all=python,rust # all-of
?tags__len__eq=2
?tags__len__gte=2 # full tier
?tags__empty=false
len range compilation. Mongo's aggregation $size errors the whole
query when the field is missing, null, or not an array. So the len__ne/
gt/gte/lt/lte operators compile to a guarded $expr:
The $cond/$isArray guard falls back to [] for anything that isn't an
array, so a missing/null/non-array value counts as length 0 instead of
failing the query.
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 would be an explicit, named,full-tier operator (tags__has_substr) so the intent is unmistakable — designed here, not shipped in Phase 3a; the shipped array profile is exactly the table above.
By default, array fields are not sortable, even when filterable — Mongo
sorts arrays by their min/max element, which is rarely what anyone means by
"sort by tags." Opt in deliberately with Filterable(sortable=True) on the
field or by naming it in FilterConfig(sortable=[...]).
Nested Pydantic models (embedded documents)¶
Nested fields are reachable by dotted path, which maps cleanly to Mongo's dot notation:
The embedding field itself (address, as opposed to its leaves) exposes
no operators of its own — filtering happens on the leaves — except the
nullability pair when it is Optional: address__isnull/address__exists.
Other Filterable options placed on the embedding field are scoped to that
field, not the subtree, with one exception:
Filterable(ops=ops.NONE)onaddressexcludes the entire subtree (the embedding field and every descendant leaf) from the filter surface.Filterable(source=...)/Filterable(param=...)onaddressrename that one path segment for the whole subtree — every descendant path is composed from the renamed segment.- Any other
Filterableoption onaddress(e.g.sortable=True) affects only the embedding field's own spec, never its descendants. - An explicit
Filterable(ops=[...])list on a non-nullable embedding field is aConfigurationErrorat registration — there is no operator it could validly name (not evenisnull, since the field can't be null).
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. Their internal__is hardcoded, independent ofFilterConfig.separator: underseparator="--", the generated name istags--len__gte(segment separator--, operator spelling untouched). Harmless — the name is still generated exactly, never parsed — but pinned so it isn't "fixed" into an inconsistency later. - 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,
FilterConfig(max_depth=N)) and configurable, to keep the generated parameter surface finite and the docs readable. The precise, shipped semantics:max_depthcounts the embedded-model boundaries a field's path crosses —address__cityis 1,address__geo__latis 2. A field whose path crosses more boundaries than the bound allows is silently skipped. A nested model sitting exactly at the bound still gets its own spec (so a nullable embedding keeps itsisnull/exists) but none of its children.max_depth=0disables nested-model traversal entirely — only top-level scalar/array fields are generated. - Cycles (self-referential and mutually-recursive models) terminate by construction: recursion depth strictly increases on every descent, so the depth bound above is what truncates them — no separate cycle detection is needed.
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.
elem parameters are full-tier (shipped that way given the subtlety):
under the default safe profile they generate nothing, and there are exactly
three opt-in paths — FilterConfig(default_profile="full"), a
Filterable(ops=...) on the element field, or a FilterConfig.operators
entry naming the elem path. text_search never applies inside elements.
Shape operators (len__*, empty) remain available on the array field
itself and merge with $elemMatch on the same field. Elem paths are never
sortable. The elem hop counts as one max_depth boundary, like an
embedding.
Adapter contract: in the AST, element-relative fields carry a literal
$elem source segment (orders.$elem.amount — $-prefixed, so it cannot
collide with a real Mongo field name; exported as
fast_pager.ast.ELEM_SOURCE_MARKER). Backends group all conditions sharing
the prefix before .$elem. into one element-match construct with keys
relative to the element. The conformance suite (doc 04) exercises this
grouping rule.
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, implicit
eq only in this release), 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. Richer
per-key operator curation is deferred to FilterSet.
Because a map key becomes part of a Mongo field path, keys are
sanitized: empty keys and keys containing ., $, or NUL are rejected —
at Filterable(keys=...) construction time (ConfigurationError), at
request time for has_key values (standard 422), and again in the compiler
(CompilationError, defense in depth for direct AST users).
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. - Nested paths compose segment by segment. Each level of a nested model
resolves its own public/source name independently by the rule above, and
the two paths are joined separately: the public path with
separator(address__city), the source path with.(address.city). Renaming one segment (e.g.Filterable(source="addr")on the embedding field) only changes that segment; descendants still compose normally on top of it (addr.city). FilterConfig.excludeis path-based, not string-based. An entry matches a field's full public path prefix by segment (exclude=["address"]removesaddressand its whole subtree), never by literal string prefixing — so a top-level field that happens to be named"address__city"is unaffected by anexclude=["address"]entry naming the nested subtree. The same rule applies toFilterConfig.operatorsandFilterConfig.sortable: all three are keyed by the field's public,separator-joined dotted-param spelling ("address__city"), the same spelling a client would use in the URL.- 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.