Codecs
What each built-in format codec does with every feature.
Every codec lives at @qnot/core/formats/<name> and works with encode(codec, query, fields),
decode(codec, value, fields) and (where noted) encodeCases(codec, cases, fields).
Export codecs (sql, mongo, jsonlogic, spel, elasticsearch) skip incomplete rules (a value
still missing), as compile() does. A rule with a present but invalid value (wrong type, out of
range, unknown field) and a valid rule the target can't express both throw by default, so an
export never silently matches more than the query (text renders what it can and never throws). Examples: a function with no
format for the codec, array operators on ANSI SQL, array groups outside Postgres, counts in
Elasticsearch. The error names the rule's path, field, operator and the reason, e.g.
qnot/sql: can't express the rule at [1] (field "tags", operator "hasAny"): the ansi dialect has no array operators (use postgres, mysql, sqlite or mssql).
UI-state codecs (rqb, raqb, jqqb) are faithful: they keep incomplete rules so they can serve as
a store format. When the target library has no equivalent of something, they throw the same kind
of path-naming error instead of dropping it.
Options
Each codec has a factory with options; the ready-made instance (sql, mongo, …) uses the defaults.
| Option | Codecs | Values | Default |
|---|---|---|---|
unsupported | sqlFormat, mongoFormat, jsonlogicFormat, spelFormat, elasticsearchFormat, textFormat | 'throw' / 'skip' (drop the rule, widening the query) | 'throw' |
emptyQuery | sql, mongo, jsonlogic, spel, elasticsearch | 'all': 1 = 1 / {} / true / '' / match_all<br>'none': 1 = 0 / { _id: { $exists: false } } / false / 'false' / match_none<br>'throw' | 'all' |
dialect, params, quoteIdentifiers, caseInsensitive | sqlFormat | see below | ansi, inline, off, off |
labels, words | textFormat | field/option labels or raw keys; AND/OR/NOT words | labels on |
emptyQueryalso applies whenunsupported: 'skip'dropped every rule.- Cases: a case whose condition can't be expressed always throws, even with
unsupported: 'skip'; it never becomesWHEN 1 = 1. textrenders every valid rule (functions without a text format fall back tofn(args)), so itsunsupportedoption never triggers.
Build schemas with defineFields and extend them with extendFields(base, more, options).
Spreading ({ ...fields }) drops the registry of functions, custom operators and messages, so
those rules then look unknown. In development, createQueryStore warns once per schema when that
happens.
Fields render under their column when one is set (SQL column, Mongo path, JsonLogic var,
Elasticsearch field). Struct fields are dotted paths. SQL quotes each segment when
quoteIdentifiers is on.
Feature matrix
| sql | mongo | jsonlogic | spel | text | elasticsearch | raqb | rqb | jqqb | |
|---|---|---|---|---|---|---|---|---|---|
| decode | ✅ (WHERE parser) | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | ✅ |
| functions (left / right) | ✅ via formats.sql | ✅ $expr | ✅ | ✅ | ✅ | right only (date math) | ✅ BasicFuncs mapping | throws | throws |
| custom operators | formats.sql | formats.mongo | formats.jsonlogic | formats.spel | formats.text | formats.elasticsearch | by id | by id | by id |
| isEmpty / isNotEmpty | ✅ | ✅ | ✅ ! / !! | ✅ <= '' / > '' | ✅ | ✅ | ✅ is_empty | by id | ✅ is_empty |
| multiEnum hasAny/All/None, setEq/Neq | pg arrays, mysql JSON, sqlite/mssql json_each; ansi unsupported | ✅ | ✅ | ✅ | ✅ | all except setEq/setNeq | ✅ (hasAll by id) | by id | by id |
| array groups some/all/none | Postgres jsonb only | ✅ $elemMatch | ✅ | ✅ | ✅ | ✅ nested | ✅ rule_group | throws | throws |
| array group counts | Postgres jsonb only | ✅ $expr + $filter | ✅ reduce | ✅ .size() | ✅ | unsupported | ✅ | throws | throws |
locked | — | — | — | — | — | — | ✅ isLocked | dropped | dropped |
cases (encodeCases) | CASE WHEN | $switch | if | ternary | sentence | — | — | — | — |
Per codec
sql (sqlFormat({ dialect, params, quoteIdentifiers, caseInsensitive }))
- isEmpty:
(c IS NULL OR c = '')for text and enum,IS NULLfor other scalars. Arrays use cardinality /JSON_LENGTH/json_array_length/jsonb_array_length; MSSQL uses anOPENJSONcount. - multiEnum on Postgres: native arrays:
&&(hasAny),@>(hasAll),NOT (… && …)(hasNone),@> AND <@(setEq). Inside array elements it uses jsonb?|/?&, which only work for string members. - multiEnum on other dialects: MySQL uses
JSON_OVERLAPS/JSON_CONTAINS. SQLite usesjson_eachand MSSQLOPENJSONsubqueries. ANSI can't express them. - Array groups: Postgres only, and the column must be jsonb. Output is
EXISTS (SELECT 1 FROM jsonb_array_elements(col) AS e1 WHERE …), with element fields read ase1->>'key'and cast per kind (::numeric,::boolean,::date,::timestamptz,::time).allisNOT EXISTS (… WHERE NOT COALESCE(cond, FALSE)); counts use(SELECT COUNT(*) …) op n. Other dialects throw (or skip withunsupported: 'skip'). - Literals are written so they mean the same under every server setting: Postgres uses
E'…'when a value has a backslash, MySQL a hex literal_utf8mb4 X'…', SQL ServerN'…'. Only JSON scalars are written; NUL is refused. - LIKE escapes
%,_and!(plus[/]on SQL Server) with!and always addsESCAPE '!'. - Parameters: with
params, function arguments, custom operator values and case results are placeholders too; placeholders are numbered in text order, so formats may reorder or repeat arguments. - Identifiers: without
quoteIdentifiers, column names must be plain identifiers (letters, digits,_,$), otherwise encoding throws. - Dates and times are string literals; the database casts them.
- Decode parses inlined SQL (not parameterized):
- Supported: comparisons,
LIKE/ILIKEpatterns (%x%,x%,%x, with an ESCAPE char), the literal forms the encoder writes (N'…',E'…',X'…'),IN,BETWEEN,IS [NOT] NULL,AND/OR/NOT, parentheses, quoted identifiers, and columns mapped back to fields. - Functions: registered ones whose sql format is
NAME(args)or a constant such asCURRENT_TIMESTAMP. - Emptiness: the encoder's
(c IS NULL OR c = '')and array-length checks come back as isEmpty/isNotEmpty. - Rejected:
_wildcards, inner%,INTERVALarithmetic, subqueries (so no array groups and no sqlite/mssql multiEnum), and always-false queries. - Single-child groups collapse on export, so they come back flattened.
- Limits: 1,000,000 characters and 200 levels of nesting; errors quote at most 120 characters.
- Supported: comparisons,
mongo
- Filter form where possible.
$expris used for function calls, field refs, setEq/setNeq, counts, and array groups whose children need$expr. - Array groups: some =
$elemMatch, none =$not: { $elemMatch }, all =$not: { $elemMatch: { $nor: [cond] } }, counts =$sizeof a$filterover$ifNull: [path, []]. NOT array groups use$nor. - isEmpty:
$in: [null, ''], or[null, []]for arrays. - multiEnum:
$in(hasAny),$all(hasAll),$nin(hasNone).$ninalso matches documents where the field is missing, whichcompile()doesn't. - Cases:
$switchbuilt from the aggregation form. A custom operator with only a filter-form format can't appear in a case. - Dates and times stay ISO strings; convert them yourself if you store BSON Dates.
- Decode:
- Reads back what it emits, plus shorthand (
{ age: 21 }, regex literals). - Functions are matched by the shape of their mongo format.
- Custom operators come back as plain rules (e.g.
near→between). - Counts of
> 0/== 0decode as some/none.
- Reads back what it emits, plus shorthand (
jsonlogic
- Follows react-querybuilder and RAQB conventions: custom
startsWith/endsWithops,{"<=": [a, var, b]}for between,!/!!for isEmpty / isNotEmpty. - multiEnum:
some+in(hasAny),andofin(hasAll),!some(hasNone); setEq = hasAll plusall … in list. - Array groups:
some/noneover{var: field}.allis written asnoneof the negated condition, because json-logic'sallis false for an empty array while ours is true. Counts use{"reduce": [{"filter": …}, {"+": [1, {"var": "accumulator"}]}, 0]}, as RAQB does. - Cases:
{"if": [c1, v1, c2, v2, …, otherwise]}. - Semantics: json-logic-js compares case-sensitively, coerces null like JS, and treats
0as empty for!. - Decode:
- Also reads RAQB's
all … in(multiselect_equals, a subset test) as setEq. - Functions are matched by the shape of their jsonlogic format.
- Also reads RAQB's
spel (two-way, RAQB-compatible)
- Mirrors RAQB
spelFormat:x.compareTo(T(java.time.Local…).parse(…)) op 0for dates, times and datetimes. Datetimes areyyyy-MM-dd HH:mm:ssin UTC.{…}.?[true].contains(x)for in.T(CollectionUtils).containsAnyfor hasAny,x.equals({…})for setEq.orders.?[cond].size() op nfor array groups.x <= ''/x > ''for emptiness.
- Our own additions: hasAll as
x.containsAll({…}); cases as nested ternaries. - Decode parses
&&/||/!and theand/or/notkeywords, comparisons, method calls, selections,T(…)types and inline lists. Functions decode when their spel format is a constant or${arg}.method(). Decode also accepts(x == null || x.isEmpty()).
text (textFormat({ labels, words }))
- Uses field labels, option labels and comparison symbols:
Age ≥ 21 AND (Plan is any of Pro, Team). - Array groups read
at least 2 Orders match (Status = paid). - Uses functions'
textformats; cases read as one sentence.
elasticsearch (export only)
boolmust / should (minimum_should_match: 1) / must_not, with term, terms, range, wildcard and exists queries. Text is case-insensitive (case_insensitive: true) unless the field setscaseSensitive.- Field refs become painless
scriptqueries. Function calls are supported on the value side only, through theirelasticsearchformat (date math such asnow-7d/d). - Array groups become
nestedqueries (map the field asnested): none =must_not nested, all =must_not nested(must_not cond). - Unsupported: counts, setEq/setNeq and functions on the left side.
raqb (two-way, faithful)
- Array groups become
rule_group(mode: 'array'): operatorsome/all/noneor a count operator withvalue. Children are named by full path (orders.qty), as RAQB does. - Functions map to BasicFuncs: lower↔LOWER(str), upper↔UPPER, now↔NOW, today↔TODAY, and
relativeDate↔RELATIVE_DATE(TODAY, minus/plus, val, dim). Others travel under their own name and
argument names, so define matching RAQB funcs. Left-side calls use
fieldSrc: 'func'. - multiEnum: multiselect_contains (hasAny), multiselect_not_contains (hasNone), multiselect_equals (setEq), multiselect_not_equals (setNeq). hasAll and custom operators travel under their own id.
- Values and locks:
isLocked↔locked. Times are writtenHH:mm:ss; datetimes areYYYY-MM-DD HH:mm:ssin UTC and decode back with aZ.
rqb, jqqb (two-way, faithful)
- Operators these libraries lack (isEmpty in rqb, the multiEnum ones, custom operators) travel under their qnot id. Register matching custom operators in those libraries to edit them there.
- Function calls and array groups throw, since neither library can represent them.
lockedisn't mapped.- jqqb carries field refs in
data.qnotRef.
Security
The sql codec is audited and fuzzed against Postgres, MySQL, SQL Server and SQLite for injection; see SECURITY.md for the threat model, guarantees and results. The other codecs build data structures (Mongo filters, JsonLogic, Elasticsearch DSL), not SQL text.
Known gaps
- Array groups aren't memoized.
encode()caches results per node, but it only knows the top-level schema. So codecs encode array-group subtrees themselves (in the element scope), and those subtrees are re-encoded whenever the array group itself is re-encoded. Passing scope through the generic encoder (actx.scopehook) would let them share the cache.