03 — Architecture¶
The architecture has one job: keep the model→params→AST pipeline completely ignorant of the database, so the only backend-specific code is a small, swappable compiler. This is what makes "extend past Mongo" a roadmap item rather than a rewrite.
The pipeline¶
┌──────────────┐ introspect ┌──────────────┐ generate ┌──────────────┐
│ Pydantic │ ─────────────► │ FieldSpec │ ────────────► │ FastAPI │
│ model │ │ tree │ params │ signature │
└──────────────┘ └──────────────┘ └──────────────┘
│ │
│ request comes in
▼ ▼
┌──────────────┐ parse + ┌──────────────┐
│ OperatorReg │ ◄──────────── │ raw query │
│ (validate) │ coerce │ params │
└──────────────┘ └──────────────┘
│
▼
┌──────────────┐ compile ┌──────────────┐
│ FilterAST │ ────────────► │ Backend │
│ (neutral) │ (adapter) │ query │
└──────────────┘ └──────────────┘
Five components, sharply separated:
1. Introspector¶
Walks a Pydantic model (v2 model_fields, FieldInfo, annotation) and produces
a tree of FieldSpec:
@dataclass(frozen=True)
class FieldSpec:
path: tuple[str, ...] # ("address", "city") for nested
source: str # DB field name ("address.city")
py_type: type # resolved, Optional-unwrapped base type
container: Container # SCALAR | LIST | NESTED | LIST_OF_NESTED | MAP
nullable: bool
annotations: FilterableMeta | None # from Annotated[...]
Resolving Optional, list[...], Annotated[...], enums, and nested models
happens here, once, at registration time. Recursion is depth-bounded and
cycle-safe (doc 02).
2. Operator registry¶
A table of operators, each declaring arity, value type, and per-backend
compile functions. The registry decides which operators a FieldSpec is
allowed to expose (type profile + overrides), and validates user config against
that at registration.
@dataclass(frozen=True)
class Operator:
name: str # "gte"
arity: Arity # SINGLE | LIST | RANGE | BOOL
value_type: ValueTypeRule # SAME_AS_FIELD | BOOL | INT
applies_to: Container # which containers it's valid on
tier: Tier # SAFE | FULL
Operators are extensible: a user (or a backend) can register a custom operator without forking the library.
3. Parameter generator — a dynamic Pydantic query model¶
This is the piece that makes every filter param show up in /docs with correct
types. Since FastAPI 0.115, a Pydantic model can be used directly as a
group of query parameters (Annotated[Params, Query()]), which gives us a much
simpler mechanism than hand-synthesizing function signatures: generate the
parameter model with pydantic.create_model() and let FastAPI do the rest.
from pydantic import create_model
from fastapi import Query
from typing import Annotated, Optional
def build_params_model(specs: list[ResolvedParam]) -> type[BaseModel]:
fields = {
p.python_safe_name: ( # sanitized identifier
Optional[p.value_annotation], # drives type + validation
Field(
None,
alias=p.url_name, # the real "name__contains"
description=p.help_text, # shows in /docs
# constraints (max_length, ge/le) flow from the field/operator
),
)
for p in specs
}
# pagination + sort fields appended here
return create_model("FilterParams", **fields)
def build_dependency(specs, params_model):
def dependency(raw: Annotated[params_model, Query()]) -> FilterQuery:
return FilterQuery.from_params(raw, specs) # validated → AST
return dependency
Key points:
- Each field is
Optional[T]with analiascarrying the__-containing public name (not a valid Python identifier); the internal field name is sanitized. Because the annotation is the real type, FastAPI/Pydantic do the coercion and emit a clean 422 on bad input — validation for free. - List operators are annotated
Optional[list[T]]so repeated/,-joined values parse natively; range/betweenuse a small custom type with a validator. - OpenAPI/Swagger shows every filter param, described and typed, with no manual schema writing.
- The generated model is also a public artifact: users can import it in tests and clients can be code-generated from the OpenAPI schema.
Fallback: for FastAPI versions older than 0.115 (or if the query-model path
hits limitations, e.g. around per-field alias handling), the same specs can
be rendered as a synthesized function signature (inspect.Parameter list with
Query(None, alias=...) defaults, assigned to dependency.__signature__) —
FastAPI builds OpenAPI by inspecting the callable's signature, so both routes
produce identical docs. The spec list is the source of truth; the rendering is
an implementation detail we can swap. A spike in Phase 1 decides the primary
mechanism and the minimum supported FastAPI version.
Spike result (2026-07): the native query-model path works as designed on FastAPI 0.140 / Pydantic 2.13 —
create_model()fields with__-containing aliases resolve correctly underAnnotated[Model, Query()], list params accept both repeated keys and comma-joined values (via aBeforeValidatorsplit), bad values yield standard 422s, and every aliased param appears typed in/openapi.json. The native path is the primary mechanism; the synthesizedinspect.Signaturefallback was not needed. Minimum FastAPI stays ≥ 0.115.
Either way, generation is memoized per (model, config) so it runs once, not per request.
4. Parser → FilterAST¶
At request time the dependency receives the validated parameter model (already type-coerced) and builds the neutral AST. The AST is the contract between the front half (HTTP/Pydantic) and the back half (databases).
@dataclass(frozen=True)
class Condition:
field: str # source path, e.g. "address.city"
op: str # "gte", "contains", "has_all", ...
value: Any # already coerced & validated
@dataclass(frozen=True)
class Group:
op: Literal["and", "or"]
members: list[Condition | Group]
@dataclass(frozen=True)
class FilterAST:
where: Group # v1: a single top-level AND group
order_by: list[Sort] # [(field, ASC|DESC)]
page: Page # offset/limit (or cursor later)
The AST is plain data: trivially testable, serializable, loggable. It is the
extension point for OR-groups later (the Group node already models it; v1 just
only ever produces a top-level AND).
5. Backend adapter (compiler)¶
A QueryCompiler turns a FilterAST into a backend query. This is the only
component that knows about a specific database.
class QueryCompiler(Protocol):
supported_ops: frozenset[str] # adapter declares what it can do
def compile_where(self, group: Group) -> Any: ...
def compile_order(self, order: list[Sort]) -> Any: ...
def compile_page(self, page: Page) -> Any: ...
- At registration time, the chosen adapter's
supported_opsis intersected with the configured operators; an operator the adapter can't compile is rejected with a clear error before the app serves traffic. - The Mongo adapter maps
Condition→Mongo operators (gte→$gte,contains→$regex,has_all→$all, …) and merges conditions on the same field into one sub-document ({"age": {"$gte": 21, "$lt": 65}}).
class MongoCompiler:
def compile_where(self, group):
clauses = [self._cond(c) for c in group.members]
merged = merge_same_field(clauses)
return merged if group.op == "and" else {"$or": clauses}
Why this separation matters¶
- Testability: the front half is tested by asserting on the
FilterAST; the back half is tested by feeding hand-built ASTs and asserting on the query dict. No database required for the vast majority of tests. - Extensibility: a new database is a new
QueryCompiler. The introspector, param generator, parser, OpenAPI integration — all unchanged. - Honesty: the adapter declares what it supports, so we can never advertise a filter param a backend can't fulfill.
The boolean-combination question (AND / OR)¶
HTTP query strings naturally express AND of equalities/ranges. That covers the overwhelming majority of real list endpoints, so v1 ships AND-only and keeps the surface clean.
We deliberately keep a forward path without committing to it now:
- The AST already has
Group(op="or"). - A future, opt-in syntax could express OR without turning query strings into a
DSL, e.g. a repeated bracketed group
?or=(status:active|status:trial)or a small JSON filter body on aPOST /users/searchcompanion route. We will pick one deliberately later, informed by real demand, rather than guessing now.
This is called out as a non-goal in doc 00 precisely so we don't accidentally grow a query language. The architecture permits it; the product resists it until asked.
Continue to 04 — Backend Roadmap.