Skip to main content

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):

OperatorMeaning
eq / bare valueEqual
neNot equal
lt / lte / gt / gteComparisons
inMatch any of a comma-separated list
not_inMatch none of a comma-separated list
likePartial text match (wildcards auto-applied)
is_nullNULL check
containsAlias 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: