Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/20914-release-sum-row.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
"@objectstack/objectql": minor
"@objectstack/spec": patch
---

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

Clause-②: no (narrowing)

<!-- 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. -->

**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.

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.

FROM → TO, per aggregation `{ function: 'sum', field }` naming a declared field:

- `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.

**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`).

**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`.

**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`.

**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`.

**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.

`@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.
62 changes: 33 additions & 29 deletions packages/objectql/src/aggregate-field-type-door.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@
* | `avg` over a `datetime` field | 200, `null` | 200, `2026` | **500 `DATABASE_ERROR`** |
* | `max` over a `number`, `min` over a `datetime`, `avg` over a `percent` (the controls) | one answer | the same | the same |
*
* [#20914] measured the `sum` row on `origin/main` `2821e9f15`, the same way:
*
* | aggregation | InMemoryDriver | SqlDriver, SQLite | SqlDriver, PostgreSQL 16 |
* |:--|:--|:--|:--|
* | `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`) |
* | `sum` over a `datetime` field | 200, `0` | 200, `4052` (the years added) | **500 `DATABASE_ERROR`** |
* | `sum` over a `formula` field | 200, `0` | 400 `INVALID_FIELD` (no column) | 400 `INVALID_FIELD` (no column) |
* | `sum` over a `percent` field (refused by the table: a rate does not add) | 200, the rates added | the same | the same |
* | `sum` over a `number`, `currency` or `boolean` (the controls) | one answer | the same | the same |
*
* ## Whose verdict it is
*
* The TYPE half is the spec table's: `AGGREGATE_FIELD_TYPE_COMPATIBILITY`
Expand All @@ -46,25 +56,28 @@
* nothing. The refusal names the row's accepted set, read off the table.
*
* The DECLARATION half is `isMultiValueField`: a multi-capable type flagged
* `multiple: true` (`select`, `lookup`, `user`, `file`, `image`) is stored in
* the JSON column the multi-option types (`MULTI_OPTION_TYPES`) are stored in
* and holds the same value — a list — but a per-TYPE table cannot see the
* flag. So the declaration takes the verdict the row gives that class: a row
* that refuses any multi-option type refuses a multi-value declaration too
* (`count_distinct`, `sum`, `avg`, `min`, `max`), and a row that accepts the
* whole class accepts it (`count`, which compares no value). A field is
* refused if either half refuses it.
* `multiple: true` (`MULTI_CAPABLE_TYPES`: `select`, `radio`, `lookup`,
* `user`, `file`, `image`) is stored in the JSON column the multi-option types
* (`MULTI_OPTION_TYPES`) are stored in and holds the same value — a list — but
* a per-TYPE table cannot see the flag. So the declaration takes the verdict
* the row gives that class: a row that refuses any multi-option type refuses a
* multi-value declaration too (`count_distinct`, `sum`, `avg`, `min`, `max`),
* and a row that accepts the whole class accepts it (`count`, which compares
* no value). A field is refused if either half refuses it.
*
* ## The row the census held back: `sum`
* ## Every row is judged — `sum` included
*
* [#20914] The triage direction sent the door to the whole table "census
* first": an authored pair the table refuses, in `examples/**` or a published
* stack, stops that row and goes back to triage. The census found one, in the
* published hotcrm stack: a grouped list view whose column summary sums a
* `formula` field (`sum` × `formula`, refused by the table — a formula is
* virtual in SQL storage). So `sum` is in {@link ROWS_HELD_FOR_TRIAGE} and is
* not judged here; releasing it is deleting that entry and flipping its pins.
* Every other row is judged.
* stack, stops that row and goes back to triage. The census found one `sum`
* pair, in the published hotcrm stack: a grouped list view whose column
* summary sums a `formula` field (`sum` × `formula`, refused by the table — a
* formula is virtual in SQL storage). The row was held for one landing, then
* released by triage's answer: that summary is a client-side list footer that
* never reaches `engine.aggregate`, and both SQL drivers already refused the
* pair, while `sum` over a `json`, `text` or `select` field answered a
* plausible `0` or a 500. ⛔ No row is held and no pair is exempted: the door
* asks the table's row whole for all six functions.
*
* ## Where it stands, and what it judges
*
Expand All @@ -79,12 +92,11 @@
* **Not judged** (no verdict, the aggregation passes on as it came): an
* aggregation that names no field (a fieldless `count`, or `'*'`), a function
* outside the table's vocabulary (that is the query schema's refusal, not a
* field-type one), a held row, an undeclared name (a relationship path
* included), a registry-less host (no field map, no verdict), and a declared
* type outside `FieldType` (a driver-internal alias such as `string` or
* `integer` on an introspected object): the table is fail-closed on
* vocabulary, and "cannot answer, do not block" is this consumer's tier, as
* the table's own TSDoc says.
* field-type one), an undeclared name (a relationship path included), a
* registry-less host (no field map, no verdict), and a declared type outside
* `FieldType` (a driver-internal alias such as `string` or `integer` on an
* introspected object): the table is fail-closed on vocabulary, and "cannot
* answer, do not block" is this consumer's tier, as the table's own TSDoc says.
*
* `INVALID_FIELD`, the code the `groupBy` door beside it answers: the verdict
* is about the NAMED field's type at a position.
Expand All @@ -107,13 +119,6 @@ import {
/** The declared `FieldType` vocabulary — the only types the table can answer for. */
const DECLARED_FIELD_TYPES: ReadonlySet<string> = new Set(FieldType.options);

/**
* Rows of the table this door does not judge yet, each held by the census
* that preceded it — see the module header. ⛔ A row is held, never trimmed:
* the door asks the table's row whole or not at all.
*/
const ROWS_HELD_FOR_TRIAGE: ReadonlySet<string> = new Set(['sum']);

/** Does the table's `fn` row accept the multi-value class — every multi-option type? */
function rowAcceptsMultiValue(fn: string): boolean {
for (const type of MULTI_OPTION_TYPES) {
Expand Down Expand Up @@ -152,7 +157,6 @@ function refusedAggregations(
const fn = (agg as { function?: unknown }).function;
if (typeof fn !== 'string') continue;
if (!Object.prototype.hasOwnProperty.call(AGGREGATE_FIELD_TYPE_COMPATIBILITY, fn)) continue;
if (ROWS_HELD_FOR_TRIAGE.has(fn)) continue;
const field = (agg as { field?: unknown }).field;
if (typeof field !== 'string' || field === '*') continue;
if (!Object.prototype.hasOwnProperty.call(fields, field)) continue;
Expand Down
Loading
Loading