Query methods & operators
Guide to building and changing a list query. The full method reference is the Catalog API.
Query object
Access the query API through list.catalog.query:
- Every
DcuplListhas acatalogwith aqueryobject - The query object manages conditions, groups, and options
- Changes to the query automatically update filtered results
The query API provides methods to add, set, remove, and execute queries.
import { Dcupl } from '@dcupl/core';
const dcupl = new Dcupl();
dcupl.models.set({
key: 'Product',
properties: [{ key: 'status', type: 'string' }],
data: [{ key: 'p1', status: 'active' }],
});
await dcupl.init();
// Create a list
const list = dcupl.lists.create({
modelKey: 'Product',
});
// Access the query object
const query = list.catalog.query;
// Add conditions, get results
query.addCondition({
attribute: 'status',
operator: 'eq',
value: 'active',
});
const items = query.items();Adding conditions
addCondition() adds filter conditions to the query:
- Pass a single
QueryConditionobject - Or pass an array of conditions
- Each condition goes into a group named after its attribute
- Conditions on different attributes combine with AND
- Conditions on the same attribute combine with OR
Use addCondition when building queries incrementally, like adding filters one at a time based on user selections. For a range on one attribute (AND) or an OR across attributes, use addGroup() with an explicit groupType.
// Single condition
query.addCondition({
attribute: 'category',
operator: 'eq',
value: 'Electronics',
});
// Different attributes combine with AND
query.addCondition([
{ attribute: 'inStock', operator: 'eq', value: true },
{ attribute: 'rating', operator: 'gte', value: 4 },
]);
// Result: category=Electronics AND inStock=true AND rating>=4
// The same attribute combines with OR
query.addCondition({
attribute: 'category',
operator: 'eq',
value: 'Computers',
});
// Result: (category=Electronics OR category=Computers)
// AND inStock=true AND rating>=4
// A range needs an 'and' group - two addCondition calls
// on 'price' would mean price>=100 OR price<=500
query.addGroup({
groupKey: 'price',
groupType: 'and',
queries: [
{ attribute: 'price', operator: 'gte', value: 100 },
{ attribute: 'price', operator: 'lte', value: 500 },
],
});Setting conditions
setCondition() replaces the conditions on one attribute:
- Replaces the conditions in the group of the condition's attribute
- Keeps the conditions on all other attributes
- Useful for single-choice filters, like a category dropdown
To start over with a completely different filter, call clear() first.
// Add some conditions first
query.addCondition({
attribute: 'category',
operator: 'eq',
value: 'Electronics',
});
query.addCondition({
attribute: 'price',
operator: 'gte',
value: 100,
});
// setCondition replaces the 'category' conditions only
query.setCondition({
attribute: 'category',
operator: 'eq',
value: 'Furniture',
});
// Now: category=Furniture AND price>=100
// Start over with a different filter
query.clear();
query.setCondition([
{ attribute: 'status', operator: 'eq', value: 'active' },
{ attribute: 'onSale', operator: 'eq', value: true },
]);
// Now: status=active AND onSale=trueQuery groups
Use addGroup() and setGroup() for complex boolean logic:
groupType: 'and'- All conditions must matchgroupType: 'or'- Any condition can matchgroupKey- Identifier to extend, replace, or remove the group later
Groups are combined with AND. addGroup() with an existing groupKey extends that group; setGroup() replaces it. addGroup() always adds a group without a groupKey as a new group, while setGroup() without a key replaces the first keyless group. Give groups a key to keep them apart.
Always set groupType: a group created or replaced (setGroup()) without one is stored as 'or'.
Groups can be nested for arbitrary boolean expressions like (A AND B) OR (C AND D).
// Add OR group: Electronics OR Computers
query.addGroup({
groupType: 'or',
groupKey: 'category-filter',
queries: [
{ attribute: 'category', operator: 'eq', value: 'Electronics' },
{ attribute: 'category', operator: 'eq', value: 'Computers' },
],
});
// setGroup replaces the group with the same groupKey
query.setGroup({
groupType: 'or',
groupKey: 'category-filter',
queries: [
{ attribute: 'category', operator: 'eq', value: 'Furniture' },
],
});
// Nested group: inStock=true AND (brand=Apple OR brand=Samsung)
query.addGroup({
groupType: 'and',
groupKey: 'stock-and-brand',
queries: [
{ attribute: 'inStock', operator: 'eq', value: true },
{
groupType: 'or',
queries: [
{ attribute: 'brand', operator: 'eq', value: 'Apple' },
{ attribute: 'brand', operator: 'eq', value: 'Samsung' },
],
},
],
});
// Result: category=Furniture
// AND inStock=true AND (brand=Apple OR brand=Samsung)Removing conditions
Remove specific conditions or clear the entire query:
| Method | Description |
|---|---|
removeCondition(condition) |
Remove one condition (pass the full condition) |
removeGroup(groupKey) |
Remove a group by its key. Attribute groups from addCondition are keyed by the attribute name. |
clear() |
Remove all conditions and groups, and the sort and pagination options |
// Setup: add conditions
query.addCondition({ attribute: 'category', operator: 'eq', value: 'Electronics' });
query.addCondition({ attribute: 'price', operator: 'gte', value: 100 });
query.addGroup({
groupKey: 'brand-filter',
groupType: 'or',
queries: [
{ attribute: 'brand', operator: 'eq', value: 'Apple' },
{ attribute: 'brand', operator: 'eq', value: 'Samsung' },
],
});
// Remove one condition
query.removeCondition({ attribute: 'price', operator: 'gte', value: 100 });
// Remove all conditions on an attribute
query.removeGroup('category');
// Remove group by key
query.removeGroup('brand-filter');
// Clear everything
query.clear();
// Query is now emptyQuery options
applyOptions() configures pagination, sorting, and projection:
| Option | Description |
|---|---|
start |
Skip N items (offset for pagination) |
count |
Limit results to N items |
sort |
{ attributes, order } - one 'ASC' or 'DESC' per attribute (uppercase) |
projection |
Select specific properties to return, e.g. { $: false, name: true } |
// Pagination: page 2, 20 items per page
query.applyOptions({
start: 20,
count: 20,
});
// Sorting: by price descending, then by name
query.applyOptions({
sort: { attributes: ['price', 'name'], order: ['DESC', 'ASC'] },
});
// Projection: only return specific fields ($: false drops the rest;
// key is always included)
query.applyOptions({
projection: { $: false, name: true, price: true, category: true },
});
// Combine all options
query.applyOptions({
start: 0,
count: 50,
sort: { attributes: ['createdAt'], order: ['DESC'] },
projection: { $: false, name: true, price: true },
});Getting results
Retrieve query results with these methods:
| Method | Returns | Use Case |
|---|---|---|
items() |
Array of matching items | Display lists, tables |
first() |
First item of the current page (an applied start counts) or undefined |
Single item lookup |
any() |
Boolean: any item on the current page (false when start is past the last match) |
Check existence |
count() |
Number of items on the current page | Page size checks |
fn.metadata().currentSize |
Number of all matching items | Display total count |
// Get all matching items (respects start/count options)
const products = query.items();
// => [{ key: 'p1', name: 'Laptop', ... }, ...]
// Get just the first match
const firstProduct = query.first();
// => { key: 'p1', name: 'Laptop', ... } or undefined
// Check if any items match
const hasResults = query.any();
// => true or false
// Count items on the current page (respects start/count)
const pageCount = query.count();
// => 20
// Total number of matching items (ignores start/count)
const totalCount = list.catalog.fn.metadata().currentSize;
// => 42
// Common pattern: pagination info
const items = query.items();
const total = list.catalog.fn.metadata().currentSize;
console.log(`Showing ${items.length} of ${total} results`);Query state
Inspect the current state of a query:
| Method | Returns | Use Case |
|---|---|---|
current() |
QueryExecutionContext | Serialize, debug, or restore query |
isEmpty() |
Boolean | Check if query has conditions |
has({ groupKey }) |
Boolean | Check for a specific group |
// Get complete query context
const context = query.current();
// => { modelKey: 'Product', groupKey: 'root', groupType: 'and',
// queries: [...], start: 0, count: 50 }
// Useful for debugging or saving to URL/storage
console.log(JSON.stringify(context, null, 2));
// Check if query is empty
if (query.isEmpty()) {
console.log('No filters applied');
}
// Check if an attribute is filtered
// (addCondition groups are keyed by the attribute name)
const hasCategory = query.has({ groupKey: 'category' });
// Check if specific group exists
const hasBrandGroup = query.has({ groupKey: 'brand-filter' });
// => true if a group with that key existsOperators
The operator property determines how values are compared:
| Category | Operators |
|---|---|
| Equality | eq, in |
| Comparison | gt, gte, lt, lte |
| Text and pattern | find |
| Type and value checks | typeof, isTruthy, size |
There are no separate "not" operators: negate any condition with options: { invert: true }. Text search uses find with a regular expression in slashes; a bare string must match the whole value. Ranges are an 'and' group of gte and lte.
Any other operator name runs the script registered under that key as a custom operator. Without one, the query throws an UnknownOperator error naming the operator and the valid ones, so a typo like 'neq' fails instead of silently returning no items.
See Operators Reference for full documentation.
// Equality
{ attribute: 'status', operator: 'eq', value: 'active' }
// Not equal: invert any condition
{ attribute: 'status', operator: 'eq', value: 'deleted', options: { invert: true } }
// Comparison
{ attribute: 'price', operator: 'gte', value: 100 }
{ attribute: 'price', operator: 'lt', value: 1000 }
// Contains: wrap the term in slashes (a bare string matches the whole value)
{ attribute: 'name', operator: 'find', value: '/laptop/', options: { regexFlags: 'i' } }
// Starts with / Ends with
{ attribute: 'sku', operator: 'find', value: '/^SKU-/' }
{ attribute: 'email', operator: 'find', value: '/@company\\.com$/' }
// In list
{ attribute: 'category', operator: 'in', value: ['Electronics', 'Computers'] }
// Not in list
{ attribute: 'status', operator: 'in', value: ['archived', 'deleted'], options: { invert: true } }
// Has a value (null counts as a value here: typeof null is 'object')
{ attribute: 'description', operator: 'typeof', value: 'undefined', options: { invert: true } }
// Has a usable value (not undefined, null or '')
{ attribute: 'description', operator: 'isTruthy', value: true }
// Array or string length
{ attribute: 'tags', operator: 'size', value: 0 }Condition options
Fine-tune condition behavior with the options property:
| Option | Description |
|---|---|
transform |
Array of transforms applied to both sides before comparing: 'lowercase', 'trim', 'removeWhitespace' |
invert |
Negate the condition result |
arrayValueHandling |
How to match array values ('some', the default, or 'every') |
regexFlags |
find only: flags for a '/regex/' value, e.g. 'i' for case-insensitive |
// Case-insensitive exact match with transform
query.addCondition({
attribute: 'name',
operator: 'eq',
value: 'laptop',
options: { transform: ['lowercase'] },
});
// Case-insensitive substring search with regexFlags
query.addCondition({
attribute: 'name',
operator: 'find',
value: '/lap/',
options: { regexFlags: 'i' },
});
// Invert: find items NOT matching
query.addCondition({
attribute: 'status',
operator: 'eq',
value: 'archived',
options: { invert: true },
});
// Equivalent to: status != 'archived'
// Array handling: at least one tag is 'sale' or 'featured'
query.addCondition({
attribute: 'tags',
operator: 'in',
value: ['sale', 'featured'],
options: { arrayValueHandling: 'some' },
});
// Array handling: every tag is 'premium' or 'verified'
// (an empty tags array never matches 'every')
query.addCondition({
attribute: 'tags',
operator: 'in',
value: ['premium', 'verified'],
options: { arrayValueHandling: 'every' },
});