.md

Query operators reference

Complete reference of all query operators available in the dcupl SDK.

Quick reference

Operator Description Supported Types
eq Exact match All
in Matches any value of a list All
find Regex, exact or object match string, object
gt Greater than string, number, date
gte Greater than or equal string, number, date
lt Less than string, number, date
lte Less than or equal string, number, date
typeof Type checking All
isTruthy Boolean truthiness check All
size Array/string length Array, string

These ten are all the built-in operators; there is no neq, nin, exists or regex (use eq or in with invert, typeof and find instead). A condition with any other operator name runs the operator script registered under that name; if there is none, the query throws an UnknownOperator error (see Custom operators).


Comparison operators

eq

Matches exact values. Works with all data types.

Signature:

{ attribute: string, operator: 'eq', value: any }

Examples:

String equality
{ attribute: 'status', operator: 'eq', value: 'active' }
Number equality
{ attribute: 'price', operator: 'eq', value: 99.99 }
Boolean equality
{ attribute: 'inStock', operator: 'eq', value: true }
Reference equality
{ attribute: 'category', operator: 'eq', value: { key: 'electronics' } }
Date equality
{ attribute: 'createdAt', operator: 'eq', value: new Date('2025-01-01') }

eq on a date property also matches the ISO string (date.toISOString()), but not a timestamp.

Array equality (exact match)
{ attribute: 'tags', operator: 'eq', value: ['sale', 'featured'] }

eq with an array value compares the whole array. To match "any of" several values, use in.


in

Matches if the attribute's value equals any value of the list. It uses the same equality as eq: a string or { key } matches a reference by its key, and a date by its ISO string. It answers like an or group of one eq condition per listed value.

Signature:

{ attribute: string, operator: 'in', value: unknown[] }

Rules:

  • On an Array<…> property or a dotted path through a multi-valued reference, arrayValueHandling: 'some' (default) matches if at least one value is listed; 'every' matches if every value is listed (and there is at least one).
  • options.invert is NOT IN. Like eq, a missing value then matches.
  • options.transform applies to the attribute value and to every listed value.
  • null in the list matches null, like eq with null.
  • An empty list matches nothing (and everything under invert).
  • value must be an array; anything else throws invalid query - in operator needs an array value, and a list doesn't store the condition (its query stays as it was).

Examples:

Any of several values
{ attribute: 'status', operator: 'in', value: ['active', 'pending'] }
Through references
{ attribute: 'customer.address.city', operator: 'in', value: ['Vienna', 'Graz'] }
Reference keys, as strings or { key }
{ attribute: 'category', operator: 'in', value: ['cat-1', { key: 'cat-2' }] }
Every tag is one of the listed ones
{ attribute: 'tags', operator: 'in', value: ['sale', 'new'], options: { arrayValueHandling: 'every' } }
NOT IN
{ attribute: 'status', operator: 'in', value: ['archived', 'deleted'], options: { invert: true } }

gt

Matches values greater than the target (exclusive).

The same value rules apply to gt, gte, lt and lte: numbers compare numerically and strings by character code, which is case-sensitive ('book' > 'M', because lowercase letters come after uppercase ones). On a date property, pass a Date (or a timestamp in milliseconds); an ISO string such as '2025-01-01' matches nothing. A missing (undefined/null) value never matches.

Signature:

{ attribute: string, operator: 'gt', value: number | string | Date }

Examples:

Number greater than
{ attribute: 'price', operator: 'gt', value: 100 }
// Matches: 100.01, 150, 200 (not 100)
Date greater than
{ attribute: 'orderDate', operator: 'gt', value: new Date('2025-01-01') }
// Matches: 2025-01-02 and later (not 2025-01-01)
String greater than (alphabetical)
{ attribute: 'name', operator: 'gt', value: 'M' }
// Matches: "MacBook", "Nike", "Zenith" (not "Apple", "Laptop")

gte

Matches values greater than or equal to the target (inclusive).

Signature:

{ attribute: string, operator: 'gte', value: number | string | Date }

Examples:

Number greater than or equal
{ attribute: 'price', operator: 'gte', value: 100 }
// Matches: 100, 100.01, 150, 200
Date greater than or equal
{ attribute: 'orderDate', operator: 'gte', value: new Date('2025-01-01') }
// Matches: 2025-01-01 and later

lt

Matches values less than the target (exclusive).

Signature:

{ attribute: string, operator: 'lt', value: number | string | Date }

Examples:

Number less than
{ attribute: 'stock', operator: 'lt', value: 10 }
// Matches: 0, 1, 9 (not 10)
Date less than
{ attribute: 'expiresAt', operator: 'lt', value: new Date() }
// Matches: Past dates only

lte

Matches values less than or equal to the target (inclusive).

Signature:

{ attribute: string, operator: 'lte', value: number | string | Date }

Examples:

Number less than or equal
{ attribute: 'price', operator: 'lte', value: 100 }
// Matches: 0, 50, 99.99, 100
Price range (combined)
productList.catalog.query.addGroup({
  groupKey: 'price',
  groupType: 'and',
  queries: [
    { attribute: 'price', operator: 'gte', value: 50 },
    { attribute: 'price', operator: 'lte', value: 150 },
  ],
});
// Matches: 50 to 150 inclusive

Put both bounds in one 'and' group. Passed to addCondition, two conditions on the same attribute are combined with OR, which matches every price.


Search operators

find

Regex, exact or object match. How it matches depends on the shape of value:

  • Slash-delimited regex (e.g. '/wireless/', '/^foo/'): the text between the slashes is a JavaScript regex. Use this for substring and pattern matching. Flags go in options.regexFlags, not after the closing slash: '/^foo/i' is a bare string and matches exactly.
  • Bare string (e.g. 'wireless'): exact equality against the whole field value, not a substring. 'Ball' does not match 'Soccer Ball'.
  • Object (e.g. { key: 'value' }): property match against the field.

A /regex/ is tested against non-string values as strings: '/^1/' on a number attribute matches 150 and 100.

Signature:

{
  attribute: string,
  operator: 'find',
  value: Record<string, unknown> | string,
  options?: { regexFlags?: string }
}

regexFlags applies only to a /regex/ value; it is ignored for any other value, and invalid flags match nothing. When you build the pattern from user input, escape regex characters before wrapping it in slashes. For search UIs, see Text search.

Examples:

Substring search
{ attribute: 'description', operator: 'find', value: '/wireless/' }
// Matches: "Bluetooth wireless headphones" (not "Wireless Mouse")
Case-insensitive substring search
{
  attribute: 'name',
  operator: 'find',
  value: '/laptop/',
  options: { regexFlags: 'i' }
}
// Matches: "Laptop", "LAPTOP", "Gaming laptop"
Exact match of the whole value
{ attribute: 'status', operator: 'find', value: 'active' }
Find in nested objects
{ attribute: 'metadata', operator: 'find', value: { key: 'value' } }
// Matches items with specific nested property

Type operators

typeof

Matches values by their JavaScript type.

Signature:

{ attribute: string, operator: 'typeof', value: TypeName }

Valid Types: 'string', 'number', 'boolean', 'object', 'undefined'

Note: To find null values, use the eq operator with value: null instead of typeof (typeof 'object' matches null too, as in JavaScript).

Examples:

Find undefined values
{ attribute: 'description', operator: 'typeof', value: 'undefined' }
// Matches items whose description is absent (not null or '')
Find defined values (with invert)
{
  attribute: 'description',
  operator: 'typeof',
  value: 'undefined',
  options: { invert: true }
}
// Matches items with any description value, null and '' included (use isTruthy true for a usable value)
Find null values (use eq, not typeof)
{ attribute: 'deletedAt', operator: 'eq', value: null }
// Matches items whose deletedAt is null (not items without a deletedAt value)
Find numeric values
{ attribute: 'discount', operator: 'typeof', value: 'number' }
// Matches items with a numeric discount

isTruthy

Evaluates JavaScript truthiness.

Signature:

{ attribute: string, operator: 'isTruthy', value: boolean }

Truthy Values: true, non-zero numbers, non-empty strings, objects Falsy Values: false, 0, '', null, undefined, NaN

On an Array<…> property each element is checked (see arrayValueHandling); an empty array matches neither true nor false.

Examples:

Find truthy values
{ attribute: 'inStock', operator: 'isTruthy', value: true }
// Matches: inStock=true, inStock=1, inStock="yes"
Find falsy values
{ attribute: 'discontinued', operator: 'isTruthy', value: false }
// Matches: discontinued=false, discontinued=0, discontinued=null

size

Matches arrays or strings by their length.

Signature:

{ attribute: string, operator: 'size', value: number }

Examples:

Exact size match
{ attribute: 'images', operator: 'size', value: 3 }
// Matches items with exactly 3 images
Empty arrays
{ attribute: 'reviews', operator: 'size', value: 0 }
// Matches items whose reviews value is an empty array,
// not items that have no reviews value at all (undefined/null never matches)
String length
{ attribute: 'sku', operator: 'size', value: 8 }
// Matches SKUs with exactly 8 characters

size always measures the whole array or string; arrayValueHandling is ignored.


Operator options

transform and invert work with every operator, arrayValueHandling with every operator except size, and regexFlags only with find.

transform

An array of transformations, applied in order to both the attribute value and the query value before comparison.

Available Transforms:

  • 'lowercase' - Convert to lowercase
  • 'trim' - Remove leading/trailing whitespace
  • 'removeWhitespace' - Remove all whitespace
Case-insensitive exact match
{
  attribute: 'name',
  operator: 'find',
  value: 'laptop',
  options: { transform: ['lowercase'] }
}
// Matches: "Laptop", "LAPTOP" (not "Gaming laptop": a bare string matches the whole value)
Multiple transforms
{
  attribute: 'email',
  operator: 'eq',
  value: 'user@example.com',
  options: { transform: ['lowercase', 'trim'] }
}

Only string values (and strings inside arrays) are transformed: on a number or date attribute a transform does nothing. A transform also makes eq and in check every item instead of using the attribute's index.


invert

Negates the operator result. Applies to every built-in operator, not to custom operators: their result is used as is, so negate inside the script or function. A missing (undefined/null) value matches neither way, except for eq, in, typeof and isTruthy, which compare against it. A list of values (an Array<…> property or a dotted path through a multi-valued reference) is checked per value: an empty list does not match the condition, so the inverted condition matches it.

NOT equals
{
  attribute: 'status',
  operator: 'eq',
  value: 'discontinued',
  options: { invert: true }
}
// Matches items where status is NOT 'discontinued'
NOT in array
{
  attribute: 'tags',
  operator: 'find',
  value: 'hidden',
  options: { invert: true }
}
// Matches items WITHOUT 'hidden' tag

arrayValueHandling

Controls how operators match against array values: Array<…> properties and dotted paths through a multi-valued reference (memberships.role compares the role of every membership).

Values:

  • 'some' (default) - Match if ANY element matches
  • 'every' - Match only if ALL elements match, and there is at least one: an empty array (or a reference with no items) never matches
Match any element (default)
{
  attribute: 'tags',
  operator: 'eq',
  value: 'featured',
  options: { arrayValueHandling: 'some' }
}
// Matches if ANY tag equals 'featured'
Match all elements
{
  attribute: 'tags',
  operator: 'find',
  value: '/promo/',
  options: { arrayValueHandling: 'every' }
}
// Matches only if ALL tags contain 'promo'

regexFlags

Flags for a find condition with a /regex/ value, e.g. 'i' for case-insensitive. Ignored by every other operator and value shape; invalid flags match nothing. Never put flags after the closing slash: '/laptop/i' is a bare string and matches only that exact text.

Case-insensitive substring
{
  attribute: 'name',
  operator: 'find',
  value: '/laptop/',
  options: { regexFlags: 'i' }
}
// Matches: "Laptop", "LAPTOP", "Gaming laptop"

Query structure

Single condition

productList.catalog.query.addCondition({
  operator: 'eq',
  attribute: 'status',
  value: 'active',
});

Multiple conditions (AND)

productList.catalog.query.addCondition([
  { operator: 'eq', attribute: 'status', value: 'active' },
  { operator: 'gte', attribute: 'price', value: 50 },
]);
// Result: status = 'active' AND price >= 50

addCondition puts each condition into the group keyed by its attribute (created as an 'or' group). Conditions on different attributes are combined with AND; conditions on the same attribute are combined with OR. For a range on one attribute, use an 'and' group (see Price range).

Grouped conditions (OR)

productList.catalog.query.addGroup({
  groupKey: 'categories',
  groupType: 'or',
  queries: [
    { operator: 'eq', attribute: 'category', value: { key: 'electronics' } },
    { operator: 'eq', attribute: 'category', value: { key: 'computers' } },
  ],
});

Nested groups

A nested group goes into the queries array, next to the conditions.

productList.catalog.query.addGroup({
  groupKey: 'main',
  groupType: 'and',
  queries: [
    { operator: 'eq', attribute: 'active', value: true },
    {
      groupKey: 'categories',
      groupType: 'or',
      queries: [
        { operator: 'eq', attribute: 'category', value: { key: 'electronics' } },
        { operator: 'eq', attribute: 'category', value: { key: 'computers' } },
      ],
    },
  ],
});
// Result: active AND (electronics OR computers)

Deep queries

Query through references using dot notation.

Single-valued reference
{ attribute: 'customer.name', operator: 'eq', value: 'John' }
// Orders whose customer's name is exactly 'John' (order -> customer reference)
Nested reference
{ attribute: 'customer.address.city', operator: 'eq', value: 'New York' }
// Query nested properties through references
Multi-valued reference
{ attribute: 'orders.status', operator: 'eq', value: 'completed' }
// Customers with at least one completed order (arrayValueHandling 'some')

Custom operators

Any operator that is not built in runs the operator script registered under that name with dcupl.scripts.set(). Without one, the query throws an UnknownOperator error (invalid query - unknown operator "X"; expected a built-in operator (…) or a registered custom operator (…)) from dcupl.query.execute() and before a list stores the condition, so a misspelt operator fails instead of matching nothing. Check error.name === 'UnknownOperator', not instanceof (see Error handling). The older dcupl.query.registerCustomOperator() still works but is deprecated; a script with the same name takes precedence over it. options.invert is not applied to a custom operator's result: negate inside the script or function.

dcupl.query.registerCustomOperator(
  operator: string,
  fn: (query: DcuplQuery, listItem: ListItem) => boolean
): void

The function receives the condition (attribute, operator, value) and the item, whose attribute values are top-level properties. It is only called for items that have a value for query.attribute; items without one never match.

Between operator
dcupl.query.registerCustomOperator('$between', (query, listItem) => {
  const value = listItem[query.attribute];
  const [min, max] = query.value as [number, number];
  return value >= min && value <= max;
});

dcupl.query.execute({
  modelKey: 'Product',
  queries: [{ attribute: 'price', operator: '$between', value: [100, 500] }],
});
The same as an operator script
dcupl.scripts.set(
  '$between',
  `const [min, max] = query.value; const value = item[query.attribute]; return value >= min && value <= max;`,
);

In TypeScript, DcuplQuery.operator only lists the built-in operators, so list.catalog.query.addCondition({ operator: '$between', ... }) does not type-check. Cast the condition ({ ... } as unknown as DcuplQuery); dcupl.query.execute() accepts it as is.


Type reference

DcuplQuery, DcuplQueryOperator and DcuplQueryGroup are exported from @dcupl/common, e.g. import type { DcuplQuery, DcuplQueryGroup } from '@dcupl/common';. DcuplQueryOptions is not exported (DcuplQuery declares its options inline); use NonNullable<DcuplQuery['options']> if you need it.

DcuplQuery

type DcuplQuery = {
  attribute: string;
  operator: DcuplQueryOperator;
  value: any;
  queryKey?: any;
  options?: DcuplQueryOptions;
};

DcuplQueryOperator

type DcuplQueryOperator = 'eq' | 'in' | 'find' | 'gt' | 'gte' | 'lt' | 'lte' | 'typeof' | 'isTruthy' | 'size';

DcuplQueryOptions

type DcuplQueryOptions = {
  transform?: ('lowercase' | 'trim' | 'removeWhitespace')[];
  invert?: boolean;
  arrayValueHandling?: 'some' | 'every';
  regexFlags?: string; // find only
};

DcuplQueryGroup

Groups combine multiple conditions with AND/OR logic. Nested groups are placed directly in the queries array.

type DcuplQueryGroup = {
  groupKey?: string;
  groupType?: 'and' | 'or'; // Set it explicitly (see below)
  queries: (DcuplQuery | DcuplQueryGroup)[];
};

Set groupType explicitly. When it is omitted, the effective behaviour depends on where the group ends up:

  • Groups given to the query builder (addGroup, setGroup, apply(group)) are stored as 'or', both when they are created and when they replace an existing group. When addGroup extends an existing keyed group, that group keeps its current groupType.
  • A group that reaches evaluation without a groupType (the root query, or a nested group passed inside a full query to apply({ groupKey: 'root', ... }), execute or dcupl.query.execute) is evaluated exactly like 'and'.

In add mode (the default), a group without a groupKey is always appended as a new group. Only keyed groups merge with an existing group of the same groupKey. In set mode (setGroup), a group without a groupKey replaces the first top-level group that has no key and is only appended when there is none. A keyed group is found at any depth (a top-level match wins), so a group nested inside a keyless wrapper is replaced or extended in place instead of getting a second top-level copy.

Example with nested groups:

// Complex filter: active products that are either electronics OR books
const query: DcuplQueryGroup = {
  groupType: 'and',
  queries: [
    { operator: 'eq', attribute: 'status', value: 'active' },
    {
      groupType: 'or',
      queries: [
        { operator: 'eq', attribute: 'category', value: 'electronics' },
        { operator: 'eq', attribute: 'category', value: 'books' },
      ],
    },
  ],
};

See also