Flat Filters Philosophy
Framework M queries its generic list API with flat query parameters —
?status=open, ?amount[gt]=100 — instead of deep, nested SQL filter trees
in the request body. This is a deliberate design choice, not a convenience
shortcut. This page explains the conventions and the reasons behind them.
The conventions
All three forms below are parsed in meta_router.py into list[FilterSpec]
and executed by the GenericRepository.
1. ?field=value — equality
GET /api/v1/Todo?status=Open
GET /api/v1/Invoice?is_paid=true
Equivalent to WHERE field = value. Values are type-coerced against the
field's schema type (a boolean field parses "true"/"false"/"1"/"0",
an IN value is split on commas, etc.).
2. ?field[operator]=value — typed operators
GET /api/v1/Invoice?amount[gt]=1000&created[gte]=2026-01-01
GET /api/v1/Todo?title[like]=urgent
GET /api/v1/User?email[in]=a@x.com,b@x.com
GET /api/v1/Todo?status[ne]=Cancelled
The operator maps to a member of FilterOperator
(framework_m_core.interfaces.repository):
| Operator | Meaning |
|---|---|
eq / bare value | Equal |
ne | Not equal |
lt / lte / gt / gte | Comparisons |
in | Match any of a comma-separated list |
not_in | Match none of a comma-separated list |
like | Partial text match (wildcards auto-applied) |
is_null | NULL check |
contains | Alias for like |
Adding a new comparison never touches the schema, a migration, or the client:
it is a member of the FilterOperator enum.
3. ?filters[N][part]=value — grouped filters (AND/OR)
GET /api/v1/Invoice?filters[0][field]=status&filters[0][operator]=eq&filters[0][value]=Draft
&filters[0][connector]=or&filters[1][field]=amount&filters[1][operator]=gt&filters[1][value]=10000
Each filters[N] entry carries field, operator, value, and optionally
connector (and/or, default and) and group_id for explicit grouping.
This is exactly how the Desk's Advanced Filtering UI serializes its filter
rows, so the wire format and the UI never drift apart.
System parameters
limit, offset, order_by (with -field, field desc, field asc),
fields (projection), bind, and include_trashed are reserved; a q
parameter is reserved for free-text search. See
Advanced Filtering for the UI workflow.
Why flat, not nested
Composable with RLS
Row-level security scoping is itself a FilterSpec list
(apply_rls_filters). Because user filters are already the same flat
FilterSpec shape, RLS constraints merge with them trivially — the
repository applies one uniform list[FilterSpec] to every query. A nested,
structured filter body would need a second, separate merge path for tenant
scoping and would make "who scoped this row" ambiguous.
Cacheable and serializer-friendly
Query parameters are primitives. They survive URL caches, shared links, bookmarks, and browser history; they serialize to OpenAPI, machine-exported corpora, and log lines without custom (de)serializers. A filter tree in a JSON body is opaque to every one of those layers.
Extensible without schema change
A new operator is an enum member; a newly filterable field needs nothing at all. Flat params also compose per-URL, so the same request surface works across the Desk UI, scripting, and BI tools without learning a bespoke query DSL.
Honest about what it is
Flat parameters do not hide the fact that they become SQL predicates — that is their point. Deep nested filter languages tend to grow their own query grammar (joins, aggregates, sub-selects) that slowly re-implements SQL while bypassing the repository's validation, type coercion, and RLS hooks.
What is deliberately avoided
A request body like {"filters": {"and": [{"field": "status", "op": "eq", "value": "Open"}]}}
is rejected in favor of the flat forms above — it adds a serialization
format, a schema, and a validation path with no expressiveness gain over
?status=Open.
Related:
- Advanced Filtering (how-to) — building filter rows in the list UI
- Tenancy Isolation — how RLS merges with user filters
- CQRS in Framework M — where filters are applied on the read side