Schema and types

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.

import { ,  } from '@qnot/core';

export const  = ({
  : .({ : 2 }),
  : .({ : 0, : 130, : true }),
  : .(['free', 'pro', 'team']),
  : .(['vip', 'beta', 'churned']),
  : .(),
  : .({ : 'Signed up' }),
  : .(),
  : .({ : .(), : .({ : 'postal_code' }) }),
  : .({ : .(), : .() }),
});

Field kinds

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.

Enum options can be plain values or { value, label } objects, and can nest with children for a tree select. leafOnly: true makes parents unselectable:

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

A query is a tree of groups and rules. It's plain JSON, so you can store it anywhere.

{
  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). Its shape follows the operator: nothing for isNull, one value for eq, a pair for between, a list for in.

Types

QueryOf<typeof fields> is the query typed against your schema. Rules are a discriminated union over every field and operator, so checking field and operator narrows value:

declare const : <typeof >;

if (. === 'age' && . === 'between' && ()) {
  .value;
value: readonly [number | null, number | null]
}

isLiteralRule rules out field refs and function calls, which carry no value. Mistakes are compile errors:

const bad: <typeof > = { : 'rule', : 'plan', : 'eq', : 'gold' };
Type '{ kind: "rule"; field: "plan"; operator: "eq"; value: "gold"; }' is not assignable to type 'FnRule | (RuleMeta & { readonly kind: "rule"; readonly field: "age"; readonly operator: "isNotNull"; readonly value?: null | undefined; readonly ref?: undefined; readonly call?: undefined; }) | ... 24 more ... | (RuleMeta & { ...; })'. Types of property 'value' are incompatible. Type '"gold"' is not assignable to type '"free" | "pro" | null | undefined'.

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:

if ((, )) {
  const  = .[0];
  if (?. === 'rule' && . === 'eq' && ()) {
    .value;
value: number
} }

Rows

RowOf<typeof fields> 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.