| title | Query Syntax |
|---|---|
| description | Database-agnostic query language with filtering, joins, aggregations, and sorting — aligned with the canonical @objectstack/spec QuerySchema |
import { Search, Filter, GitMerge, BarChart } from 'lucide-react';
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).
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): {/* os:check */}
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$inread, 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)
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
}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/plugins/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:
| Member | Status |
|---|---|
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 18, 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).
// 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' | 'array_agg' | 'string_agg';
field?: string; // optional for COUNT(*)
alias: string; // result column alias
distinct?: boolean; // DISTINCT before aggregation — in-memory path only
filter?: FilterCondition; // [EXPERIMENTAL — not enforced] FILTER WHERE clause — never applied
}
// FieldNode — one entry of the select list. A field name, optionally dotted to
// reach through a relationship ('owner.name'). Related *records* come 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`
};const customers = await engine.find('customer');
// SQL: SELECT * FROM customer;
// MongoDB: db.customer.find({})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 })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)Filters use the where clause with MongoDB-style $op operators (object syntax).
The simplest filter — a field-value pair implies $eq:
const query: QueryAST = {
object: 'customer',
where: {
industry: 'tech', // Implicit: { $eq: 'tech' }
},
};
// SQL: WHERE industry = 'tech'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| Operator | Description | Example |
|---|---|---|
$eq |
Equal (implicit default) | { status: 'active' } or { status: { $eq: 'active' } } |
$ne |
Not equal | { status: { $ne: 'closed' } } |
$gt |
Greater than | { revenue: { $gt: 10000 } } |
$gte |
Greater or equal | { score: { $gte: 80 } } |
$lt |
Less than | { age: { $lt: 65 } } |
$lte |
Less or equal | { discount: { $lte: 20 } } |
$in |
In list | { stage: { $in: ['proposal', 'negotiation'] } } |
$nin |
Not in list | { status: { $nin: ['deleted', 'archived'] } } |
$contains |
String contains | { name: { $contains: 'Inc' } } |
$notContains |
String does not contain | { name: { $notContains: 'test' } } |
$startsWith |
String starts with | { email: { $startsWith: 'admin' } } |
$endsWith |
String ends with | { domain: { $endsWith: '.com' } } |
$between |
Range (inclusive) | { close_date: { $between: ['2024-01-01', '2024-12-31'] } } |
$null |
Null check | { manager_id: { $null: true } } / { phone: { $null: false } } |
$exists |
Field exists (NoSQL) | { metadata: { $exists: true } } |
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'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%')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 <= 100const query: QueryAST = {
object: 'customer',
where: {
$not: {
status: { $in: ['deleted', 'suspended'] },
},
},
};
// SQL: WHERE NOT (status IN ('deleted', 'suspended'))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)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 type | Canonical form | Meaning |
|---|---|---|
date |
YYYY-MM-DD |
Timezone-naive calendar day |
datetime |
YYYY-MM-DDTHH:MM:SS.sssZ |
A UTC instant |
time |
HH:MM:SS — .fff only when the milliseconds are non-zero |
Timezone-naive wall-clock time of day |
{/* os:check */}
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.
{/* os:check */}
// Field IS NULL
where: { manager_id: { $null: true } }
// Field IS NOT NULL
where: { phone: { $null: false } }
// Field exists (NoSQL)
where: { metadata: { $exists: true } }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) } },
});Sorting uses the orderBy array of SortNode objects.
const query: QueryAST = {
object: 'customer',
orderBy: [{ field: 'company_name', order: 'asc' }],
};
// SQL: ORDER BY company_name ASCconst query: QueryAST = {
object: 'opportunity',
orderBy: [
{ field: 'priority', order: 'desc' },
{ field: 'created_at', order: 'asc' },
],
};
// SQL: ORDER BY priority DESC, created_at ASCInternal callers reaching engine.find() directly are unaffected: a dotted
orderBy there still falls through to the driver backstop and orders nothing.
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.
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' }
// }
// ]const query: QueryAST = {
object: 'opportunity',
fields: ['name'],
expand: {
account: { object: 'account', fields: ['company_name'] },
owner: { object: 'user', fields: ['name', 'email'] },
},
};const query: QueryAST = {
object: 'task',
fields: ['title', 'assignee'],
expand: {
assignee: { object: 'user', fields: ['name', 'email'] },
project: {
object: 'project',
expand: {
org: { object: 'org', fields: ['name'] },
},
},
},
};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.
Aggregations use the aggregations array with AggregationNode objects, combined with groupBy for grouping.
const count = await engine.count('customer', {
where: { industry: 'tech' },
});
// SQL: SELECT COUNT(*) FROM customer WHERE industry = 'tech'
// Result: 42const 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 stageconst 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, array_agg, string_agg.
const query: QueryAST = {
object: 'opportunity',
fields: ['stage', 'owner_name'],
groupBy: ['stage', 'owner_name'],
aggregations: [
{ function: 'count', alias: 'count' },
{ function: 'sum', field: 'amount', alias: 'total' },
],
};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 } },
});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.
query.distinct was removed in @objectstack/spec 18 (#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.
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 (SqlDriver reports
supports.fullTextSearch: false).
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 is the driver's, not the expansion's: the
expansion emits a plain $contains, which SqlDriver compiles to a parameterised
LIKE '%…%' with no case folding — so the dialect's own LIKE/collation rules decide —
while the in-memory driver matches with a case-insensitive regex. Only select /
status option labels are matched case-insensitively by the expansion itself.
fuzzy, boost, operator, minScore, language, and highlight carry
[EXPERIMENTAL — not enforced] markers (#4286): the schema accepts them, the
expansion ignores them.
query.joins was removed in @objectstack/spec 18 (#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 —
a dotted fields path ('owner.name') for a single related column, or two queries
joined in application code.
query.windowFunctions was removed in @objectstack/spec 18 (#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 ordersThat 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).
// 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).
`query.cursor` was **removed in `@objectstack/spec` 18** (#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.
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,
});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)),
);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.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: ...
}**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:
{/* os:check */}
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:
| Legacy | Canonical | Notes |
|---|---|---|
filters (array of tuples) |
where (FilterCondition object) |
Use parseFilterAST() to convert |
sort |
orderBy |
Array of { field, order } objects |
aggregate |
aggregations |
Same 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 |
skip |
offset |
Number |
select |
fields |
Array of FieldNode (field-name strings) |
populate |
expand |
Map of field name → nested QueryAST |