One filter language. Three surfaces. Zero injection risk.
gqlith ships one filter engine — a filter DSL for your Postgres-backed API. You write the same grammar in a GraphQL `where`, a REST query string, or an MCP tool call — and it compiles to parameterized SQL on the way down. Every operator a real list screen needs. Across the relations you actually query. Past the line most filter dialects stop at.
GET /api/v1/recipes ?filter=cuisine.name icontains 'italian' AND reviews ANY (rating >= 4) AND deletedAt is null &sort=-createdAt &page[size]=20
{ "name": "graphql_query", "arguments": { "query": "{ recipes(limit: 20, filterql: \"cuisine.name icontains 'italian' AND reviews ANY (rating >= 4) AND deletedAt is null\", orderBy: [createdAt_DESC]) { edges { node { id title } } } }" }}
# operators
What it can express
Grouped by what you actually reach for. Every operator below is real syntax — copy any line into a search box, a URL, or an MCP filter argument and it will parse and run.
Comparisons
=
!=
<
<=
>
>=
between2and8
notbetween18and65
isdistinctfrom:x
Strings & patterns
like'Pa%'
ilike'%pasta%'
icontains'pasta'
starts_with'quick-'
not_starts_with'draft-'
regex'^[A-Z]'
iregex'^pasta'
fts'"quick dinner" -mushroom'
Lists & ranges
statusin('draft','published')
statusnotin('archived','spam')
servingsin [2,8)
servingsin [2,8]
servingsin(2,8]
Arrays & JSONB
tagscontains('vegan','gluten-free')
tagsoverlap('vegan','dairy-free')
tagscontained_by('a','b','c')
metahas_key'tier'
metahas_keys_any('tier','plan')
nutrition__calories>400
nutrition__macros__proteinG>=20
nutrition__ingredientsANY(organic=true)
Relations
cuisine.nameicontains'italian'
author.team.name='Platform'
reviewsANY(rating>=5)
reviewsALL(rating>=3)
reviewsNONE(flaggedistrue)
Relative time
createdAt>=now-7d
createdAt>=now-1M
createdAt>=now/d
createdAt>=now/M
createdAt>=now-1M/M
expiresAt<now+30d
One operator, many values
titleicontains(any)('react','vue','angular')
titleicontains(all)('pasta','fresh')
filenameends_with(any)('.jpg','.png','.gif')
slugnot_starts_with(all)('draft-','arch-')
Null, boolean, empty
deletedAtisnull
cuisineIdisnotnull
isSharedistrue
tagsisempty
tagsisnotempty
Aggregates → HAVING
count(*)>=5
count(distinctassignee)>1
avg(rating)>=4
max(priceCents)<10000
Your own SQL as a predicate
recipes_rated_at_least(4)
NOTrecipes_rated_at_least(4)
in_season()
Named parameters
status=:status
tenantId=:tenantId
createdAt>=:since
rolein:roles
# beyond
Beyond where most filter dialects stop
Every GraphQL engine ships a `where`. Most stop at eq / in / contains, on the row's own columns. filtr goes past that — without leaving its lane.
filtr is a predicate language — it filters rows, it does not transform values. There are no tolower()-style value transforms and no arithmetic in the WHERE clause, and they are not on the roadmap. What IS callable inside a filter: the aggregate functions (compiled to HAVING) and any SETOF Postgres function you tag with @gqlith.filterPredicate — so when you genuinely need SQL-level logic, you write it as SQL and the filter language calls it.
# in the wild
Real filters from real screens
Concrete predicates the kind of app you ship actually needs. None of these require a hand-written resolver.
My recent published recipes in Italian cuisine, plus any unpublished drafts I authored.
Every front-end goes through the same path. There is no string concatenation anywhere; user input never reaches the database as SQL text.
01
Parse
A GraphQL `WhereInput`, a FiltrQL string, or an MCP tool argument all parse to the same AST. The AST is shape-checked against the entity's schema before any SQL is touched.
02
Resolve
Identifiers are mapped to columns and join paths. Unknown fields fail loudly at the API layer with a structured error pointing at `source.parameter` or `source.pointer`.
03
Compile
The AST is compiled to a parameterized SQL fragment. Literals become `$1`, `$2`, …; the database driver binds them as values, never as text. Even regex sources are parameterized.
04
Compose
The compiled fragment composes with row-level filters, tenancy and soft-delete predicates — written once as SQL comments — into a single WHERE clause. One model, every protocol.
The same pipeline runs whether the filter came from a search box, a URL, or an AI agent. The AI agent cannot construct a SQL string. It can only describe a predicate.
# per protocol
One filter, native to each protocol
The grammar is shared. The shape adapts — typed JSON for GraphQL, a text DSL for REST, a string argument for MCP — so each surface still feels native.
GraphQL
WhereInput — a typed JSON object
Operators are typed per field, so the IDE autocompletes them. Logical combinators (`AND` / `OR` / `NOT`) compose with named field filters in the same object.
Single query parameter. Reads aloud like the predicate it is. Pairs with `?sort=`, `?fields[…]=` and `?page[…]=`.
rest.http
GET /api/v1/recipes ?filter=status = 'published' AND servings >= 4 AND cuisine.name icontains 'italian' AND reviews ANY (rating >= 5) &sort=-createdAt
MCP
The same FiltrQL string, taught to the agent
AI agents are not asked to construct SQL — or to guess your grammar. `filter_help` returns operators and example filters built from your real columns, and the same `filterql` string rides inside the governed `graphql_query` tool. Permission-projected like every other surface.