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: