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
WhereInputobject —{ status: { eq: 'published' } }. - REST uses FiltrQL, a human-readable text DSL —
status = 'published'. - MCP agents pass FiltrQL as the
filterqlargument insidegraphql_querydocuments, withfilter_helpas 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
| FiltrQL | WhereInput | Case | Meaning |
|---|---|---|---|
like | like | sensitive | SQL wildcard (%, _) |
ilike | ilike | insensitive | SQL wildcard |
icontains | icontains | insensitive | Contains substring |
starts_with | startsWith | sensitive | Starts with |
istarts_with | iStartsWith | insensitive | Starts with |
ends_with | endsWith | sensitive | Ends with |
iends_with | iEndsWith | insensitive | Ends with |
regex / ~ | regex | sensitive | Regular expression |
iregex / ~* | iregex | insensitive | Regular expression |
fts | fts | — | Full-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.