Skip to content

Changelog

fast-pager follows SemVer; 0.x versions signal an unstable API, and breaking changes bump the minor version and are called out below. See the Roadmap for what's coming next, and the development plan for the execution detail behind each stage.

0.4.0 — Stage 5: SQLAlchemy backend + conformance suite

The database is now genuinely a deployment detail. SQLAlchemyCompiler (fast_pager.backends.sqlalchemy, behind the [sqlalchemy] extra — the core still imports no database code) compiles the same neutral AST to SQLAlchemy 2.0 constructs: conditions to ColumnElement[bool] expressions, groups to and_/or_, sorts to asc()/desc(), the window to limit/offset. On FilterQuery that surfaces as q.to_sqlalchemy(model) / q.sort_sqlalchemy(model), plus the one-liner q.apply_sqlalchemy(select(UserRow)) (WHERE + ORDER BY + LIMIT/OFFSET, model inferred from a single-entity select). Substring operators use SQLAlchemy's autoescape LIKE helpers — %/_ in user input match literally, mirroring the Mongo re.escape() guarantee — with the LIKE case-sensitivity dialect caveats documented rather than papered over (i* variants are the guaranteed-insensitive forms). Nested models map to JSON columns: dotted paths compile to JSON path access with value-type-driven CASTs (datetimes as ISO strings, enums by value).

What SQL can't express is declared, not faked (design doc 04): backends now declare capabilities (nested_paths, elem_match) alongside supported_ops, and the SQLAlchemy backend rejects the array family, elem element matching, has_key, regex, text_search, and exists with a CompilationError naming the operator — never a silently dropped filter. The optional FilterDepends(..., backend=...) hook moves that check to registration time: the generated parameter surface is intersected with the backend's declaration and mismatches raise ConfigurationError at startup, naming every offending parameter.

Backing it all is the backend conformance suite (fast_pager.conformance): a fixed battery of AST inputs covering every operator/container the core produces — merging, escaping/anchoring, $elem grouping, guarded length ranges, unsafe-map-key rejection, sort, paging — plus a runner enforcing the backend-neutral semantics; adapters supply their own expected-output tables. Both first-party backends run the full battery in CI, and third-party adapters can run it to claim compatibility. A second example app (examples/sqlalchemy_app/) serves the same endpoints as the Mongo example against a real in-memory SQLite database — only the backend swaps. See the new Backends reference (selection guide + capability matrix). Strictly additive for applications; one contract note for adapter authors: the QueryCompiler protocol now includes the capabilities attribute, so custom compilers must declare it to satisfy the protocol. 100% test coverage, mypy --strict clean.

0.3.0 — Stage 4: response envelope & ergonomics

The "list + total count" shape is now one line. Page[T] is a generic Pydantic response envelope (items / total / limit / offset), so response_model=Page[User] keeps response validation and the OpenAPI schema exactly right, and await q.paginate(collection) runs the compiled find (+ optional count) and fills it in. The collection is duck-typed against the standard Mongo surface (find → cursor with sort/skip/limit iterated via to_list/async/plain iteration, count_documents, estimated_document_count) with awaitables detected at runtime — motor, pymongo sync, pymongo async, and in-memory test fakes all work, and the core still imports no driver (a [mongo] extra exists purely as an install convenience). The count cost is a knob: total="exact" (default) counts with the same filter, "none" skips counting (Page.total is None), and "estimated" uses the metadata-based estimated_document_count() only for unfiltered queries — a filtered query falls back to an exact count per the design doc's pinned rule, so total is never a number unrelated to the results. Rounding out the ergonomics, FilterConfig(pagination="page") switches a route's generated parameters to a 1-based page + page_size pair (same default_limit/max_limit guardrails) that resolve to the identical internal window — q.limit/q.offset/q.skip, backends, and paginate() are strategy-agnostic.

One breaking change (0.x minor bump per SemVer): the internal AST pagination-window dataclass previously exported as fast_pager.Page / fast_pager.ast.Page is renamed PageSpec — the public Page name now means the response envelope, matching design doc 01. Code that imported Page for AST work (custom backends implementing QueryCompiler.compile_page, direct AST construction) must switch to PageSpec; everything else is strictly additive. See the updated Sorting & Pagination tutorial and the new Pagination reference. 100% test coverage, mypy --strict clean.

0.2.0 — Stage 3 complete: FilterSet + example app

Stage 3 caps off with FilterSet (design doc 01 Option B): a declarative class holding a strict allow-list filter surface apart from the model — Meta.model plus a fields mapping keyed by public dotted param names, where anything not listed is not filterable (the safe posture for public APIs). Values are exact operator lists (or "__all__" / ops.ALL for everything the type supports, still allow_regex-gated), validated at class definition with the usual rich ConfigurationError. The mapping occupies layer 4 of the precedence ladder — the same layer as FilterConfig.operators, which it replaces on FilterSet routes (that knob, exclude, and sortable are rejected inside Meta.config; Meta.sortable is the sortable allow-list, and the sortable default becomes "listed and scalar") — and the model-level absolutes (ops.NONE, sortable=False) remain final. Custom declared filters add parameters no generated name covers: active_since = Filter(field="last_login", op="gte") — attribute name (or param=) as the public name, value typed by the target field + operator, compiled through the normal AST path, inheritable via abstract (Meta-less) base classes. FilterDepends(UserFilter) returns the same uniform FilterQuery as the zero-config and Filterable paths — call sites never change — and multiple FilterSets per model (public vs. admin) coexist trivially. A runnable example app (users + addresses + tags + orders + metadata map, all three declaration styles, no MongoDB needed) ships under examples/mongo_app/ and is exercised in CI.

That completes the whole 0.1.x0.2.0 arc — Stage 3 grew the type system from flat scalars to the full compound surface: arrays of scalars (0.1.1), nested models via dotted paths (0.1.2), arrays of nested models (elem → single $elemMatch) and dict[str, T] maps (0.1.3), and now allow-list filter surfaces (0.2.0). Strictly additive: no behavior changes to anything shipped in 0.1.3. See the new FilterSet tutorial. 100% test coverage, mypy --strict clean.

0.1.3 — Stage 3 checkpoint: arrays of nested models + maps

The compound-type tables of design doc 02 are now complete. Arrays of nested models (list[NestedModel]) get element matching via the elem path segment: every condition sharing an orders__elem__ prefix in one request compiles into a single $elemMatch — same-element semantics (?orders__elem__amount__gte=100&orders__elem__status=refunded finds one order that is both), the loudly-documented opposite of Mongo's independent dotted-path default. elem parameters are full-tier given that subtlety (generated under default_profile="full" or an explicit per-field/per-route operator opt-in); the array field itself keeps the safe-tier shape operators (len__*/empty, reusing the 3a compilation) plus isnull/exists when Optional; the elem hop counts as one max_depth boundary exactly like an embedding; elem paths are never sortable; text_search never applies inside elements. Maps (dict[str, T]) stay unfilterable by default; a Filterable annotation enables has_key (key presence, with the key — request input inserted into a field path — rejected with a 422 when it contains ., $, or null bytes), and Filterable(keys=["region"]) additionally generates typed, eq-only value-at-key parameters (?metadata__region=emea{"metadata.region": "emea"}). Unsupported map shapes (non-str keys, unsupported value types) with a Filterable raise ConfigurationError at registration, as does keys= on a non-map field. Strictly additive: no behavior changes to anything shipped in 0.1.2. See the new arrays-of-nested-models and maps sections of the Operator Reference and the matching tutorial sections. 100% test coverage, mypy --strict clean.

0.1.2 — Stage 3 checkpoint: nested models

Nested Pydantic models (embedded documents) are now filterable by dotted path: introspection recurses into a field whose type is another model, so ?address__city__contains=ams compiles to {"address.city": {"$regex": "ams"}} — and everything shipped for flat fields carries over to nested leaves unchanged (scalar operators, the array family for a nested list[T], Filterable(...), type_profiles, bare-eq sugar, sorting by public name, FilterConfig keys in the dotted-param spelling "address__city"). Recursion is depth-bounded by the new FilterConfig(max_depth=...) knob (default 2 embedded-model levels below the root; deeper fields are silently skipped), which keeps the parameter surface finite and makes self-referential models safe by construction. The embedding field itself exposes no operators — except isnull/exists on an Optional embedding — and a whole subtree opts out with Filterable(ops=ops.NONE) on the embedding field or FilterConfig(exclude=["address"]). Generated-name collisions (a literal address__city field vs. a nested address.city path) raise a ConfigurationError at registration naming both sources. list[NestedModel] and dict stay unfilterable until v0.1.3. Strictly additive: no behavior changes to anything shipped in 0.1.1. See the new nested-models section in the Operator Reference and the nested-models section of the filtering tutorial. 100% test coverage, mypy --strict clean.

0.1.1 — Stage 3 checkpoint: arrays of scalars

list[T] and set[T] fields (any supported scalar element type, including Optional[list[T]]) are now filterable with their own membership and shape operator family: has, has_any, has_all, len__eq and the empty operator in the safe tier, plus the len__ne/len__gt/len__gte/ len__lt/len__lte comparisons in the full tier (they compile to $expr over $size, which can't use an index). The empty operator pins down the classic empty-vs-missing Mongo trap — a missing field matches neither empty=true nor empty=false — and array fields deliberately get no scalar operators (tags__contains doesn't exist) and are not sortable by default (Filterable(sortable=True) opts in). max_list_length guards has_any/has_all exactly as it does in/nin. Strictly additive: no behavior changes to anything shipped in 0.1.0. See the new array tables in the Operator Reference and the arrays section of the filtering tutorial. 100% test coverage, mypy --strict clean.

0.1.0 — first minor release: Stages 1–2 finalized

Stages 1–2 are complete and consolidated: the core filter → sort → paginate pipeline, the MongoDB query compiler, zero-config FilterQuery[Model] / FilterDepends(Model) for scalar fields, per-field Filterable(...) control, per-type profiles, strict mode, and this documentation site. No functional changes from 0.0.3 — this release is the polish milestone: every page on this site now describes exactly the shipped, installable API, with the "not released yet" caveats retired.

0.0.3 — Stage 2 checkpoint: per-field control

The filter surface is now curatable. Annotated[T, Filterable(...)] metadata on the model controls each field: ops=[...] for an exact operator allow-list (with ops.ALL / ops.NONE markers, the latter making a field explicitly unfilterable), source= to point the compiled Mongo query at a different document key, param= to rename the public URL parameter, and sortable= to override sortability per field (including sort-only fields). FilterConfig grows type_profiles={...} for per-type operator overrides and unknown_params="strict" — a standard 422 on unrecognized field__op parameters instead of silently ignoring them. Precedence is a four-layer ladder (route per-field > field Filterable > per-type profile > global profile), every misconfiguration still fails at route registration, and the error messages now name the field, the operator, and the valid alternatives. See the new Controlling the filter surface tutorial. 100% test coverage, mypy --strict clean.

0.0.2 — Stage 1 checkpoint: the core works

The README example is real: zero-config FilterQuery[Model] / FilterDepends(Model) for scalar fields (str, numerics, bool, datetimes, UUID, Enum/Literal, Optional), typed filter/sort/pagination query parameters in OpenAPI, standard 422s on bad input, and a MongoDB compiler producing plain query dicts (no driver dependency). Safety defaults on: safe operator profile, regex gated off, list/filter/limit caps, sortable allow-list. 100% test coverage, mypy --strict clean.

Still to come for v0.1.0: per-field Filterable(...) control and final polish — see the roadmap below.

0.0.1 — placeholder release

The current state of the project on PyPI: the package name is reserved and the repository has CI + fully automated release tooling (scripts/release.sh, tag-triggered PyPI publish via Trusted Publishing), but no library functionality yet. Nothing in this documentation site is installable against 0.0.1 — see Getting Started.

Coming next

Stage 6 ships cursor/keyset pagination in v0.5.0: opaque cursor tokens with an automatic unique tiebreaker (_id / primary key) appended to the sort key, on at least one backend and conformance-tested — plus the published adapter authoring guide documenting the QueryCompiler contract and conformance-suite usage for third-party backends. Tracked in design doc 05 — Roadmap & Release Plan.