Skip to main content

RSQL

RSQL is the single expression language Covara uses everywhere it needs a predicate: the ?filter= query parameter, authorization scopes, subscription matching, aggregations, and batch operations. One string is compiled to Drizzle SQL for the database and executed in-memory for live subscription matching, so a filter behaves identically as a query and as a subscription.

GET /api/users?filter=status=="active";age>18
// the same predicate, built safely in TypeScript:
rsql`status==${"active"};age>${18}`

This page is the full reference: the language, the operators, the TypeScript builder, and its two uses — query filters and scopes.

The language

Comparison

field==value field!=value
field>value field>=value
field<value field<=value

Symbolic and named operators are equivalent:

SymbolicNamedMeaning
>=gt=greater than
>==ge=greater than or equal
<=lt=less than
<==le=less than or equal

Combinators & grouping

CombinatorMeaningForms
ANDboth hold; · && · and
OReither holds, · || · or
Groupprecedence( … )
status=="active";age>18
role=="admin",role=="moderator"
(status=="active";age>18),(role=="admin")

Sets, ranges, strings, null, booleans

field=in=(a,b,c) field=out=(a,b,c) # set membership
field=between=[18,65] field=nbetween=[0,17] # range (inclusive); [..] or (..)
field%="pat" field!%="pat" field=ilike="pat" # LIKE / NOT LIKE / case-insensitive
field=contains="t" field=startswith="t" field=endswith="t" # (+ i-prefixed variants)
field=regex="^a" field=iregex="^a" # regex (in-memory uses JS RegExp)
field=length=N field=minlength=N field=maxlength=N
field=isnull=true field=isempty=true # null / null-or-empty checks
field==true field==false # matches true/1/"true" and false/0/"false"

% matches any sequence, _ a single character. ISO-8601 date strings are auto-detected and parsed (createdAt>"2024-01-15").

Value types & escaping

name=="John Doe" # strings (quoted)
age==25 price==19.99 # numbers (unquoted)
active==true # booleans
deletedAt==null # null
tags=in=("tech","news") # sets
name=="John \"Johnny\" Doe" # escape quotes inside strings
path=="C:\\Users\\John" # escape backslashes

When you build RSQL in code, the builder handles all of this escaping for you.

Operator reference

OperatorDescriptionExample
==Equalsstatus=="active"
!=Not equalsstatus!="deleted"
>, =gt=Greater thanage>18
>=, =ge=Greater than or equalage>=18
<, =lt=Less thanage<65
<=, =le=Less than or equalage<=65
=in=In listrole=in=("a","b")
=out=Not in listrole=out=("x","y")
%=LIKEname%="%john%"
!%=NOT LIKEname!%="%test%"
=ilike=Case-insensitive LIKEname=ilike="%john%"
=nilike=Case-insensitive NOT LIKEname=nilike="%test%"
=contains=Contains substringname=contains="john"
=icontains=Case-insensitive containsname=icontains="john"
=startswith=Starts withname=startswith="Dr."
=istartswith=Case-insensitive starts withname=istartswith="dr."
=endswith=Ends withemail=endswith=".com"
=iendswith=Case-insensitive ends withemail=iendswith=".COM"
=ieq=Case-insensitive equalsstatus=ieq="ACTIVE"
=ine=Case-insensitive not equalsstatus=ine="deleted"
=isnull=Is nulldeletedAt=isnull=true
=isempty=Null or empty stringbio=isempty=true
=between=In range (inclusive)age=between=[18,65]
=nbetween=Not in rangeage=nbetween=[0,17]
=regex=Regex matchemail=regex="^admin@"
=iregex=Case-insensitive regexname=iregex="^john"
=length=Exact string lengthcode=length=6
=minlength=Minimum string lengthpassword=minlength=8
=maxlength=Maximum string lengthusername=maxlength=20

Building RSQL in TypeScript

Instead of concatenating strings, build expressions with the rsql tag and helpers (exported from covara and covara/auth). Every value is escaped, so dynamic input can't break out of the expression or inject operators. Each builder returns a CompiledScope.

The rsql tag

Static text is the expression; interpolated values are escaped by type.

import { rsql } from "covara";

rsql`authorId==${user.id}`; // authorId=="user-123"
rsql`age=ge=${18};role=in=${roles}`; // age=ge=18;role=in=("admin","user")
rsql`name=contains=${query}`; // any operator works — it's the full language
Interpolated valueBecomes
stringquoted, with " ' \ escaped
number / booleanraw
Datequoted ISO 8601
arrayparenthesized list — ("a","b")
null / undefinednull
CompiledScopeparenthesized sub-expression — see composition
Values are escaped; field names and operators are not

Only interpolated values are escaped. The static template text — field names and operators — is trusted code. Never build a template's field names/operators from untrusted input.

Comparison helpers

For computed fields/operators, the helpers read better than a template:

HelperEmitsHelperEmits
eq(f, v)f==vinList(f, vs)f=in=(…)
ne(f, v)f!=vnotIn(f, vs)f=out=(…)
gt / gtef=gt=v / f=ge=vlike(f, p)f%=p
lt / ltef=lt=v / f=le=vnotLike(f, p)f!%=p
isNull(f)f=isnull=trueisNotNull(f)f=isnull=false
import { eq, gte, inList, like } from "covara";

eq("status", "active"); // status=="active"
inList("role", ["admin", "editor"]); // role=in=("admin","editor")
like("email", "%@acme.com"); // email%="%@acme.com"
note
No NOT combinator

The grammar has no NOT, so there's no not() helper — use the negated operators: ne, notIn, notLike, isNotNull.

Combinators

import { and, or, eq, gt } from "covara";

and(eq("status", "active"), gt("age", 18)); // (status=="active");(age=gt=18)
or(eq("role", "admin"), eq("role", "owner")); // (role=="admin"),(role=="owner")

eq("status", "active").and(gt("age", 18)); // fluent form on any CompiledScope

and/or drop empty scopes, so conditional pieces compose cleanly:

and(eq("orgId", orgId), isAdmin ? emptyScope() : eq("ownerId", userId));
// admins → orgId=="…"; everyone else → (orgId=="…");(ownerId=="…")

Composing expressions

A CompiledScope interpolated into a template embeds as a parenthesized sub-expression (not a quoted string), so expressions compose — including nested rsql calls:

const mine = eq("authorId", user.id);
rsql`published==${true};${mine}`; // published==true;(authorId=="user-123")
rsql`status==${"active"};${rsql`tier=in=${["pro"]}`}`; // status=="active";(tier=in=("pro"))

rsql`${a};${b}` is exactly and(a, b).

Empty scopes in templates

and/or skip empties, but a raw template can't — rsql`a==1;${emptyScope()}` yields the dangling a==1;(). Use the combinators when a piece may be empty.

Special expressions

BuilderExpressionMeaning
allScope()*match everything (no restriction)
emptyScope()(empty)match nothing (deny)

Utilities & the CompiledScope type

import { scopeFromString, isCompiledScope } from "covara";

scopeFromString('status=="active"'); // wrap a raw RSQL string
isCompiledScope(value); // runtime type guard
interface CompiledScope {
toString(): string; // the RSQL expression
isEmpty(): boolean;
and(other: CompiledScope): CompiledScope;
or(other: CompiledScope): CompiledScope;
}

As a query filter

The most common use: filtering reads. Send a filter string on any list/count/aggregate/search endpoint; subscriptions apply it to the live stream too.

HTTP

GET /api/users?filter=status=="active";age>18

Client — pass a string, or build it with the query builder (which escapes for you):

import { rsql } from "covara";

client.resources.users.filter('status=="active";age>18').list();
client.resources.users.filter(rsql`status==${status};age>${minAge}`).list();

useLiveList("/api/users", { filter: 'status=="active"' }); // React

Restrict which columns may be filtered with fields.filterable — a filter on a non-allowed column returns 400 FilterParseError.

In authorization scopes

A scope is just an RSQL expression returned per operation; the framework combines it with the request filter so a user can only ever see/act on rows their scope allows. Build scopes with the same rsql tag and helpers:

useResource(posts, {
db, id: posts.id,
auth: {
read: async (user) =>
user?.role === "admin" ? allScope() : rsql`authorId==${user?.id};published==${true}`,
update: async (user) => (user ? eq("authorId", user.id) : emptyScope()),
},
});

combineScopes(scope, filter?) merges a scope with an incoming ?filter= (the basis of secure queries) — * adds no restriction, an empty scope denies:

import { combineScopes } from "covara";
combineScopes(eq("ownerId", user.id), 'status=="archived"');
// (ownerId=="user-123");(status=="archived")

See Authorization scopes for how scopes are resolved and enforced (including the higher-level scopePatterns).

Custom operators

Define operators with a SQL convert (for queries) and a JS execute (for subscription matching) so query and live behavior stay consistent.

import { sql } from "drizzle-orm";

useResource(usersTable, {
id: usersTable.id,
db,
customOperators: {
"=jsoncontains=": {
convert: (lhs, rhs) => sql`JSON_CONTAINS(${lhs}, ${rhs})`,
execute: (lhs, rhs) => JSON.parse(String(lhs)).includes(rhs),
},
},
});
GET /api/users?filter=permissions=jsoncontains="write"

Best practices

  • Always interpolate dynamic values through the rsql tag or helpers — never string-concatenate user input. Escaping is what makes filters and scopes injection-safe.
  • Template for readability, helpers for dynamicsrsql`ownerId==${id}` for fixed shapes; eq/and/or when fields/operators/membership are computed.
  • allScope() / emptyScope() for the extremes — grant unrestricted access or deny, instead of inventing always-true/false expressions.
  • Keep query and subscription behavior identical — when adding a custom operator, always provide both convert and execute.