Skip to content

Commit 16c5a33

Browse files
fix(objectql)!: per-aggregation filter and having refusals belong to the query, not the data — row-independent walk, shape and type doors, unknown having keys, temporal addDays pairs (#20147)
Fixes #20122 Fixes #20123 Fixes #20127 Clause-②: no (narrowing) A refusal on `engine.aggregate` is now a property of the query, never of the data. The per-aggregation `filter` and `having` are judged once, before any driver is asked for a row, and an empty table refuses what a full one refuses. This is the combined claim of #20122 (the chain head), #20123 and #20127, with the seat's amendment 5832109186 folding the per-aggregation filter's shape door into #20122, and amendment 2 (5832826252) ruling that an array filter is refused too. | commit | card | change | |:--|:--|:--| | `866212209f` | #20122 | the per-aggregation `filter` loop runs the comparand-TYPE door and a row-independent walk (`assertAggregationFilterIsEvaluable`) | | `71e3ae2092` | #20123 | a `having` key naming no column of the aggregated row is refused | | `1690b79b59` | #20127 | `having` evaluates `addDays` only between two temporal columns of one class; each aggregated column's class is read statically (`aggregatedRowColumnClasses`) | | `3c2fa822db` | | merge of `main` at `949e99bed9`, where PR #20144 landed | | `a17770aa77` | #20122 | the shape gate `where` takes, on the per-aggregation `filter` (seat amendment 5832109186) | | `796b06a12e` | | changeset prose only | | `211cfba477` | #20122 | an array per-aggregation `filter`, `[]` included, is refused (seat amendment 2, 5832826252, ruling A) | Session `session_01Bvd69VPa6puiNzzPUroDBx`, branch `claude/issue-20122-aggregate-filter-doors`. The three card commits sit on base `f09d4122bc`. The branch merged `main` at `949e99bed9`, and head is `211cfba477` (`engine.ts` `45301fa9e4`, `having-filter.ts` `876293a1e9`). Readings below name the commit they were taken on. ## 1. Measured first A scratch harness (not committed) ran each shape through the public `ObjectQL.aggregate` and through `POST /api/v1/data/:object/query` (`RestServer`, then `ObjectStackProtocolImplementation.findData`, then `ObjectQL.aggregate`). It used a real `InMemoryDriver` and a real `SqlDriver` (better-sqlite3 `:memory:`), on a populated table (6 rows, groups c1, c2, c3) and on an empty one, and counted driver calls. Per-aggregation filters ran grouped and ungrouped. Each `having` ran on both `applyHaving` doors: the native `driver.aggregate()` door, and the fallback forced by an extra per-aggregation filter. That is 1464 cells per tree, plus 72 for six extra shape-door shapes. The base is `f09d4122bc`, and the shape-door base is the merged tree before `a17770aa77`. On every row below, both drivers and both doors agreed. ### H1 (#20122): held for the walker's refusals, partly falsified for the type door | `aggregations[i].filter` | base, empty table | base, populated | head | |:--|:--|:--|:--| | `{ amount: { $median: 1 } }`, `$nand`, `$regex`, `$regex` with `$options`, `$like`, a dangling `$like` escape, `$ilike`, a non-`$` key beside an operator, `$median` under `$not`, a bare `{ $field }` | `200 []` grouped, `[{ n: 0 }]` ungrouped | 400 `INVALID_FILTER`, raised after `find` (1 call) | 400 `INVALID_FILTER`, 0 driver calls, both populations, engine and REST | | an empty or non-string `$icontains` | the same at the engine; the REST door already refused it (`VALIDATION_FAILED` / 400), whatever the rows | the same | 400 at the engine, 0 calls; REST unchanged | | `{ nope: { $median: 1 } }` (a column the source row does not carry) | `[]` | counted no row in any group, no error | 400, 0 calls | | `$median` in a `$or` branch after one that held | `[]` | counted EVERY row (c1 2, c2 3, c3 1) | 400, 0 calls | | a `{ $field }` as an `$in` member or `$contains` pattern / as a `$nin` member or `$exists` operand | `[]` | no row / every row | 400, 0 calls | | `addDays: 1.5` / `addDays: '7'` | `[]` | answered (c1 1, c2 2, c3 1 / c1 2, c2 2, c3 1) | 400, 0 calls | | type door: `{ $eq: { v: 1 } }`, an implicit `undefined`, `{ $eq: new Map() }`, a function under `$gt`, an `undefined` `$in` member, a bigint beyond 2^53, `{ $gt: { $field: 5 } }` | `[]` | counted no row | 400, the type door's words rooted at `aggregations[i].filter`, 0 calls | | type door: a `Symbol` under `$ne` / under `$gt` | `[]` | every row / a raw `TypeError` with no `code` and no `status` | 400, 0 calls | So H1 holds for every walker refusal. For the type-mismatched comparands the premise was only half right. They were not refused on a populated table either: they answered silently, apart from the uncoded `Symbol` throw. The head refuses all of them before any read, as H1's head asks. The exact-range bigint changes an answer instead of narrowing. `{ amount: { $in: [400n, 20n] } }` counted no row, and it now counts c1 1, c3 1, because it is narrowed as `where` narrows it. ### H2 (#20123): held | `having` | base, both populations | head | |:--|:--|:--| | `{ totl: { $gt: 100 } }`, `{ totl: 500 }`, `{ amount: { $gt: 100 } }` (a source column), `{ $and: [{ total: { $gt: 0 } }, { totl: … }] }`, `{ 'customer_id.name': 'x' }`, `{ customer_id: 'c1' }` under an aliased groupBy | no group, no error | 400 `INVALID_FILTER`, 0 driver calls, both doors, engine and REST | | `{ totl: { $ne: 1 } }`, `{ totl: { $exists: false } }`, `{ $not: { totl: … } }`, `{ $or: [{ total: { $gt: 0 } }, { totl: … }] }` | EVERY group on a populated set | 400, 0 calls | Controls, accepted and byte-identical: a groupBy column, an aggregation alias, a `count` alias, a structured item's alias (`cust`), keys nested under `$and` / `$or` / `$not`. ### H3 (#20127): held | `having` pair with `addDays` | base, populated | head | |:--|:--|:--| | two numeric aliases (`total` vs `max_cap`) | no group | 400, "addDays adds whole days to a date or datetime column, and "max_cap" is numeric — an offset has no meaning on it." | | a `count` against itself, `addDays: 0` | every group | 400, same words | | `date` vs numeric, numeric vs `date`, groupBy text vs `date` | no group | 400, driver-sql's cross-class sentence | | `date` vs `datetime` / `datetime` vs `date` | c3 / c2, c3 | 400, cross-class | | a `date` pair whose offset column is text or `date` | no group | 400, "the addDays offset … is not a numeric column, and a day offset must be a number of days." | Controls, byte-identical: `date` / `date` with `7`, with `-3`, with an offset from a numeric `max`, and with an offset from a `count`; `datetime` / `datetime`; a `day` bucket against a `date`; and every `{ $field }` pair WITHOUT `addDays`. ### H4: held 664 control cells over 43 control shapes answered byte-identically at base and head. They cover the #20122 controls (implicit equality, `$gt`, `$in`, `$nin`, `$between`, `$icontains`, `$startsWith`, `$ne: null`, `$exists`, `$null`, `$or`, `$not`, `{}`, a scalar `{ $field }`, `addDays` date pairs, an exact bigint, a `Date` bound), the #20123 and #20127 controls above, the structured-groupBy probes, and the H5 shapes the shape gate leaves alone. ### H5: folded in (seat amendments 5832109186 and 5832826252) | `aggregations[i].filter` | base, engine | base, REST | head, engine | |:--|:--|:--|:--| | a string | memory: every row of every group; sql: `NOT_IMPLEMENTED` / 501 (driver-sql's native aggregate saw the key) | `VALIDATION_FAILED` / 400 | 400 `INVALID_FILTER`, 0 driver calls | | a number, `true`, `false`, `0`, `''` | every row of every group, both drivers | `VALIDATION_FAILED` / 400 | 400, 0 calls | | a `Map`, a `Date`, a `Set` | every row of every group | (not JSON) | 400, 0 calls | | `[]` | no filter (every row) | `VALIDATION_FAILED` / 400 | 400 `INVALID_FILTER`, 0 driver calls (from `211cfba477`) | | `[['amount', '>', 100]]` | counted no row: the walker reads its index keys as column names | `VALIDATION_FAILED` / 400 | 400 `INVALID_FILTER`, 0 driver calls (from `211cfba477`) | | a null-prototype filter object | filters correctly | (not JSON) | unchanged | The reach is in-process only: the REST door refuses every JSON shape through `AggregationNodeSchema`. An array is refused because the slot is declared `FilterConditionSchema`, which admits no array form (the condition-array sugar is lowered on `where` alone), and REST already refused every array there with `VALIDATION_FAILED`: one rule at both doors, as `having`'s condition check has on #20099. ## 2. What changed - **`packages/objectql/src/engine.ts`, `ObjectQL.aggregate` only.** - The per-aggregation `filter` loop now opens with the shape gate `where` takes. It reuses `isWhereFilterObject` / `describeNonFilterWhere` from PR #20144 and `where`'s words, adapted because no array is accepted here: "`aggregate('order'): 'aggregations[1].filter' must be a filter object, received string "…". It was not applied, and an unapplied filter would have aggregated every row of each group for that aggregation.`". An array of any length, `[]` included, is refused first, naming the object form: "`… must be a filter object, received an array ([["amount",">",100]]). The condition-array form … is input-only sugar lowered on 'where' alone …`". `undefined`, `null`, a plain object and a null-prototype object pass as before. - After the doors the loop already ran, it adds `normalizeFilterComparandTypes` rooted at `aggregations[i].filter`, copying a narrowed bigint on write, and then `assertAggregationFilterIsEvaluable`. - The `having` entry passes `aggregatedRowColumnClasses(groupBy, aggregations, object fields)` to `assertHavingIsEvaluable`. - **`packages/objectql/src/having-filter.ts`.** - The row-independent walk takes a scope: the clause whose words it speaks (`having`, or `aggregations[i].filter` through `aggregationFilterClause`), plus, where the position has a closed namespace, the column set and the column classes. - `assertAggregationFilterIsEvaluable(filter, index)` runs the walk with the per-aggregation clause and no column set. The filter reads the object's raw columns, whose names the engine does not judge on `where` either, so the `{ $field }` NAME check stays `having`'s. - The #20123 refusal (`unknownHavingColumnError`) collects keys naming no column of `aggregatedRowColumns` at any depth and refuses them once the rest of the clause has passed. Operators are judged first, so `{ nope: { $median: 1 } }` keeps its operator refusal, and its pin is unchanged. The refusal names every unknown key, the first with its position, and lists the columns. It opens the way the REST ingress's unknown-`where`-field refusal does: "`having` filters on 'totl' at having.totl, which is not a column of the aggregated row … so the query was refused instead of answered." - For #20127, `aggregatedRowColumnClasses` reads each column's class statically. A groupBy projection takes its field's declared type through the spec's value-class sets. A `day` bucket is a `date`, by the `YYYY-MM-DD` label contract, and a coarser bucket is a text label. `count` / `count_distinct` / `sum` / `avg` are numeric, and `min` / `max` take their field's type. A class the declaration cannot tell (no field map, an undeclared field, a `formula`) is not judged. `assertOffsetPairIsTemporal` applies driver-sql's `addDays` arm in its order, in its sentences: same class, then a temporal referent, then a numeric offset column. - **Tests**: - `engine-aggregate-filter.test.ts` gains the #20122 tables. Each refusal runs on both stand-in driver kinds, empty and populated, grouped and ungrouped, asserting `code`, `status`, one message and 0 driver calls. The walker rows are held to the per-row floor's words, and the type rows to the type door's own words. The file also gains controls and the shape-gate table. - `engine-aggregate-having-comparand-shape.test.ts` gains the #20123 and #20127 tables, both doors, empty and populated, with controls. - **Three changesets**, one per card, all `@objectstack/objectql` `minor`. ## 3. Declaration (H6) `Clause-②: no (narrowing)`, BREAKING, `minor`. The accept set only narrows. The one answer change of an already-accepted input is the bigint narrowing, a correction to the answer `where` gives. - `check-changeset-no-major --base origin/main`: "✓ This diff introduces no `major` bump." Its level axis reads NOT APPLICABLE locally, with no `pull_request` payload; CI reads it from this body. - `check-adr-0087-registration --base origin/main`: "✓ check-adr-0087-registration: 3 declared-breaking changeset(s), each carrying an ADR-0087 disposition." - 20122: `not-required (already-registered filter-between-field-reference-endpoint-refused, filter-icontains-comparand-refused-at-parse, filter-regex-options-retired)`. - 20123 and 20127: `not-required (no-migration-prescription)`. Their tables record before and after and prescribe no rewrite: a typo'd column or a non-temporal `addDays` pair has no accepted spelling to migrate to. ## 4. Tests, reverse verification, ablation At `211cfba477`: - `@objectstack/objectql`, whole suite, both vitest projects: `Test Files 317 passed (317)` · `Tests 5589 passed (5589)`. The two aggregate files alone: `Test Files 2 passed (2)` · `Tests 211 passed (211)`. - `pnpm --filter @objectstack/objectql run typecheck`: exit 0. `check:test-typecheck` reads "OK … 40 file(s) / 234 error(s) / 65 pinned signature(s)", unchanged. At `a17770aa77` (before the array refusal; not re-run for `211cfba477`): - Consumer suites, run against the rebuilt `objectql` `dist/`, which carries the new symbols (2 hits each): - REST (`list-view-grouping-query-door`, `rest-server-canonical-query-ast`, `request-schema-gate.conformance`): 81 passed, 1 skipped (pre-existing); - `metadata-protocol` (`protocol.query-param-arity`, `protocol.read-verb-canonical-fold`): 64 passed; - `plugin-security` `predicate-guard`: 10 passed; - the dogfood aggregate and analytics tests (`analytics-inline-dataset-admission`, `analytics-label-scope`, `analytics-rls`, `analytics-timezone`, `date-bucket-parity-conformance`, `date-bucket-parity-turso`, `empty-group-bucket-parity`, `group-key-read-shape-parity`) plus PR #20144's `engine-where-shape-refusal`: `Test Files 9 passed` · `Tests 55 passed`. - **Reverse verification.** `engine.ts` and `having-filter.ts` were restored to their merge-base blobs (`4f5d28170b`, `7d9f8ace64`) under an EXIT/INT/TERM trap, with the fix committed first. The two test files then read `59 failed | 150 passed (209)`, which is exactly the new refusal and narrowing rows. The restore was proven by HEAD-blob equality and an empty `git diff HEAD`. At `1690b79b59`, before the shape gate, the same run against the `f09d4122bc` blobs read `53 failed | 148 passed (201)`. - **Ablation**, one per door, through `scripts/ablation-replace.mjs`: the first four at `1690b79b59`, the shape gate at `a17770aa77` and again, branch by branch, at `211cfba477`. Each anchor hit once (x1 to x0), the blob moved, and the file was restored to its HEAD blob with `git diff HEAD` empty. The tests import `./engine.js` from source, so no `dist/` is on their resolution path. | ablated | failed / total | red set | |:--|:--|:--| | `assertAggregationFilterIsEvaluable(typed, i)` | 19 / 201 | the 12 walker rows and the 7 reference rows | | the type-door call (the filter passed as is) | 11 / 201 | the 10 type rows and the bigint narrowing | | the #20123 key push | 12 / 201 | the 10 unknown-key rows, the "every key named" row, the aliased-groupBy row | | the #20127 `assertOffsetPairIsTemporal` call | 11 / 201 | the 10 pair rows and the month-bucket row | | the shape gate's condition (to `false`), at `a17770aa77` | 6 / 209 | the 6 shape rows | | at `211cfba477`: the array branch's condition (to `false`) | 3 / 211 | the 3 array rows | | at `211cfba477`: the non-object branch's condition (to `false`) | 6 / 211 | the 6 non-object rows | | at `211cfba477`: both conditions (the whole gate) | 9 / 211 | the 6 non-object and 3 array rows | ## 5. Gates - Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, 7 paths against merge base `949e99bed`: 64 commands. Each was run before any pipe and its exit code recorded, at `796b06a12e`: all 64 exit 0. At `211cfba477` the gates the patch round touches were re-run: - `check-changeset-no-major --base origin/main --event` (this body): "✓ This diff introduces no `major` bump." · "✓ LEVEL AXIS: this PR declares clause-② `no (narrowing)`, and no package whose `packages/**/src/**` it moves is graded `patch`." - `check-adr-0087-registration --base origin/main`: "✓ check-adr-0087-registration: 3 declared-breaking changeset(s), each carrying an ADR-0087 disposition." - `check-empty-changeset --base origin/main`: "✓ No empty-frontmatter changeset introduced by this diff (3 declaring changeset(s) added)." - `check-issue-citations --base 949e99b`: "every citation this change adds resolves" (25 judged). - `check:nul-bytes`: "OK (scanned 9571 text file(s) … no raw ASCII control bytes)". `--ran`: "Run reconciliation — 64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN." - `check:dual-build-cjs-loads` first exited 3 (PREREQUISITE NOT MET). The eight packages it named were built, and it reran at exit 0. - `node scripts/check-issue-citations.mjs --base 949e99b`: "every citation this change adds resolves" (24 judged). - Lint, a declared narrowing, because `pnpm lint` is CI's. `eslint --no-inline-config --format json` over the 4 changed `.ts` files reads 4 files, 0 errors, 0 warnings, 0 fatal. `--print-config` returns a config for each file. `eslint.config.mjs` sets no `parserOptions.project` and no typed rule, so no untouched file's verdict can move. - Control-byte self-scan of the 7 changed files: grep exit 1 (none). ## 6. Deviations and conflicts, declared - **The array question is ruled.** The first round kept arrays outside the shape gate, as the first amendment instructed, and held a condition array's old answer (no row) in a test marked as kept, not approved. The seat ruled A in amendment 2 (5832826252): an array, `[]` included, is refused, because `AggregationNodeSchema.filter` is `FilterConditionSchema` and REST already refuses every array. `211cfba477` does that; the held control became three refusal rows. - **#20123's code.** The card's suggested shape said "the unknown-field refusal's envelope", which is `INVALID_FIELD`. The dispatch's H2 said `INVALID_FILTER`. This PR uses `INVALID_FILTER`, the code of every other `having` refusal, including #20099's unresolved `{ $field }` refusal over the same column set. The name is a column of the query's own projection, not a field of the object, and the words follow the unknown-`where`-field refusal. - **Real drivers in the pins.** The committed pins use the stand-in drivers with call counters. `@objectstack/objectql` has no driver dependency, and the amendment keeps the file surface unchanged. The `driver-memory` and `SqlDriver` legs, through the engine and through REST, are the scratch measurement above. - **The `{ $field }` refusal words for a bare reference in a per-aggregation filter.** On a populated table this was already refused, as an unsupported `$field` operator. It is now refused whatever the rows, in the walk's bare-reference words, with the same code and status. No committed test pinned the old text. ## Acceptance notes Observed and not fixed here. The report carries each with its class and evidence; the seat files the per-aggregation family as one class-closure card (amendment 2, 5832826252). - **Docs drift check (5832744152), read and receipted here.** It names six hand-written pages, each only through the `count_distinct` literal in `NUMERIC_RESULT_FUNCTIONS`. Re-read against this change: none states anything it falsifies. `data-modeling/queries.mdx` already gives `having`'s namespace as the aggregated row's own columns, and `protocol/objectql/query-syntax.mdx` gives the `addDays` class rule this change now applies to `having`. One pre-existing stale row, not introduced here: `query-syntax.mdx`'s table of members "not executed on the `find()` path" still calls `aggregations[].filter` "EXPERIMENTAL — not enforced", although the engine has enforced it since #10576. Left to the docs lane. The two release-owned pages are read-only. - The per-aggregation filter still lacks `where`'s temporal-comparand door. `{ placed_on: { $gt: 'not-a-date' } }` counts no row on both drivers, while the same predicate as a `where` is refused 400. - A `Date` comparand in a per-aggregation filter counts no row against an ISO-text `datetime` column on both drivers: the walker compares a string with a `Date`. `driver-sql` answers the rows for the same `where`. - `addDays` on a numeric pair in a per-aggregation filter still answers by coercion (no row). The #20127 rule classifies only the aggregated row's columns; the per-aggregation position would read its classes from the object's declared types, where `driver-sql` withholds the reason from the wire. - A `{ $field }` in a per-aggregation filter naming no field of the object counts no row, where `driver-sql` refuses it on `where`. The engine keeps its registry-less tolerance on names. - `POST /data/:object/query` with `aggregations: [{ …, filter: { nope: 1 } }]` answers 200 with zero counts. The REST ingress refuses the same unknown key in `where` (`INVALID_FIELD` / 400), and it does not judge per-aggregation filter keys. - A `having` `{ $field }` pair across classes WITHOUT `addDays` (a sum against a date) still answers by coercion. `driver-sql` refuses cross-class pairs on `where`, but `FieldReferenceSchema` declares the class rule only for `addDays`, and this change keeps to that. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 437bb0d commit 16c5a33

7 files changed

Lines changed: 973 additions & 26 deletions
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@objectstack/objectql": minor
3+
---
4+
5+
fix(objectql)!: a per-aggregation `filter` (`aggregations[i].filter`) on `engine.aggregate` is judged once, before any row is read — its refusals no longer depend on whether the table has rows, and it takes the shape gate and the comparand-type door `where` takes (#20122)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (already-registered filter-between-field-reference-endpoint-refused, filter-icontains-comparand-refused-at-parse, filter-regex-options-retired) this change adds no new transition. Each refusal below was already made by the per-aggregation filter's walker on a populated table, or is a shared door `where` already takes, run unmodified on one more position: the first id's replacement states that a { $field } reference is legal as the WHOLE comparand of $eq / $ne / $gt / $gte / $lt / $lte only, which is the position refusal the walker now makes whatever the rows; the other two register the $icontains comparand and the retired $regex / $options refusals it already made on a populated table. The comparand-type set was ruled at the shared face with no ledger entry. An unknown operator has no meaning to migrate to. An array filter is refused because the slot's declared type, FilterConditionSchema, admits no array form, exactly as `having`'s condition check already refuses one, and the REST door already refused it. A stored measure filter reaches this position through the analytics service, which lowers it; the refused inputs are ones that position never evaluated as written, so there is no rewrite for `objectstack migrate meta` to perform. The table below is the author-facing remedy for each row, not a mechanical rewrite. -->
10+
11+
**BREAKING**: this narrows what a per-aggregation `filter` accepts on `engine.aggregate`, and on the REST aggregate query (`POST /data/:object/query` with `aggregations`) that forwards it there. Every refusal below is `INVALID_FILTER` / 400, raised in the engine's per-aggregation loop, before any driver is asked for a row, identically on an empty and on a populated table. The refusal names the aggregation that carries the filter (`aggregations[1].filter`). It ships as `minor` under the launch-window convention for accept-set narrowings.
12+
13+
The engine evaluates a per-aggregation filter itself, in the in-memory fallback, once per source row of each bucket, with the same walker `having` uses. So the walker's refusals were reached only when a row was. Measured on the base through `engine.aggregate` on `driver-memory` and `driver-sql`, and through `POST /data/:object/query` on both, over six rows in three groups, with `{ function: 'count', alias: 'n', filter }`:
14+
15+
| you wrote in `aggregations[i].filter` | what it did before | write instead |
16+
|:--|:--|:--|
17+
| an unknown or retired operator (`{ amount: { $median: 1 } }`, `$nand`, `$regex`, `$regex` with `$options`), an operator of `where` the walker does not evaluate (`$like`, `$ilike`), a non-`$` key beside an operator, or an empty or non-string `$icontains` | refused on a populated table only. An empty table answered `200 []` with a `groupBy`, and `[{ n: 0 }]` without one. (At the REST door an empty or non-string `$icontains` was already refused at ingress, whatever the rows.) | the operator the refusal names |
18+
| an unknown operator on a column the source row does not carry (`{ nope: { $median: 1 } }`) | counted no row, with no error, on a populated table too | the operator the refusal names |
19+
| an unknown operator in a `$or` branch after one that held (`{ $or: [{ amount: { $gt: 0 } }, { amount: { $median: 1 } }] }`) | counted EVERY row on a populated table: the walk stopped at the branch that held | the operator the refusal names |
20+
| `{ amount: { $field: 'cap' } }` (a reference with no operator) | refused as an unsupported `$field` operator on a populated table only | `{ amount: { $eq: { $field: 'cap' } } }`, or `$ne` / `$gt` / `$gte` / `$lt` / `$lte` |
21+
| a `{ $field }` reference as an `$in` / `$nin` member, a `$contains` pattern, or an `$exists` operand | compared the reference object itself: no row under `$in` / `$contains`, every row under `$nin` / `$exists` | a literal there, or the comparison as one of the six scalar operators |
22+
| a `{ $field }` reference whose `addDays` is not an integer (`1.5`, `'7'`) | counted rows by the in-memory evaluator's own reading of that offset, which `FieldReferenceSchema` refuses | a whole-day `addDays` |
23+
| `{ amount: { $eq: { v: 1 } } }`, `{ amount: undefined }`, `{ $eq: new Map() }`, a function, an `undefined` `$in` member, a bigint beyond 2^53, or `{ $gt: { $field: 5 } }` | counted no row (each measured under the operator shown, or in the implicit slot). The same comparand in `where` is refused by the comparand-type door; the per-aggregation filter now gets that door's refusal, rooted at its own position | a string, number, bigint, boolean, `null` or `Date` |
24+
| a `Symbol` comparand | under `$ne`, counted every row; under `$gt`, threw a raw `TypeError` with no `code` and no `status` on a populated table | a literal of one of the types above |
25+
| a `filter` that is not a filter object: a string (`"stage = 'won'"`), a number, `true` / `false`, `''`, a `Map`, a `Date` or a `Set` | dropped: the aggregation read every row of its group, with no error. `driver-sql`'s native aggregate answered a non-empty string with `NOT_IMPLEMENTED` / 501. (The REST door already refused the JSON-expressible ones, through `AggregationNodeSchema`.) Now refused by the shape gate `where` takes, with the aggregation named (`'aggregations[1].filter' must be a filter object, received …`) | a filter object, `{ stage: 'won' }` |
26+
| an array, `[]` included: a condition array (`[['amount', '>', 100]]`, `['and', …]`) or an empty one | a condition array counted NO row: the walker read its index positions as column names. `[]` was read as no filter. The REST door already refused every array here with `VALIDATION_FAILED` / 400. Now refused in-process too (`'aggregations[1].filter' must be a filter object, received an array (…)`), because `AggregationNodeSchema.filter` is declared `FilterConditionSchema`, which admits no array form: the condition-array sugar is lowered on `where` alone | the object form, `{ amount: { $gt: 100 } }`; omit `filter` for no filter |
27+
28+
Not refused, but answering differently:
29+
30+
- **An exact-range bigint comparand is narrowed to a number, as it is in `where`.** `{ amount: { $in: [400n, 20n] } }` counted no row, because `[400n].includes(400)` is false. It now counts the rows it names. The caller's aggregation entry is not edited.
31+
32+
Who is affected: a per-aggregation filter reaches `engine.aggregate` from a direct engine call, from the REST aggregate query, and from the analytics service, which lowers a dataset measure's own `filter` onto it. On a populated table the refusals in the first and fourth rows above were already raised; what changes there is that an empty table refuses them too. The two shape rows are reachable in-process only: the REST door already refuses a non-object `filter` and every array. Callers in a deployment were NOT measured.
33+
34+
Not changed: implicit equality, scalar ordering bounds, `$in` / `$nin` lists, a two-bound `$between`, `$icontains` / `$startsWith` with a non-empty string, `$ne: null`, `$exists`, `$null`, `$or` / `$not` composition, a `{ $field }` reference as the whole comparand of a scalar comparison (with or without a whole-day `addDays`), an exact bigint in the implicit slot, and `{}`, on both drivers, measured identical before and after. The zero-row filter `{ $not: {} }`, which the analytics service lowers FALSE to, still counts no row (a unit pin, green against the base code too). `null`, an absent `filter` and a null-prototype filter object are not refused by the shape gate, as they are not on `where`, and answer as before.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
"@objectstack/objectql": minor
3+
---
4+
5+
fix(objectql)!: a `having` key that names no column of the aggregated row is refused on `engine.aggregate`, instead of answering as if that column had no value in every group (#20123)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) this change adds no transition to migrate. A `having` key that names no column of the aggregated row never had a meaning: the rows carry exactly the columns the query projects, so such a key could only ever read "no value". There is no accepted spelling it can be mechanically rewritten to — which of the query's columns the author meant is an authoring decision, and the refusal prints the list. `having` is a request-only key: no metadata type stores it, so there is no stored document for `objectstack migrate meta` to rewrite. The table below records the answer each shape had and has; it prescribes no rewrite. -->
10+
11+
**BREAKING**: this narrows what `having` accepts on `engine.aggregate`, and on the REST aggregate query (`POST /data/:object/query`) that forwards it there. A `having` key — at any depth under `$and` / `$or` / `$not` — must name a column of the aggregated row: a groupBy projection (the field name, or a structured item's `alias`) or an aggregation alias. Any other key is refused with `INVALID_FILTER` / 400, once per query, before any driver is asked for a row, on both the native `driver.aggregate()` path and the in-memory fallback, whether or not any group exists. It ships as `minor` under the launch-window convention for accept-set narrowings.
12+
13+
The engine evaluates `having` itself, per aggregated row, and read a key the row does not carry as a column with no value. So a typo for an alias answered like a real query. Measured on the base through `engine.aggregate` on `driver-memory` and `driver-sql`, both paths, and through `POST /data/:object/query` on both, over three groups by `customer_id`, each with a positive `total` (a `sum` alias) beside a `count` alias `n`:
14+
15+
| `having` | before | now |
16+
|:--|:--|:--|
17+
| `{ totl: { $gt: 100 } }`, `{ totl: 500 }`, or `{ amount: { $gt: 100 } }` (a source column the aggregated row does not project) | no group, no error | refused, naming the key, its position and the query's columns |
18+
| `{ totl: { $ne: 1 } }`, `{ totl: { $exists: false } }`, or `{ $not: { totl: { $gt: 100 } } }` | EVERY group, no error | refused |
19+
| `{ $or: [{ total: { $gt: 0 } }, { totl: { $gt: 100 } }] }` | every group whose `total` is positive: the walk stopped at the branch that held | refused |
20+
| `{ $and: [{ total: { $gt: 0 } }, { totl: { $gt: 100 } }] }` | no group | refused |
21+
| `{ 'customer_id.name': 'c1' }` (a dotted path) | no group | refused |
22+
| `{ customer_id: 'c1' }` when the groupBy item is `{ field: 'customer_id', alias: 'cust' }` | no group: the row projects `cust` | refused; `{ cust: 'c1' }` answers |
23+
24+
The refusal opens the way the REST ingress's refusal of an unknown `where` field does ("filters on 'totl' … which is not a column of the aggregated row"), names every unknown key, and lists the aggregated row's columns. Its code is `INVALID_FILTER`, the code of every other `having` refusal: the name is a column of the query's own projection, not a field of the object. It is judged after the rest of the clause: a condition on an unknown column that also carries an unknown operator (`{ nope: { $median: 1 } }`) is still refused for its operator first, as before.
25+
26+
Who is affected: `having` is a request-only key (`QuerySchema.having`, `EngineAggregateOptions.having`), and no metadata type stores it. Every `having` in this repository's docs and published skills names an aggregation alias of its own query (`{ order_count: { $gt: 5 } }` and the like), which answers exactly as before. Callers of `engine.aggregate` and of the REST aggregate query in a deployment were NOT measured.
27+
28+
Not changed: a key naming a groupBy column, a structured item's alias, a `count` / `sum` / `max` alias, or any of those under `$and` / `$or` / `$not`, answers exactly as before on both paths, measured identical before and after.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@objectstack/objectql": minor
3+
---
4+
5+
fix(objectql)!: in `engine.aggregate({ having })`, a `{ $field, addDays }` reference is evaluated only between two temporal columns of one class, with a numeric offset column, as `FieldReferenceSchema.addDays` declares, instead of answering by epoch-millisecond coercion (#20127)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) this change adds no transition to migrate. It enforces on `having` the rule `FieldReferenceSchema.addDays` already declares ("between two temporal columns of the same class (date/date, datetime/datetime)", the offset a whole number or a numeric column), which `driver-sql` already enforces on `where`. A refused pair never had the meaning the declaration gives `addDays`: on a number, a text or a mixed pair, it was answered by the in-memory evaluator's coercion. There is no accepted spelling it can be mechanically rewritten to: whether the author meant two date columns, no offset, or a different column is an authoring decision. `having` is a request-only key: no metadata type stores it, so there is no stored document for `objectstack migrate meta` to rewrite. The table below records the answer each pair had and has; it prescribes no rewrite. -->
10+
11+
**BREAKING**: this narrows what `having` accepts on `engine.aggregate`, and on the REST aggregate query (`POST /data/:object/query`) that forwards it there. A `{ $field }` reference that carries `addDays` in one of the six scalar comparisons is now refused with `INVALID_FILTER` / 400 unless the column it filters and the column it references are both `date` or both `datetime`, and an `addDays` offset read from a column reads a numeric one. The refusal is raised once per query, before any driver is asked for a row, on both the native `driver.aggregate()` path and the in-memory fallback, whether or not any group exists. It ships as `minor` under the launch-window convention for accept-set narrowings.
12+
13+
An aggregated row has no declared field types, so each column's class is now read off the query and the object's declaration, before any row exists:
14+
15+
- a groupBy projection takes its field's declared type. A `day` date bucket is a `date`, because its label is `YYYY-MM-DD` on every face. A `week`, `month`, `quarter` or `year` bucket is a text label;
16+
- `count`, `count_distinct`, `sum` and `avg` are numeric;
17+
- `min` and `max` take the type of the field they read.
18+
19+
A column whose class the declaration cannot tell is not judged: an object with no field map, a field it does not declare, or a `formula` field.
20+
21+
`having` resolved every pair through `@objectstack/formula`'s evaluator, which reads a number as epoch milliseconds, while `driver-sql` refuses the same pair on `where`. The refusal reuses `driver-sql`'s sentences for the pair, naming each aggregated column's class where `driver-sql` names a stored type ("is numeric" for "is stored as numeric"), because an aggregated column is computed rather than stored. Measured on the base through `engine.aggregate` on `driver-memory` and `driver-sql`, both paths, and through `POST /data/:object/query` on both, over three groups with a `sum` alias `total`, a `max` of a number `max_cap`, `max` / `min` of two `date` fields, `max` / `min` of two `datetime` fields and a `count` `n`:
22+
23+
| `having` | before | now |
24+
|:--|:--|:--|
25+
| `{ total: { $gt: { $field: 'max_cap', addDays: 1 } } }` (two numeric columns) | no group, no error | refused: "addDays adds whole days to a date or datetime column, and "max_cap" is numeric — an offset has no meaning on it." |
26+
| `{ n: { $gte: { $field: 'n', addDays: 0 } } }` (a count against itself) | every group | refused, in the same words |
27+
| a `date` column against a numeric column, a numeric column against a `date` one, or the `customer_id` groupBy text column against a `date` one | no group | refused, naming both columns and their classes: "… and a cross-class comparison answers differently in SQL (storage-class ordering) than in memory (JS coercion) — compare same-class columns." |
28+
| a `date` column against a `datetime` one | one group | refused, in the cross-class words |
29+
| a `datetime` column against a `date` one | two groups | refused, in the cross-class words |
30+
| a `date` pair whose `addDays` reads a text column or a `date` column | no group | refused: "the addDays offset … is not a numeric column, and a day offset must be a number of days." |
31+
32+
Who is affected: `having` is a request-only key (`QuerySchema.having`, `EngineAggregateOptions.having`), and no metadata type stores it. No `having` in this repository's docs and published skills carries a `{ $field }` reference. Callers of `engine.aggregate` and of the REST aggregate query in a deployment were NOT measured.
33+
34+
Not changed, measured identical before and after on both paths: a `date` / `date` pair and a `datetime` / `datetime` pair, with a positive or negative whole-day literal or with an offset read from a numeric column (`max` of a number, or a `count`); a `day` date bucket against a `date` column; and any `{ $field }` comparison WITHOUT `addDays`, including a numeric pair and a numeric column against a `date` one. A per-aggregation `filter` (`aggregations[i].filter`) is not judged by this rule: it reads the object's raw columns, and this change classifies only the aggregated row's.

0 commit comments

Comments
 (0)