Skip to content

Commit a949ee8

Browse files
committed
fix(objectql)!: engine aggregate judges the sum row too — sum over a refused type answers INVALID_FIELD / 400 on every driver
The aggregate x field-type door held the table's sum row back for a census answer; triage released it. ROWS_HELD_FOR_TRIAGE is deleted (it would be empty), the held sum pins flip to refusal pins, and the spec TSDoc states that the door asks every row. Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2821e9f commit a949ee8

7 files changed

Lines changed: 183 additions & 62 deletions
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@objectstack/objectql": minor
3+
"@objectstack/spec": patch
4+
---
5+
6+
fix(objectql)!: the engine's `aggregate` judges the `sum` row of the aggregate × field-type table too, so `sum` over a type the table refuses answers `INVALID_FIELD` / 400 on every driver instead of `0` in memory and on SQLite and a 500 on PostgreSQL
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: not-required (already-registered dataset-measure-aggregate-field-type-refused) the pairs this change refuses are exactly the pairs the sum row of AGGREGATE_FIELD_TYPE_COMPATIBILITY already refuses, and the table is not edited: that id's prescription covers sum / avg over every field class the table refuses (widened to them by a later change that registered against it), with its routes (count, an aggregate the type accepts, or a numeric field for a quantity stored otherwise). This change lets the engine door ask the one row it had skipped; it refuses a query shape, not a stored one, and no authorable key, export or stored row moves. -->
11+
12+
**BREAKING** (`@objectstack/objectql`): this narrows what `aggregate` accepts, on every driver and for every caller that reaches the engine — the REST query door, a flow or hook, a roll-up summary's recompute, and the analytics strategy that lowers a cube query onto `engine.aggregate`. Shipped as `minor` under the launch-window convention for accept-set narrowings. No export or published type changes.
13+
14+
This completes the change of this same release that made the engine's `aggregate` ask the table for `min`, `max` and `avg`, and that held the `sum` row back. Its paragraph "Not judged yet: `sum`" is superseded: this entry is the later word, and the door now asks every row of the table.
15+
16+
FROM → TO, per aggregation `{ function: 'sum', field }` naming a declared field:
17+
18+
- `sum` over a type outside `number`, `currency`, `rating`, `slider`, `progress`, `summary`, `boolean` and `toggle` — a `percent` (a rate does not add), the temporal types (`date`, `datetime`, `time`), the string family (`text`, `email`, `url`, `phone`, …), the option and reference types (`select`, `radio`, `lookup`, `master_detail`, `tree`, `user`), `autonumber`, the file family, the structured-JSON types (`json`, `composite`, `repeater`, `record`, `location`, `address`, `vector`), the multi-option types (`multiselect`, `checkboxes`, `tags`) and `formula` — and over any `select`, `radio`, `lookup`, `user`, `file` or `image` declared `multiple: true`: FROM whatever the driver answered TO `400 INVALID_FIELD`. Measured through `engine.aggregate` over two rows: a `json`, `text`, `select` or `tags` field summed to `0` in memory and on SQLite and answered 500 `DATABASE_ERROR` on PostgreSQL 16 (`function sum(json) does not exist`); a `datetime` field summed to `0` in memory, to the years added on SQLite and a 500 on PostgreSQL; a `formula` field summed to `0` in memory and was already refused `400 INVALID_FIELD` by both SQL drivers, which have no column for it; a `percent` field added the rates on all three.
19+
20+
**What an author sees now.** `400 INVALID_FIELD`, naming the position (`aggregations[0].field`), what the function does and the field with its declaration (`sums 'meta', a declared json field — a structured-JSON value`), saying the query was not run, and naming the types `sum` accepts, read off the table, inside the first 500 characters the REST door keeps. The thrown error carries `field`, `fields` (every offending aggregation), `object` and `param` (`aggregations`).
21+
22+
**What to write instead.** Sum a field of a type `sum` accepts: `number`, `currency`, `rating`, `slider`, `progress`, `summary`, `boolean` or `toggle`. A rate stored as a `percent` is averaged (`avg` accepts it), or the quantity it is a rate of is summed. A value computed by a `formula` is stored in a numeric field of its own when it must be summed on the server. A question that was counting in disguise is `count`.
23+
24+
**Who is affected.** A caller that asked `sum` of such a field on the in-memory driver or SQLite and read the `0` as a real total, and a caller that summed a `percent` field on any driver. On PostgreSQL the other measured pairs were already refused (a 500, or a 400 for a `formula`), and on SQLite so was a `formula`. Metadata that lowers onto `engine.aggregate` takes the same verdict at run time: a roll-up summary (`summaryOperations`) whose `sum` names such a child field records a failed recompute, a grouped list view's server-side header summary is refused, and a chart or metric component's `sum` over such a field is refused. No example app authors such a pair. One published stack authors a `sum` list-column summary over a `formula` field; that summary is computed client-side and does not reach `engine.aggregate`.
25+
26+
**Unchanged.** Every pair the table accepts, `sum` over the eight types above included; `count` over any field; an aggregation that names no field; an undeclared name or a relationship path, which this door does not judge (the REST door answers an unknown name `INVALID_FIELD` before the engine is reached); a field whose declared type is outside `FieldType`.
27+
28+
**A correction to the earlier entry of this release.** It listed the multi-capable types declared `multiple: true` as `select`, `lookup`, `user`, `file` or `image`; the list is `select`, `radio`, `lookup`, `user`, `file` and `image` (`MULTI_CAPABLE_TYPES`). A `radio` declared `multiple: true` was refused by `min` / `max` / `avg` there all the same, by its type's own row, and it is refused by `sum` here.
29+
30+
`@objectstack/spec`: the TSDoc of `AGGREGATE_FIELD_TYPE_COMPATIBILITY` and `isAggregateCompatibleWithFieldType` states that the engine's `aggregate` door asks every row of the table, where it said "the other rows" while one was held, and names `radio` among the multi-capable types. The table and the predicate are unchanged.

‎packages/objectql/src/aggregate-field-type-door.ts‎

Lines changed: 33 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,16 @@
3636
* | `avg` over a `datetime` field | 200, `null` | 200, `2026` | **500 `DATABASE_ERROR`** |
3737
* | `max` over a `number`, `min` over a `datetime`, `avg` over a `percent` (the controls) | one answer | the same | the same |
3838
*
39+
* [#20914] measured the `sum` row on `origin/main` `2821e9f15`, the same way:
40+
*
41+
* | aggregation | InMemoryDriver | SqlDriver, SQLite | SqlDriver, PostgreSQL 16 |
42+
* |:--|:--|:--|:--|
43+
* | `sum` over a `json`, `text`, single-value `select` or `tags` field | 200, `0` | 200, `0` | **500 `DATABASE_ERROR`** (`function sum(json)` — or `sum(text)`, `sum(character varying)` — `does not exist`) |
44+
* | `sum` over a `datetime` field | 200, `0` | 200, `4052` (the years added) | **500 `DATABASE_ERROR`** |
45+
* | `sum` over a `formula` field | 200, `0` | 400 `INVALID_FIELD` (no column) | 400 `INVALID_FIELD` (no column) |
46+
* | `sum` over a `percent` field (refused by the table: a rate does not add) | 200, the rates added | the same | the same |
47+
* | `sum` over a `number`, `currency` or `boolean` (the controls) | one answer | the same | the same |
48+
*
3949
* ## Whose verdict it is
4050
*
4151
* The TYPE half is the spec table's: `AGGREGATE_FIELD_TYPE_COMPATIBILITY`
@@ -46,25 +56,28 @@
4656
* nothing. The refusal names the row's accepted set, read off the table.
4757
*
4858
* The DECLARATION half is `isMultiValueField`: a multi-capable type flagged
49-
* `multiple: true` (`select`, `lookup`, `user`, `file`, `image`) is stored in
50-
* the JSON column the multi-option types (`MULTI_OPTION_TYPES`) are stored in
51-
* and holds the same value — a list — but a per-TYPE table cannot see the
52-
* flag. So the declaration takes the verdict the row gives that class: a row
53-
* that refuses any multi-option type refuses a multi-value declaration too
54-
* (`count_distinct`, `sum`, `avg`, `min`, `max`), and a row that accepts the
55-
* whole class accepts it (`count`, which compares no value). A field is
56-
* refused if either half refuses it.
59+
* `multiple: true` (`MULTI_CAPABLE_TYPES`: `select`, `radio`, `lookup`,
60+
* `user`, `file`, `image`) is stored in the JSON column the multi-option types
61+
* (`MULTI_OPTION_TYPES`) are stored in and holds the same value — a list — but
62+
* a per-TYPE table cannot see the flag. So the declaration takes the verdict
63+
* the row gives that class: a row that refuses any multi-option type refuses a
64+
* multi-value declaration too (`count_distinct`, `sum`, `avg`, `min`, `max`),
65+
* and a row that accepts the whole class accepts it (`count`, which compares
66+
* no value). A field is refused if either half refuses it.
5767
*
58-
* ## The row the census held back: `sum`
68+
* ## Every row is judged — `sum` included
5969
*
6070
* [#20914] The triage direction sent the door to the whole table "census
6171
* first": an authored pair the table refuses, in `examples/**` or a published
62-
* stack, stops that row and goes back to triage. The census found one, in the
63-
* published hotcrm stack: a grouped list view whose column summary sums a
64-
* `formula` field (`sum` × `formula`, refused by the table — a formula is
65-
* virtual in SQL storage). So `sum` is in {@link ROWS_HELD_FOR_TRIAGE} and is
66-
* not judged here; releasing it is deleting that entry and flipping its pins.
67-
* Every other row is judged.
72+
* stack, stops that row and goes back to triage. The census found one `sum`
73+
* pair, in the published hotcrm stack: a grouped list view whose column
74+
* summary sums a `formula` field (`sum` × `formula`, refused by the table — a
75+
* formula is virtual in SQL storage). The row was held for one landing, then
76+
* released by triage's answer: that summary is a client-side list footer that
77+
* never reaches `engine.aggregate`, and both SQL drivers already refused the
78+
* pair, while `sum` over a `json`, `text` or `select` field answered a
79+
* plausible `0` or a 500. ⛔ No row is held and no pair is exempted: the door
80+
* asks the table's row whole for all six functions.
6881
*
6982
* ## Where it stands, and what it judges
7083
*
@@ -79,12 +92,11 @@
7992
* **Not judged** (no verdict, the aggregation passes on as it came): an
8093
* aggregation that names no field (a fieldless `count`, or `'*'`), a function
8194
* outside the table's vocabulary (that is the query schema's refusal, not a
82-
* field-type one), a held row, an undeclared name (a relationship path
83-
* included), a registry-less host (no field map, no verdict), and a declared
84-
* type outside `FieldType` (a driver-internal alias such as `string` or
85-
* `integer` on an introspected object): the table is fail-closed on
86-
* vocabulary, and "cannot answer, do not block" is this consumer's tier, as
87-
* the table's own TSDoc says.
95+
* field-type one), an undeclared name (a relationship path included), a
96+
* registry-less host (no field map, no verdict), and a declared type outside
97+
* `FieldType` (a driver-internal alias such as `string` or `integer` on an
98+
* introspected object): the table is fail-closed on vocabulary, and "cannot
99+
* answer, do not block" is this consumer's tier, as the table's own TSDoc says.
88100
*
89101
* `INVALID_FIELD`, the code the `groupBy` door beside it answers: the verdict
90102
* is about the NAMED field's type at a position.
@@ -107,13 +119,6 @@ import {
107119
/** The declared `FieldType` vocabulary — the only types the table can answer for. */
108120
const DECLARED_FIELD_TYPES: ReadonlySet<string> = new Set(FieldType.options);
109121

110-
/**
111-
* Rows of the table this door does not judge yet, each held by the census
112-
* that preceded it — see the module header. ⛔ A row is held, never trimmed:
113-
* the door asks the table's row whole or not at all.
114-
*/
115-
const ROWS_HELD_FOR_TRIAGE: ReadonlySet<string> = new Set(['sum']);
116-
117122
/** Does the table's `fn` row accept the multi-value class — every multi-option type? */
118123
function rowAcceptsMultiValue(fn: string): boolean {
119124
for (const type of MULTI_OPTION_TYPES) {
@@ -152,7 +157,6 @@ function refusedAggregations(
152157
const fn = (agg as { function?: unknown }).function;
153158
if (typeof fn !== 'string') continue;
154159
if (!Object.prototype.hasOwnProperty.call(AGGREGATE_FIELD_TYPE_COMPATIBILITY, fn)) continue;
155-
if (ROWS_HELD_FOR_TRIAGE.has(fn)) continue;
156160
const field = (agg as { field?: unknown }).field;
157161
if (typeof field !== 'string' || field === '*') continue;
158162
if (!Object.prototype.hasOwnProperty.call(fields, field)) continue;

0 commit comments

Comments
 (0)