ObjectStackObjectStack

Query Syntax

Database-agnostic query language with filtering, joins, aggregations, and sorting — aligned with the canonical @objectstack/spec QuerySchema

ObjectQL queries are expressed as Abstract Syntax Trees (AST) in JSON format. This enables database-agnostic querying—write once, compile to PostgreSQL, MongoDB, SQLite, or any supported driver.

All query syntax in this document follows the canonical QuerySchema defined in @objectstack/spec (packages/spec/src/data/query.zod.ts). Filtering uses the where + MongoDB-style $op object syntax from FilterConditionSchema (packages/spec/src/data/filter.zod.ts).

Query Philosophy

Traditional SQL:

-- Tightly coupled to PostgreSQL
SELECT c.name, c.email, a.company_name
FROM contact c
LEFT JOIN account a ON c.account_id = a.id
WHERE c.is_active = true AND a.industry = 'tech'
ORDER BY c.created_at DESC
LIMIT 10;

ObjectQL (Canonical Spec Format):

import type { QueryAST } from '@objectstack/spec/data';

const query: QueryAST = {
  object: 'contact',
  fields: ['name', 'email', 'account'],
  where: { is_active: true },
  // Related records are loaded through `expand` — not through a JOIN and not
  // through a dotted `account.industry` path (see §2 and §4).
  expand: {
    account: { object: 'account', fields: ['company_name'] },
  },
  orderBy: [{ field: 'created_at', order: 'desc' }],
  limit: 10,
};

Runtime compilation:

  • PostgreSQL / MySQL → parameterised single-table SQL (@objectstack/driver-sql); related records are a second, batched $in read, not a JOIN
  • MongoDB → Native queries + aggregation pipeline (@objectstack/driver-mongodb)
  • SQLite → Portable SQL, in-process or in-browser via @objectstack/driver-sqlite-wasm
  • In-Memory → In-process evaluation, no external database (@objectstack/driver-memory)

Query Structure

The QueryAST (Canonical)

The canonical query structure is defined by QuerySchema in @objectstack/spec:

import type { QueryAST } from '@objectstack/spec/data';

// QueryAST — full structure
interface QueryAST {
  object: string;                          // Target object (required)
  fields?: FieldNode[];                    // Projection (SELECT) — field names
  where?: FilterCondition;                 // Filtering (WHERE) — MongoDB-style $op
  search?: string | FullTextSearch;        // Full-text search — the query text (canonical), or the structured form
  searchFields?: string[];                 // Narrow the search (server-intersected — narrows only, never widens)
  orderBy?: SortNode[];                    // Ordering (ORDER BY)
  limit?: number;                          // Max records (LIMIT)
  offset?: number;                         // Skip records (OFFSET)
  top?: number;                            // Alias for limit (OData compat)
  aggregations?: AggregationNode[];        // Aggregation functions
  groupBy?: GroupByNode[];                 // GROUP BY targets (string | object)
  having?: FilterCondition;                // HAVING — engine-enforced after aggregation
  expand?: Record<string, QueryAST>;       // Recursive relation loading
}

The protocol shape is wider than what the data engine executes.

QuerySchema validates the whole structure above, but IDataEngine.find() plus the shipped drivers run a subset. SqlDriver.find() builds only where / orderBy / limit / offset / fields (packages/drivers/driver-sql/src/sql-driver.ts), and expand is resolved afterwards by the engine as a batched $in read (packages/objectql/src/engine.ts). These members validate but are not executed on the find() path:

MemberStatus
aggregations[].filter[EXPERIMENTAL — not enforced] — a SQL FILTER (WHERE …) affordance neither the SQL builders nor the in-memory fallback applies
search.fuzzy / boost / operator / minScore / language / highlight[EXPERIMENTAL — not enforced] — only query and fields drive the expansion

top is the exception that is honored — the engine normalises it to limit.

The #4286 sweep (ADR-0049 enforce-or-remove) settled every other declared-but-inert member. Removed — tombstoned in @objectstack/spec 17, so a query carrying one fails to parse with the upgrade prescription and authoring it is a tsc error: joins (related records are read through expand), windowFunctions (a SQL-driver door remains: SqlDriver.findWithWindowFunctions()), cursor (express the keyset as a where predicate on the sort key — §7), and distinct (unique values via groupBy / count_distinct / the drivers' distinct() door; its only observable effect was suppressing the REST list count, which is truthful again). Enforced: having (§5). The experimental flags above are tracked in the liveness ledger (packages/spec/liveness/query.json).

One member of AggregationNode was settled separately, in #6815: the per-aggregation distinct flag is removed on the same terms. It escaped the #4286 sweep because that sweep asked which members no executor reads and this one had a reader — one out of six. The engine's in-memory fallback deduplicated before applying the function, while driver-sql, driver-turso, driver-mongodb, driver-memory and the analytics SQL builder all ignored it, so { function: 'sum', field: 'amount', distinct: true } answered a deduplicated sum on the fallback path and an ordinary sum on every SQL datasource — one query, two plausible numbers, chosen by which backend served it. The live deduplicating spelling is the count_distinct function (COUNT(DISTINCT field) on both SQL faces since #6409); SUM(DISTINCT …) / AVG(DISTINCT …) have no replacement, because no backend ever computed them here.

Key Types

// SortNode — ORDER BY element
interface SortNode {
  field: string;
  order: 'asc' | 'desc';   // default: 'asc'
}

// AggregationNode — aggregation definition
interface AggregationNode {
  function: 'count' | 'sum' | 'avg' | 'min' | 'max' | 'count_distinct';
  field?: string;        // optional for COUNT(*)
  alias: string;         // result column alias
  filter?: FilterCondition; // [EXPERIMENTAL — not enforced] FILTER WHERE clause — never applied
}
// `distinct?: boolean` was REMOVED in protocol 17 (#6815) — see the callout above.

// FieldNode — one entry of the select list. One of the queried object's OWN
// column names. The type is `string`, so a dotted path ('owner.name') still
// PARSES, but it resolves nothing: no driver ever implemented dotted
// projection, and the ingress refuses it (400 INVALID_FIELD, #7532). Related
// data — whole records and single related columns alike — comes from `expand`,
// not from inside this list.
//
// The `{ field, fields, alias }` nested-select member this union used to carry
// was REMOVED in protocol 17 (#4196): nothing produced it and nothing read
// `.fields`/`.alias`, so it was dropped by the SQL and memory drivers,
// projected as a column named "[object Object]" by MongoDB, and refused as an
// unknown field by the REST ingress. `expand` is the one spelling.
type FieldNode = string;

// GroupByNode — GROUP BY target
type GroupByNode = string | {
  field: string;
  dateGranularity?: 'day' | 'week' | 'month' | 'quarter' | 'year';
  alias?: string;        // defaults to `field`
};

1. Basic Queries

Select All Records

const customers = await engine.find('customer');

// SQL: SELECT * FROM customer;
// MongoDB: db.customer.find({})

Select Specific Fields

const customers = await engine.find('customer', {
  fields: ['company_name', 'industry', 'annual_revenue'],
});

// SQL: SELECT company_name, industry, annual_revenue FROM customer;
// MongoDB: db.customer.find({}, { company_name: 1, industry: 1, annual_revenue: 1 })

Limit and Offset

const customers = await engine.find('customer', {
  limit: 10,
  offset: 20,  // Skip first 20, get next 10
});

// SQL: SELECT * FROM customer LIMIT 10 OFFSET 20;
// MongoDB: db.customer.find().skip(20).limit(10)

2. Filtering

Filters use the where clause with MongoDB-style $op operators (object syntax).

Implicit Equality

The simplest filter — a field-value pair implies $eq:

const query: QueryAST = {
  object: 'customer',
  where: {
    industry: 'tech',       // Implicit: { $eq: 'tech' }
  },
};

// SQL: WHERE industry = 'tech'

Explicit Operators

Use $op keys for non-equality comparisons:

// Not equal
const query: QueryAST = {
  object: 'customer',
  where: {
    status: { $ne: 'inactive' },
  },
};
// SQL: WHERE status != 'inactive'

// Greater than
const query: QueryAST = {
  object: 'customer',
  where: {
    annual_revenue: { $gt: 1000000 },
  },
};
// SQL: WHERE annual_revenue > 1000000

Supported Operators

OperatorDescriptionExample
$eqEqual (implicit default){ status: 'active' } or { status: { $eq: 'active' } }
$neNot equal{ status: { $ne: 'closed' } }
$gtGreater than{ revenue: { $gt: 10000 } }
$gteGreater or equal{ score: { $gte: 80 } }
$ltLess than{ age: { $lt: 65 } }
$lteLess or equal{ discount: { $lte: 20 } }
$inIn list{ stage: { $in: ['proposal', 'negotiation'] } }
$ninNot in list{ status: { $nin: ['deleted', 'archived'] } }
$containsString contains, case-sensitive{ name: { $contains: 'Inc' } }
$icontainsString contains, ignoring ASCII case{ name: { $icontains: 'inc' } }
$notContainsString does not contain, case-sensitive{ name: { $notContains: 'test' } }
$startsWithString starts with, case-sensitive{ email: { $startsWith: 'admin' } }
$endsWithString ends with, case-sensitive{ domain: { $endsWith: '.com' } }
$likeLIKE pattern — you write the wildcards, case-sensitive{ name: { $like: '%Industries' } }
$ilikeSame pattern language, ignoring ASCII case{ name: { $ilike: '%industries' } }
$betweenRange (inclusive){ close_date: { $between: ['2024-01-01', '2024-12-31'] } }
$nullNull check{ manager_id: { $null: true } } / { phone: { $null: false } }
$existsField exists (NoSQL){ metadata: { $exists: true } }

$like is not a spelling of $contains

The two take different things and picking the wrong one is a silently wrong answer rather than an error:

  • $contains takes TEXT. It is matched literally as a substring, and %, _ and regex metacharacters in your comparand are ordinary characters — escaped on your behalf.
  • $like takes a PATTERN. % matches any sequence, _ matches exactly one character, and a backslash escapes either. The pattern is matched against the whole value, so a pattern with no wildcards is an exact comparison, not a substring search.
{ name: { $contains: 'Industries' } }  // Acme Industries, Industries Ltd, Industries
{ name: { $like: 'Industries' } }      // Industries          — exact
{ name: { $like: '%Industries' } }     // Acme Industries, Industries — ends with

A pattern ending in a lone unpaired backslash is refused (INVALID_FILTER): no reading of it survives every backend, so it is rejected rather than guessed.

Backend coverage. $like / $ilike are executed by the SQL family (driver-sql, driver-sqlite-wasm, driver-turso on both transports), driver-memory, and the in-memory matchesFilter evaluator. driver-mongodb, ObjectQL having and the analytics compilers refuse them with INVALID_FILTER rather than approximating them — use $contains / $icontains there. That split is deliberate: a backend that quietly answered a different question is the defect these operators exist to end (#7536).

Case Sensitivity

The string operators compare case-sensitively. $icontains is the one that does not, and the case it ignores is ASCII case onlyA-Z against a-z, and nothing else.

// Case-sensitive: matches "acme corp", NOT "ACME Corp"
{ name: { $contains: 'acme' } }

// ASCII case-insensitive: matches BOTH "acme corp" and "ACME Corp"
{ name: { $icontains: 'acme' } }

café does not match CAFÉ. Outside A-Z/a-z, $icontains compares literally — accented Latin, Cyrillic, Greek and every other script are matched exactly as written. If your users search non-ASCII text, $icontains is not an accent- or case-blind search, and treating it as one will silently return fewer rows than expected.

The boundary is ASCII because that is the only fold every backend can actually deliver. SQLite compiled without ICU — which is what driver-sqlite-wasm and driver-turso run on — folds ASCII only in both LOWER() and LIKE, so a Unicode promise here would be a guarantee three of the five backends could not keep. See #4706.

The comparand is always matched literally. % and _ are ordinary characters, not LIKE wildcards, and . / * / + are ordinary characters, not regex metacharacters — { name: { $icontains: 'a.b' } } matches a.b and not axb.

Status: $icontains is implemented on every backend and every evaluation face, and folds the same ASCII domain on all of them — #5702 did the SQL family and #6520 did the rest (the in-memory driver's query, matcher and analytics faces, MongoDB, ObjectQL's having, the RLS write-side check, and the analytics SQL compilers). A filter using it means the same thing whether your tests run on the in-memory double or your production runs SQL.

The other half of the case rules above has landed too: $contains / $startsWith / $endsWith / $notContains are case-sensitive by ruling and are so on every backend — #6682 removed the hardcoded $options: 'i' that folded them on MongoDB and the case-insensitive regex that folded them on the in-memory driver's query and analytics faces. If you were relying on $contains being loose on the in-memory driver, that is a row-set change: write $icontains when you want a fold. The shared standard both halves are measured against is FILTER_TEXT_CASES (@objectstack/spec/data), which all five drivers now run.

$regex — removed

$regex (and its $options companion) was never a declared operator and is retired (#4706). It could not mean one thing across the backends: driver-sql compiled it to a LIKE-escaped substring match, so a.b matched only the literal a.b, while driver-memory evaluated it as a real RegExp, so the same filter also matched axb — and an invalid pattern was caught and answered zero rows, in silence. A real regex is not implementable on all five backends: driver-turso's remote transport speaks a wire protocol with no way to register a SQLite REGEXP function.

Instead ofWrite
{ name: { $regex: 'acme' } }{ name: { $icontains: 'acme' } }
{ name: { $regex: 'acme', $options: 'i' } }{ name: { $icontains: 'acme' } }
{ name: { $regex: '^acme' } }{ name: { $startsWith: 'acme' } }
{ name: { $regex: 'acme$' } }{ name: { $endsWith: 'acme' } }

A pattern that genuinely needs a regular expression has no filter-level replacement — narrow the query with the declared operators and match in application code. The prescriptions above are declared as data in RETIRED_FILTER_OPERATORS (@objectstack/spec/data), so every backend's refusal quotes the same sentence.

Multiple Conditions (Implicit AND)

Multiple keys in where are combined with AND logic:

const query: QueryAST = {
  object: 'opportunity',
  where: {
    stage: 'Closed Won',
    amount: { $gt: 50000 },
    close_date: { $gte: '2024-01-01' },
  },
};

// SQL: WHERE stage = 'Closed Won' AND amount > 50000 AND close_date >= '2024-01-01'

Logical OR ($or)

const query: QueryAST = {
  object: 'contact',
  where: {
    $or: [
      { title: { $contains: 'CEO' } },
      { title: { $contains: 'President' } },
      { title: { $contains: 'Founder' } },
    ],
  },
};

// SQL: WHERE (title LIKE '%CEO%' OR title LIKE '%President%' OR title LIKE '%Founder%')

Logical AND ($and)

Explicit $and is useful when you need multiple conditions on the same field:

const query: QueryAST = {
  object: 'product',
  where: {
    $and: [
      { price: { $gte: 10 } },
      { price: { $lte: 100 } },
    ],
  },
};

// SQL: WHERE price >= 10 AND price <= 100

Logical NOT ($not)

const query: QueryAST = {
  object: 'customer',
  where: {
    $not: {
      status: { $in: ['deleted', 'suspended'] },
    },
  },
};

// SQL: WHERE NOT (status IN ('deleted', 'suspended'))

Complex Logic (AND + OR)

const query: QueryAST = {
  object: 'opportunity',
  where: {
    type: 'new_business',                  // AND (type = new_business)
    $or: [                                 // AND (
      { amount: { $gt: 100000 } },         //   amount > 100000
      { is_strategic: true },              //   OR is_strategic = true
    ],                                     // )
  },
};

// SQL: WHERE type = 'new_business'
//      AND (amount > 100000 OR is_strategic = true)

Date, Datetime, and Time Filters

Before a comparison is built, the driver puts the comparand into the same canonical form the column is stored in (SqlDriver.coerceFilterValue) — the identical function the write path uses, on every dialect, so the two sides of a comparison can never be decided by their shapes disagreeing:

Field typeCanonical formMeaning
dateYYYY-MM-DDTimezone-naive calendar day
datetimeYYYY-MM-DDTHH:MM:SS.sssZA UTC instant
timeHH:MM:SS.fff only when the milliseconds are non-zeroTimezone-naive wall-clock time of day
import type { FilterCondition } from '@objectstack/spec/data';

// `date` field — a bare calendar day matches that day
const onDay: FilterCondition = { close_date: '2024-01-15' };

// `date` range — $between is inclusive on both ends
const inYear: FilterCondition = {
  close_date: { $between: ['2024-01-01', '2024-12-31'] },
};

// `datetime` field — a bare `YYYY-MM-DD` is completed to midnight UTC
// (`2024-01-15T00:00:00.000Z`), so this is an exact-instant match, not "that day"
const atMidnight: FilterCondition = { created_at: '2024-01-15' };

// A whole UTC day on a `datetime` needs a half-open range
const duringDay: FilterCondition = {
  created_at: { $gte: '2024-01-15', $lt: '2024-01-16' },
};

// `time` field — `'09:00'` is completed to `'09:00:00'`
const businessHours: FilterCondition = {
  start_time: { $gte: '09:00', $lte: '18:00' },
};

A bare YYYY-MM-DD bound is a calendar day. As a lower bound ($gte) it means the start of that day (midnight UTC); as an upper bound ($lte, or the max of a $between) it covers the whole day — on a datetime column the driver compiles it half-open (< next day), so the $between above includes everything that happened on Dec 31. A full ISO timestamp keeps exact-instant semantics on every operator.

Null Checks

// Field IS NULL
where: { manager_id: { $null: true } }

// Field IS NOT NULL
where: { phone: { $null: false } }

// Field exists (NoSQL)
where: { metadata: { $exists: true } }

Filtering Across Relationships

Relation traversal inside where is not supported. Neither the nested form (where: { account: { industry: 'tech' } }) nor a dotted path (where: { 'account.industry': 'tech' }) is resolved. SqlDriver.applyFilters() only recognises a nested object as an operator map when its keys start with $; anything else is compiled as a comparison against a single column of the queried table, and a dotted key is emitted verbatim, so Knex renders it as "account"."industry" against a table that was never joined.

Filter on the local foreign key, or run two queries:

const techAccounts = await engine.find('account', {
  where: { industry: 'tech', annual_revenue: { $gt: 1000000 } },
  fields: ['id'],
});

const opportunities = await engine.find('opportunity', {
  where: { account_id: { $in: techAccounts.map((a) => a.id) } },
});

3. Sorting

Sorting uses the orderBy array of SortNode objects.

Over the REST/protocol ingress, orderBy also accepts '-created_at', ['-created_at'] and {created_at: 'desc'} — all normalized to the SortNode[] above. A sort naming a field the object does not have is 400 INVALID_SORT there, rather than being dropped. Internal callers reaching engine.find() directly are unaffected.

Single Field Sort

const query: QueryAST = {
  object: 'customer',
  orderBy: [{ field: 'company_name', order: 'asc' }],
};

// SQL: ORDER BY company_name ASC

Multiple Fields

const query: QueryAST = {
  object: 'opportunity',
  orderBy: [
    { field: 'priority', order: 'desc' },
    { field: 'created_at', order: 'asc' },
  ],
};

// SQL: ORDER BY priority DESC, created_at ASC

orderBy only reaches columns of the queried table. Over the REST/protocol ingress a dotted path (account.company_name) is 400 INVALID_SORT (#4256): no driver can order by it — SqlDriver would render it as "account"."company_name" against a table that was never joined, and until the path was refused, the unknown-column backstop retried without the sort and answered 200 with unordered rows. Denormalise the value onto the queried object as a stored field — one this object's own rows carry, written when the source changes — when you need to sort by it.

Internal callers reaching engine.find() directly are unaffected: a dotted orderBy there still falls through to the driver backstop and orders nothing.

Do not denormalise onto a formula field to sort by it. A formula field is virtual: no driver materialises a column for it (the engine evaluates it after the driver returns), so ORDER BY on one hits the same unknown-column backstop and is silently dropped — 200, every row present, arbitrary order. Measured on a real SqlDriver (better-sqlite3) and on InMemoryDriver: rows inserted C A E B D come back C A E B D for both asc and desc, while the same query on a stored column returns A B C D E / E D C B A.

A rollup/summary field does get a real, maintained column and can be sorted on — but it aggregates child records (count/sum/min/max/ avg), so it cannot carry a looked-up parent's column such as account.company_name. For that, write the value onto a stored field of the queried object and keep it in sync (a trigger or flow on the source record).

This page taught the formula/rollup version until #6924, and so did the 400 INVALID_SORT hint itself (#4256) — both are corrected together. The same correction on the search axis is #6673.


4. Relationships (Expand)

The expand property enables recursive loading of related records through the reference field types — lookup, master_detail, user and tree (REFERENCE_VALUE_TYPES). Each key is a relationship field name; the value is a nested QueryAST. Over the REST/protocol ingress, a key that is not one of those is 400 INVALID_FIELD rather than a silently absent relation.

Basic Expand

const query: QueryAST = {
  object: 'opportunity',
  fields: ['name', 'amount'],
  expand: {
    account: {
      object: 'account',
      fields: ['company_name'],
    },
  },
};

// Result:
// [
//   {
//     name: 'Big Deal',
//     amount: 100000,
//     account: { company_name: 'Acme Corp' }
//   }
// ]

Multiple Relationships

const query: QueryAST = {
  object: 'opportunity',
  fields: ['name'],
  expand: {
    account: { object: 'account', fields: ['company_name'] },
    owner: { object: 'user', fields: ['name', 'email'] },
  },
};

Nested Expand (Deep Loading)

const query: QueryAST = {
  object: 'task',
  fields: ['title', 'assignee'],
  expand: {
    assignee: { object: 'user', fields: ['name', 'email'] },
    project: {
      object: 'project',
      expand: {
        org: { object: 'org', fields: ['name'] },
      },
    },
  },
};

The engine resolves expand via batch $in queries (driver-agnostic) with a default max depth of 3.

Filtered Expand

Expansion follows the same reference field types as above — lookup, master_detail, user and tree — i.e. the foreign key lives on the object you are querying. The nested QueryAST can filter (where) and select (fields) the related records:

const query: QueryAST = {
  object: 'task',
  fields: ['title', 'assignee'],
  expand: {
    // assignee is a lookup → user; only resolve assignees that are still active.
    assignee: {
      object: 'user',
      where: { active: { $eq: true } },
      fields: ['name', 'email'],
    },
  },
};

The nested where is AND-merged with the batch $in the engine uses to load related records, so a related record is attached only when it also matches your filter. A foreign key whose target is filtered out is left as the raw id (unresolved) rather than dropped.

The nested fields is a projection of the related record, and you do not have to name id in it to make the expansion work. id is the join key the batch $in re-attaches by, so the engine adds it to its own sub-read and strips it back out when you did not ask for it — the attached record carries exactly the columns you listed. Name id explicitly when you want it (e.g. to link to the related record).

Per-parent shaping (limit / offset / orderBy) is not honored on the expand path. The engine batch-loads every parent's related records in a single $in query and then re-attaches them to each parent by foreign key, so it cannot express a per-parent page size, and the injected order follows each parent's own foreign-key value rather than the nested orderBy. To paginate or order related records, query the related object directly with its own where + orderBy + limit.


5. Aggregations

Aggregations use the aggregations array with AggregationNode objects, combined with groupBy for grouping.

Count

const count = await engine.count('customer', {
  where: { industry: 'tech' },
});

// SQL: SELECT COUNT(*) FROM customer WHERE industry = 'tech'
// Result: 42

Group By with Aggregations

const query: QueryAST = {
  object: 'opportunity',
  fields: ['stage'],
  groupBy: ['stage'],
  aggregations: [
    { function: 'count', alias: 'count' },
    { function: 'sum', field: 'amount', alias: 'total_amount' },
  ],
};

// Result:
// [
//   { stage: 'Prospecting', count: 10, total_amount: 500000 },
//   { stage: 'Qualification', count: 5, total_amount: 250000 }
// ]

SQL compilation:

SELECT
  stage,
  COUNT(*) AS count,
  SUM(amount) AS total_amount
FROM opportunity
GROUP BY stage

Over the REST/protocol ingress, both axes are validated before any driver runs: a groupBy entry or aggregations[].field naming a field the object does not have is 400 INVALID_FIELD (the in-memory fallback used to collapse every row into one null-keyed bucket, and to answer sum(<typo>) with 0), and a shape the spec cannot read — a non-array, an entry naming no field, an unknown function or dateGranularity, a missing alias — is 400 INVALID_QUERY. count with no field (or field: '*') is the one legitimate field-less form. Internal callers reaching engine.aggregate() directly are unaffected.

Aggregation Functions

const query: QueryAST = {
  object: 'opportunity',
  aggregations: [
    { function: 'count', alias: 'count' },
    { function: 'sum', field: 'amount', alias: 'total' },
    { function: 'avg', field: 'amount', alias: 'average' },
    { function: 'min', field: 'amount', alias: 'min_amount' },
    { function: 'max', field: 'amount', alias: 'max_amount' },
  ],
};

// Result:
// { count: 100, total: 5000000, average: 50000, min_amount: 10000, max_amount: 500000 }

Schema enum: count, sum, avg, min, max, count_distinct.

Only count, sum, avg, min, and max are portable today. count_distinct is declared and is implemented by the MongoDB driver and by the engine's in-memory aggregation fallback, but not yet by the SQL drivers — on SqlDriver (and on the Turso driver, both transports) it is refused as a capability gap: 501 NOT_IMPLEMENTED, "declared but not implemented by this backend". That is deliberately a different answer from a function the schema enum never declared (median), which is 400 INVALID_QUERY — the caller's mistake — so an author who wrote count_distinct is never told they made a typo (#5907). Its SQL lowering (COUNT(DISTINCT field)) is scheduled: the declaration leads the implementation here by decision, not by drift.

Removed in 17: array_agg and string_agg were declared by this enum and compiled by no SQL backend, so which backend could compute them was unpredictable to the author. Both were retired (#6188, ADR-0049 enforce-or-remove) and are now refused at parse with a prescription. There is no replacement in the query vocabulary — read the rows with an ordinary fields query and shape them in the caller, or materialise the roll-up as a stored field. os migrate meta --from 16 rewrites affected dataset measures.

Group By Multiple Fields

const query: QueryAST = {
  object: 'opportunity',
  fields: ['stage', 'owner_name'],
  groupBy: ['stage', 'owner_name'],
  aggregations: [
    { function: 'count', alias: 'count' },
    { function: 'sum', field: 'amount', alias: 'total' },
  ],
};

HAVING Clause

Enforced since #4286 (ADR-0049, resolved to enforce). The engine applies having itself, AFTER aggregation, identically on the native-driver path and the in-memory fallback (packages/objectql/src/having-filter.ts) — the same correct-first / optimize-later two-tier shape date bucketing uses; native SQL HAVING pushdown can come later behind a driver capability flag without changing these semantics. The REST findData() aggregate branch forwards the clause.

having references the aggregated row's own columns — aggregation aliases and groupBy projections — with the ordinary FilterCondition operators and $and / $or / $not. An unknown operator rejects the query loudly rather than being ignored (an ignored operator would silently return unfiltered aggregates — the ADR-0078 failure mode enforcement exists to end).

// Only accounts with > $1M pipeline
const rows = await engine.aggregate('opportunity', {
  groupBy: ['account_id'],
  aggregations: [
    { function: 'sum', field: 'amount', alias: 'total' },
  ],
  having: { total: { $gt: 1_000_000 } },
});

Date Bucketing

A groupBy entry may be an object carrying dateGranularity, which buckets a date/datetime column into uniform periods. The bucketed value is projected under the field name (or alias, on the in-memory path):

const revenueByMonth = await engine.aggregate('order', {
  where: { status: 'completed' },
  groupBy: [{ field: 'created_at', dateGranularity: 'month' }],
  aggregations: [
    { function: 'sum', field: 'total_amount', alias: 'revenue' },
  ],
});

Granularities: day, week, month, quarter, year. The engine pushes the bucket down to the driver only when it advertises native support for that granularity (supports.queryDateGranularity); otherwise it falls back to in-memory bucketing over the driver's raw rows.


6. Advanced Queries

Distinct

query.distinct was removed in @objectstack/spec 17 (#4286): no driver ever rendered SELECT DISTINCT, and the flag's only observable effect was mis-wired — it silently suppressed the REST list count (total/hasMore degraded to a page-local estimate) while still returning duplicate rows. The key is tombstoned and QueryBuilder.distinct() was removed with it; the count suppression is gone, so total is truthful again. Unique combinations come from groupBy, deduplicated counts from the count_distinct aggregation, and one column's distinct values from the driver's own distinct() method (implemented by the SQL and in-memory drivers; it is not part of the IDataDriver contract):

const industries = await driver.distinct('account', 'industry');

SqlDriver.distinct() presents each value exactly the way find() presents that column — a date as YYYY-MM-DD and a time as HH:MM:SS[.fff] on every dialect, a datetime folded to canonical UTC ISO on SQLite (the one dialect where storage differs from presentation; Postgres and MySQL hand back their own native temporal value) — and then re-deduplicates the presented values, because SQL DISTINCT compares the stored form.

It is tenant-scoped on the same terms as find() — the organization_id predicate is applied when the call carries options.tenantId (the fourth argument), and the example above omits it, so it returns the column's values across every tenant. Until #6792 the predicate was dropped even when tenantId was supplied, which made a scoped call disclose every other tenant's values for that column.

The search parameter does not reach a full-text index. The engine expands it into an $or of $contains predicates across the object's server-resolved searchable fields (ADR-0061) and deletes search from the AST before the driver sees it — every driver already runs $or/$contains, so no driver support is needed (which is also why the old supports.fullTextSearch capability bit had no reader and was retired in 17.0.0, #4634).

search takes the query text itself — that is the canonical spelling (ADR-0061 D1: the client says what to search for, the server decides which fields), and it is what every surface sends. The structured form is equivalent for the two members that drive the expansion, and carries the experimental knobs below:

// Canonical — the server resolves the fields from object metadata
const query: QueryAST = {
  object: 'article',
  search: 'ObjectStack tutorial',
  searchFields: ['title', 'content'],   // optional narrowing
  limit: 10,
};

// Structured form — `query` + `fields` mean exactly the same thing
const structured: QueryAST = {
  object: 'article',
  search: { query: 'ObjectStack tutorial', fields: ['title', 'content'] },
  limit: 10,
};

Field resolution is server-side and never client-trusted: the requested fields (searchFields, or search.fields in the structured form) are intersected with the object's declared searchableFields (or, absent those, an auto-default of the name field plus short-text/enum fields), so naming a field outside that set can never widen the search — and over the REST/protocol ingress it is 400 INVALID_FIELD outright (#4254), because the engine-side intersection alone used to drop the unknown name and fall back to scanning the full searchable set. Internal callers reaching engine.find() directly keep the tolerant intersection. Multiple whitespace-separated terms are AND-ed and fields are OR-ed. Case sensitivity comes from the operator the expansion emits, not from the expansion: it emits a plain $contains, which is case-sensitive by the rule in Case Sensitivity above. Note what that means for search — a user typing acme does not find ACME Corp. Only select / status option labels are matched case-insensitively by the expansion itself.

Measured today: every driver matches that rule. The $contains alignment landed in three steps — #6518 made SqlDriver case-exact per dialect (GLOB on the SQLite dialects, LIKE unchanged on Postgres, LIKE over a binary cast on MySQL), and #6682 removed driver-mongodb's hardcoded $options: 'i' and then the case-insensitive regex driver-memory used on its query and analytics faces. So running your tests on the in-memory double no longer returns rows a SQL or MongoDB deployment would not — the divergence this callout warned about is closed, and FILTER_TEXT_CASES holds all five drivers to it. Whether the expansion should emit $icontains instead of $contains — i.e. whether search is case-insensitive by definition — remains a separate open question, and one that can now actually be answered, since both operators mean one thing everywhere.

fuzzy, boost, operator, minScore, language, and highlight carry [EXPERIMENTAL — not enforced] markers (#4286): the schema accepts them, the expansion ignores them.

Search targets are this object's own columns. A dotted path is not one of them: searchFields: ['project_id.name'] is refused at the ingress rather than dropped, because the search axis does not resolve traversal the way fields, sort and filters do:

Unknown field 'project_id.name' on object 'task'. '$searchFields' narrows which
columns 'search' scans, so a name the object does not declare cannot narrow
anything — and the engine used to drop it and scan the default columns instead,
answering a NARROWER search with a WIDER one. 'search' scans this object's own
columns; a related record's column cannot be a search target.

The declarative answer is a mirror field: copy the related record's title into a stored field on this object and declare that field searchable — a task list searched by project name carries a project_name text column on task, maintained on write and listed in task.searchableFields, so the expansion stays a single-table $or of $contains. The mirror must be stored: a formula field is virtual, no driver materializes a column for it, and a $contains against one has nothing to scan. Cross-object search paths are rejected by design, not pending — see Schema Design → Searching by a related record's title for the maintenance hooks.

Joins — removed (#4286)

query.joins was removed in @objectstack/spec 17 (#4286, ADR-0049 enforce-or-remove): no driver ever read it, so a query carrying joins silently ran as a single-table query. The key is tombstoned — authoring it is a tsc error, and a query that still carries it (even as an empty array) fails to parse with the upgrade prescription. The JoinNode / JoinType / JoinStrategy exports left with it.

Use expand (§4) for relationship loading — the live spelling for related records, and for single related columns too, since its nested QueryAST both filters (where) and selects (fields) the related record's columns. Otherwise, two queries joined in application code.

A dotted fields path is not the alternative. 'owner.name' still parsesFieldNode is string, a shape check — but no driver ever resolved one, and the ingress refuses it with 400 INVALID_FIELD (#7532). Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes) — the same remedy the sort axis prescribes (#6924).

expand needs the foreign key in the projection. The relation is carried by the FK column, so a narrowed projection that projects it away leaves expansion nothing to resolve:

{ fields: ['title'],                expand: { project_id: { object: 'project' } } }
// -> nothing to resolve; no related record comes back

{ fields: ['title', 'project_id'],  expand: { project_id: { object: 'project' } } }
// -> works

Window Functions — removed from the request surface (#4286)

query.windowFunctions was removed in @objectstack/spec 17 (#4286): find() never applied it, so every OVER clause it declared was silently dropped. The key is tombstoned, and the WindowFunction / WindowSpec / WindowFunctionNode exports left with it — they declared field / over / frame members that no executor ever read.

Window functions remain a SQL-driver door: findWithWindowFunctions(), which is not on the IDataDriver contract and is not surfaced by IDataEngine. Its input is the driver's own flat shape — function name, alias, and optional flat partitionBy / orderBy:

const ranked = await driver.findWithWindowFunctions('order', {
  windowFunctions: [
    {
      function: 'row_number',
      alias: 'rank',
      partitionBy: ['customer_id'],
      orderBy: [{ field: 'amount', order: 'desc' }],
    },
  ],
});

// SQL: SELECT *, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY amount DESC) AS rank
//      FROM orders

That method always selects * plus the window columns — it does not honor a fields projection, and niladic rendering means argument-taking functions (LAG(field)) emit without their argument. For request-level analytics use aggregations + groupBy (§5).

Tenancy works exactly as it does on find(): the driver applies its organization_id predicate only when the call carries options.tenantId. The example above omits it and therefore reads across every tenant — the intended unscoped/admin path, but a deliberate choice rather than a default to inherit. (Until #6792 this door dropped options.tenantId even when it was supplied, so a scoped call still returned every tenant's rows.)


7. Pagination

Offset-Based Pagination

// Page 1 (records 0-9)
const page1 = await engine.find('customer', {
  limit: 10,
  offset: 0,
});

// Page 2 (records 10-19)
const page2 = await engine.find('customer', {
  limit: 10,
  offset: 10,
});

Drawback: Slow for large offsets (database still scans all skipped rows).

Determinism: the walk above visits every customer exactly once even though it names no orderBy. A driver orders any paged read by a unique column of its own — appended to your sort keys, or standing alone when you gave none — because an unordered LIMIT/OFFSET slices an arrangement nothing holds steady: SQL leaves row order to the plan, and MongoDB's natural order moves when a document does. Page 2 would otherwise repeat a row from page 1 and drop another, with every page full and every row real (objectui#3106, #4363). A query with no limit/offset is untouched — nothing is being sliced, so no order is imposed.

Keyset Pagination

query.cursor was removed in @objectstack/spec 17 (#4286): no driver ever implemented keyset pagination, so a cursor was accepted and ignored and every page came back identical — a caller looping "until hasMore is false" never terminated. The key is tombstoned (on EngineQueryOptions too) and QueryBuilder.cursor() was removed with it. Express the keyset as an ordinary where predicate on the sort key — the pattern below is the supported one; a first-class cursor, if ever designed, will be a response-minted opaque token:

// First page
const page = await engine.find('customer', {
  limit: 10,
  orderBy: [{ field: 'created_at', order: 'asc' }],
});

// Next page — seek past the last row instead of offsetting.
// The comparand is canonicalised by the same function that wrote the column
// (`coerceFilterValue` → `storageDatetimeValue`), so the range compare is an
// ordinary indexable comparison on every dialect: canonical UTC ISO text on
// SQLite, `timestamptz` on Postgres, `DATETIME(3)` on MySQL.
const next = await engine.find('customer', {
  where: { created_at: { $gt: page[page.length - 1].created_at } },
  limit: 10,
  orderBy: [{ field: 'created_at', order: 'asc' }],
});

Advantage: Consistent performance regardless of page depth.


8. Real-World Examples

CRM: Open Opportunities

const openOpportunities = await engine.find('opportunity', {
  where: {
    stage: { $nin: ['Closed Won', 'Closed Lost'] },
    owner_id: currentUser.id,
  },
  orderBy: [{ field: 'amount', order: 'desc' }],
  fields: ['name', 'amount', 'close_date'],
  expand: {
    account: { object: 'account', fields: ['company_name'] },
  },
});
const products = await engine.find('product', {
  where: {
    is_active: true,
    inventory_qty: { $gt: 0 },
    category_id: { $in: selectedCategories },
    price: { $between: [minPrice, maxPrice] },
  },
  search: {
    query: searchTerm,
    fields: ['name', 'description'],
  },
  orderBy: [{ field: 'popularity_score', order: 'desc' }],
  limit: 20,
});

Analytics: Revenue by Month

month is not a column — bucket the created_at instant with dateGranularity. Note that engine.aggregate() accepts only where / groupBy / aggregations (plus a timezone for bucketing): there is no orderBy or limit on this path, so sort the returned rows yourself.

const monthlyRevenue = await engine.aggregate('order', {
  where: {
    status: 'completed',
    // `created_at` is a datetime — a bare date is read as midnight UTC
    created_at: { $gte: '2024-01-01' },
  },
  groupBy: [{ field: 'created_at', dateGranularity: 'month' }],
  aggregations: [
    { function: 'sum', field: 'total_amount', alias: 'revenue' },
    { function: 'count', alias: 'order_count' },
    { function: 'avg', field: 'total_amount', alias: 'avg_order' },
  ],
});

const sorted = monthlyRevenue.sort((a, b) =>
  String(a.created_at).localeCompare(String(b.created_at)),
);

9. Error Handling

Unknown Fields Are Tolerated

Unknown field names are not rejected by engine.find() — a projected field that doesn't exist on the object is silently dropped (matching OData / SELECT * tolerance), so a stale field reference never fails the whole query:

const rows = await engine.find('customer', {
  fields: ['name', 'nonexistent'],  // `nonexistent` is not on the schema
});
// Returns each row with `name`; `nonexistent` is omitted — no error thrown.

This tolerance does not extend to the REST/protocol ingress: a select / fields naming a column the object does not have is 400 INVALID_FIELD there, because dropping it silently answered a narrower projection with a wider one — a projection left with no known field falls all the way back to every field. The engine's tolerance guards internal callers (hooks, flows, expand sub-reads, registry-less hosts) that never pass through that ingress.

Security Violations

Row-level scoping is applied by narrowing the query — the security middleware AND-merges its read filter into where, so an over-broad filter returns fewer rows rather than throwing. What throws is an operation the caller is not permitted to run at all, or a predicate that references a field the caller cannot read:

try {
  await engine.find('account', { where: { owner_id: currentUser.id } });
} catch (error) {
  // PermissionDeniedError:
  //   [Security] Access denied: operation 'find' on object 'account' ...
  //   [Security] Access denied: query on 'account' references field(s) not
  //              readable by the caller: ...
}

Legacy Compatibility

Tuple / Array / 三元组 Syntax — UI Builder Input Only

The tuple/array format (e.g. ['status', '=', 'active']) and the filters key are legacy input formats used by some UI-layer filter builders (FilterBuilder, ObjectUI). They are not the canonical protocol format.

Before entering the ObjectQL protocol or IDataEngine, tuple filters must be converted to the canonical where + $op object format using the parseFilterAST() utility from @objectstack/spec/data:

import { parseFilterAST } from '@objectstack/spec/data';

// UI Builder output (tuple format)
const uiFilter = ['and', ['status', '=', 'active'], ['priority', '>', 3]];

// Convert to canonical format
const where = parseFilterAST(uiFilter);
// → { $and: [{ status: 'active' }, { priority: { $gt: 3 } }] }

Similarly, the following legacy field names should not be used in new code:

LegacyCanonicalNotes
filters (array of tuples)where (FilterCondition object)Use parseFilterAST() to convert
sortorderByArray of { field, order } objects
aggregateaggregationsSame AggregationNode[]; the SQL and MongoDB drivers read either key, but engine.aggregate() forwards only aggregations
expand (string array)expand (Record)Map of field name → nested QueryAST
skipoffsetNumber
selectfieldsArray of FieldNode (field-name strings)
populateexpandMap of field name → nested QueryAST

Next Steps

On this page