Filter language
This is the exhaustive grammar behind crates/filter,
the one parser/compiler that backs the filter query parameter, every
API rule (listRule/viewRule/createRule/updateRule/deleteRule),
and createRule’s submitted-payload check. It parses strings like
status = "active" && (author = @request.auth.id || tags.name ?= "public")into an expression tree, then compiles that tree into a parameterized
SQL WHERE fragment for either SQLite or Postgres — it never evaluates
a filter by pulling rows into the application and checking them in Rust
(the one exception is realtime’s in-process rule check against a single
in-memory record snapshot, via the same AST).
Operators
Section titled “Operators”| Operator | Meaning | “Any of” form |
|---|---|---|
= |
Equals (case-sensitive) | ?= |
!= |
Not equals | ?!= |
> |
Greater than | ?> |
>= |
Greater than or equal | ?>= |
< |
Less than | ?< |
<= |
Less than or equal | ?<= |
~ |
LIKE (case-insensitive; wraps the pattern in %...% and escapes \, %, _, emitting ESCAPE '\') |
?~ |
!~ |
NOT LIKE | ?!~ |
Boolean combinators are && and ||, with (/) for grouping. An
operand that already contains a literal % is treated as a hand-written
LIKE pattern and used verbatim rather than re-escaped.
null and "" (empty string) are the same “empty” value on both sides
of a comparison; != also matches SQL NULL columns, matching
PocketBase’s own semantics rather than three-valued SQL logic.
The ? “any of” prefix — read this before using it
Section titled “The ? “any of” prefix — read this before using it”The ? prefix only changes anything on a joined identifier: a
relation, a back-relation, or @collection.name.path. On those, the
bare operator means every joined row must satisfy the condition, and
the ?-prefixed form means at least one does — which matters most on
a permissive API rule, where “any” is usually what you want:
comments_via_post.text = "nice" requires every joined comment to equal
"nice"; comments_via_post.text ?= "nice" finds a post with at
least one matching comment.
On a plain multi-value column (a multi-select or multi-relation
field compared bare, without :each), the ? prefix changes nothing:
the column is compared as its raw JSON array text, so tags = "rust"
and tags ?= "rust" both fail to match ["go", "rust"]. Unpack the
array first with the :each modifier — tags:each ?= "rust" is the
working “contains” form; tags:each = "rust" means every element
equals "rust" (true only for a single-element array).
Identifiers and modifiers
Section titled “Identifiers and modifiers”An identifier is a dotted field path, optionally through relations
(author.name), back-relations (comments_via_post.title, the
reverse direction of a relation field), JSON paths (data.some.key),
or geo coordinates (loc.lat, loc.lon). Any identifier can carry one
modifier:
| Modifier | Effect |
|---|---|
:isset |
True if the path resolves to a non-null value at all — the only way to test presence independent of a value comparison. |
:length |
Compares the element count of a multi-value field instead of its contents (tags:length > 0). Ignored (not rejected) on a single-valued path, matching PocketBase. |
:each |
Unpacks a multi-value field so the comparison applies per-element instead of to the raw JSON text — required to make ?=-style “contains” queries work on a plain array column. |
:lower |
Lower-cases both sides before comparing, for a case-insensitive =/!= (~ is already case-insensitive). |
Back-relations and @collection.<name> references compile to LEFT JOINs shared by every reference to the same collection within one
expression, so several conditions can constrain the same joined row —
this is what makes the all/any distinction above meaningful rather than
incidental.
Macros
Section titled “Macros”Date/time: @now, @second, @minute, @hour, @weekday, @day,
@month, @year, @yesterday, @tomorrow, @todayStart, @todayEnd,
@monthStart, @monthEnd, @yearStart, @yearEnd.
Request context, available inside any rule (not the plain filter query
param, which has no request to read):
| Macro | Resolves to |
|---|---|
@request.auth[.path] |
The authenticated record — bare, its id; .path reaches any of its own fields, e.g. @request.auth.role. |
@request.body.path (alias @request.data.path) |
A field from the submitted create/update payload. |
@request.query.path |
A query-string parameter on the current request. |
@request.headers.name |
A request header, by name. |
@request.method |
The HTTP method of the current request. |
@request.context |
Which surface triggered the rule check (e.g. distinguishing a plain API call from one made through a batch request). |
@collection.name.path |
A correlated reference into another collection, joined the same way a back-relation is — the mechanism Teams uses to scope a collection by membership. |
Functions
Section titled “Functions”geoDistance(lonA, latA, lonB, latB) — great-circle-ish distance between
two coordinates, usable against a literal or a geoPoint field’s
.lon/.lat paths, e.g.
geoDistance(loc.lon, loc.lat, -122.42, 37.77) < 50000.
Semantics worth knowing before writing a rule
Section titled “Semantics worth knowing before writing a rule”- A failing
listRuleyields an empty list, never an error. A failingviewRule/updateRule/deleteRuleis a404(the record looks like it doesn’t exist); a failingcreateRuleis a400; anullrule (superuser-only) is a403. See Error responses. createRulehas no row yet to attach aWHEREto — it’s evaluated as aFROM-lessSELECT 1 WHERE <expr>against the submitted payload instead of a second, JSON-only evaluator, so the exact same compiler and grammar apply to a rule that runs before the record exists.- A back-relation or
@collection.Xcomparison against a record with no joined rows at all compares as""— sox_via_y.id = ""finds those records, butx_via_y:length = 0does not, since:lengthonly applies to an in-hand multi-value field, not a joined set. - Parsed rules are cached in a bounded LRU (
parse_cached), so a frequently-hit rule string is only parsed once per process, not once per request.
Not supported, by design
Section titled “Not supported, by design”A general-purpose scripting layer. The grammar above is intentionally small enough to read and audit in a single rule-string field on a collection editor — logic that doesn’t fit in one filter expression belongs in a JS hook or a Rust plugin, not in a bigger rule language.