From f25b463f2233f8e84d2cee00ded50a3b3df36be8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 00:05:14 +0000 Subject: [PATCH 1/3] fix(driver-sql): fold an all-NULL sum to 0 in the aggregate presentation, and pin it on every face SQL SUM skips NULLs and answers NULL once it has skipped every row of a group, so driver-sql answered null where the engine's in-memory tier, driver-memory and driver-mongodb answer 0 and emptyGroupValueFor rules 0. Measured null on better-sqlite3, live PostgreSQL 16.13 and live MySQL 8.0.46. Ruled option A on #15546 (maintainer, 2026-09-07): the SQL face moves. The fold reads the identity from emptyGroupValueFor at the aggregate door; the statement is unchanged. The conformance fixture gains a nullable numeric column, amount, NULL in every east row, and three cases pin the ruled answer on every enrolled face; every harness that seeds the rows declares the column. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --- .../driver-sql-all-null-sum-folds-to-zero.md | 10 ++ ...er-11635-boolean-aggregand-answers.test.ts | 3 + ...sql-driver-aggregation-conformance.test.ts | 14 ++- packages/drivers/driver-sql/src/sql-driver.ts | 69 ++++++++++++- ...qlite-wasm-aggregation-conformance.test.ts | 4 + ...rso-remote-aggregation-conformance.test.ts | 4 + .../spec/src/data/aggregation-conformance.ts | 98 +++++++++++++++++-- 7 files changed, 190 insertions(+), 12 deletions(-) create mode 100644 .changeset/driver-sql-all-null-sum-folds-to-zero.md diff --git a/.changeset/driver-sql-all-null-sum-folds-to-zero.md b/.changeset/driver-sql-all-null-sum-folds-to-zero.md new file mode 100644 index 0000000000..c312a5e451 --- /dev/null +++ b/.changeset/driver-sql-all-null-sum-folds-to-zero.md @@ -0,0 +1,10 @@ +--- +"@objectstack/driver-sql": minor +"@objectstack/spec": minor +--- + +`SqlDriver.aggregate` answers `0` — not `null` — for a `sum` over a group whose aggregand is NULL in every row, matching the engine's in-memory aggregate tier and the identity `emptyGroupValueFor` already declares (#15546; maintainer ruling 2026-09-07, option A: a non-empty group whose aggregand is absent and an empty group are the SAME case for `sum`, and the SQL face is the one that moves). + +SQL `SUM` skips NULLs and answers NULL once it has skipped everything, so on every dialect this driver targets (measured on better-sqlite3, live PostgreSQL 16.13 and live MySQL 8.0.46) a grouped list view with a `sum` summary on a nullable number or currency column rendered a BLANK total for a group whose column was empty in every row — while the same view on a deployment whose query took the engine's in-memory path rendered `0`. Which path answered was decided by a driver capability bit the caller never sees. The fold is part of the driver's aggregate presentation (`foldEmptyAggregateAnswers`): the compiled statement is unchanged (no `COALESCE`), the answer is the JS number `0` on every dialect, and `avg`/`min`/`max` — which have no identity over nothing — still answer `null`. The identity is read from `emptyGroupValueFor` rather than restated, so the two faces cannot drift apart on it again. + +`@objectstack/spec`: the aggregate-vocabulary conformance fixture gains a NULLABLE numeric column. `AggregationRow.amount` (`number | null`) is NULL in every row of the `east` group and in two of the four `west` rows, and `AGGREGATION_CASES` gains the three cases that pin the ruled answer on every enrolled face — `sum(amount)` grouped by region (`east` 0 / `west` 40), its `count(amount)` reachability control (`east` 0 / `west` 2, which is what proves the nulls were stored as nulls), and the ungrouped partial-null control (40). A harness that runs the table MUST declare `amount` as a nullable numeric column and seed its nulls AS nulls, exactly as it already must for `stage`; a `0` written in place of a null turns the cell green for the wrong reason. diff --git a/packages/drivers/driver-sql/src/sql-driver-11635-boolean-aggregand-answers.test.ts b/packages/drivers/driver-sql/src/sql-driver-11635-boolean-aggregand-answers.test.ts index 38a7151a08..2cd07a7e30 100644 --- a/packages/drivers/driver-sql/src/sql-driver-11635-boolean-aggregand-answers.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-11635-boolean-aggregand-answers.test.ts @@ -87,6 +87,9 @@ describe(`[#11635] driver-sql — boolean aggregands answer the ruled values (${ region: { type: 'string' }, stage: { type: 'string' }, score: { type: 'number' }, + // [#15546] The fixture rows carry a nullable `amount` too — declared + // so the verbatim seed below lands every column it carries. + amount: { type: 'number' }, flag: { type: 'boolean' }, }, }, diff --git a/packages/drivers/driver-sql/src/sql-driver-aggregation-conformance.test.ts b/packages/drivers/driver-sql/src/sql-driver-aggregation-conformance.test.ts index 40a20f8e7e..666f8a67aa 100644 --- a/packages/drivers/driver-sql/src/sql-driver-aggregation-conformance.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-aggregation-conformance.test.ts @@ -179,6 +179,11 @@ const CONFORMANCE_OBJECT = { // Nullable, and it must stay that way — see `AggregationRow.stage`. stage: { type: 'text', name: 'stage' }, score: { type: 'number', name: 'score' }, + // [#15546] Nullable, and it must stay that way — see `AggregationRow.amount`: + // the `east` group is NULL in every row, which is the cell the ruled + // `sum` → `0` fold is pinned on. A `NOT NULL` column, or a `0` seeded in + // place of a null, turns that cell green for the wrong reason. + amount: { type: 'number', name: 'amount' }, // [#11152] Declared `type: 'boolean'` on purpose — see `AggregationRow.flag`: // the ruled point of the boolean cases is that aggregation answers NUMBERS // (min=0/max=1) even where the declared type would present a row read as a @@ -247,13 +252,18 @@ describe(`[#6409] SqlDriver — aggregate vocabulary conformance (${cell.label}) expect(rows.map((r: any) => String(r.id))).toEqual(['1', '2', '3', '4', '5', '6']); for (const r of rows as any[]) { const seeded = AGGREGATION_ROWS.find((s) => s.id === String(r.id))!; - expect([r.region, r.stage ?? null, Number(r.score)], r.id) - .toEqual([seeded.region, seeded.stage, seeded.score]); + expect([r.region, r.stage ?? null, Number(r.score), r.amount ?? null], r.id) + .toEqual([seeded.region, seeded.stage, seeded.score, seeded.amount]); } // The property the null cases hang off, asserted directly: an empty string // in place of a null would keep every `count_distinct` case green at the // wrong number. expect((rows as any[]).filter((r) => r.stage === null)).toHaveLength(2); + // [#15546] The property the all-null `sum` cell hangs off: four NULL + // amounts, both `east` rows among them. A `0` seeded in place of a null + // answers the ruled `0` without the fold ever running. + expect((rows as any[]).filter((r) => r.amount === null), 'null amounts').toHaveLength(4); + expect((rows as any[]).filter((r) => r.region === 'east' && r.amount === null), 'east all-null').toHaveLength(2); // [#11152] The property the boolean cases hang off: 3 true / 3 false. A // seed that folded the flags turns every boolean case into a test of the // wrong table. `Boolean(...)` because the ROW read is presentation-shaped diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 64b54958e1..d418bec214 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -20,7 +20,7 @@ import { parseAutonumberFormat, renderAutonumber, resolveAutonumberFormat, readA // The DECLARED aggregate vocabulary (#5907). Read from the spec so this driver's // "the protocol has no such function" refusal cannot drift from what // `AggregationNodeSchema.function` actually admits. -import { AggregationFunction } from '@objectstack/spec/data'; +import { AggregationFunction, emptyGroupValueFor } from '@objectstack/spec/data'; import { STRUCTURED_JSON_TYPES, FILE_REFERENCE_TYPES, MULTI_OPTION_TYPES, NUMERIC_VALUE_TYPES } from '@objectstack/spec/data'; // [#5659] The Filter Protocol's boolean identity reduction — `$and: []` is TRUE, // `$or: []` is FALSE, `{}` is a TRUE disjunct, `$not: {}` is FALSE. One @@ -8498,6 +8498,12 @@ export class SqlDriver implements IDataDriver { // See {@link presentReadColumns}. const presentedOutput = new Map(); + // [#15546] Result columns whose NULL answer folds to the identity the + // platform declares for that aggregate over NOTHING (`emptyGroupValueFor`, + // spec `data/aggregation-policy.ts`), keyed like `presentedOutput` by the + // column name the caller will read. See {@link foldEmptyAggregateAnswers}. + const foldedOutput = new Map(); + if (query.groupBy) { // groupBy items may be plain strings ('region') or structured objects // ({ field: 'closed_at', dateGranularity: 'quarter' }). For structured @@ -8627,6 +8633,12 @@ export class SqlDriver implements IDataDriver { } else { builder.select(this.knex.raw(`${rawFunc} as ${this.aliasIdentifierSql(agg.alias)}`, [fieldExpr])); } + // [#15546] What this aggregate answers over NOTHING, read from the + // policy rather than restated: `sum` (and the two counts, which never + // arrive as NULL) fold to `0`; `avg`/`min`/`max` have no identity and + // their NULL passes through. See {@link foldEmptyAggregateAnswers}. + const identity = emptyGroupValueFor(funcName); + if (identity !== undefined) foldedOutput.set(agg.alias, identity); // `min`/`max` are the only supported functions that hand back a value // OF the column rather than a count/total derived from it, so they are // the only ones whose result still needs the column's presentation. @@ -8718,7 +8730,60 @@ export class SqlDriver implements IDataDriver { // {@link SqlDriver.aggregateBackendFault}. throw this.aggregateBackendFault(object, query, error); } - return this.presentReadColumns(rows, presentedOutput); + return this.presentReadColumns(this.foldEmptyAggregateAnswers(rows, foldedOutput), presentedOutput); + } + + /** + * [#15546] Fold the NULL a SQL aggregate answers over an all-NULL aggregand + * to the identity the platform declares for that aggregate over NOTHING. + * + * SQL `SUM` skips NULLs, and once it has skipped every row of a group it + * answers NULL — on every dialect this driver targets. Measured 2026-09-07 + * on better-sqlite3, live PostgreSQL 16.13 and live MySQL 8.0.46: `sum` over + * a group of three rows whose column is NULL in each of them is `null` on + * all three, for `number` and `currency` columns alike. The engine's + * in-memory aggregate tier (`objectql`'s `in-memory-aggregation.ts`) answers + * `0` for the same rows, as do `driver-memory` and `driver-mongodb`'s + * lowering, and `emptyGroupValueFor` (spec `data/aggregation-policy.ts`) + * rules that summing nothing is `0` — a measured fact, not missing data. + * Which face answered was decided by a driver capability bit the caller + * never sees (`engine.ts`'s `typeof drv.aggregate === 'function'` fork), so + * one grouped list view rendered a blank total on one deployment and `0` on + * another. Maintainer ruling 2026-09-07 on #15546 (option A): three rows + * whose aggregand is absent and zero rows are the SAME case for `sum` — the + * addend set is empty either way — and this face is the one that moves. + * + * The identity is READ from the policy rather than restated here, so the + * other half of the same rule holds by construction: an aggregate whose + * `emptyGroupValueFor` is `undefined` (`avg`/`min`/`max`) has no answer over + * nothing, is never registered, and its NULL reaches the caller untouched. + * `count`/`count_distinct` register too but never arrive as NULL — `COUNT` + * answers `0` on its own — so the entry is inert for them, deliberately + * rather than special-cased away. + * + * Presentation, not compilation. The statement is unchanged — no `COALESCE` + * — so the emitted-SQL pins and the per-dialect result-type parsing above + * are untouched, and the answer is the JS number `0` on every dialect, the + * same value the in-memory tier produces. Only `null` folds: an `undefined` + * would mean the column was never projected, a different defect that must + * stay visible. Rows are mutated in place, as {@link presentReadColumns} + * does. The unaliased branch of {@link SqlDriver.aggregate} is not tracked, + * for the reason `presentedOutput` gives: `alias` is required by + * `AggregationNodeSchema`, and that branch lands under a dialect-dependent + * column name. + * + * Pinned on every enrolled face by the `sum(amount)` cases of + * `AGGREGATION_CASES` (spec `data/aggregation-conformance.ts`). + */ + protected foldEmptyAggregateAnswers(rows: any, identities: Map): any { + if (identities.size === 0 || !Array.isArray(rows)) return rows; + for (const row of rows) { + if (!row || typeof row !== 'object') continue; + for (const [column, identity] of identities) { + if (row[column] === null) row[column] = identity; + } + } + return rows; } /** diff --git a/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-aggregation-conformance.test.ts b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-aggregation-conformance.test.ts index 8c64443924..2f1f05d6fc 100644 --- a/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-aggregation-conformance.test.ts +++ b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-aggregation-conformance.test.ts @@ -66,6 +66,10 @@ describe('[#6409] driver-sqlite-wasm — aggregate vocabulary conformance', () = // column nullable, which is what the null-bearing rows need. stage: { type: 'string' }, score: { type: 'number' }, + // [#15546] Nullable, like `stage` — see `AggregationRow.amount`: the + // `east` group is NULL in every row, the cell the ruled `sum` → `0` + // answer is pinned on. + amount: { type: 'number' }, // [#11152] Declared `type: 'boolean'` on purpose — see // `AggregationRow.flag`: the ruled boolean cases answer NUMBERS // (min=0/max=1) over the 0/1 INTEGER storage. diff --git a/packages/drivers/driver-turso/src/turso-remote-aggregation-conformance.test.ts b/packages/drivers/driver-turso/src/turso-remote-aggregation-conformance.test.ts index be0c7fa72c..0ba01418ca 100644 --- a/packages/drivers/driver-turso/src/turso-remote-aggregation-conformance.test.ts +++ b/packages/drivers/driver-turso/src/turso-remote-aggregation-conformance.test.ts @@ -73,6 +73,10 @@ const CONFORMANCE_OBJECT = { // Nullable, and it must stay that way — see `AggregationRow.stage`. stage: { type: 'string' }, score: { type: 'number' }, + // [#15546] Nullable, like `stage` — see `AggregationRow.amount`: the `east` + // group is NULL in every row, the cell the ruled `sum` → `0` answer is + // pinned on. + amount: { type: 'number' }, // [#11152] Declared `type: 'boolean'` on purpose — see `AggregationRow.flag`. // SQLite stores it 0/1 INTEGER, and the ruled boolean cases (min=0/max=1, // sum=3, avg=0.5) are answered in exactly that numeric domain. diff --git a/packages/spec/src/data/aggregation-conformance.ts b/packages/spec/src/data/aggregation-conformance.ts index 983812e2d3..886f7173e4 100644 --- a/packages/spec/src/data/aggregation-conformance.ts +++ b/packages/spec/src/data/aggregation-conformance.ts @@ -195,9 +195,32 @@ export interface AggregationRow { * A non-null numeric column for the arithmetic aggregates. Deliberately * all-distinct so `count_distinct(score)` equals the row count: the pair of * `count_distinct` cases then straddles the interesting axis — one column - * where dedup and nulls both bite, one where neither does. + * where dedup and nulls both bite, one where neither does. Its NULLABLE + * twin is {@link AggregationRow.amount} (#15546). */ score: number; + /** + * [#15546] The NULLABLE numeric aggregand — the column `score` deliberately + * is not. `east` is NULL in EVERY row and `west` in two of its four, so the + * grouped `sum(amount)` case reaches the cell no other column can: a group + * that is NOT empty whose addend set IS. + * + * SQL `SUM` skips NULLs and answers NULL once it has skipped everything; + * the engine's in-memory tier, `driver-memory` and `driver-mongodb`'s + * lowering answer `0` for the same rows; `emptyGroupValueFor` + * (`aggregation-policy.ts`) rules that summing nothing is `0`. Which face + * answered a grouped list view's `sum` summary was decided by a driver + * capability bit the caller never sees, so the same view rendered a blank + * total on one deployment and `0` on another. Ruled 2026-09-07 (maintainer, + * option A on #15546): a non-empty group whose aggregand is NULL in every + * row sums to `0`, on every face — which is what the `east` cell pins. + * + * Harnesses MUST declare it nullable and seed the nulls AS nulls, exactly + * as for {@link AggregationRow.stage}: a `NOT NULL` column, or a `0` written + * in place of a null, turns the `east` cell green for the wrong reason. Its + * `count(amount)` control answers `0` only while the nulls are real. + */ + amount: number | null; /** * [#11152] The non-null BOOLEAN aggregand — 3 true / 3 false, so `sum` and * `avg` cannot agree with a face that dropped the booleans (`0` / `null`, @@ -224,15 +247,17 @@ export interface AggregationRow { /** * The fixture, as stored. `west` carries the duplicate (`won` twice) and a * null; `east` carries one value and a null, so BOTH groups exercise null - * exclusion while only one exercises dedup. + * exclusion while only one exercises dedup. [#15546] On `amount`, `east` is + * NULL in every row and `west` in two of four, so one group has an EMPTY + * addend set and the other a partial one. */ export const AGGREGATION_ROWS: readonly AggregationRow[] = [ - { id: '1', region: 'west', stage: 'won', score: 10, flag: true }, - { id: '2', region: 'west', stage: 'won', score: 20, flag: false }, - { id: '3', region: 'west', stage: 'lost', score: 30, flag: false }, - { id: '4', region: 'west', stage: null, score: 40, flag: false }, - { id: '5', region: 'east', stage: 'won', score: 50, flag: true }, - { id: '6', region: 'east', stage: null, score: 60, flag: true }, + { id: '1', region: 'west', stage: 'won', score: 10, flag: true, amount: 10 }, + { id: '2', region: 'west', stage: 'won', score: 20, flag: false, amount: null }, + { id: '3', region: 'west', stage: 'lost', score: 30, flag: false, amount: 30 }, + { id: '4', region: 'west', stage: null, score: 40, flag: false, amount: null }, + { id: '5', region: 'east', stage: 'won', score: 50, flag: true, amount: null }, + { id: '6', region: 'east', stage: null, score: 60, flag: true, amount: null }, ] as const; /** @@ -481,6 +506,63 @@ export const AGGREGATION_CASES: readonly AggregationCase[] = [ ], }, + // ── [#15546] the NULLABLE aggregand: summing nothing is 0, on every face ── + // + // `amount` is NULL in every `east` row. SQL `SUM` skips NULLs and answers + // NULL once it has skipped everything — measured `null` on better-sqlite3, + // live PostgreSQL 16.13 and live MySQL 8.0.46 before the driver-sql fold — + // while the engine's in-memory tier answers `0` (its reduce starts at the + // identity and `toNumber(null)` is 0), and so do `driver-memory` and + // `driver-mongodb`'s lowering. The two platform faces are picked per query + // by a driver capability bit the caller cannot see, so the same grouped list + // view rendered a blank total on one deployment and `0` on another. + // Maintainer ruling 2026-09-07 (#15546, option A): a non-empty group whose + // aggregand is NULL in every row sums to `0` — the addend set is empty + // either way, and `emptyGroupValueFor` already says summing nothing is `0`. + // The other half of that policy (`avg`/`min`/`max` have no identity and stay + // null) is outside this table's `value: number` vocabulary and is pinned per + // face. + { + name: 'sum(amount) grouped by region — east is NULL in every row and sums to 0', + function: 'sum', + field: 'amount', + groupBy: 'region', + expected: [ + { group: 'east', value: 0 }, + { group: 'west', value: 40 }, + ], + note: + '#15546: `east` has two rows and nothing to add. A face that hands SQL\'s ' + + 'NULL through answers null here (a blank tile, indistinguishable from ' + + '"not computed"), and `west` at 40 keeps a face that folded EVERY sum ' + + 'to 0 from passing.', + }, + { + name: 'count(amount) grouped by region — the nulls are real', + function: 'count', + field: 'amount', + groupBy: 'region', + expected: [ + { group: 'east', value: 0 }, + { group: 'west', value: 2 }, + ], + note: + '#15546: the reachability control for the case above. COUNT(col) is ' + + 'defined over non-null values on every backend, so `east` at 0 proves ' + + 'the seed stored NULLs — a harness that wrote 0 in place of a null ' + + 'answers 2 here and turns the sum cell green for the wrong reason.', + }, + { + name: 'sum(amount) over the whole table skips the nulls', + function: 'sum', + field: 'amount', + expected: [{ group: null, value: 40 }], + note: + '#15546: the partial-null control. Four nulls among six rows contribute ' + + 'nothing and the two values add to 40 on every face — the case that was ' + + 'always green, which is why the all-null cell went unmeasured.', + }, + // ── [#6401] the group column's NAME, not its value ──────────────────────── { name: 'groupBy alias renames the projected group column', From 3611073dea183456792cca7604759074d3140da8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 00:21:33 +0000 Subject: [PATCH 2/3] test(conformance): keep a NULL aggregate answer as null in the SQL-family and in-memory harnesses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Number(null) is 0 — the ruled answer for the all-null sum cell — so the unconditional Number(r.n) coercion made that cell green with the driver-sql fold ablated (measured 85/85 green against a driver answering null). The harnesses, not the faces, were holding the observable. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --- .../src/sql-driver-aggregation-conformance.test.ts | 8 +++++++- .../src/sqlite-wasm-aggregation-conformance.test.ts | 5 ++++- .../src/turso-remote-aggregation-conformance.test.ts | 6 +++++- .../src/in-memory-aggregation-conformance.test.ts | 7 ++++++- 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/drivers/driver-sql/src/sql-driver-aggregation-conformance.test.ts b/packages/drivers/driver-sql/src/sql-driver-aggregation-conformance.test.ts index 666f8a67aa..a5c2ceed7c 100644 --- a/packages/drivers/driver-sql/src/sql-driver-aggregation-conformance.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-aggregation-conformance.test.ts @@ -216,8 +216,14 @@ const actualFor = (c: AggregationCase, rows: Array>) => // `groupByAlias ?? groupBy`. Reading `c.groupBy` unconditionally is the bug // this axis exists to catch: it is green on a face that ignores the alias. const groupKey = c.groupByAlias ?? c.groupBy; + // [#15546] A NULL answer is kept as `null`, never coerced: `Number(null)` is + // `0`, which is the ruled answer for the all-null `sum` cell — so the + // unconditional `Number(r.n)` this read as before made that cell green with + // the fold ABLATED (measured: 85/85 green against a driver answering + // `null`). The harness, not the driver, was holding the observable. The + // string-typed answers node-pg hands back (`"6"` for `COUNT`) still coerce. return rows - .map((r) => ({ group: groupKey ? String(r[groupKey]) : null, value: Number(r.n) })) + .map((r) => ({ group: groupKey ? String(r[groupKey]) : null, value: r.n === null ? null : Number(r.n) })) .sort((x, y) => String(x.group).localeCompare(String(y.group))); }; diff --git a/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-aggregation-conformance.test.ts b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-aggregation-conformance.test.ts index 2f1f05d6fc..e08b7f6a47 100644 --- a/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-aggregation-conformance.test.ts +++ b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-aggregation-conformance.test.ts @@ -48,7 +48,10 @@ const actualFor = (c: AggregationCase, rows: Array>) => // this axis exists to catch: it is green on a face that ignores the alias. const groupKey = c.groupByAlias ?? c.groupBy; return rows - .map((r) => ({ group: groupKey ? String(r[groupKey]) : null, value: Number(r.n) })) + // [#15546] A NULL answer stays `null` — `Number(null)` is `0`, the ruled + // answer for the all-null `sum` cell, so coercing would let a face that + // hands SQL's NULL through pass as if it had folded. + .map((r) => ({ group: groupKey ? String(r[groupKey]) : null, value: r.n === null ? null : Number(r.n) })) .sort((x, y) => String(x.group).localeCompare(String(y.group))); }; diff --git a/packages/drivers/driver-turso/src/turso-remote-aggregation-conformance.test.ts b/packages/drivers/driver-turso/src/turso-remote-aggregation-conformance.test.ts index 0ba01418ca..1d63bc751c 100644 --- a/packages/drivers/driver-turso/src/turso-remote-aggregation-conformance.test.ts +++ b/packages/drivers/driver-turso/src/turso-remote-aggregation-conformance.test.ts @@ -101,8 +101,12 @@ const actualFor = (c: AggregationCase, rows: Array>) => // `groupByAlias ?? groupBy`. Reading `c.groupBy` unconditionally is the bug // this axis exists to catch: it is green on a face that ignores the alias. const groupKey = c.groupByAlias ?? c.groupBy; + // [#15546] A NULL answer is kept as `null`, never coerced: `Number(null)` is + // `0`, the ruled answer for the all-null `sum` cell, so an unconditional + // `Number(r.n)` reads a face that hands SQL's NULL through as if it had + // folded — the harness holding the observable instead of the face. return rows - .map((r) => ({ group: groupKey ? String(r[groupKey]) : null, value: Number(r.n) })) + .map((r) => ({ group: groupKey ? String(r[groupKey]) : null, value: r.n === null ? null : Number(r.n) })) .sort((x, y) => String(x.group).localeCompare(String(y.group))); }; diff --git a/packages/objectql/src/in-memory-aggregation-conformance.test.ts b/packages/objectql/src/in-memory-aggregation-conformance.test.ts index 1c8c532e1e..242cbf2b4e 100644 --- a/packages/objectql/src/in-memory-aggregation-conformance.test.ts +++ b/packages/objectql/src/in-memory-aggregation-conformance.test.ts @@ -96,8 +96,13 @@ const astFor = (c: AggregationCase): Pick const actualFor = (c: AggregationCase, rows: Array>) => { const groupKey = c.groupByAlias ?? c.groupBy; + // [#15546] A NULL answer stays `null` — `Number(null)` is `0`, the ruled + // answer for the all-null `sum` cell, so coercing would let a tier that + // answered null there pass as if it had folded. This face answers `0` on + // its own (the reduce starts at the identity); the pin has to be able to + // see it stop doing so. return rows - .map((r) => ({ group: groupKey ? String(r[groupKey]) : null, value: Number(r.n) })) + .map((r) => ({ group: groupKey ? String(r[groupKey]) : null, value: r.n === null ? null : Number(r.n) })) .sort((x, y) => String(x.group).localeCompare(String(y.group))); }; From 99d4549570ba0e51a35955461b293a5aa79c1ab3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 00:29:17 +0000 Subject: [PATCH 3/3] fix(driver-turso): the remote transport folds an all-NULL sum to 0 like the local face TursoDriver picks the remote compiler or SqlDriver's from url, so without this the same driver answered the all-NULL sum 0 locally and null remotely. Measured null on the enrolled remote face with a null-preserving harness. Same fold, same policy read (#15546). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --- .../driver-sql-all-null-sum-folds-to-zero.md | 3 ++ .../driver-turso/src/remote-transport.ts | 47 ++++++++++++++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/.changeset/driver-sql-all-null-sum-folds-to-zero.md b/.changeset/driver-sql-all-null-sum-folds-to-zero.md index c312a5e451..1b89dcf2db 100644 --- a/.changeset/driver-sql-all-null-sum-folds-to-zero.md +++ b/.changeset/driver-sql-all-null-sum-folds-to-zero.md @@ -1,5 +1,6 @@ --- "@objectstack/driver-sql": minor +"@objectstack/driver-turso": minor "@objectstack/spec": minor --- @@ -7,4 +8,6 @@ SQL `SUM` skips NULLs and answers NULL once it has skipped everything, so on every dialect this driver targets (measured on better-sqlite3, live PostgreSQL 16.13 and live MySQL 8.0.46) a grouped list view with a `sum` summary on a nullable number or currency column rendered a BLANK total for a group whose column was empty in every row — while the same view on a deployment whose query took the engine's in-memory path rendered `0`. Which path answered was decided by a driver capability bit the caller never sees. The fold is part of the driver's aggregate presentation (`foldEmptyAggregateAnswers`): the compiled statement is unchanged (no `COALESCE`), the answer is the JS number `0` on every dialect, and `avg`/`min`/`max` — which have no identity over nothing — still answer `null`. The identity is read from `emptyGroupValueFor` rather than restated, so the two faces cannot drift apart on it again. +`@objectstack/driver-turso`: the REMOTE transport's `aggregate` carries the same fold (`RemoteTransport.foldEmptyAggregateAnswers`). `TursoDriver` picks the remote compiler or the local `SqlDriver` one from `url`, so without it the same driver would have answered the all-NULL `sum` as `0` locally and `null` remotely — one query, two answers, decided by a connection string, the seam the shared conformance table exists to close. Measured `null` on the enrolled remote face before the fold. + `@objectstack/spec`: the aggregate-vocabulary conformance fixture gains a NULLABLE numeric column. `AggregationRow.amount` (`number | null`) is NULL in every row of the `east` group and in two of the four `west` rows, and `AGGREGATION_CASES` gains the three cases that pin the ruled answer on every enrolled face — `sum(amount)` grouped by region (`east` 0 / `west` 40), its `count(amount)` reachability control (`east` 0 / `west` 2, which is what proves the nulls were stored as nulls), and the ungrouped partial-null control (40). A harness that runs the table MUST declare `amount` as a nullable numeric column and seed its nulls AS nulls, exactly as it already must for `stage`; a `0` written in place of a null turns the cell green for the wrong reason. diff --git a/packages/drivers/driver-turso/src/remote-transport.ts b/packages/drivers/driver-turso/src/remote-transport.ts index 8ea56d50d6..44612eb651 100644 --- a/packages/drivers/driver-turso/src/remote-transport.ts +++ b/packages/drivers/driver-turso/src/remote-transport.ts @@ -34,7 +34,7 @@ import { resolveFilterSubtreeProvenance } from '@objectstack/spec/data'; // The DECLARED aggregate vocabulary (#5907) — read from the spec so this // transport's "the protocol has no such function" refusal cannot drift from what // `AggregationNodeSchema.function` admits, nor from the local driver's twin. -import { AggregationFunction } from '@objectstack/spec/data'; +import { AggregationFunction, emptyGroupValueFor } from '@objectstack/spec/data'; import type { DriverQuery } from '@objectstack/spec/contracts'; // [#8413] What a `unique: true` FIELD becomes, from the one place that decides // it. `uniqueIndexesFromFields`' own contract is that it is "the ONLY place @@ -1369,6 +1369,10 @@ export class RemoteTransport { this.assertSafeIdentifier(object); const selectParts: string[] = []; + // [#15546] Result columns whose NULL answer folds to the identity the + // platform declares for that aggregate over NOTHING — the twin of + // `SqlDriver.aggregate`'s `foldedOutput`. See {@link foldEmptyAggregateAnswers}. + const foldedOutput = new Map(); // [#6212] `groupBy` is `GroupByNode[]` — a UNION of a bare field name and a // structured `{ field, dateGranularity?, alias? }` entry — so reading it as @@ -1501,6 +1505,13 @@ export class RemoteTransport { const alias = agg.alias || `${func}_${field === '*' ? 'all' : field}`; const argSql = lowering.distinct ? `distinct ${fieldSql}` : fieldSql; selectParts.push(`${lowering.sql}(${argSql}) AS ${this.aliasIdentifierSql(alias)}`); + // [#15546] What this aggregate answers over NOTHING, read from the policy + // rather than restated: `sum` (and the two counts, which never arrive as + // NULL) fold to `0`; `avg`/`min`/`max` have no identity and their NULL + // passes through. Keyed by the OUTPUT column — every aggregation here + // has one, defaulted or caller-supplied. + const identity = emptyGroupValueFor(func); + if (identity !== undefined) foldedOutput.set(alias, identity); } if (selectParts.length === 0) selectParts.push('*'); @@ -1524,7 +1535,7 @@ export class RemoteTransport { try { const result = await this.client!.execute({ sql, args }); - return this.mapRows(result); + return this.foldEmptyAggregateAnswers(this.mapRows(result), foldedOutput); } catch (error: any) { if ( error.message && @@ -1537,6 +1548,38 @@ export class RemoteTransport { } } + /** + * [#15546] Fold the NULL SQL answers for an aggregate over an all-NULL + * aggregand to the identity the platform declares for that aggregate over + * NOTHING — the twin of `SqlDriver.foldEmptyAggregateAnswers`, and the reason + * it is here: `TursoDriver` picks this compiler or the local one from `url`, + * so without it the SAME driver answered `sum` over a group whose column is + * NULL in every row as `0` locally and `null` remotely — the #5907/#6203 + * shape, one query, two answers, decided by a connection string. Measured + * on the enrolled remote face (libsql IS SQLite) before this fold: `null`. + * + * `emptyGroupValueFor` (spec `data/aggregation-policy.ts`) is READ rather + * than restated, so `avg`/`min`/`max` — no identity over nothing — are never + * registered and their NULL reaches the caller untouched; `count` and + * `count_distinct` register but never arrive as NULL. Presentation, not + * compilation: the statement is unchanged. Only `null` folds — an + * `undefined` would mean the column was never projected, a different defect + * that must stay visible. Rows are mutated in place, as `mapRows` builds + * them. Pinned by the `sum(amount)` cases of `AGGREGATION_CASES`. + */ + private foldEmptyAggregateAnswers( + rows: Record[], + identities: Map, + ): Record[] { + if (identities.size === 0) return rows; + for (const row of rows) { + for (const [column, identity] of identities) { + if (row[column] === null) row[column] = identity; + } + } + return rows; + } + async create(object: string, data: Record): Promise> { await this.ensureConnected();