~/gqlith — docs/filtering.md
Early access gqlith is in private early access — the code is not publicly available yet. These docs preview the surface so you know what to expect. Join the list →

Docs / Reference

Filtering

One filter language with three front-ends — FiltrQL text DSL, GraphQL WhereInput, MCP filterql string — all compiled to parameterized SQL.

gqlith ships one filter engine with three syntactic front-ends:

  • GraphQL uses a JSON WhereInput object — { status: { eq: 'published' } }.
  • REST uses FiltrQL, a human-readable text DSL — status = 'published'.
  • MCP agents pass FiltrQL as the filterql argument inside graphql_query documents, with filter_help as the built-in, schema-aware reference.

All three parse to the same AST and compile to parameterized SQL. There is no SQL-injection path through any of them.

Field names are the camelCase GraphQL names (createdAt, cuisineId), not the snake_case database columns — the same names on every surface.

Operators

The two front-ends mirror each other. Reach for whichever is natural for the surface you’re on.

Comparison

status = 'published'       → { status: { eq: 'published' } }
servings >= 4              → { servings: { gte: 4 } }
status != 'archived'       → { status: { neq: 'archived' } }
servings between 2 and 8   → { servings: { between: { from: 2, to: 8 } } }
age not between 18 and 65  → { age: { notBetween: { from: 18, to: 65 } } }
cuisineId is distinct from :x   → { cuisineId: { isDistinctFrom: … } }

is distinct from is the NULL-safe inequality — NULL compares as a value instead of poisoning the predicate.

Strings

FiltrQLWhereInputCaseMeaning
likelikesensitiveSQL wildcard (%, _)
ilikeilikeinsensitiveSQL wildcard
icontainsicontainsinsensitiveContains substring
starts_withstartsWithsensitiveStarts with
istarts_withiStartsWithinsensitiveStarts with
ends_withendsWithsensitiveEnds with
iends_withiEndsWithinsensitiveEnds with
regex / ~regexsensitiveRegular expression
iregex / ~*iregexinsensitiveRegular expression
ftsftsFull-text search

Every pattern operator has a negated twin — not_starts_with / notStartsWith, not_ends_with, not_istarts_with, not_iends_with.

% matches zero or more characters, _ matches exactly one, and * is an alias for %. The starts_with / ends_with / icontains family auto-escapes wildcards in the value, so a literal % is matched literally. Use like / ilike if you want wildcard semantics. Inside quoted strings, \', \", \\, \n, \t and \r escape as expected.

fts uses Postgres websearch semantics, so the query string itself is expressive:

description fts 'italian summer'        -- both words
description fts '"quick dinner"'        -- exact phrase
description fts 'pasta OR risotto'      -- either
description fts 'pasta -mushroom'       -- exclude

Lists and ranges

status in ('draft', 'published')
status not in ('archived', 'spam')
servings between 2 and 8
servings in [2, 8)            -- 2 ≤ servings < 8
servings in [2, 8]            -- both bounds inclusive
servings in (2, 8]            -- 2 < servings ≤ 8

A list and a range disambiguate by bracket: (a, b) is always a list; using at least one square bracket makes it a range.

Null, boolean, empty

deletedAt is null
cuisineId is not null
isShared is true
tags is empty
tags is not empty

is empty is defined for strings, JSONB and arrays — for a numeric or boolean column, use is null.

Arrays and JSONB

tags contains ('vegan', 'gluten-free')
tags overlap ('vegan', 'gluten-free')
allergenTags contained_by ('nuts', 'gluten')
meta has_key 'tier'
meta has_keys_any ('tier', 'plan')
meta has_keys_all ('tier', 'plan')

For JSONB columns with a declared shape, filter typed leaves directly with __ paths — real operators, real casts, enum values validated:

nutrition__calories > 400
nutrition__macros__proteinG >= 20
nutrition__spiciness = 'mild'
nutrition__ingredients ANY (grams > 100 AND organic = true)

Custom fields

Tenant-defined custom fields filter like real columns — a custom points field is just points > 10 in FiltrQL, with the same operator set as its declared kind.

Logical combinators

status = 'published' AND servings >= 4
status = 'draft' OR status = 'published'
NOT deletedAt is null
(status = 'draft' OR status = 'published') AND servings >= 4

Precedence is NOT > AND > OR. Parentheses override.

Relations

cuisine.name icontains 'italian'
author.team.name = 'Platform'

# to-many quantifiers
reviews ANY (rating >= 5)
reviews ALL (rating >= 3)
reviews NONE (flagged is true)

Time, relative

createdAt >= now-7d
createdAt >= now-1M
createdAt >= now/d         -- floor to midnight today
createdAt >= now/M         -- floor to first of month
createdAt >= now-1M/M      -- floor to first of last month

Units: ms, s, m (minutes), h, d, w, M (months), y. Rounding (/unit) is only valid on now.

One operator, many values

title icontains(any) ('react', 'vue', 'angular')
title icontains(all) ('react', 'hooks')
slug ends_with(any) ('-v1', '-v2', '-v3')

(any) matches at least one; (all) requires all. The modifier works across the comparison and pattern operators (gt(any), startsWith(all), …); the WhereInput form is { title: { startsWith: { $value: ["API", "SDK"], $modifier: "any" } } }.

Aggregate filters — HAVING for free

Aggregate expressions in a filter compile to HAVING on the grouped query — the same grammar, one level up:

status = 'open' AND count(*) >= 5
count(distinct assignee) > 1
avg(rating) >= 4

Functions: count(*), count(distinct col), sum, avg, min, max, stddev, variance — usable anywhere the grouped-aggregate surface accepts a filter, including plain REST ?filter=.

Your own SQL as a predicate

Tag a SETOF Postgres function with @gqlith.filterPredicate and call it inside any filter — it compiles to a semi-join on the function’s rows:

recipes_rated_at_least(4)
NOT recipes_rated_at_least(4)
in_season() AND tags contains ('vegan')

Named parameters

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

Parameters are passed alongside the query at execution time — never interpolated into it.

Sorting

status = 'published' ORDER BY createdAt DESC
ORDER BY priority ASC, createdAt DESC
ORDER BY rating DESC NULLS LAST

Sort keys are single camelCase columns with optional NULLS FIRST / NULLS LAST. On REST, the dedicated ?sort= parameter does the same job — ?sort=-createdAt,status.