# Evaluating queries in JavaScript (/docs/concepts/evaluation) Compile a query into a predicate and filter rows in the browser or on the server. `compile` turns a query into a plain function once; call it on as many rows as you like. ```ts import { compile } from '@qnot/core'; const matches = compile(fields, query); const result = users.filter(matches); ``` The row type defaults to `RowOf`. Struct fields read nested objects (`address.city` reads `row.address.city`), and array groups iterate the row's array. To read rows of another shape, pass `get`: ```ts const matches = compile(fields, query, { get: (user, field) => (field === 'plan' ? user.subscription.plan : user[field as keyof User]), }); ``` `matches(fields, query, row)` compiles and tests a single row in one call. ## Semantics [#semantics] - **Incomplete rules are skipped** by default, as the export codecs do, so a half-built query still filters; `incompleteRules: false` makes them fail closed instead. - **Invalid rules match nothing** by default (a present value of the wrong type, out of range, or on an unknown field), so an untrusted query can never widen; `invalidRules: 'skip'` leaves them out. An empty query matches everything. - **Text compares case-insensitively** unless the field sets `caseSensitive: true`. - **Dates and date-times** compare as instants; times compare as `HH:mm[:ss]` strings. - **Null and undefined** fail every comparison except `isNull` and `isEmpty`; `notIn` and `notBetween` don't match missing values. - **`isEmpty`** matches `null`, `undefined`, `''` and empty arrays; `isNull` only `null`/`undefined`. - **Multi-value fields:** `hasAny`, `hasAll`, `hasNone`, `setEq`, `setNeq` compare the row's array against the rule's list. - **Array groups** quantify over the array: `some`, `all` (true for an empty array), `none`, or a count (`{ count: 'gte', value: 3 }`, or `between`). - **Functions** run their `evaluate`; [custom operators](/docs/guides/operators-and-functions) run theirs. ## Switch cases [#switch-cases] `compileCases` compiles an ordered list of conditions with results; the first match wins. ```ts import { compileCases } from '@qnot/core'; const route = compileCases(fields, { cases: [ { when: vipQuery, then: 'concierge' }, { when: emeaQuery, then: 'emea-team' }, ], otherwise: 'self-serve', }); route(user); // { value: 'emea-team', index: 1 } ``` See [Routing and switch cases](/docs/guides/routing-and-cases). # Formats (/docs/concepts/formats) Export a query to SQL, MongoDB, JsonLogic, Elasticsearch, SpEL or text, and read other libraries' JSON both ways. A codec converts between qnot's query JSON and another representation. Each lives at its own subpath, so you only bundle the ones you import. ```ts import { encode, decode } from '@qnot/core'; import { sqlFormat } from '@qnot/core/formats/sql'; const sql = encode(sqlFormat({ dialect: 'postgres' }), query, fields); const back = decode(sqlFormat({ dialect: 'postgres' }), "age >= 21 AND plan IN ('pro', 'team')", fields); ``` | Codec | Import | Direction | | --- | --- | --- | | SQL (Postgres, MySQL, SQLite, SQL Server, ANSI) | `sql`, `sqlFormat(options)` from `formats/sql` | export, and import of inlined `WHERE` clauses | | MongoDB | `mongo` from `formats/mongo` | two-way | | JsonLogic | `jsonlogic` from `formats/jsonlogic` | two-way | | SpEL (RAQB-compatible) | `spel` from `formats/spel` | two-way | | Elasticsearch | `elasticsearch` from `formats/elasticsearch` | export | | Plain English | `text`, `textFormat(options)` from `formats/text` | export | | react-querybuilder JSON | `rqb` from `formats/rqb` | two-way | | react-awesome-query-builder JSON | `raqb` from `formats/raqb` | two-way | | jQuery QueryBuilder JSON | `jqqb` from `formats/jqqb` | two-way | ## SQL with parameters [#sql-with-parameters] Set `params` to get placeholders and a separate parameter list; the return type follows the option. ```ts const pg = sqlFormat({ dialect: 'postgres', params: 'numbered' }); const { sql, params } = encode(pg, query, fields); // "age >= $1 AND plan IN ($2, $3)", [21, 'pro', 'team'] ``` `params` can be `'positional'` (`?`), `'numbered'` (`$1`) or `'named'` (`:p1`, with an object of params). Strings are escaped, LIKE wildcards in user input are escaped with an `ESCAPE` clause, and each field renders as its `column` when it has one. `quoteIdentifiers` and `caseInsensitive` are options too. ## Two kinds of codec [#two-kinds-of-codec] **Export codecs** (sql, mongo, jsonlogic, spel, elasticsearch, text) skip rules that are incomplete (a value still missing), the same way `compile()` does. A *valid* rule the target can't express (a function with no format for that target, array operators on ANSI SQL, array groups outside Postgres) **throws** by default, naming the rule's path, field, operator and the reason, so an export never silently matches more than the query: ```text 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) ``` Each codec has a factory with options; the ready-made instance (`sql`, `mongo`, …) uses the defaults. | Option | Values | Default | | --- | --- | --- | | `unsupported` | `'throw'`, or `'skip'` to drop the rule (widening the query) | `'throw'` | | `emptyQuery` | `'all'` (`1 = 1`, `{}`, `true`…), `'none'` (`1 = 0`, `false`…), or `'throw'` | `'all'` | ```ts const strict = sqlFormat({ dialect: 'postgres', params: 'numbered', emptyQuery: 'throw' }); ``` **UI-state codecs** (rqb, raqb, jqqb) are faithful: they keep incomplete rules, so they can serve as a store's `format`. When the other library can't represent something (array groups and function calls in react-querybuilder, for example), they throw the same kind of error instead of dropping it. ## Memoized per node [#memoized-per-node] `encode` caches each node's output by the node object. Because the store's JSON is structurally shared, re-encoding after an edit only redoes the edited rule and its ancestors, even for 1,000-rule queries. ## Drop-in format mode [#drop-in-format-mode] Give a store a two-way codec as `format` and its `value`/`onChange` speak that shape. Saved queries keep working while the library underneath changes. See [Migrating from react-querybuilder](/docs/guides/migrate-react-querybuilder) and [from RAQB](/docs/guides/migrate-raqb). ## Switch cases [#switch-cases] Codecs that support it export a whole [switch](/docs/guides/routing-and-cases) with `encodeCases`: SQL `CASE WHEN`, JsonLogic `if`, MongoDB `$switch`, SpEL ternaries, and a sentence in text. ## Writing your own [#writing-your-own] A codec is an object with `encodeRule`, `encodeGroup`, an optional `finish`, and `decode` for two-way formats: ```ts import type { Codec } from '@qnot/core'; const prefix: Codec = { name: 'prefix', encodeRule: (rule) => `(${rule.operator} ${String(rule.field)} ${JSON.stringify(rule.value)})`, encodeGroup: (group, children) => (children.length ? `(${group.combinator} ${children.join(' ')})` : undefined), }; ``` Return `undefined` from either to leave that node out. The [codec reference](/docs/reference/codecs) lists what each built-in codec does with every feature. # Schema and types (/docs/concepts/schema) Fields describe what can be queried. The query's types follow from them. A schema is an object of fields built with `f`, passed through `defineFields`. `defineFields` pins literal types (so `'free' | 'pro'` stays a union, not `string`), flattens struct fields into dotted paths, and attaches custom operators, functions and messages to the field set. ```ts twoslash import { defineFields, f } from '@qnot/core'; export const fields = defineFields({ name: f.text({ minLength: 2 }), age: f.number({ min: 0, max: 130, integer: true }), plan: f.enum(['free', 'pro', 'team']), tags: f.multiEnum(['vip', 'beta', 'churned']), verified: f.boolean(), signedUp: f.date({ label: 'Signed up' }), opensAt: f.time(), address: f.struct({ city: f.text(), zip: f.text({ column: 'postal_code' }) }), orders: f.array({ product: f.text(), qty: f.number() }), }); ``` ## Field kinds [#field-kinds] | Builder | Value type | Default operators | | --- | --- | --- | | `f.text()` | `string` | `eq neq contains notContains startsWith endsWith in notIn isEmpty isNotEmpty isNull isNotNull` | | `f.number()` | `number` | `eq neq lt lte gt gte between notBetween in notIn isNull isNotNull` | | `f.boolean()` | `boolean` | `eq neq isNull isNotNull` | | `f.date()` | ISO date string | `eq neq lt lte gt gte between notBetween isNull isNotNull` | | `f.time()` | `HH:mm[:ss]` string | same as date | | `f.datetime()` | ISO date-time string | same as date | | `f.enum(values)` | one of `values` | `eq neq in notIn isNull isNotNull` | | `f.multiEnum(values)` | an array of `values` | `hasAny hasAll hasNone setEq setNeq isEmpty isNotEmpty isNull isNotNull` | | `f.struct(fields)` | (flattened) | becomes `address.city`, `address.zip`… | | `f.array(fields)` | a list of records | `isEmpty isNotEmpty isNull isNotNull`; query its items with [array groups](/docs/guides/arrays-and-structs) | | `f.custom({ kind, operators })` | your type | your operators | Every builder takes common options: `label`, `description`, `column` (the name exporters use), `operators` (restrict and order), `excludeOperators`, `extraOperators` (custom ones), `defaultOperator`, `defaultValue`, `required` (default `true`), `hidden` (not offered in pickers), `comparable` (can be compared against, default `true`), `validate`, and UI hints (`widget`, `placeholder`, `prefix`, `suffix`, `meta`). See [Field builders](/docs/reference/fields). Enum options can be plain values or `{ value, label }` objects, and can nest with `children` for a tree select. `leafOnly: true` makes parents unselectable: ```ts f.enum([ { value: 'emea', label: 'EMEA', children: [{ value: 'de', label: 'Germany' }, { value: 'fr', label: 'France' }] }, { value: 'amer', label: 'Americas', children: [{ value: 'us', label: 'United States' }] }, ], { leafOnly: true }); ``` ## The query shape [#the-query-shape] A query is a tree of groups and rules. It's plain JSON, so you can store it anywhere. ```ts { kind: 'group', combinator: 'and', // or 'or' not: true, // optional: negate the group children: [ { kind: 'rule', field: 'age', operator: 'gte', value: 21 }, { kind: 'rule', field: 'age', operator: 'gt', ref: 'minAge' }, // compare with another field { kind: 'rule', field: 'signedUp', operator: 'gt', call: { fn: 'relativeDate', args: { amount: { value: 7 }, unit: { value: 'day' }, direction: { value: 'ago' } } } }, { kind: 'group', combinator: 'or', children: [ /* … */ ] }, ], } ``` A rule's right side is exactly one of `value` (a literal), `ref` (another field) or `call` (a [function](/docs/guides/operators-and-functions)). Its shape follows the operator: nothing for `isNull`, one value for `eq`, a pair for `between`, a list for `in`. ## Types [#types] `QueryOf` is the query typed against your schema. Rules are a discriminated union over every field and operator, so checking `field` and `operator` narrows `value`: ```ts twoslash import { defineFields, f, isLiteralRule, type RuleOf } from '@qnot/core'; const fields = defineFields({ age: f.number(), plan: f.enum(['free', 'pro']) }); // ---cut--- declare const rule: RuleOf; if (rule.field === 'age' && rule.operator === 'between' && isLiteralRule(rule)) { rule.value; // ^? } ``` `isLiteralRule` rules out field refs and function calls, which carry no `value`. Mistakes are compile errors: ```ts twoslash // @errors: 2322 import { defineFields, f, type RuleOf } from '@qnot/core'; const fields = defineFields({ age: f.number(), plan: f.enum(['free', 'pro']) }); // ---cut--- const bad: RuleOf = { kind: 'rule', field: 'plan', operator: 'eq', value: 'gold' }; ``` ### Drafts and strict queries [#drafts-and-strict-queries] While someone is typing, values can be missing, so `QueryOf` allows `null` in value slots. Once a query passes validation it can be narrowed to `StrictQueryOf`, where they can't: ```ts twoslash import { defineFields, f, isValidQuery, isLiteralRule, type QueryOf } from '@qnot/core'; const fields = defineFields({ age: f.number() }); declare const query: QueryOf; // ---cut--- if (isValidQuery(fields, query)) { const first = query.children[0]; if (first?.kind === 'rule' && first.operator === 'eq' && isLiteralRule(first)) { first.value; // ^? } } ``` ### Rows [#rows] `RowOf` is the record shape a query is evaluated against. Struct paths become nested objects and array fields become lists of their element rows. `compile()` uses it for its argument type. | Type | Meaning | | --- | --- | | `QueryOf` | A draft query: values may be `null`. | | `StrictQueryOf` | A validated query: no missing values. | | `RuleOf` / `NodeOf` | One rule / one rule or group. | | `RowOf` | The row a query is evaluated against. | | `ValueOf` / `OperatorsOf` | A field definition's value type / operators. | | `AnyFieldName` | Root field names plus array element fields. | | `Query`, `Rule`, `Group` | The untyped wire shapes. | # The store (/docs/concepts/store) A normalized tree with per-node subscriptions, which is why edits stay fast. Every builder, in every framework, sits on a `QueryStore` from `@qnot/core`. It owns the query, applies edits, validates, keeps history, and tells exactly the right parts of the UI to update. ```ts import { createQueryStore } from '@qnot/core'; const store = createQueryStore({ fields, initial: savedQuery }); ``` ## Nodes and ids [#nodes-and-ids] The store doesn't keep the query as one nested object. It keeps a flat map from node id to node, where groups hold their children's ids. Ids are internal and never appear in the JSON you save. ```ts store.rootId; // the root group's id store.getGroup(store.rootId).children; // ['1', '2', '5'] store.getRule('2'); // { id: '2', parent: '0', kind: 'rule', field: 'age', … } ``` Edits go through the store by id, and each one replaces a single node object: ```ts store.setRuleValue(id, 30); store.setOperator(id, 'between'); // the value becomes [30, null] store.setField(id, 'plan'); // operator and value adapt to the new field store.addRule(groupId, { field: 'plan', operator: 'in', value: ['pro'] }); store.move(id, otherGroupId, 0); store.remove(id); ``` When the operator changes arity, the value is reshaped rather than thrown away: `30` becomes `[30, null]` for `between` and `[30]` for `in`. Changing to a field of the same kind keeps the value. [Settings](/docs/reference/settings) can make both reset instead. ## Why it's fast [#why-its-fast] Each node has its own subscribers. Editing a rule replaces that rule's object and notifies only that rule's subscribers; its siblings and parents don't re-render. A keystroke costs the same with ten rules as with a thousand. ```ts store.subscribe(id, () => console.log('this rule changed')); store.subscribe(() => console.log('anything changed')); ``` The JSON view is structurally shared: `store.getQuery()` rebuilds only the edited node and its ancestors, so unchanged subtrees keep their object identity. Format codecs memoize per node object, so exporting after an edit only re-encodes the edited branch. Permission changes (locks, read-only, limits) have their own channel, `store.subscribePermissions`, so a value edit never makes every row recompute whether it can be removed. ## Transactions and history [#transactions-and-history] Several edits can be one change: one notification, one undo step. ```ts store.batch(() => { store.setCombinator(store.rootId, 'or'); store.addRule(store.rootId); }); store.undo(); store.redo(); store.canUndo(); ``` Typing into one value merges into a single undo step (edits to the same value within a second). `store.reset(query)` loads a fresh baseline and clears history, for saved views and "discard changes". Pass `history: false` to turn history off, or `{ limit, mergeWindowMs }` to tune it. ## Controlled values and formats [#controlled-values-and-formats] `setQuery` and `setValue` load a whole query and reconcile it node by node: unchanged subtrees keep their ids (so React keeps their components), and passing back the store's own output is free. With a `format`, `getValue`/`setValue` speak that format instead of qnot's JSON. This is how the store becomes a drop-in for react-querybuilder or RAQB: ```ts import { rqb } from '@qnot/core/formats/rqb'; const store = createQueryStore({ fields, format: rqb, initial: savedRqbQuery }); store.getValue(); // react-querybuilder's RuleGroupType ``` ## Validation, permissions, settings [#validation-permissions-settings] `store.issues(id)` returns the node's validation issues, cached until the node changes, and `store.isValid()` checks the whole tree. `store.can(action, id)` answers whether the user may add, remove, move, edit or lock a node under the current [settings](/docs/guides/settings) and locks. The mutation methods check the same rules and ignore edits they refuse. See the [store reference](/docs/reference/store) for every method. # Validation and sanitizing (/docs/concepts/validation) Per-node issues from the schema, strict narrowing, and cleaning queries loaded from storage. Validation comes from the schema: `required`, `min`/`max`/`integer` on numbers, `minLength`/`maxLength`/`pattern` on text, membership in enum options, well-formed dates and times, `between` pairs in order, field refs of the same kind, known functions with valid arguments, and your own `validate` function. ```ts import { defineFields, f, validateQuery } from '@qnot/core'; const fields = defineFields({ age: f.number({ min: 0, integer: true }), email: f.text({ pattern: /^[^@\s]+@[^@\s]+$/ }), code: f.text({ validate: (v) => (v.startsWith('TN-') ? undefined : 'Codes start with TN-') }), }); validateQuery(fields, query); // [{ path: [0], code: 'integer', message: 'Must be a whole number', severity: 'error' }, …] ``` Each issue has a `code`, a `message`, a `severity` (an empty group is a `warning`, everything else an `error`), a `path` of child indexes, and for pairs and lists the `index` of the offending element. ## In the store and the UI [#in-the-store-and-the-ui] The store validates lazily per node and caches the result until that node changes: ```ts store.issues(ruleId); // readonly Issue[], same array until the rule changes store.isValid(); // no error anywhere ``` The hooks expose them as `issues` on each rule and group, so a UI can mark just the invalid row. The default builders add the `qn-invalid` class and a tooltip with the messages. ## Narrowing to strict [#narrowing-to-strict] `isValidQuery` is a type guard from `QueryOf` to `StrictQueryOf`, where values can't be `null`. `assertValidQuery` throws an error listing every problem with its path, which suits a server handler: ```ts import { assertValidQuery } from '@qnot/core'; assertValidQuery(fields, body); // throws: "qnot: invalid query:\n at [0] Must be at least 0" // body is StrictQueryOf from here on ``` ## Sanitizing stored queries [#sanitizing-stored-queries] Queries saved months ago may not fit today's schema. `sanitizeQuery` cleans one up and returns the same object when nothing needed fixing: ```ts import { sanitizeQuery } from '@qnot/core'; const clean = sanitizeQuery(fields, saved, { removeIncompleteRules: true, // rules whose only problem is a missing value removeEmptyGroups: true, // groups left with no children (not the root) removeInvalidListValues: true, // list values no longer among the options removeUnknown: true, // rules on fields or operators the schema no longer has }); ``` | Option | Default | RAQB equivalent | | --- | --- | --- | | `removeIncompleteRules` | `false` | `removeIncompleteRulesOnLoad` | | `removeInvalidRules` | `false` | none: drops any rule with an error | | `removeEmptyGroups` | `false` | `removeEmptyGroupsOnLoad` | | `removeInvalidListValues` | `false` | `removeInvalidMultiSelectValuesOnLoad` | | `removeUnknown` | `false` | none | On a server, prefer rejecting over cleaning. Dropping a tampered rule like `age >= -5` makes the query match more rows, not fewer. `removeIncompleteRules` only drops rules with missing values and keeps invalid ones so `assertValidQuery` can reject them; `removeInvalidRules` and `removeUnknown` are opt-in for that reason. A store can sanitize everything it loads (`initial`, `setQuery`, `setValue`) with the `sanitize` option of `createQueryStore`. ## Messages [#messages] Override any message per code, for wording or translation, when you define the fields: ```ts const fields = defineFields( { age: f.number({ min: 0 }) }, { messages: { required: 'Fill this in', min: (issue) => `Too small (${issue.message})` } }, ); ``` See [i18n](/docs/guides/i18n) for translating the UI's own labels. # Lists and nested fields (/docs/guides/arrays-and-structs) Struct fields become dotted paths; array fields are queried with "some / all / none / at least N" groups. ## Struct fields [#struct-fields] `f.struct` groups fields under one name. `defineFields` flattens them into dotted paths, which is what rules, exporters and `compile()` use: ```ts const fields = defineFields({ name: f.text(), address: f.struct({ city: f.text(), zip: f.text({ column: 'postal_code' }) }, { label: 'Address' }), }); Object.keys(fields); // ['name', 'address.city', 'address.zip'] ``` Field pickers group them under the struct's label (each field gets `group: 'Address'`). `compile()` reads `row.address.city`, MongoDB and JsonLogic use the dotted path, and SQL uses the `column` when there is one. ## Array fields [#array-fields] An `f.array` field holds a list of records, like a user's orders. You don't compare the list directly; you put an **array group** on it, whose rules are about one element at a time: ```ts const fields = defineFields({ name: f.text(), orders: f.array({ product: f.text(), qty: f.number(), status: f.enum(['paid', 'refunded']) }), }); const query: QueryOf = { kind: 'group', combinator: 'and', children: [ { kind: 'group', combinator: 'and', of: { field: 'orders', match: { count: 'gte', value: 3 } }, children: [{ kind: 'rule', field: 'status', operator: 'eq', value: 'paid' }], }, ], }; // "at least 3 orders where status = paid" ``` `match` is one of: | `match` | Means | | --- | --- | | `'some'` | at least one element matches | | `'all'` | every element matches (true for an empty list) | | `'none'` | no element matches | | `{ count: 'gte' \| 'gt' \| 'lte' \| 'lt' \| 'eq' \| 'neq', value: n }` | the number of matching elements compares to `n` | | `{ count: 'between', value: [lo, hi] }` | the number of matches is within a range | Inside the group, rules use the element's fields (`status`, not `orders.status`), and the types follow: `ArrayGroupOf` types the children against the element schema. In the store, `store.fieldsFor(id)` returns the scope a node sees, and `store.addArrayGroup(groupId, 'orders', 'some')` adds one. The default builders show "[some ▾] of Orders where" with a "+ List condition" button. Array fields offer `isEmpty`/`isNotEmpty` as plain rules too. ## Exporting array groups [#exporting-array-groups] | Codec | Output | | --- | --- | | SQL | Postgres `jsonb` columns: `EXISTS (SELECT 1 FROM jsonb_array_elements(col) …)`, `COUNT(*)` for counts. Other dialects throw (or skip with `unsupported: 'skip'`). | | MongoDB | `$elemMatch`; counts via `$expr` + `$size` of `$filter` | | JsonLogic | `some` / `none`, counts with `reduce` over `filter` | | Elasticsearch | `nested` queries (counts throw) | | SpEL | `orders.?[cond].size()` | | RAQB | `rule_group` | | react-querybuilder, jQuery QB | throw: they have no equivalent | # Building a custom UI (/docs/guides/custom-ui) Your own builder from the headless hooks, with coss ui as the example kit. The default builder is deliberately plain. For a product, build the builder from the hooks with the components you already have. This is how the playground's coss ui builder is made, and the same shape works for shadcn/ui, MUI or your own design system. ## The pieces [#the-pieces] You need three components: a group, a rule, and a value editor. The hooks hand each one everything it needs. ```tsx import { fieldOptions } from '@qnot/core'; import { QueryProvider, useGroup, useNode, useRule } from '@qnot/react'; import { memo } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from '@/components/ui/select'; import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; export function CossQueryBuilder({ store }: { store: QueryStore }) { return ( ); } const Group = memo(function Group({ id }: { id: string }) { const { group, can, setCombinator, addRule, addGroup } = useGroup(id); return (
v[0] && setCombinator(v[0])} disabled={!can.editGroup}> all any
{group.children.map((child) => )}
); }); function Node({ id }: { id: string }) { return useNode(id)?.kind === 'group' ? : ; } ``` The rule picks a field, an operator, and renders the editor the field and operator call for: ```tsx const Rule = memo(function Rule({ id }: { id: string }) { const { rule, def, editor, operators, fieldOptions, issues, can, setField, setOperator, setValue, remove } = useRule(id); const invalid = issues.some((i) => i.severity === 'error'); return (
{editor.type === 'text' && ( setValue(e.target.value)} /> )} {editor.type === 'select' && ( ({ value: String(o.value), label: o.label }))} value={String(rule.value ?? '')} onChange={setValue} /> )} {/* number, date, multiselect, boolean, between pairs … */}
); }); ``` ## The editor spec [#the-editor-spec] `editor` describes what to render, so your UI never has to know the operator table: | `editor.type` | Render | | --- | --- | | `text`, `textarea` | a text input | | `number`, `slider` | a number input or a slider (`def.min`, `def.max`, `def.step`) | | `date`, `time`, `datetime` | the matching picker | | `boolean` | a switch or yes/no select | | `select` | a single select of `editor.options` (indented trees use `depth`, disabled parents use `disabled`) | | `multiselect` | a multi select or chips of `editor.options` | | `list` | free-form values (comma-separated input, chips) | | `none` | nothing (`isNull`, `isEmpty`…) | `editor.arity` says how many: `'one'`, `'two'` (render two editors for `between`), `'many'`, or `'none'`. `editor.async` means options come from `def.search(query, signal)`: use a combobox that calls it as the user types. `parseInput`, `formatInput`, `parseList` and `formatList` from `@qnot/core` convert between input strings and typed values. ## Field refs and functions [#field-refs-and-functions] When `editor.allowsRef` is true the right side can be a field or a function instead of a literal. `useRule` gives you `source` (`'value' | 'field' | 'function'`), `refOptions`, `functionOptions`, `setRef` and `setCall`. `defaultCall(fields, name)` builds a call with each argument at its default. ## Keep it fast [#keep-it-fast] - Pass ids, not nodes, and wrap components in `memo`. - Don't subscribe rows to the whole query: `useQuery` re-renders on every change. - Long trees: the default stylesheet puts `content-visibility: auto` on nested groups so off-screen ones skip layout. Do the same on your group container. The playground's coss builder is a complete version with tree selects, async comboboxes, function editors, array groups, locks and tooltips. Install it into your project with [shadcn](/docs/guides/shadcn) and change whatever you like. # Translation (/docs/guides/i18n) Override UI labels, operator names and validation messages. There are three kinds of text, each overridable. ## UI labels [#ui-labels] The default builders read their strings from `DEFAULT_LABELS`. Pass `labels` with any subset: ```tsx ``` The keys: `and`, `or`, `not`, `addRule`, `addGroup`, `addArrayGroup`, `remove`, `clone`, `drag`, `lock`, `unlock`, `compareField`, `compareFunction`, `compareValue`, `compareWith`, `useField`, `useFunction`, `yes`, `no`, `empty`, `rangeSeparator`, `some`, `all`, `none`, `count`, `matchOf`, `matchWhere`, `countGte`, `countLte`, `countEq`, `countGt`, `countLt`. ## Field and option labels [#field-and-option-labels] Field labels come from `label` (or the humanized key); option labels from `{ value, label }`. Build the schema per locale, or map labels before calling `defineFields`. ## Validation messages [#validation-messages] Pass `messages` keyed by issue code to `defineFields`, as strings or functions: ```ts const fields = defineFields(schema, { messages: { required: 'Pflichtfeld', min: (issue) => issue.message.replace('Must be at least', 'Mindestens'), }, }); ``` Codes: `unknownField`, `unknownOperator`, `operatorNotAllowed`, `required`, `type`, `option`, `min`, `max`, `integer`, `minLength`, `maxLength`, `pattern`, `range`, `ref`, `function`, `emptyGroup`, `arrayField`, `custom`. Operator labels (`equals`, `is between`…) come from the operator table. For now, translate them in your own UI from `operator.value`; a `labels` hook for operators is planned. # Migrating from RAQB (/docs/guides/migrate-raqb) From react-awesome-query-builder's config and Immutable tree to a typed schema and plain JSON. Keep stored RAQB trees working with the `raqb` format: `value` / `onChange` read and write RAQB's JSON tree (`QbUtils.getTree` output), including `rule_group` array groups, functions mapped to RAQB's BasicFuncs, and locks. ```tsx import { QueryBuilder } from '@qnot/react'; import { raqb } from '@qnot/core/formats/raqb'; ; ``` There's no Immutable.js and no Redux store: the value is plain JSON. ## Config to schema [#config-to-schema] | RAQB `config` | qnot | | --- | --- | | `fields.x.type: 'text' \| 'number' \| 'boolean' \| 'date' \| 'time' \| 'datetime'` | `f.text()`, `f.number()`, … | | `type: 'select'` + `listValues` | `f.enum([...])` | | `type: 'multiselect'` | `f.multiEnum([...])` | | `treeselect` / `treemultiselect` + `treeValues` | `f.enum` / `f.multiEnum` with nested `children`; `treeSelectOnlyLeafs` → `leafOnly` | | `!struct` + `subfields` | `f.struct({...})` (flattened to `a.b` paths) | | `!group` (`mode: 'some'` / `'array'`) | `f.array({...})` + [array groups](/docs/guides/arrays-and-structs) | | `fieldSettings.min/max/step` | `min`, `max`, `step` | | `fieldSettings.validateValue` | `validate` | | `fieldSettings.asyncFetch` | `search` | | `allowCustomValues` | `allowCustom` | | `fieldName` | `column` | | `label2`, `tooltip` | `description` | | `hideForSelect` / `hideForCompare` | `hidden` / `comparable: false` | | `excludeOperators` | `excludeOperators` | | `preferWidgets: ['slider']`, `textarea` | `widget: 'slider'`, `widget: 'textarea'` | | `funcs` | `defineFunction` + `defineFields(…, { functions })` | | custom operators | `defineOperator` + `extraOperators` | | `settings.*` | store [settings](/docs/guides/settings) | | `removeIncompleteRulesOnLoad` etc. | [`sanitizeQuery`](/docs/concepts/validation) or the store's `sanitize` option | | `compressConfig` | `fieldsToJSON` / `fieldsFromJSON` | | ternary mode | [`compileCases`](/docs/guides/routing-and-cases) | ## Exports [#exports] `QbUtils.sqlFormat`, `mongodbFormat`, `jsonLogicFormat`, `spelFormat`, `elasticSearchFormat` and `queryString` map to the `sql`, `mongo`, `jsonlogic`, `spel`, `elasticsearch` and `text` codecs. `loadFromJsonLogic`, `loadFromSpel` and `SqlUtils.loadFromSql` map to `decode` with those codecs. ## Not carried over [#not-carried-over] - **Custom conjunctions** (XOR, NOR). NOR is NOT + OR; XOR has no portable meaning in SQL or MongoDB. - **`validateValue` returning a `fixedValue`.** Validation reports; it doesn't rewrite values. - **Slider `marks`, `rows` for textareas, price formatting options.** The schema carries `widget`, `step`, `prefix` and `suffix`; anything more goes in `meta` for your UI. - **RAQB's antd / MUI / Bootstrap / Fluent widget packages.** Build the UI from the hooks with your kit instead. ## Speed [#speed] At 1,000 rules, adding a rule drops from about 95 ms to 1.5 ms, a keystroke from about 8 ms to 0.4 ms, first render from about 590 ms to 120 ms, and the JavaScript from 193 KB to about 22 KB gzipped, both counting their SQL export (see the [benchmarks](/#benchmarks)). # Migrating from react-querybuilder (/docs/guides/migrate-react-querybuilder) Keep your saved queries. The store speaks react-querybuilder's JSON through the rqb format. You don't need a migration script. Give the store the `rqb` format and its `value` / `onChange` read and write react-querybuilder's `RuleGroupType`, so queries already in your database load as-is and keep being saved in the same shape. ```tsx import { QueryBuilder } from '@qnot/react'; import { rqb } from '@qnot/core/formats/rqb'; // before: ; ``` ## Fields [#fields] react-querybuilder's `fields` array becomes a `defineFields` object. Typical mappings: | react-querybuilder | qnot | | --- | --- | | `{ name, label }` | `name: f.text({ label })` | | `inputType: 'number'` | `f.number()` | | `inputType: 'date'` / `'datetime-local'` / `'time'` | `f.date()` / `f.datetime()` / `f.time()` | | `valueEditorType: 'select', values` | `f.enum(values.map((v) => ({ value: v.name, label: v.label })))` | | `valueEditorType: 'multiselect'` on a list field | `f.multiEnum(…)` | | `valueEditorType: 'checkbox'` | `f.boolean()` | | `operators` | `operators` (qnot names; see below) | | `validator` | `validate` (return a message) | | `defaultValue`, `defaultOperator` | same | ## Operators [#operators] | react-querybuilder | qnot | | --- | --- | | `=`, `!=`, `<`, `<=`, `>`, `>=` | `eq`, `neq`, `lt`, `lte`, `gt`, `gte` | | `contains`, `doesNotContain` | `contains`, `notContains` | | `beginsWith`, `endsWith` | `startsWith`, `endsWith` | | `doesNotBeginWith`, `doesNotEndWith` | a NOT group around `startsWith` / `endsWith` | | `in`, `notIn` | `in`, `notIn` | | `between`, `notBetween` | `between`, `notBetween` | | `null`, `notNull` | `isNull`, `isNotNull` | `valueSource: 'field'` maps to `ref`. Between and in values written as comma-separated strings (`"26,52"`) decode into arrays. RQB's independent-combinators shape decodes too (`fromRQB`). ## What changes [#what-changes] - Rules no longer need `id`s; the store keeps its own. - `formatQuery(query, 'sql')` becomes `encode(sqlFormat(), query, fields)` on the native query, or `encode(sqlFormat(), decode(rqb, rqbQuery, fields), fields)` on the RQB shape. - Features react-querybuilder can't store (array groups, function calls) throw when encoded to `rqb`. Switch the stored format to qnot's own JSON (the default) before using them. ## Speed [#speed] At 1,000 rules a keystroke drops from about 57 ms to 0.4 ms and adding a rule from about 52 ms to 1.5 ms, first render from about 480 ms to 120 ms, and memory from 34 MB to 14 MB (see the [benchmarks](/#benchmarks)). # Custom operators and functions (/docs/guides/operators-and-functions) Add operators like "within distance", and functions like lower() or relativeDate(), with output for each format. ## Functions [#functions] A function call can stand on either side of a rule. On the right it's `call`; on the left it replaces the field name: ```ts // signedUp > 30 days ago { kind: 'rule', field: 'signedUp', operator: 'gt', call: { fn: 'relativeDate', args: { amount: { value: 30 }, unit: { value: 'day' }, direction: { value: 'ago' } } } } // lower(name) = 'ada' { kind: 'rule', field: { fn: 'lower', args: { text: { ref: 'name' } } }, operator: 'eq', value: 'ada' } ``` Each argument is `{ value }`, `{ ref: 'field' }` or another call, so calls nest. `builtinFunctions` covers RAQB's basic set: `lower`, `upper`, `length`, `now`, `today` and `relativeDate`. Register functions when you define the fields: ```ts import { builtinFunctions, defineFields, defineFunction, f } from '@qnot/core'; const daysSince = defineFunction({ label: 'Days since', returns: f.number(), args: { date: f.date() }, evaluate: ({ date }) => Math.floor((Date.now() - Date.parse(String(date))) / 864e5), formats: { sql: ({ date }) => `(CURRENT_DATE - ${date})`, text: ({ date }) => `days since ${date}`, }, }); const fields = defineFields({ signedUp: f.date(), lastSeen: f.date() }, { functions: { ...builtinFunctions, daysSince } }); ``` - `returns` is a field schema: it decides which operators apply and which editor the UI shows. - `args` are field schemas too; `required: false` makes one optional. - `evaluate` powers `compile()`. - `formats` maps a codec name (`sql`, `mongo`, `jsonlogic`, `spel`, `elasticsearch`, `text`) to an encoder that gets the arguments already encoded for that format, plus their raw values. Exporting a rule whose function has no format for that codec throws (or is skipped with `unsupported: 'skip'`). Function calls are typed loosely (`FnCall` with a string name); validation checks the function exists, its return kind fits the field, and its arguments are valid. Turn them off in the UI with `settings.allowFunctions: false`. ## Custom operators [#custom-operators] ```ts import { defineFields, defineOperator, f } from '@qnot/core'; const near = defineOperator({ id: 'near', arity: 'one', label: 'is within', evaluate: (distance, km) => typeof distance === 'number' && typeof km === 'number' && distance <= km, formats: { sql: ({ field, value }) => `${field} <= ${value}`, mongo: ({ field, raw }) => ({ [String(field)]: { $lte: raw } }), }, }); const fields = defineFields( { distance: f.number({ extraOperators: ['near'] }) }, { operators: [near] }, ); ``` `extraOperators` adds custom operators to a field's defaults and to its type, so `{ field: 'distance', operator: 'near', value: 5 }` type-checks. `arity` decides the value shape (`'none'`, `'one'`, `'two'`, `'many'`). Codecs call `formats[codecName]` with the rendered field, the rendered value and the raw value; without one, export codecs throw (or skip, with `unsupported: 'skip'`) and the UI-state codecs pass the operator through by id. Built-in operators also record their `inverse` (`eq`/`neq`, `in`/`notIn`…), available from `operatorInfo(fields, op).inverse`, for code that wants to push a NOT down instead of wrapping it. # React (/docs/guides/react) The default builder, the store hooks, and swapping in your own components. ```bash pnpm add @qnot/core @qnot/react ``` ## The default builder [#the-default-builder] `` takes either a `store` you created, or store options (`fields`, `initial` or `value`/`onChange`, `format`, `settings`, `sanitize`) and creates one. ```tsx import '@qnot/core/styles.css'; import { QueryBuilder } from '@qnot/react'; ; ``` | Prop | | | --- | --- | | `components` | Replace `Group`, `Rule` or `ValueEditor`; the rest keep their defaults. | | `labels` | Override any UI string (see [i18n](/docs/guides/i18n)). | | `showNot` | Show the NOT toggle on groups (default `true`). | | `showLock` | Show lock toggles on rules and groups (default `false`). | | `className` | Added to the root `.qn` element. | ## Owning the store [#owning-the-store] `useQueryStore(options)` creates a store for the component's lifetime. Options are read once, except `value` (synced into the store), `onChange` (always the latest) and `settings` (applied when the object changes). Share it with ``: ```tsx import { QueryBuilder, QueryProvider, useHistory, useIsValid, useQueryStore } from '@qnot/react'; function Editor() { const store = useQueryStore({ fields, initial: saved, settings: { maxRules: 50 } }); return ( ); } function Toolbar() { const { canUndo, canRedo, undo, redo } = useHistory(); const valid = useIsValid(); return (/* … */); } ``` ## Hooks [#hooks] All hooks read the store from the nearest ``. Pass your schema as a type parameter for typed results. | Hook | Returns | Re-renders when | | --- | --- | --- | | `useRule(id)` | the rule, its field `def`, `editor` spec, `operators`, `fieldOptions`, `refOptions`, `functionOptions`, `source`, `issues`, `can`, `locked`, and actions | that rule changes, or its permissions flip | | `useGroup(id)` | the group, `isRoot`, `arrayFields`, `issues`, `can`, `locked`, and actions | that group changes, or its permissions flip | | `useNode(id)` | the raw node | that node changes | | `useQuery()` | the whole query as JSON | anything changes | | `useQueryValue()` | the query in the store's `format` | anything changes | | `useIsValid()` | `boolean` | validity flips | | `useHistory()` | `{ canUndo, canRedo, undo, redo }` | either flag flips | | `useSettings()` | the store's settings | settings change | | `useStore()` | the store itself | never | `useRule` actions: `setField`, `setOperator`, `setValue`, `setRef`, `setCall`, `setLocked`, `remove` (asks `confirmRemove` first) and `clone`. `useGroup` actions: `setCombinator`, `setNot`, `setMatch`, `setLocked`, `addRule`, `addGroup`, `addArrayGroup`, `remove`, `clone`. `can` holds booleans for what the user may do now (`editField`, `editOperator`, `editValue`, `remove`, `clone`, `lock` for rules; `addRule`, `addGroup`, `editGroup`, `remove`, `clone`, `lock` for groups). Disable controls with it; the store refuses the same edits anyway. ## Writing your own UI [#writing-your-own-ui] A custom builder is a group component that renders its children by id, and a rule component: ```tsx import { QueryProvider, useGroup, useNode, useRule } from '@qnot/react'; import { memo } from 'react'; const Group = memo(function Group({ id }: { id: string }) { const { group, addRule, setCombinator } = useGroup(id); return (
{group.children.map((child) => )}
); }); function Node({ id }: { id: string }) { return useNode(id)?.kind === 'group' ? : ; } const Rule = memo(function Rule({ id }: { id: string }) { const { rule, fieldOptions, operators, editor, setField, setOperator, setValue, remove } = useRule(id); /* render rule.field, rule.operator, and an editor for editor.type */ }); ``` Wrap each piece in `memo` and pass only the id: the per-node subscriptions do the rest, and editing one rule re-renders only that rule. The [custom UI guide](/docs/guides/custom-ui) builds a complete one with coss ui. ## Drag and drop [#drag-and-drop] Three hooks wire HTML5 drag and drop to the store's `move`. They work in the default builder and in your own: ```tsx import { useDragHandle, useDropTarget, useGroupDropZone } from '@qnot/react'; function Row({ id }: { id: string }) { const { isDragging, handleProps } = useDragHandle(id); const { position, dropProps } = useDropTarget(id); // position: 'before' | 'after' | null return (
⠿ {/* … */}
); } ``` `useGroupDropZone(groupId)` makes a group's children area accept drops at its end, including when it's empty. Moves respect `canReorder`, `canRegroup`, `maxDepth` and locks. Touch devices don't fire HTML5 drag events, so drag and drop is mouse-only for now. # Routing and switch cases (/docs/guides/routing-and-cases) Ordered conditions with results, first match wins, like a routing form or CASE WHEN. Many uses of a query builder aren't one filter but a list of them: route a form response to the first matching team, pick a price tier, assign a support queue. That's RAQB's "ternary mode" and SQL's `CASE WHEN`. ```ts import { compileCases, type CasesOf } from '@qnot/core'; const routes: CasesOf = { cases: [ { label: 'Enterprise', when: enterpriseQuery, then: { team: 'sales' } }, { label: 'EMEA', when: emeaQuery, then: { team: 'emea-support' } }, ], otherwise: { team: 'self-serve' }, }; const route = compileCases(fields, routes); route(response); // { value: { team: 'emea-support' }, index: 1 } ``` `index` is the matching case, or `-1` when `otherwise` was used. ## Editing routes [#editing-routes] Each route's condition is an ordinary query, so give each one its own store and builder. A routing form editor is a list of cards, each holding a `QueryBuilder` (or your own UI over the hooks), a result picker, and controls to reorder and duplicate routes. A common touch is an "all / any / none of these rules" switch, which maps to AND, OR and NOT+OR on the group. ## Exporting [#exporting] `encodeCases` exports the whole switch with codecs that support it: ```ts import { encodeCases } from '@qnot/core'; import { sqlFormat } from '@qnot/core/formats/sql'; encodeCases(sqlFormat({ dialect: 'postgres' }), { cases: routes.cases.map((c) => ({ when: c.when, then: c.then.team })), otherwise: 'self-serve' }, fields); // CASE WHEN … THEN 'sales' WHEN … THEN 'emea-support' ELSE 'self-serve' END ``` SQL gives `CASE WHEN`, JsonLogic an `if` chain, MongoDB `$switch`, SpEL nested ternaries and text a sentence. Elasticsearch and the UI-state codecs don't support cases. # On the server (/docs/guides/server) Share one schema, validate what the browser sends, and run it as parameterized SQL or in memory. `@qnot/core` has no DOM and no framework, so the same schema module runs in the browser and in Node, Bun, Deno or an edge function. The browser builds a query; the server decides whether to trust it. ```ts title="fields.ts (shared)" import { builtinFunctions, defineFields, f } from '@qnot/core'; export const fields = defineFields( { name: f.text(), age: f.number({ min: 0, integer: true }), plan: f.enum(['free', 'pro', 'team']), signedUp: f.date(), }, { functions: builtinFunctions }, ); ``` ```ts title="server.ts" import { assertValidQuery, encode, type QueryOf } from '@qnot/core'; import { sqlFormat } from '@qnot/core/formats/sql'; import { fields } from './fields'; // emptyQuery: 'throw' refuses a query with no rules instead of matching every row. const postgres = sqlFormat({ dialect: 'postgres', params: 'numbered', emptyQuery: 'throw' }); export async function searchUsers(body: unknown) { const query = body as QueryOf; assertValidQuery(fields, query); // throws with each bad rule's path and message const { sql, params } = encode(postgres, query, fields); return db.query(`SELECT * FROM users WHERE ${sql}`, params); } ``` Given `age ≥ 21`, `plan in (pro, team)`, `name contains "O'Brien; DROP TABLE users"` and `signedUp > relativeDate(30 days ago)`, that produces: ```sql age >= $1 AND plan IN ($2, $3) AND name LIKE $4 ESCAPE '!' AND signedUp > CURRENT_DATE - INTERVAL '30 day' -- params: [21, 'pro', 'team', "%O'Brien; DROP TABLE users%"] ``` User input only ever reaches the database as bound parameters: rule values, list items, function arguments, custom operator values and case results. The `INTERVAL` text comes from `relativeDate`, which only accepts a whole amount and a known unit. ## What to check [#what-to-check] - **Validate, don't sanitize.** `assertValidQuery` rejects bad rules. Avoid `sanitizeQuery` with `removeInvalidRules` or `removeUnknown` on untrusted input: dropping a rule makes the query match more rows, not fewer. - **Decide what empty means.** A query with no rules is valid; by default it exports as `1 = 1`. `emptyQuery: 'none'` makes it match nothing, `'throw'` refuses it. - **Keep `unsupported: 'throw'`** (the default) so a rule the dialect can't express fails loudly instead of being dropped. - **Map columns.** Give fields a `column` when the API name differs from the database column. - **Limit size.** `settings.maxRules` and `maxDepth` limit the UI; on the server, check the size of the incoming JSON yourself. ## Security [#security] The SQL output was audited for injection against Postgres (including `standard_conforming_strings = off`), MySQL (including `NO_BACKSLASH_ESCAPES`), SQL Server and SQLite, in inline and parameterized mode, and CI keeps fuzzing it against all four. In short: - Only JSON scalars are written into SQL, escaped so they mean the same thing under every server setting; NUL characters are refused. - Fields, refs, functions and operators must exist in your schema (own keys only); anything else throws, and malformed groups always throw. - Invalid rules and rules a dialect can't express throw by default, so a tampered query never silently matches more rows. - Custom operator and function `formats.sql` are your SQL: they receive escaped literals or placeholders, but never interpolate `raw` values without checking them. Threat model, guarantees per dialect, and the audit results: [SECURITY.md](https://github.com/cosscom/qnot/blob/main/SECURITY.md). ## In memory [#in-memory] The same query filters rows without a database: ```ts import { compile } from '@qnot/core'; const matches = compile(fields, query); const result = rows.filter(matches); ``` The repository's `examples/server` runs this whole flow as a script. # Locks, read-only and limits (/docs/guides/settings) Settings that decide what people can change, and how the UI reflects them. Settings live on the store, change at runtime, and every binding's UI follows: controls disable, buttons hide or grey out, and the store refuses edits the settings forbid. ```ts const store = createQueryStore({ fields, settings: { maxRules: 20, maxDepth: 2, canRegroup: false }, }); store.setSettings({ readOnly: true }); ``` | Setting | Default | | | --- | --- | --- | | `readOnly` | `false` | Nothing can be edited. | | `immutable` | — | `{ groups, fields, operators, values }`: freeze one part (RAQB's `immutable*Mode`). | | `maxRules` | — | Most rules in the whole query. | | `maxDepth` | — | Deepest group nesting below the root. | | `canReorder` | `true` | Move nodes within their group. | | `canRegroup` | `true` | Move nodes into another group. | | `canDeleteLocked` | `false` | Allow removing locked nodes. | | `canLeaveEmptyGroup` | `true` | When `false`, removing a group's last child removes the group. | | `emptyNewGroups` | `false` | New groups start empty instead of with one rule. | | `allowRefs` | `true` | Field-to-field comparisons. | | `allowFunctions` | `true` | Function values and left sides. | | `confirmRemove` | — | `(node) => boolean \| Promise`, asked by `requestRemove` (the UI's remove buttons). | | `operatorOnFieldChange` | `'keep'` | `'reset'` always takes the new field's default operator. | | `valueOnFieldChange` | `'keep'` | `'reset'` always resets the value. | | `valueOnOperatorChange` | `'keep'` | `'reset'` resets instead of reshaping. | ## Locks [#locks] Any rule or group can be locked; a locked group locks everything inside it. Locks are part of the query JSON (`locked: true`), so they persist. ```ts store.setLocked(groupId, true); store.isLocked(childId); // true ``` Show lock toggles in the default builders with `showLock`. Locked nodes get the `qn-locked` class. ## Asking the store [#asking-the-store] `store.can(action, id)` answers for `addRule`, `addGroup`, `remove`, `clone`, `move` (with a target group), `editField`, `editOperator`, `editValue`, `editGroup` and `lock`. The hooks expose these as `can` and update them through `store.subscribePermissions`, which fires only when settings, locks or (under limits) the tree's shape change, never on value edits. # Install with shadcn (/docs/guides/shadcn) Add the coss ui query builder or any example to your project as source, with one command. The site is a [shadcn registry](https://ui.shadcn.com/docs/registry). `shadcn add` copies qnot's coss ui query builder, or a whole example, into your project as source you own, installs the npm packages it needs (`@qnot/core`, `@qnot/react`, `lucide-react`), and pulls the [coss ui](https://coss.com/ui) components it uses from coss's own registry. ## Before you start [#before-you-start] Your project needs Tailwind CSS v4 and a `components.json`. If you don't use coss ui yet, set it up first: ```sh pnpm dlx shadcn@latest init @coss/style ``` Files land under `components/qnot/` (your `components` alias), and their imports are rewritten to your aliases. Everything installed from this registry is MIT licensed. ## The query builder [#the-query-builder] ```tsx 'use client'; import { createQueryStore, defineFields, f } from '@qnot/core'; import { useState } from 'react'; import { CossQueryBuilder } from '@/components/qnot/query-builder'; const fields = defineFields({ name: f.text(), age: f.number({ min: 0 }), plan: f.enum(['free', 'pro']) }); export function Filters() { const [store] = useState(() => createQueryStore({ fields })); return ; } ``` ## Other UI kits [#other-ui-kits] The builder also comes in shadcn/ui, MUI and plain-HTML variants, the ones on the [UI kits](/kits) page. Each one installs `query-builder-core`, the kit-agnostic builder they share (`components/qnot/kits/shared`), plus the kit's own controls: | Item | Install into | Brings | |---|---|---| | `query-builder-shadcn` | a Radix-based shadcn/ui project (`pnpm dlx shadcn@latest init --base radix`) | the shadcn/ui components it uses, in your project's style | | `query-builder-mui` | any React project | `@mui/material`, `@mui/icons-material`, Emotion | | `query-builder-headless` | any Tailwind project | nothing else: plain HTML to restyle | ```tsx 'use client'; import { createQueryStore } from '@qnot/core'; import { useState } from 'react'; import { QueryBuilder } from '@/components/qnot/kits/mui'; import { fields } from './fields'; export function Filters() { const [store] = useState(() => createQueryStore({ fields })); return ; } ``` `query-builder-shadcn` is written against the Radix-based shadcn/ui components. Projects using a Base UI style (`"style": "base-…"` in `components.json`, which plain `shadcn init` can create) get type errors; use the coss ui builder there, which is built on Base UI. The coss ui builder (`query-builder`) and the shadcn/ui variant (`query-builder-shadcn`) both install their components into `components/ui`, and their components share names (`select`, `button`, `tooltip`…). Pick one of the two per project; mixing them overwrites one with the other. ## An example [#an-example] Every [example](/examples) is a registry block: the page shows its full source and the command to install it. Each example exports a `Demo` component: ```tsx import { Demo as ProductFilters } from '@/components/qnot/examples/product-filters'; export default function Page() { return ; } ``` ## Every item [#every-item] Items depend on each other where they share code, so installing an example brings the builder and helpers it uses. Running `shadcn add` again asks before overwriting files you've changed. # Solid (/docs/guides/solid) Fine-grained primitives over the store, and a Solid QueryBuilder. ```bash pnpm add @qnot/core @qnot/solid ``` ## The default builder [#the-default-builder] ```tsx import '@qnot/core/styles.css'; import { QueryBuilder } from '@qnot/solid'; import { createSignal } from 'solid-js'; const [query, setQuery] = createSignal(saved); ; ``` `QueryBuilder` takes `store`, or `fields` with `initial` or `value` / `onChange`, `format` and `settings`; plus `components`, `labels`, `class`, `showNot`, `showLock`. ## Primitives [#primitives] `useQueryStore(options)` creates a store; `` shares it; `useStore()` reads it. `useRule(id)` returns separate accessors (`rule`, `field`, `operator`, `value`, `ref`, `call`, `def`, `editor`, `operators`, `issues`, `source`, `can`…), so an editor that reads only `value()` re-runs only when the value changes. Also: `useGroup`, `useNode`, `useQuery`, `useQueryValue`, `useIsValid`, `useHistory`, `useSettings`, `useDragHandle`, `useDropTarget`, `useGroupDropZone`. The package ships source through the `solid` export condition. `vite-plugin-solid` resolves it; if React and Solid share a Vite app, limit each JSX plugin to its own files with `include`/`exclude`. # Svelte (/docs/guides/svelte) Rune-friendly reactive helpers and Svelte 5 components. ```bash pnpm add @qnot/core @qnot/svelte ``` ## The default builder [#the-default-builder] ```svelte ``` `QueryBuilder` takes `store`, or `fields` with `initial` / `bind:value`, `format` and `settings`; plus `onchange`, `components`, `labels`, `class`, `showNot` and `showLock`. ## Reactive helpers [#reactive-helpers] `setQueryStore(store)` puts a store in context; `getQueryStore()` reads it. The `create*` helpers return objects whose getters read shared per-store signals (one per node, fed by the store's per-node subscriptions), so they work in components and in plain `.svelte.ts` modules, and each one depends only on what it reads: ```svelte ``` Available: `createNode`, `createRule`, `createGroup`, `createQuery`, `createQueryValue`, `createIsValid`, `createHistory`, `createSettings`, and `createDragHandle`, `createDropTarget`, `createGroupDropZone`. The default `Group`, `Rule`, `ValueEditor` and `FunctionEditor` components are exported for reuse. The package ships `.svelte` source through the `svelte` export condition; `@sveltejs/vite-plugin-svelte` compiles it. # Vanilla DOM (/docs/guides/vanilla) mountQueryBuilder with no framework, and DOM helpers for drag and drop. ```bash pnpm add @qnot/core @qnot/dom ``` ```ts import '@qnot/core/styles.css'; import { createQueryStore } from '@qnot/core'; import { mountQueryBuilder } from '@qnot/dom'; const store = createQueryStore({ fields, initial: saved }); const dispose = mountQueryBuilder(document.getElementById('filters')!, store, { showLock: true }); store.subscribe(() => save(store.getQuery())); // later: dispose(); ``` Each node subscribes to its own id and patches only its own elements, with a keyed reconcile per group, so the vanilla builder keeps the same "one rule, one update" behaviour as the framework bindings. ## Options [#options] `mountQueryBuilder(el, store, options)` takes `components`, `labels`, `className`, `showNot` and `showLock`. A custom value editor is a factory returning `{ el, update(props), destroy? }`; it's created once and patched in place as the rule changes. ## Drag and drop helpers [#drag-and-drop-helpers] `bindDragHandle`, `bindDropTarget` and `bindGroupDropZone` attach the store's drag-and-drop to your own elements, and `dropPositionOf` reads where a drop would land. # Vue (/docs/guides/vue) Composables over the store, and a QueryBuilder component that supports v-model. ```bash pnpm add @qnot/core @qnot/vue ``` ## The default builder [#the-default-builder] `QueryBuilder` takes a `store`, or `fields` with `initial`/`v-model`, `format` and `settings`. It emits `update:modelValue` and `change`. ```vue ``` Like the React builder, it accepts `components` and `labels` to swap pieces and strings, and `showNot` / `showLock`. The components are written with `h()`, so no SFC compiler is involved. ## Composables [#composables] `useQueryStore(options)` creates a store tied to the component's scope; `provideQueryStore(store)` shares it with descendants, which read it with `useStore()`. ```ts import { provideQueryStore, useHistory, useQueryStore, useRule } from '@qnot/vue'; const store = useQueryStore({ fields, initial: saved, onChange: save }); provideQueryStore(store); const { canUndo, undo } = useHistory(); ``` The composables mirror React's hooks: `useRule(id)`, `useGroup(id)`, `useNode(id)`, `useQuery()`, `useQueryValue()`, `useIsValid()`, `useHistory()`, `useSettings()`, and the drag hooks `useDragHandle`, `useDropTarget`, `useGroupDropZone`. They return `shallowRef`s and `computed`s fed by the store's per-node subscriptions, not deep reactivity, so editing one rule only updates that rule's component. `useRule(id)` returns `rule`, `fields`, `def`, `editor`, `operators`, `fieldOptions`, `refOptions`, `functionOptions`, `source`, `locked`, `can` and `issues` as refs, plus the actions `setField`, `setOperator`, `setValue`, `setRef`, `setCall`, `setLocked`, `remove` and `clone`. `useQueryStore` accepts `value` as a ref or getter for controlled use. # Introduction (/docs) A query builder that stays fast at a thousand rules, typed from your schema to the SQL it writes. qnot is the part every filter bar, segment builder, routing form and alert rule shares: a tree of conditions that people edit, that you validate, and that ends up as SQL, a MongoDB filter, JsonLogic or a function you run in JavaScript. It comes as a framework-agnostic core and thin bindings: | Package | What it is | | --- | --- | | `@qnot/core` | Schema builders, the query store, validation, evaluation, and format codecs. No DOM, no framework. Runs in the browser and on the server. | | `@qnot/react` | Hooks over the store, plus a small unstyled ``. | | `@qnot/vue`, `@qnot/svelte`, `@qnot/solid` | The same hooks and builder for each framework. | | `@qnot/dom` | A vanilla DOM builder: `mountQueryBuilder(el, store)`. | ## Why another query builder [#why-another-query-builder] - **Types that follow your schema.** Declare fields once; every rule's value narrows by field and operator. Hover `rule.value` below: ```ts twoslash import { defineFields, f, isLiteralRule, type RuleOf } from '@qnot/core'; const fields = defineFields({ age: f.number({ min: 0 }), plan: f.enum(['free', 'pro', 'team']), }); declare const rule: RuleOf; if (rule.field === 'plan' && rule.operator === 'in' && isLiteralRule(rule)) { rule.value; // ^? } ``` - **Edits stay O(1).** The store keeps nodes by id and notifies only the node that changed, so a keystroke costs about half a millisecond at 1,000 rules, where react-querybuilder takes about 60 ms (see the [benchmarks](/#benchmarks)). - **Every format, both ways.** SQL with parameters in five dialects, MongoDB, JsonLogic, Elasticsearch, SpEL and a plain-English summary, plus two-way codecs for react-querybuilder, react-awesome-query-builder and jQuery QueryBuilder JSON. - **Any UI.** Use the default builder, or build your own from the hooks with coss ui, shadcn/ui, MUI or anything else. - **Feature parity with react-awesome-query-builder:** struct and list fields, functions, custom operators, locks, read-only mode, limits, switch cases, schema JSON. Every page is available as Markdown: add `.md` to its URL, or fetch [/llms.txt](/llms.txt) for the index and [/llms-full.txt](/llms-full.txt) for everything. # Installation (/docs/installation) Add the core and the binding for your framework. qnot is split so you only ship what you use. The core is plain TypeScript with no dependencies; each binding depends on the core and on its framework as a peer. ```bash pnpm add @qnot/core @qnot/react ``` Needs React 18 or later (the hooks use `useSyncExternalStore`). ```bash pnpm add @qnot/core @qnot/vue ``` Needs Vue 3.5. The components are written with `h()`, so no SFC compiler is required. ```bash pnpm add @qnot/core @qnot/svelte ``` Needs Svelte 5. The package ships `.svelte` components through the `svelte` export condition. ```bash pnpm add @qnot/core @qnot/solid ``` Needs solid-js 1.9. Ships source through the `solid` export condition, which `vite-plugin-solid` picks up. ```bash pnpm add @qnot/core @qnot/dom ``` No framework at all. ## Styles [#styles] The default builders render plain elements with stable `tn-*` class names. An optional stylesheet gives them a neutral look that follows `prefers-color-scheme`: ```ts import '@qnot/core/styles.css'; ``` Skip it if you build your own UI from the hooks, or style the `tn-*` classes yourself. The full list is exported as `CLASS` from `@qnot/core`. ## Formats [#formats] Format codecs are subpath imports, so a bundle only includes the ones you use: ```ts import { sqlFormat } from '@qnot/core/formats/sql'; import { mongo } from '@qnot/core/formats/mongo'; import { rqb } from '@qnot/core/formats/rqb'; ``` See [Formats](/docs/concepts/formats) for the full list. qnot is at 0.0 and not yet on npm. The API on these pages is the one in the repository and may still change before 1.0. # Quick start (/docs/quick-start) A typed query builder in React, from schema to SQL. ### Define your fields [#define-your-fields] The schema is the one place you describe what can be queried. Everything else, from the UI's editors to the types of rule values and the SQL, follows from it. ```ts twoslash title="fields.ts" import { defineFields, f } from '@qnot/core'; export const fields = defineFields({ name: f.text(), age: f.number({ min: 0, integer: true }), plan: f.enum(['free', 'pro', 'team']), verified: f.boolean(), signedUp: f.date({ label: 'Signed up' }), }); ``` ### Render a builder [#render-a-builder] `` creates a store from your fields and renders the default, unstyled UI. ```tsx title="Filters.tsx" import '@qnot/core/styles.css'; import { QueryBuilder } from '@qnot/react'; import type { QueryOf } from '@qnot/core'; import { fields } from './fields'; export function Filters({ onChange }: { onChange: (q: QueryOf) => void }) { return ; } ``` ### Use the query [#use-the-query] The value is qnot's own JSON: a tree of groups and rules, typed by your schema. ```ts twoslash import { defineFields, f, type QueryOf } from '@qnot/core'; const fields = defineFields({ age: f.number(), plan: f.enum(['free', 'pro', 'team']) }); // ---cut--- const query: QueryOf = { kind: 'group', combinator: 'and', children: [ { kind: 'rule', field: 'age', operator: 'gte', value: 21 }, { kind: 'rule', field: 'plan', operator: 'in', value: ['pro', 'team'] }, ], }; ``` Turn it into SQL, or filter rows directly: ```ts import { compile, encode } from '@qnot/core'; import { sqlFormat } from '@qnot/core/formats/sql'; const { sql, params } = encode(sqlFormat({ dialect: 'postgres', params: 'numbered' }), query, fields); // sql: "age >= $1 AND plan IN ($2, $3)", params: [21, 'pro', 'team'] const adults = users.filter(compile(fields, query)); ``` ## Store-first or controlled [#store-first-or-controlled] `` accepts store options directly, which is enough for most forms. When other parts of the page need the query (a preview, a results table, undo buttons), create the store yourself and share it: ```tsx import { QueryBuilder, QueryProvider, useHistory, useQuery, useQueryStore } from '@qnot/react'; function Page() { const store = useQueryStore({ fields, initial: savedQuery }); return ( ); } function Preview() { const query = useQuery(); // re-renders on every change return
{JSON.stringify(query, null, 2)}
; } ``` It also works controlled, like an input. Passing back the value it gave you costs nothing, because the store recognises its own output and skips reconciling: ```tsx const [query, setQuery] = useState(initialQuery); ; ``` ## Next [#next] # Codecs (/docs/reference/codecs) What each built-in format codec does with every feature. Every codec lives at `@qnot/core/formats/` 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 [#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 | - **`emptyQuery`** also applies when `unsupported: 'skip'` dropped every rule. - **Cases:** a case whose condition can't be expressed always throws, even with `unsupported: 'skip'`; it never becomes `WHEN 1 = 1`. - **`text`** renders every valid rule (functions without a text format fall back to `fn(args)`), so its `unsupported` option 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 [#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 [#per-codec] ### sql (`sqlFormat({ dialect, params, quoteIdentifiers, caseInsensitive })`) [#sql-sqlformat-dialect-params-quoteidentifiers-caseinsensitive-] - **isEmpty:** `(c IS NULL OR c = '')` for text and enum, `IS NULL` for other scalars. Arrays use cardinality / `JSON_LENGTH` / `json_array_length` / `jsonb_array_length`; MSSQL uses an `OPENJSON` count. - **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 uses `json_each` and MSSQL `OPENJSON` subqueries. 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 as `e1->>'key'` and cast per kind (`::numeric`, `::boolean`, `::date`, `::timestamptz`, `::time`). `all` is `NOT EXISTS (… WHERE NOT COALESCE(cond, FALSE))`; counts use `(SELECT COUNT(*) …) op n`. Other dialects throw (or skip with `unsupported: '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 Server `N'…'`. Only JSON scalars are written; NUL is refused. - **LIKE** escapes `%`, `_` and `!` (plus `[`/`]` on SQL Server) with `!` and always adds `ESCAPE '!'`. - **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`/`ILIKE` patterns (`%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 as `CURRENT_TIMESTAMP`. - Emptiness: the encoder's `(c IS NULL OR c = '')` and array-length checks come back as isEmpty/isNotEmpty. - Rejected: `_` wildcards, inner `%`, `INTERVAL` arithmetic, 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. ### mongo [#mongo] - Filter form where possible. `$expr` is 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 = `$size` of a `$filter` over `$ifNull: [path, []]`. NOT array groups use `$nor`. - **isEmpty:** `$in: [null, '']`, or `[null, []]` for arrays. - **multiEnum:** `$in` (hasAny), `$all` (hasAll), `$nin` (hasNone). `$nin` also matches documents where the field is missing, which `compile()` doesn't. - **Cases:** `$switch` built 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` / `== 0` decode as some/none. ### jsonlogic [#jsonlogic] - Follows react-querybuilder and RAQB conventions: custom `startsWith`/`endsWith` ops, `{"<=": [a, var, b]}` for between, `!` / `!!` for isEmpty / isNotEmpty. - **multiEnum:** `some`+`in` (hasAny), `and` of `in` (hasAll), `!some` (hasNone); setEq = hasAll plus `all … in list`. - **Array groups:** `some` / `none` over `{var: field}`. `all` is written as `none` of the negated condition, because json-logic's `all` is 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 `0` as 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. ### spel (two-way, RAQB-compatible) [#spel-two-way-raqb-compatible] - **Mirrors RAQB `spelFormat`:** - `x.compareTo(T(java.time.Local…).parse(…)) op 0` for dates, times and datetimes. Datetimes are `yyyy-MM-dd HH:mm:ss` in UTC. - `{…}.?[true].contains(x)` for in. - `T(CollectionUtils).containsAny` for hasAny, `x.equals({…})` for setEq. - `orders.?[cond].size() op n` for array groups. - `x <= ''` / `x > ''` for emptiness. - **Our own additions:** hasAll as `x.containsAll({…})`; cases as nested ternaries. - **Decode** parses `&&`/`||`/`!` and the `and`/`or`/`not` keywords, 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 })`) [#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' `text` formats; cases read as one sentence. ### elasticsearch (export only) [#elasticsearch-export-only] - `bool` must / 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 sets `caseSensitive`. - **Field refs** become painless `script` queries. **Function calls** are supported on the value side only, through their `elasticsearch` format (date math such as `now-7d/d`). - **Array groups** become `nested` queries (map the field as `nested`): 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) [#raqb-two-way-faithful] - **Array groups** become `rule_group` (`mode: 'array'`): operator `some`/`all`/`none` or a count operator with `value`. 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 written `HH:mm:ss`; datetimes are `YYYY-MM-DD HH:mm:ss` in UTC and decode back with a `Z`. ### rqb, jqqb (two-way, faithful) [#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. - `locked` isn't mapped. - jqqb carries field refs in `data.qnotRef`. ## Security [#security] The sql codec is audited and fuzzed against Postgres, MySQL, SQL Server and SQLite for injection; see [SECURITY.md](https://github.com/cosscom/qnot/blob/main/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 [#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 (a `ctx.scope` hook) would let them share the cache. # Field builders (/docs/reference/fields) Every option of f.text, f.number, f.enum and the rest. All builders live on `f` from `@qnot/core` and return a `FieldDef`. Pass the result object to `defineFields`. ## Common options [#common-options] Every builder accepts these. | Option | Type | | | --- | --- | --- | | `label` | `string` | Shown in pickers. Defaults to the humanized key (`signedUp` → "Signed up"). | | `description` | `string` | Longer help text (RAQB `tooltip`). | | `column` | `string` | Name exporters use (SQL column, Mongo path, JsonLogic var). | | `operators` | `Op[]` | Restrict and order the operators. Narrows the type. | | `excludeOperators` | `OperatorId[]` | Drop operators from the defaults. | | `extraOperators` | `string[]` | Add [custom operators](/docs/guides/operators-and-functions) registered with `defineFields`. | | `defaultOperator` | `Op` | Used for new rules (default: the first operator). | | `defaultValue` | `V \| null` | Used for new rules. | | `required` | `boolean` | A rule on this field needs a value (default `true`). | | `hidden` | `boolean` | Left out of field pickers; still usable as a comparison target. | | `comparable` | `boolean` | Can be the target of a field-to-field rule (default `true`). | | `validate` | `(value) => string \| undefined` | Return a message to fail. Runs after the built-in checks. | | `widget` | `'input' \| 'textarea' \| 'slider' \| 'select' \| 'radio' \| 'switch' \| …` | A hint for UIs; the core never reads it. | | `placeholder`, `prefix`, `suffix` | `string` | UI hints. | | `meta` | `Record` | Anything else your UI needs, passed through. | ## Builders [#builders] | Builder | Extra options | | --- | --- | | `f.text(opts)` | `minLength`, `maxLength`, `pattern`, `caseSensitive` | | `f.number(opts)` | `min`, `max`, `step`, `integer` | | `f.boolean(opts)` | — | | `f.date(opts)`, `f.time(opts)`, `f.datetime(opts)` | — | | `f.enum(values, opts)` | `leafOnly`, `allowCustom`, `search` | | `f.multiEnum(values, opts)` | `leafOnly`, `allowCustom`, `search` | | `f.struct(fields, { label })` | — (flattened by `defineFields`) | | `f.array(fields, opts)` | — (query with array groups) | | `f.custom({ kind, operators, defaultValue, options, … })` | your own kind and value type | `values` for `f.enum` / `f.multiEnum` are plain values or `{ value, label, children?, disabled? }`. `search(query, signal)` returns a promise of options for remote or huge lists; with `search` set, option membership isn't validated. ## defineFields [#definefields] ```ts defineFields(fields, { operators?: OperatorDef[]; // custom operators functions?: Record; messages?: Partial string)>>; }); ``` Returns the fields with literal types pinned and struct fields flattened (`FlattenFields`). The options are attached to the returned object, so anything that receives these fields (the store, codecs, `compile`) sees them. ## extendFields [#extendfields] Don't spread a schema (`{ ...fields, extra }`): the custom operators, functions and messages live on a hidden property and would be lost. Use `extendFields(base, more, options?)` to add or override fields; it merges registries too. ## Serializing a schema [#serializing-a-schema] `fieldsToJSON(fields, { fns })` turns a schema into JSON for sending from a server, with functions (validators, searches) referenced by name from `fns`; `fieldsFromJSON(json, { fns, schema })` rebuilds it. Custom operators and functions must be passed again in `schema`. # Hooks by framework (/docs/reference/hooks) The same store API in React, Vue, Svelte, Solid and the DOM. Every binding exposes the same concepts under its framework's naming. Each rule and group subscribes only to its own node, and permissions come from the store's permission channel. | Concept | React / Solid | Vue | Svelte | DOM | | --- | --- | --- | --- | --- | | Create a store | `useQueryStore` | `useQueryStore` | `createQueryStore` (core) | `createQueryStore` (core) | | Share it | `` | `provideQueryStore` | `setQueryStore` | pass to `mountQueryBuilder` | | Read it | `useStore` | `useStore` | `getQueryStore` | — | | A rule | `useRule(id)` | `useRule(id)` | `createRule(store, id)` | `DefaultRule` | | A group | `useGroup(id)` | `useGroup(id)` | `createGroup(store, id)` | `DefaultGroup` | | A node | `useNode(id)` | `useNode(id)` | `createNode(store, id)` | — | | Whole query | `useQuery` | `useQuery` | `createQuery` | `store.getQuery()` | | Value in `format` | `useQueryValue` | `useQueryValue` | `createQueryValue` | `store.getValue()` | | Validity | `useIsValid` | `useIsValid` | `createIsValid` | `store.isValid()` | | Undo / redo | `useHistory` | `useHistory` | `createHistory` | `store.undo()`… | | Settings | `useSettings` | `useSettings` | `createSettings` | `store.settings` | | Drag handle | `useDragHandle(id)` | `useDragHandle(id)` | `createDragHandle` | `bindDragHandle` | | Drop target | `useDropTarget(id)` | `useDropTarget(id)` | `createDropTarget` | `bindDropTarget` | | Group drop zone | `useGroupDropZone(id)` | `useGroupDropZone(id)` | `createGroupDropZone` | `bindGroupDropZone` | | Default builder | `` | `QueryBuilder` | `QueryBuilder.svelte` | `mountQueryBuilder(el, store)` | | Function editor | `FunctionEditor` | `FunctionEditor` | `FunctionEditor.svelte` | built in | What they return differs by framework: plain values in React, refs and computeds in Vue, objects with reactive getters in Svelte, and accessors in Solid (`rule()`, `value()`…). The fields are the same: see [React](/docs/guides/react#hooks) for the full list. # Operators (/docs/reference/operators) Every built-in operator, its value shape, and the kinds that use it by default. | Id | Label | Value | Field ref / function | Default on | | --- | --- | --- | --- | --- | | `eq` | equals | one | yes | all except multiEnum, array | | `neq` | does not equal | one | yes | all except multiEnum, array | | `lt` | less than | one | yes | number, date, time, datetime | | `lte` | at most | one | yes | number, date, time, datetime | | `gt` | greater than | one | yes | number, date, time, datetime | | `gte` | at least | one | yes | number, date, time, datetime | | `contains` | contains | text | no | text | | `notContains` | does not contain | text | no | text | | `startsWith` | starts with | text | no | text | | `endsWith` | ends with | text | no | text | | `between` | is between | `[a, b]` | no | number, date, time, datetime | | `notBetween` | is not between | `[a, b]` | no | number, date, time, datetime | | `in` | is any of | list | no | text, number, enum | | `notIn` | is none of | list | no | text, number, enum | | `hasAny` | has any of | list | no | multiEnum | | `hasAll` | has all of | list | no | multiEnum | | `hasNone` | has none of | list | no | multiEnum | | `setEq` | is exactly | list | no | multiEnum | | `setNeq` | is not exactly | list | no | multiEnum | | `isEmpty` | is empty | none | no | text, multiEnum, array | | `isNotEmpty` | is not empty | none | no | text, multiEnum, array | | `isNull` | is null | none | no | all | | `isNotNull` | is not null | none | no | all | "Value" is the shape of `rule.value`: `one` matches the field's type, `text` is always a string (substring matching), `[a, b]` a pair, `list` an array. `OPERATORS` holds the table, `operatorInfo(fields, id)` looks up built-in and custom operators, and each has an `inverse` (`eq` ↔ `neq`, `in` ↔ `notIn`…). Per kind, the defaults are exported as `TEXT_OPERATORS`, `NUMBER_OPERATORS`, `DATE_OPERATORS` (also used by time and datetime), `BOOLEAN_OPERATORS`, `ENUM_OPERATORS`, `MULTI_ENUM_OPERATORS` and `ARRAY_OPERATORS`. # Settings (/docs/reference/settings) The StoreSettings object. Pass `settings` to `createQueryStore` (or `useQueryStore` / ``), and change them later with `store.setSettings(partial)`. The [settings guide](/docs/guides/settings) explains each one. ```ts interface StoreSettings { readOnly?: boolean; immutable?: { groups?: boolean; fields?: boolean; operators?: boolean; values?: boolean }; maxDepth?: number; maxRules?: number; canReorder?: boolean; // default true canRegroup?: boolean; // default true canDeleteLocked?: boolean; // default false canLeaveEmptyGroup?: boolean; // default true emptyNewGroups?: boolean; // default false allowRefs?: boolean; // default true allowFunctions?: boolean; // default true confirmRemove?: (node: QueryNode) => boolean | Promise; operatorOnFieldChange?: 'keep' | 'reset'; valueOnFieldChange?: 'keep' | 'reset'; valueOnOperatorChange?: 'keep' | 'reset'; } ``` `Action`, as passed to `store.can`: `'addRule' | 'addGroup' | 'remove' | 'clone' | 'move' | 'editField' | 'editOperator' | 'editValue' | 'editGroup' | 'lock'`. # Store (/docs/reference/store) Every method of QueryStore. ```ts createQueryStore({ fields, initial?, format?, history?, settings?, sanitize? }) ``` | Option | | | --- | --- | | `fields` | The schema from `defineFields`. | | `initial` | Starting query (in `format`'s shape when given). Defaults to an empty AND group. | | `format` | A two-way codec; `getValue`/`setValue` then speak its shape. | | `history` | `false`, or `{ limit = 100, mergeWindowMs = 1000 }`. On by default. | | `settings` | See [settings](/docs/reference/settings). | | `sanitize` | `SanitizeOptions` applied to `initial`, `setQuery` and `setValue`. | ## Reading [#reading] | | | | --- | --- | | `rootId`, `version`, `ruleCount`, `settings`, `fields` | | | `get(id)`, `getRule(id)`, `getGroup(id)` | Nodes by id (`getRule`/`getGroup` throw on the wrong kind). | | `path(id)`, `depth(id)` | Child indexes from the root; group nesting depth. | | `fieldsFor(id)` | The schema a node sees (the element schema inside array groups). | | `isLocked(id)` | The node or an ancestor is locked. | | `can(action, id, target?)` | Whether an action is allowed now. | | `getQuery()` | The query as JSON, structurally shared. | | `getNodeJSON(id)` | One subtree as JSON. | | `getValue()` | The query in the store's `format`. | | `issues(id)`, `isValid()` | Validation. | ## Subscribing [#subscribing] | | | | --- | --- | | `subscribe(id, fn)` | When that node changes. | | `subscribe(fn)` | After every change. | | `subscribePermissions(fn)` | When settings, locks, or (under limits) the tree's shape change. | Each returns an unsubscribe function. ## Editing [#editing] | | | | --- | --- | | `addRule(groupId, rule?, index?)` | Returns the new id, or `null` if not allowed. | | `addGroup(groupId, group?, index?)` | New groups contain one rule unless `emptyNewGroups`. | | `addArrayGroup(groupId, field, match?, index?)` | | | `insert(groupId, node, index?)` | Any JSON subtree. | | `remove(id)`, `requestRemove(id)` | `requestRemove` asks `confirmRemove` first. | | `clear()` | Remove every child of the root. | | `move(id, groupId, index)`, `canMove(id, groupId)` | | | `clone(id)` | Inserted after the original. | | `setField(id, field)`, `setOperator(id, op)`, `setRuleValue(id, value)` | | | `setRef(id, field \| null)`, `setCall(id, call \| null)` | Switch the right side to a field or a function, or back to a value. | | `setCombinator(id, 'and' \| 'or')`, `setNot(id, boolean)`, `setMatch(id, quantifier)` | | | `setLocked(id, boolean)` | | | `setQuery(query)`, `setValue(value)` | Load a query, reconciled node by node. | | `reset(query?)` | Load as a fresh baseline and clear history. | | `setSettings(partial)` | | | `batch(fn, { history?, merge? })` | One notification, one undo step. | ## History [#history] `undo()`, `redo()`, `canUndo()`, `canRedo()`, `clearHistory()`. ## Lifecycle [#lifecycle] `dispose()` drops every subscriber. # Utilities (/docs/reference/utilities) Helpers for building UIs, converting values, and working with the schema. ## For UIs [#for-uis] | | | | --- | --- | | `editorFor(def, operator, fields)` | The `EditorSpec` a rule needs: `type`, `arity`, `options` (flattened trees with `depth`), `allowsRef`, `async`. | | `fieldOptions(fields)` | Pickable fields: `{ value, label, group?, description?, isArray }`; hidden fields left out. | | `operatorOptions(def, fields)` | `{ value, label }` for a field's operators. | | `refOptions(fields, field)` | Fields a rule may compare against (same kind, comparable). | | `functionOptions(fields, kind?)` | Registered functions, optionally by return kind. | | `countOptions(labels?)` | The count comparisons for array groups. | | `defaultCall(fields, name)` | A function call with each argument at its default. | | `parseInput(def, raw)` / `formatInput(value)` | Between an input's string and a typed value. | | `parseList(def, raw)` / `formatList(value)` | Between a comma-separated string and a list. | | `optionValues(def)` | Every selectable option value, flattened. | | `CLASS`, `DEFAULT_LABELS` | The default builders' class names and strings. | | `createDnd(store)`, `dropIndexFromPointer(rect, y, index)` | The framework-free drag-and-drop state machine the bindings use. | ## Values and rules [#values-and-rules] | | | | --- | --- | | `makeRule(fields, partial?)` | A complete rule with the schema's defaults. | | `changeField(fields, rule, field, options?)` | A rule moved to another field, keeping what still fits. | | `changeOperator(fields, rule, op, options?)` | A rule with its value reshaped for a new operator. | | `coerceValue(fields, def, op, value)` | Reshape a value for an operator's arity. | | `isGroup`, `isRule`, `isLiteralRule`, `fieldName` | Type guards and helpers. | ## Schema [#schema] | | | | --- | --- | | `fieldLabel(name, def)` | The label, or the humanized key. | | `columnOf(fields, name)` | The name exporters use. | | `flattenOptions(options, leafOnly?)` | Tree options as a flat list. | | `elementFields(fields, arrayField)` | The element schema of an array field. | | `leftDef(fields, field)` / `ruleDef(fields, rule)` | The schema of a rule's left side (a field, or a function's return). | | `operatorInfo(fields, id)` | A built-in or custom operator's metadata. | | `functionOf(fields, call)`, `evaluateOperand(…)` | Look up and run functions. | | `registryOf(fields)` | The custom operators, functions and messages attached by `defineFields`. | | `fieldsToJSON`, `fieldsFromJSON` | Serialize a schema. | | `getPath(row, path)` | The default row reader: plain keys, then dotted paths. |