Skip to content

Commit a75311d

Browse files
fix(objectql)!: engine aggregate asks the field-type table for every row — min / max / avg over a refused type answer INVALID_FIELD / 400 on every driver (#21037)
Part of #20914 Clause-②: no (narrowing) #20914 remains open for one row: the census found an authored `sum` over a type the table refuses, so the `sum` row is held back here and goes back to triage (section "The census" below). Every other row of the table is judged by this PR. ## What this changes `engine.aggregate` now asks `AGGREGATE_FIELD_TYPE_COMPATIBILITY` (`@objectstack/spec/data`, through `isAggregateCompatibleWithFieldType`) for EVERY aggregation that names a declared field, not only for `count_distinct`, with the `isMultiValueField` declaration half beside it. A pair the table refuses answers `INVALID_FIELD` / `400` before any driver is resolved. No second table: the door reads the table's rows and names the row's accepted set in its words, read off the table. - The door is `count-distinct-json-stored-door.ts` generalized and renamed `aggregate-field-type-door.ts` (`assertAggregationFieldTypesAccepted`), at the same call site in `engine.ts`, right after the `groupBy` door. The old name would have lied about what it judges. - `count_distinct`'s refusal words are byte-identical, so its #20808 pins are unchanged (the one clause of its GUARD pin that said "no verdict for any other function" is flipped, see Pins). - The declaration half reads the table too: a field declared `multiple: true` holds the same value as the multi-option types (a list in a JSON column), so it takes the verdict the row gives that class. Rows refusing any multi-option type (`count_distinct`, `avg`, `min`, `max`) refuse it; `count`, which accepts every type, accepts it. - Fail-closed tiers kept for every function: no field map, an undeclared name or a relationship path, a type outside `FieldType` (a driver alias such as `string` / `integer`), a fieldless aggregation or `'*'`, and a function outside the table's vocabulary all get no verdict. Pinned. ## FROM → TO, measured on three drivers Through `engine.aggregate` (the door the REST query route, flows, hooks, roll-up recompute and the analytics ObjectQL strategy reach), two rows, real `InMemoryDriver`, `SqlDriver` on SQLite and a private PostgreSQL 16.14. Before = `origin/main` `dfe5a0863`; after = this branch's built `dist` at `9497f4c61`. | aggregation | before: memory / SQLite / PostgreSQL | after, all three | |:--|:--|:--| | `max` over `json` | `{"a":1}` / `"{\"b\":1}"` (a string) / **500** `function max(json) does not exist` | 400 `INVALID_FIELD` | | `min` over `json` | `{"a":1}` / `"{\"a\":1}"` / **500** | 400 `INVALID_FIELD` | | `max` / `min` over `tags` | an array / a serialized array / **500** | 400 `INVALID_FIELD` | | `max` over `select` with `multiple: true` | `["a","b"]` / `"[\"a\"]"` / **500** | 400 `INVALID_FIELD` | | `avg` over `datetime` | `null` / `2026` / **500** | 400 `INVALID_FIELD` | | `avg` over `tags` | `null` / `0` / **500** | 400 `INVALID_FIELD` | | `max` over `text`, `min` over single `select`, `max` over `email` (string-class rows, as ruled) | `"y"` / `"y"` / `"y"` | 400 `INVALID_FIELD` | | controls: `max` `number`, `min` `datetime`, `avg` `percent`, `max` `boolean`, `sum` `number`, `count_distinct` `text` | one answer each | unchanged | | `sum` over `json` / `text` / single `select` (held row) | `0` / `0` / **500** | unchanged (held) | The words: `aggregate('OBJ'): aggregations[0].field takes the max of 'meta', a declared json field — a structured-JSON value, which the engine does not take the max of. The query was NOT run. max accepts a field of type number, currency, percent, rating, slider, progress, summary, date, datetime, time, boolean or toggle: aggregate a field of one of those types, or count the rows with count.` followed by the reason. The route lands inside the 500 characters the REST door keeps. The thrown error carries `code`, `status`, `httpStatus`, `field`, `fields`, `object`, `param`. ## The census (and the held `sum` row) Query: every line carrying an aggregate-function literal `'min' | 'max' | 'sum' | 'avg'` (either quote), across `examples/**` and the published hotcrm stack, each hit resolved by hand to its object and the field's declared type, then asked of `isAggregateCompatibleWithFieldType`. - `examples/**` at `dfe5a0863` (crm, todo, multi-package, showcase, embed-objectql): **21 lines** (dataset measures, roll-up `summaryOperations`, a `kpi` metric, an `ObjectChart` aggregate, two cube measures). **0 `min` / `max`.** Every `sum` / `avg` is over `currency`, `number`, `progress` or `summary`: **0 refused pairs.** - hotcrm (`objectstack-ai/hotcrm` cloned at `4ca8e2d4bb`, source read, deps not installed): **41 lines** in `src` / `apps` plus 2 in `test`. **0 `min` / `max`.** **1 refused pair:** `src/sales/views/forecast.view.ts:28`, the grouped list view `all_forecasts` on `crm_forecast` declares `summary: 'sum'` on `expected_amount`, a `Field.formula`. The table refuses `sum` × `formula` (a formula is virtual in SQL storage). - In-repo shipped sources (`packages/**`, tests excluded): 0 refused pairs. - Positive control: the same query finds the hotcrm hit itself, and over `packages/services/service-analytics/src/__tests__/aggregate-datetime-measure-refusal.test.ts` it finds that file's authored `avg` over a `datetime`. Per triage's direction ("A hit stops that row and goes back to triage"), the `sum` row is held: `ROWS_HELD_FOR_TRIAGE` in the door, named in its header. Evidence for triage: on the base, `sum` over a `formula` already answers 400 `INVALID_FIELD` on SQLite and PostgreSQL (the SQL driver has no column) and `0` in memory; and at objectui `be0ad00` a list view's column `summary` is a client-side footer, while the server header query reads `object-grid.aggregations`, so this hotcrm pair does not reach `engine.aggregate` through the console today. Releasing the row is deleting that one entry and flipping the `sum` pins. ## Other doors on the engine path (H2) No other door enforces a row of the table. The `groupBy` door (`group-by-structured-json-door.ts`) judges a group KEY, not a function's operand, and stays beside this one; the number-comparand, temporal-comparand, text-operator and no-operator-object doors judge filter comparands. So there is one door asking the table, and one verdict per pair. ## Pins - `packages/objectql/src/engine-aggregate-field-type-door.test.ts` (new, recording driver, so the in-memory cell by construction): `max` / `min` over `json`, `tags`, `address`, a `multiple: true` select; `max` over a `lookup` with `multiple: true` under `groupBy: ['title']` (the shape a sibling card measured as PG 500 / SQLite serialized text / memory array); `avg` over `datetime` and `json`; `max` over `text`, `min` over `select`; first-offending-position across functions; scalar controls reach the driver; `sum` held for every type; a GUARD that asks every judged row × every `FieldType` against the table with per-row floors; the fail-closed tiers. Each refusal asserts `code` + `status` + `httpStatus` and the field, declaration, function and position in the message. - `packages/rest/src/data-aggregate-field-type-door.test.ts` (new, `POST /api/v1/data/:object/query` over `SqlDriver`): SQLite always, live PostgreSQL where `OS_TEST_POSTGRES_URL` is set, MySQL a named skip. No CI job sets those variables for this package; the local PostgreSQL 16.14 run is below. - Flipped, not deleted: `engine-json-stored-group-distinct-door.test.ts`'s GUARD clause "no verdict for any other function" now asserts `count` and the held `sum` pass and `avg` / `min` / `max` over `json` are refused in the same envelope. `engine-nested-object-door.test.ts` reached `having` through a `max` over a `master_detail` and a `json` field; those two cases now pin the earlier refusal at this door (`INVALID_FIELD`, no `having.` words, no read). Its `lookup` group-key case is unchanged. ## Ablation (from committed code) `node scripts/ablation-replace.mjs` (WRAP, trap-restored) replaced the held-row check in `aggregate-field-type-door.ts` with one that also skips every function but `count_distinct` (marker `ablation-20914-A1`): anchor 1 to 0, blob `67c76fa23d15` to `80100111e881`. `pnpm --filter @objectstack/objectql build`, then `ablation-dist-preflight.mjs` found the marker in 4 built files. Predicted red on the new pins only. Observed: objectql 8 failed / 22 passed (the new suite, the flipped GUARD clause and the flipped `having` fixture; every `count_distinct` pin green), REST 4 failed / 10 passed / 7 skipped (the SQLite and PostgreSQL refusal cells; controls and the #20808 cells green). Restore: blob equals HEAD, `git diff HEAD` empty, rebuilt, `--absent` found the marker in 0 of 14 built files and the tree clean, pins green again (30 / 30, 14 passed + 7 skipped). ## Tests (at `9497f4c61`, the merge of `origin/main` `2f2fa11d7`) - `pnpm --filter @objectstack/objectql exec vitest run --project local`: 349 files, 6830 passed. - `OS_TEST_POSTGRES_URL=(private PG 16.14) pnpm --filter @objectstack/rest exec vitest run --project local`: 252 files, 5055 passed, 42 skipped (MySQL cells). - `pnpm --filter @objectstack/service-analytics exec vitest run`: 150 files, 3458 passed. - `@objectstack/metadata-protocol` and `@objectstack/plugin-security` full suites at `e37028132`, before the merge of `origin/main` (not re-run after it; the merge moved metadata-protocol's flow read path, not an aggregate path): 194 files / 2886 passed, and 150 files / 3262 passed. - `typecheck` for objectql and rest: exit 0, test-typecheck ledgers held (objectql 40 / 234 / 65, rest 0). - `pnpm --filter @objectstack/spec build` and `check:generated`: all 15 generated artifacts up to date. - Lint, narrowed and proven: eslint (`allowInlineConfig: false`) over the 7 changed `.ts` files: 0 ignored, 7 results, 0 errors, 0 warnings; no file has `parserOptions.project` / `projectService`, so type-aware linting is off and this diff cannot move a verdict on any untouched file. - Driver conformance ledger: 50 covered cells, 0 DEBT, 0 exempt, before and after. ## Gates `node scripts/pm/dispatch-gates.mjs --commands` at `9497f4c61`: 88 derived, 88 run, all exit 0; `--ran` with exit codes: 0 NOT-MEASURED. `check:dual-build-cjs-loads` and `check:type-check-debt` first answered PREREQUISITE NOT MET (exit 3), then 0 after `turbo run build` over every package (71 tasks). Also run: `check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`, `check:error-status-conformance`, all exit 0. `check:adr-0087-registration` accepts `not-required (already-registered dataset-measure-selecting-aggregate-field-type-refused, dataset-measure-aggregate-field-type-refused)`. ## Beyond the claimed file surface - `packages/spec/src/data/aggregate-field-type-compatibility.ts`: TSDoc only, three sentences that ship in `dist/data/index.d.ts` and said the engine door reads only the `count_distinct` row. The table's value is untouched (the diff has no non-comment line). - `packages/objectql/src/engine-nested-object-door.test.ts`: the fixture triage above. ## Acceptance notes - `no-operator-object-door.ts`'s `having` words for an aggregated column that "carries a" relation or JSON type were reached only through `min` / `max` over such a field. The door now refuses those pairs first, so that branch is unreachable through `engine.aggregate` for a declared field. Not touched here. - `.changeset/20783-groupby-structured-json-refused.md` (pending) lists a structured-JSON field as an aggregated `min` / `max` column as unchanged; this PR's changeset says it is the later word on that shape, as the #20808 changeset did for its two shapes. - `content/docs/data-modeling/queries.mdx` still says `count_distinct` is not lowered by the SQL drivers; it is (measured `2` on SQLite and PostgreSQL). Not made false by this change. --- _Generated by [Claude Code](https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0c5a71b commit a75311d

9 files changed

Lines changed: 885 additions & 180 deletions
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"@objectstack/objectql": minor
3+
"@objectstack/spec": patch
4+
---
5+
6+
fix(objectql)!: the engine's `aggregate` asks the aggregate × field-type table for every aggregation over a declared field, so `min` / `max` / `avg` over a type the table refuses answer `INVALID_FIELD` / 400 on every driver instead of one answer per driver
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: not-required (already-registered dataset-measure-selecting-aggregate-field-type-refused, dataset-measure-aggregate-field-type-refused) the pairs this change refuses are exactly the pairs AGGREGATE_FIELD_TYPE_COMPATIBILITY already refuses, and the table is not edited: every refused min / max pair is registered under protocol major 18 by the first id and every refused avg pair by the second, each with its routes (count, a sort for a first or last record, or a numeric / temporal field for a quantity stored as text or JSON). This change adds a query-time reader of the same table at the engine door; 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+
FROM → TO, per aggregation `{ function, field }` naming a declared field:
15+
16+
- `min` / `max` over a type outside the numeric, temporal and boolean classes — the structured-JSON types (`json`, `composite`, `repeater`, `record`, `location`, `address`, `vector`), the multi-option types (`multiselect`, `checkboxes`, `tags`), the string family (`text`, `email`, `url`, `phone`, …), the option and reference types (`select`, `radio`, `lookup`, `master_detail`, `tree`, `user`), `autonumber`, the file family and `formula` — and over any `select`, `lookup`, `user`, `file` or `image` declared `multiple: true`: FROM whatever the driver answered (a document or an array in memory, the serialized text on SQLite, a 500 on PostgreSQL for a JSON-stored field; a collation-dependent string for a text field) TO `400 INVALID_FIELD`.
17+
- `avg` over a type outside the numeric and boolean classes — a `date`, `datetime` or `time` field included: FROM `null` in memory, a coerced number on SQLite (the average YEAR for a datetime), a 500 on PostgreSQL, TO `400 INVALID_FIELD`.
18+
- `count_distinct` is unchanged: it was already refused over the JSON-stored types, in the same words.
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 (`takes the max of 'meta', a declared json field — a structured-JSON value`), saying the query was not run, and naming the types the function 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+
**Why a refusal.** `AGGREGATE_FIELD_TYPE_COMPATIBILITY` already declares which pairs every backend answers the same way, and the dataset compile and lint legs refuse the rest; the engine door asked only its `count_distinct` row. Measured through `engine.aggregate` over two rows: `max` over a `json` field answered `{ a: 1 }` in memory, the string `'{"b":1}'` on SQLite and 500 `DATABASE_ERROR` on PostgreSQL 16 (`function max(json) does not exist`); a `tags` field and a `multiple: true` select or lookup split the same way; `avg` over a `datetime` answered `null`, `2026` and a 500. One query, three answers.
23+
24+
**What to write instead.** Aggregate a field of a type the function accepts — for `min` / `max`: `number`, `currency`, `percent`, `rating`, `slider`, `progress`, `summary`, `date`, `datetime`, `time`, `boolean` or `toggle`; for `avg`: the same minus the temporal three. A question that was counting in disguise is `count` (or `count_distinct` over a scalar-stored field). A first or last record by a text value is a sort on a list, not an aggregate. A quantity stored as text or JSON belongs in a numeric or temporal field of its own, aggregated there.
25+
26+
**Who is affected.** A caller that asked `min` / `max` / `avg` of such a field on the in-memory driver or SQLite and read the answer as a real one; on PostgreSQL a JSON-stored field was already a 500. Metadata that lowers onto `engine.aggregate` takes the same verdict at run time: a roll-up summary (`summaryOperations`) whose `min` / `max` / `avg` 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 `aggregate` over such a field is refused. No example app and no published stack authors such a pair.
27+
28+
**Not judged yet: `sum`.** The `sum` row of the table is held back at this door: a published stack authors a `sum` column summary over a `formula` field, a pair the table refuses, so that row awaits its own decision. `sum` over any field reaches the driver as before.
29+
30+
**Unchanged.** Every pair the table accepts; `count` over any field, a JSON-stored one included; 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`. The structured-JSON `groupBy` entry of this same release lists a structured-JSON field as an aggregated `min` / `max` column as unchanged; this entry is the later word on that shape.
31+
32+
`@objectstack/spec`: the TSDoc of `AGGREGATE_FIELD_TYPE_COMPATIBILITY` and `isAggregateCompatibleWithFieldType` no longer says the engine's `aggregate` door reads only the `count_distinct` row. The table itself is unchanged.
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The engine's `aggregate` asks the aggregate × field-type table for every
5+
* aggregation that names a declared field: a pair the table refuses is
6+
* refused with `INVALID_FIELD` / 400, naming the field, its declared type, the
7+
* function and the position, before any driver is asked.
8+
*
9+
* ## What ran before this door
10+
*
11+
* [#20808] measured `count_distinct` on `origin/main` `42d78b97fe`, through
12+
* `POST /api/v1/data/:object/query` (`{ aggregations: [{ function:
13+
* 'count_distinct', field: FIELD, alias: 'n' }] }`) over three rows, two of
14+
* which hold equal values under the counted field:
15+
*
16+
* | counted field | InMemoryDriver | SqlDriver, SQLite | SqlDriver, PostgreSQL 16 |
17+
* |:--|:--|:--|:--|
18+
* | a `text` or single-value `select` (the control) | 2 | 2 | 2 |
19+
* | a structured-JSON field (`json`, `composite`, `repeater`, `record`, `location`, `address`, `vector`) | 3 | 2 (3 for `json`, whose three documents differ) | **500 `DATABASE_ERROR`** |
20+
* | a multi-value field (`multiselect`, `checkboxes`, `tags`; `select`, `lookup`, `user`, `file`, `image` with `multiple: true`) | 3 | 2 | **500 `DATABASE_ERROR`** |
21+
*
22+
* The in-memory driver compares each row's value by identity, so equal
23+
* documents count apart; SQLite compares the serialized text; PostgreSQL has
24+
* no equality operator for a `json` column ("could not identify an equality
25+
* operator for type json") and answers 500. One query, three answers.
26+
*
27+
* [#20914] measured the other rows on `origin/main` `dfe5a0863` through
28+
* `engine.aggregate` over two rows — the door the REST query route, a flow, a
29+
* hook and the analytics ObjectQL strategy all reach:
30+
*
31+
* | aggregation | InMemoryDriver | SqlDriver, SQLite | SqlDriver, PostgreSQL 16 |
32+
* |:--|:--|:--|:--|
33+
* | `max` / `min` over a `json` field | 200, a document (`{ a: 1 }`) | 200, a string (`'{"b":1}'`) | **500 `DATABASE_ERROR`** (`function max(json) does not exist`) |
34+
* | `max` / `min` over a `tags` field, or a `select` with `multiple: true` | 200, an array | 200, a serialized array | **500 `DATABASE_ERROR`** |
35+
* | `avg` over a `tags` field | 200, `null` | 200, `0` | **500 `DATABASE_ERROR`** |
36+
* | `avg` over a `datetime` field | 200, `null` | 200, `2026` | **500 `DATABASE_ERROR`** |
37+
* | `max` over a `number`, `min` over a `datetime`, `avg` over a `percent` (the controls) | one answer | the same | the same |
38+
*
39+
* ## Whose verdict it is
40+
*
41+
* The TYPE half is the spec table's: `AGGREGATE_FIELD_TYPE_COMPATIBILITY`
42+
* (`@objectstack/spec/data`), asked through `isAggregateCompatibleWithFieldType`
43+
* for the aggregation's own function — ⛔ never a second list here. The table
44+
* is ruled (its module TSDoc carries the rulings, the string-class `min` /
45+
* `max` rows included); this door applies it as it stands and re-rules
46+
* nothing. The refusal names the row's accepted set, read off the table.
47+
*
48+
* 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.
57+
*
58+
* ## The row the census held back: `sum`
59+
*
60+
* [#20914] The triage direction sent the door to the whole table "census
61+
* 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.
68+
*
69+
* ## Where it stands, and what it judges
70+
*
71+
* At the entry of `aggregate`, right after the `groupBy` door
72+
* (`group-by-structured-json-door.ts`, which answers a different question —
73+
* a group KEY, not a function's operand — and stays beside this one), before
74+
* the per-aggregation `filter` doors and before any driver is resolved — so it
75+
* holds for every caller that reaches the engine: the REST query door, a flow,
76+
* a hook, a roll-up summary's recompute, and the analytics strategy that
77+
* lowers a cube measure onto `engine.aggregate`.
78+
*
79+
* **Not judged** (no verdict, the aggregation passes on as it came): an
80+
* aggregation that names no field (a fieldless `count`, or `'*'`), a function
81+
* 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.
88+
*
89+
* `INVALID_FIELD`, the code the `groupBy` door beside it answers: the verdict
90+
* is about the NAMED field's type at a position.
91+
*
92+
* @see https://github.com/objectstack-ai/objectstack/issues/20808
93+
* @see https://github.com/objectstack-ai/objectstack/issues/20914
94+
*/
95+
96+
import { StandardErrorCode } from '@objectstack/spec/api';
97+
import {
98+
AGGREGATE_FIELD_TYPE_COMPATIBILITY,
99+
FieldType,
100+
MULTI_OPTION_TYPES,
101+
STRUCTURED_JSON_TYPES,
102+
isAggregateCompatibleWithFieldType,
103+
isMultiValueField,
104+
type AggregationFunction,
105+
} from '@objectstack/spec/data';
106+
107+
/** The declared `FieldType` vocabulary — the only types the table can answer for. */
108+
const DECLARED_FIELD_TYPES: ReadonlySet<string> = new Set(FieldType.options);
109+
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+
117+
/** Does the table's `fn` row accept the multi-value class — every multi-option type? */
118+
function rowAcceptsMultiValue(fn: string): boolean {
119+
for (const type of MULTI_OPTION_TYPES) {
120+
if (!isAggregateCompatibleWithFieldType(fn, type)) return false;
121+
}
122+
return true;
123+
}
124+
125+
/** One aggregation the table (or the declaration half) refuses. */
126+
interface RefusedAggregation {
127+
/** The aggregation's function — a row of the table. */
128+
readonly fn: string;
129+
readonly field: string;
130+
readonly type: string;
131+
/** The declared field carries `multiple: true` (said in the words). */
132+
readonly multiple: boolean;
133+
/** A multi-value field (the route differs for `count_distinct`: filter by one member). */
134+
readonly multiValue: boolean;
135+
/** A structured-JSON type (`STRUCTURED_JSON_TYPES`). */
136+
readonly structuredJson: boolean;
137+
/** `aggregations[i].field`. */
138+
readonly position: string;
139+
}
140+
141+
/**
142+
* The aggregations that name a declared field the table refuses for their
143+
* function, or a declared multi-value field the row refuses, in order.
144+
*/
145+
function refusedAggregations(
146+
fields: Record<string, unknown>,
147+
aggregations: readonly unknown[],
148+
): RefusedAggregation[] {
149+
const hits: RefusedAggregation[] = [];
150+
for (const [i, agg] of aggregations.entries()) {
151+
if (agg === null || typeof agg !== 'object' || Array.isArray(agg)) continue;
152+
const fn = (agg as { function?: unknown }).function;
153+
if (typeof fn !== 'string') continue;
154+
if (!Object.prototype.hasOwnProperty.call(AGGREGATE_FIELD_TYPE_COMPATIBILITY, fn)) continue;
155+
if (ROWS_HELD_FOR_TRIAGE.has(fn)) continue;
156+
const field = (agg as { field?: unknown }).field;
157+
if (typeof field !== 'string' || field === '*') continue;
158+
if (!Object.prototype.hasOwnProperty.call(fields, field)) continue;
159+
const def = fields[field] as { type?: unknown; multiple?: unknown } | undefined;
160+
const type = def?.type;
161+
if (typeof type !== 'string' || !DECLARED_FIELD_TYPES.has(type)) continue;
162+
const multiple = def?.multiple === true;
163+
const multiValue = isMultiValueField({ type, multiple });
164+
const typeRefused = !isAggregateCompatibleWithFieldType(fn, type);
165+
const declarationRefused = multiValue && !rowAcceptsMultiValue(fn);
166+
if (!typeRefused && !declarationRefused) continue;
167+
hits.push({
168+
fn,
169+
field,
170+
type,
171+
multiple,
172+
multiValue,
173+
structuredJson: STRUCTURED_JSON_TYPES.has(type),
174+
position: `aggregations[${i}].field`,
175+
});
176+
}
177+
return hits;
178+
}
179+
180+
/**
181+
* What each function does to its field, and its negation, in the refusal's
182+
* words — total over `AggregationFunction`, so a function joining the table is
183+
* a `tsc` error here until its words are written. (`count` accepts every type,
184+
* so it never reaches the words; it is here for totality.)
185+
*/
186+
const FUNCTION_WORDS: Readonly<Record<AggregationFunction, { readonly does: string; readonly doesNot: string }>> = {
187+
count: { does: 'counts', doesNot: 'does not count' },
188+
count_distinct: { does: 'counts distinct', doesNot: 'does not count distinct' },
189+
sum: { does: 'sums', doesNot: 'does not sum' },
190+
avg: { does: 'averages', doesNot: 'does not average' },
191+
min: { does: 'takes the min of', doesNot: 'does not take the min of' },
192+
max: { does: 'takes the max of', doesNot: 'does not take the max of' },
193+
};
194+
195+
/** `a, b or c` — the row's accepted types, in the table's order. */
196+
function acceptedTypesOf(fn: string): string {
197+
const row: readonly string[] = AGGREGATE_FIELD_TYPE_COMPATIBILITY[fn as AggregationFunction];
198+
return row.length > 1 ? `${row.slice(0, -1).join(', ')} or ${row[row.length - 1]}` : row.join('');
199+
}
200+
201+
/**
202+
* The route and the reason for the FIRST refused aggregation. The route comes
203+
* before the reason so it lands inside the 500 characters the REST door keeps.
204+
*/
205+
function routeAndReason(first: RefusedAggregation): string {
206+
if (first.fn === 'count_distinct') {
207+
const route = first.multiValue
208+
? 'Count the records that hold one member instead: count with '
209+
+ `where { "${first.field}": { "$contains": VALUE } }, one query per member.`
210+
: 'Count distinct values of a field that stores one scalar value: store the part you count in a '
211+
+ 'field of its own and count_distinct that field, or count the rows with count.';
212+
return `${route} `
213+
+ 'A JSON-stored value is no distinct key the drivers share: one counted every row apart, one '
214+
+ 'compared the serialized text, one refused the statement.';
215+
}
216+
const route = `${first.fn} accepts a field of type ${acceptedTypesOf(first.fn)}: aggregate a field `
217+
+ 'of one of those types, or count the rows with count.';
218+
const reason = first.multiValue || first.structuredJson
219+
? 'A JSON-stored value has no order or arithmetic the drivers share: one answered from the '
220+
+ 'documents in memory, one from their serialized text, one refused the statement.'
221+
: 'The aggregate × field-type table accepts only the pairs every backend answers alike, and it '
222+
+ 'refuses this one.';
223+
return `${route} ${reason}`;
224+
}
225+
226+
/**
227+
* Refuse an aggregation whose (function, declared field type) pair the
228+
* aggregate × field-type table refuses — `INVALID_FIELD` / 400, before any
229+
* driver is asked. See the module header.
230+
*
231+
* The words put the position and the verdict first, then that the query did
232+
* not run, then the route, then the reason: the REST door keeps the first 500
233+
* characters of a 4xx message (`CLIENT_MESSAGE_MAX`), and the route must be
234+
* inside them.
235+
*/
236+
export function assertAggregationFieldTypesAccepted(
237+
object: string,
238+
schema: unknown,
239+
aggregations: unknown,
240+
): void {
241+
if (!Array.isArray(aggregations) || aggregations.length === 0) return;
242+
const fields = (schema as { fields?: unknown } | undefined)?.fields;
243+
if (!fields || typeof fields !== 'object') return;
244+
const hits = refusedAggregations(fields as Record<string, unknown>, aggregations);
245+
if (hits.length === 0) return;
246+
const [first] = hits;
247+
const words = FUNCTION_WORDS[first.fn as AggregationFunction];
248+
const declared = first.multiple ? `${first.type} field with multiple: true` : `${first.type} field`;
249+
const kind = first.multiValue
250+
? ' — a multi-value field'
251+
: first.structuredJson ? ' — a structured-JSON value' : '';
252+
const err = new Error(
253+
`aggregate('${object}'): ${first.position} ${words.does} '${first.field}', a declared ${declared}`
254+
+ `${kind}, which the engine ${words.doesNot}`
255+
+ (hits.length > 1 ? ` (also: ${hits.slice(1).map((h) => `'${h.field}'`).join(', ')})` : '')
256+
+ `. The query was NOT run. ${routeAndReason(first)}`,
257+
) as Error & {
258+
code?: string; status?: number; httpStatus?: number;
259+
field?: string; fields?: string[]; object?: string; param?: string;
260+
};
261+
err.code = StandardErrorCode.enum.INVALID_FIELD;
262+
err.status = 400;
263+
// …and `httpStatus`, the same number under ADR-0112 D5's spelling — what a
264+
// consumer holding the THROWN error reads; `status` stays for the HTTP doors.
265+
err.httpStatus = 400;
266+
err.field = first.field;
267+
err.fields = hits.map((h) => h.field);
268+
err.object = object;
269+
err.param = 'aggregations';
270+
throw err;
271+
}

0 commit comments

Comments
 (0)