~/gqlith — filtering.md

# filtering

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.

  • Operators beyond eq / in / contains.
  • Across relations, with quantifiers.
  • Injection-safe by construction.
  • Identical semantics on GraphQL, REST and MCP.

# the same filter, three ways

The same filter, three ways

Italian recipes with at least one four-star review, no soft-deleted rows, sorted by recency. One predicate. Three surfaces.

query {
  recipes(
    where: {
      cuisine: { name: { icontains: "italian" } }
      reviews: { some: { rating: { gte: 4 } } }
      deletedAt: { isNull: true }
    }
    orderBy: [createdAt_DESC]
    first: 20
  ) {
    edges { node { id title cuisine { name } } }
  }
}

# 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

    • =
    • !=
    • <
    • <=
    • >
    • >=
    • between 2 and 8
    • not between 18 and 65
    • is distinct from :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

    • status in ('draft', 'published')
    • status not in ('archived', 'spam')
    • servings in [2, 8)
    • servings in [2, 8]
    • servings in (2, 8]
  • Arrays & JSONB

    • tags contains ('vegan', 'gluten-free')
    • tags overlap ('vegan', 'dairy-free')
    • tags contained_by ('a','b','c')
    • meta has_key 'tier'
    • meta has_keys_any ('tier','plan')
    • nutrition__calories > 400
    • nutrition__macros__proteinG >= 20
    • nutrition__ingredients ANY (organic = true)
  • Relations

    • cuisine.name icontains 'italian'
    • author.team.name = 'Platform'
    • reviews ANY (rating >= 5)
    • reviews ALL (rating >= 3)
    • reviews NONE (flagged is true)
  • 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

    • title icontains(any) ('react','vue','angular')
    • title icontains(all) ('pasta','fresh')
    • filename ends_with(any) ('.jpg','.png','.gif')
    • slug not_starts_with(all) ('draft-','arch-')
  • Null, boolean, empty

    • deletedAt is null
    • cuisineId is not null
    • isShared is true
    • tags is empty
    • tags is not empty
  • Aggregates → HAVING

    • count(*) >= 5
    • count(distinct assignee) > 1
    • avg(rating) >= 4
    • max(priceCents) < 10000
  • Your own SQL as a predicate

    • recipes_rated_at_least(4)
    • NOT recipes_rated_at_least(4)
    • in_season()
  • Named parameters

    • status = :status
    • tenantId = :tenantId
    • createdAt >= :since
    • role in :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.

Capabilityfiltr (gqlith)Typical GraphQL `where` dialectOData 4.01
Comparison + logical (AND / OR / NOT) Yes Yes Yes
Wildcard pattern (`like` / `ilike`) Yes — with auto-escape on prefix/suffix/contains Varies Yes
Regex Yes (`regex`, `iregex`) Rare `matchesPattern`
Full-text search Yes (`fts`) Rare `$search`
Range with bracket notation `[a, b)` Yes — inclusive / exclusive bounds per side No No
Array operators (`contains`, `overlap`, `containedBy`) Yes — native PG array semantics Limited No
Typed JSONB paths (declared shapes, `nutrition__calories`) Yes No No
Relation dot-notation (`cuisine.name`) Yes Sometimes Yes (`/Customer/Name`)
To-many quantifiers (`ANY` / `ALL` / `NONE`) Yes Rare No
Relative time (`now-7d`, `now/M` rounding) Yes No No
Apply operator to many values (`(any)` / `(all)`) Yes No No
Named parameters (`:name`) Yes No No
Aggregate filters → HAVING (`count(*) >= 5`) Yes Rare Via the `$apply` extension
Your own SQL functions as predicates Yes — `@gqlith.filterPredicate` No No
Value-transform functions (`tolower`, `substring`, `length`) No — see below No Yes
Arithmetic operators (`+ - * /`) No — see below No Yes

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.

    (authorId = :userId)
    AND ((status = 'published' AND cuisine.name icontains 'italian')
         OR status = 'draft')
    ORDER BY createdAt DESC
  • Orders with at least one item over $50, paid in the last calendar month.

    items ANY (priceCents >= 5000)
    AND paidAt >= now-1M/M
    AND paidAt < now/M
  • Vegan and gluten-free recipes that are currently in season — no soft-deleted rows.

    tags contains ('vegan', 'gluten-free')
    AND ingredients ALL (inSeason is true)
    AND deletedAt is null
  • Comments from the last 7 days on posts I follow, excluding flagged or deleted.

    post.followers ANY (userId = :userId)
    AND createdAt >= now-7d
    AND deletedAt is null
    AND flagged is false
  • Pro-tier notifications on a typed JSONB payload that have not been seen.

    payload__tier = 'pro'
    AND seenAt is null
    ORDER BY createdAt DESC
  • Users active in the last week with at least one purchase, not banned, in specific roles.

    lastLoginAt >= now-7d
    AND purchases ANY (status = 'paid')
    AND status != 'banned'
    AND role in ('admin', 'editor', 'moderator')
  • Busy tickets: open, grouped work with real volume — HAVING without writing SQL.

    status = 'open'
    AND count(*) >= 5
    AND count(distinct assignee) > 1
  • Reusable: tenant-bound query parameterized for a saved view.

    tenantId = :tenantId
    AND status in :statuses
    AND createdAt >= :since
    AND createdAt <= :until

# safety

Safe by construction

Every front-end goes through the same path. There is no string concatenation anywhere; user input never reaches the database as SQL text.

  1. 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.

  2. 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`.

  3. 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.

  4. 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.

graphql.json
{
  status: { eq: "published" },
  servings: { gte: 4 },
  cuisine: { name: { icontains: "italian" } },
  reviews: { some: { rating: { gte: 5 } } }
}
REST

FiltrQL — a human-readable text DSL

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.

mcp.json
{ "name": "filter_help",
  "arguments": { "entity": "recipes" } }

{
  "name": "graphql_query",
  "arguments": {
    "query": "{ recipes(limit: 20,
      filterql: \"cuisine.name
        icontains 'italian'\")
      { edges { node { id title } } } }"
  }
}

There is more — the full grammar is in the reference.

Operator-by-operator. Every duration unit, every modifier, every escape rule. Read it when you need it; the page above is the pitch.

Open the filtering reference →
request_access.sh

Be among the first to generate your backend.

gqlith is in private early access. Leave your email and we’ll bring you in as we open it up — a small group at a time.