@@ -22,14 +22,10 @@ Expert instructions for constructing data queries using the ObjectStack
2222Query DSL. This skill covers filter expressions, sorting, pagination,
2323aggregation, full-text search, and the expand system for related records.
2424
25- ** Schema vs. runtime:** the ` QueryAST ` schema declares more than the engine
26- currently executes. Sections below marked
27-
28- > ⚠️ ** Schema-reserved — NOT executed by the engine yet.**
29-
30- describe properties that validate against the schema but are silently
31- ignored (or rejected) at runtime. Never emit them in production queries —
32- each caveat shows the working alternative.
25+ ** Schema vs. runtime:** every callout below says which side a property is on —
26+ ⛔ ** REMOVED** (tombstoned; a query carrying it fails to parse), ⚠️ ** not
27+ enforced** (validates, then silently ignored — never emit it), ✅ ** Enforced** .
28+ Each removal callout names the live replacement.
3329
3430---
3531
@@ -217,25 +213,22 @@ Filter through relationships without an explicit join:
217213
218214### Field References (Cross-Field Comparisons)
219215
220- > ⚠️ ** Schema-reserved — NOT executed by the engine yet.** ` $field ` exists
221- > only in the filter schema. No engine or driver code interprets it — the
222- > ` { $field: '...' } ` object binds as a ** literal value** , so the query
223- > silently returns zero rows. Do not use it.
216+ > ✅ ** Enforced.** A ` { $field: '...' } ` comparand compares two columns of the
217+ > same row. The in-memory evaluator resolves the reference against the record;
218+ > ` driver-sql ` pushes it down as a column-to-column predicate. Same rows.
224219
225220``` typescript
226- // ❌ Schema-valid but NOT executed — matches nothing
221+ // ✅ Accounts whose actual revenue beat the estimate
227222{
228223 where : {
229224 actual_revenue : { $gt : { $field : ' estimated_revenue' } }
230225 }
231226}
232227```
233228
234- ** Working alternatives:**
235- - Define a ** formula field** on the object that computes the comparison
236- (e.g. ` exceeds_estimate ` as a boolean), then filter on it:
237- ` { where: { exceeds_estimate: true } } ` (see ** objectstack-data** ).
238- - Fetch both fields and compare in ** application code** .
229+ Legal in a ** comparison** position only. As an ` $in ` / ` $nin ` member or a
230+ ` $between ` endpoint it is refused at parse — no evaluation path resolves a
231+ reference there.
239232
240233---
241234
@@ -331,12 +324,11 @@ unique or near-unique column such as `created_at` or `id`) so
331324| ` max ` | Maximum | ` MAX(field) ` |
332325| ` count_distinct ` | Unique count | ` COUNT(DISTINCT field) ` |
333326
334- > ⚠️ ** Driver support varies.** On SQL datasources the driver executes only
335- > ` count ` / ` sum ` / ` avg ` / ` min ` / ` max ` and ** throws** on ` count_distinct ` ;
336- > the per-aggregation ` distinct: true ` flag is also ignored there. The
337- > in-memory fallback path (driver-rest, driver-memory, timezone/date-bucket
338- > fallbacks) supports all six functions plus ` distinct ` . For portable queries,
339- > stick to the first five.
327+ > ✅ ** All six are portable.** ` count_distinct ` lowers to ` COUNT(DISTINCT x) `
328+ > on every SQL face and computes identically on the in-memory path, so the
329+ > declared-but-uncompiled set is empty. The per-aggregation ` distinct: true `
330+ > flag went the other way — ** removed in 17** , refused at parse; the live
331+ > spelling for a deduplicated count is ` count_distinct ` .
340332
341333> ** Removed in 17.** ` array_agg ` and ` string_agg ` left this vocabulary:
342334> declared but lowered by no SQL backend, so whether they worked depended on
@@ -387,25 +379,23 @@ const rows = await engine.aggregate('deal', {
387379
388380### Filtered Aggregation
389381
390- > ⚠️ ** Per -aggregation ` filter ` is schema-reserved — NOT executed by the
391- > engine yet. ** The SQL driver ignores it and the in-memory path ignores it
392- > too, so a ` filter ` -carrying aggregation returns the ** unfiltered ** number —
393- > silently wrong results. ** Working alternative: ** issue one aggregate call
394- > per condition, moving the condition into the query-level ` where ` :
382+ > ✅ ** Enforced. ** A per -aggregation ` filter ` scopes that one measure, so a
383+ > total and a conditional count share one call. Any aggregation carrying a
384+ > non-empty ` filter ` forces the in-memory path — no driver compiles a
385+ > conditional aggregate, and one reached directly refuses ` NOT_IMPLEMENTED ` ;
386+ > unfiltered aggregations keep native push-down.
395387
396388``` typescript
397- // ❌ filter on the aggregation is silently ignored
398- // { function: 'count', alias: 'high_value_orders',
399- // filter: { amount: { $gt: 1000 } } }
400-
401- // ✅ Separate aggregate calls, condition in `where`
402- const [totals] = await engine .aggregate (' order' , {
403- aggregations: [{ function: ' count' , alias: ' total_orders' }],
404- });
405- const [highValue] = await engine .aggregate (' order' , {
406- where: { amount: { $gt: 1000 } },
407- aggregations: [{ function: ' count' , alias: ' high_value_orders' }],
389+ // ✅ Total and conditional counts in ONE call
390+ const [kpis] = await engine .aggregate (' order' , {
391+ aggregations: [
392+ { function: ' count' , alias: ' total_orders' },
393+ { function: ' count' , alias: ' high_value_orders' ,
394+ filter: { amount: { $gt: 1000 } } },
395+ ],
408396});
397+ // An unknown operator inside `filter` refuses INVALID_FILTER/400 — it never
398+ // silently answers the unfiltered number.
409399```
410400
411401---
@@ -475,10 +465,10 @@ Load related records through lookup/master_detail fields:
475465
476466Only the ** ` query ` + ` fields ` ** subset of the search schema executes. The
477467engine expands the search string into a driver-agnostic filter: each term
478- becomes an ` $or ` of ` $contains ` predicates across the resolved searchable
479- fields, and multiple whitespace-separated terms are ** AND-ed ** (every term
480- must hit some field). Matching is case-insensitive; ` select ` / ` status `
481- fields match by option * label* , mapped to stored values.
468+ becomes an ` $or ` of ` $icontains ` predicates ( the case-INSENSITIVE twin of the
469+ case-sensitive ` $contains ` ) across the resolved searchable fields, and multiple
470+ whitespace-separated terms are ** AND-ed ** (every term must hit some field).
471+ ` select ` / ` status ` fields match by option * label* , mapped to stored values.
482472
483473``` typescript
484474{
@@ -491,8 +481,8 @@ fields match by option *label*, mapped to stored values.
491481}
492482// Executes as:
493483// { $and: [
494- // { $or: [{ title: { $contains : 'machine' } }, { content: { $contains : 'machine' } }] },
495- // { $or: [{ title: { $contains : 'learning' } }, { content: { $contains : 'learning' } }] },
484+ // { $or: [{ title: { $icontains : 'machine' } }, { content: { $icontains : 'machine' } }] },
485+ // { $or: [{ title: { $icontains : 'learning' } }, { content: { $icontains : 'learning' } }] },
496486// ]}
497487```
498488
@@ -529,19 +519,18 @@ maintained on write and listed in `task.searchableFields`:
529519 limit : 20 ,
530520}
531521// Expands to a single-table scan — no traversal, every driver:
532- // { $and: [{ $ or: [
533- // { name: { $contains : 'apollo' } },
534- // { project_name: { $contains : 'apollo' } },
535- // ]}]}
522+ // { $or: [
523+ // { name: { $icontains : 'apollo' } },
524+ // { project_name: { $icontains : 'apollo' } },
525+ // ]}
536526```
537527
538528❌ The mirror must be a ** stored** field — a ` formula ` field is virtual, no
539- driver materializes a column for it, so a ` $contains ` predicate against one has
540- nothing to scan. Nothing rejects the mistake for you: ` searchableFields ` admits
541- any field the object declares, so a formula entry clears both lint and the
542- ingress gate and then never matches. The trade-off is mirror maintenance — hooks
543- on both write paths (child re-parented, parent renamed) plus a backfill for rows
544- written around the hooks.
529+ driver materializes a column for it, so a search predicate against one has
530+ nothing to scan. Two guards catch that: lint errors on a virtual
531+ ` searchableFields ` entry, and the ingress gate refuses one by name. The
532+ trade-off is mirror maintenance — hooks on both write paths (child re-parented,
533+ parent renamed) plus a backfill for rows written around the hooks.
545534
546535Cross-object search paths are rejected by design, not pending. Modelling side of
547536this (the field, the hooks, the lint wording): ** objectstack-data → Search Fields
@@ -606,25 +595,21 @@ use [`expand`](#expand-related-records).
606595
607596### Dashboard Aggregation Pattern
608597
609- Unconditional KPIs can share one aggregate call; a KPI with its own
610- condition needs a ** separate call ** with the condition in ` where `
611- (per-aggregation ` filter ` is schema-reserved — see Filtered Aggregation) :
598+ Every KPI on a dashboard shares ** one** aggregate call — unconditional
599+ measures plain, conditional ones carrying their own ` filter ` . ` where ` scopes
600+ the whole call, so reach for it only when every measure wants the same scope :
612601
613602``` typescript
614- // KPI dashboard: unconditional aggregations share one call
603+ // KPI dashboard: one call, conditional measures scoped per aggregation
615604const [kpis] = await engine .aggregate (' deal' , {
616605 aggregations: [
617606 { function: ' count' , alias: ' total_deals' },
618607 { function: ' sum' , field: ' amount' , alias: ' pipeline_value' },
619608 { function: ' avg' , field: ' amount' , alias: ' avg_deal_size' },
609+ { function: ' count' , alias: ' won_deals' ,
610+ filter: { stage: ' closed_won' } },
620611 ],
621612});
622-
623- // Conditional KPI: separate call, condition in `where`
624- const [won] = await engine .aggregate (' deal' , {
625- where: { stage: ' closed_won' },
626- aggregations: [{ function: ' count' , alias: ' won_deals' }],
627- });
628613```
629614
630615---
@@ -636,9 +621,9 @@ code — the renderer issues the queries for you:
636621
637622| Query Need | Pattern |
638623| :--| :--|
639- | KPI widgets | Aggregates (` sum ` , ` count ` , ` avg ` ) over the object, each conditional KPI scoped by the widget/dataset filter. Add ` compareTo: 'previousPeriod' \| 'previousYear' ` on the widget for a one-line period-over-period delta. |
640- | Time-series chart | Date filters + ` categoryGranularity : 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'` for server-side bucketing — never bucket by hand on the client. Pair with ` compareTo ` for an aligned YoY overlay. |
641- | Matrix report | ` groupingsDown ` + ` groupingsAcross ` + ` dateGranularity: 'quarter' ` |
624+ | KPI widgets | Aggregates (` sum ` , ` count ` , ` avg ` ) over the object, each conditional KPI scoped by the widget/dataset filter. Add ` compareTo: { kind: 'previousPeriod' \| 'previousYear' } ` on the widget for a one-line period-over-period delta (the bare string form was removed in 17) . |
625+ | Time-series chart | Date filters + ` dateGranularity : 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'` on the widget's dataset selection for server-side bucketing — never bucket by hand on the client. Pair with ` compareTo ` for an aligned YoY overlay. |
626+ | Matrix report | Dataset-bound ` rows ` (down) + ` columns ` (across) + a ` dateGranularity ` dimension |
642627| Funnel summary | Multi-level grouping (` owner -> stage ` ) + aggregated measures |
643628| Operational filter | Prefer declarative operators (` $ne ` , ` $nin ` , ` $gte ` ) over hardcoded SQL |
644629
0 commit comments