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
13 changes: 13 additions & 0 deletions .changeset/driver-sql-all-null-sum-folds-to-zero.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@objectstack/driver-sql": minor
"@objectstack/driver-turso": 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/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.
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -211,8 +216,14 @@ const actualFor = (c: AggregationCase, rows: Array<Record<string, unknown>>) =>
// `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)));
};

Expand Down Expand Up @@ -247,13 +258,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
Expand Down
69 changes: 67 additions & 2 deletions packages/drivers/driver-sql/src/sql-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -8498,6 +8498,12 @@ export class SqlDriver implements IDataDriver {
// See {@link presentReadColumns}.
const presentedOutput = new Map<string, ReadPresentationKind>();

// [#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<string, number>();

if (query.groupBy) {
// groupBy items may be plain strings ('region') or structured objects
// ({ field: 'closed_at', dateGranularity: 'quarter' }). For structured
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<string, number>): 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;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ const actualFor = (c: AggregationCase, rows: Array<Record<string, unknown>>) =>
// 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)));
};

Expand All @@ -66,6 +69,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.
Expand Down
47 changes: 45 additions & 2 deletions packages/drivers/driver-turso/src/remote-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, number>();

// [#6212] `groupBy` is `GroupByNode[]` — a UNION of a bare field name and a
// structured `{ field, dateGranularity?, alias? }` entry — so reading it as
Expand Down Expand Up @@ -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('*');
Expand All @@ -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 &&
Expand All @@ -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<string, unknown>[],
identities: Map<string, number>,
): Record<string, unknown>[] {
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<string, unknown>): Promise<Record<string, unknown>> {
await this.ensureConnected();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -97,8 +101,12 @@ const actualFor = (c: AggregationCase, rows: Array<Record<string, unknown>>) =>
// `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)));
};

Expand Down
Loading
Loading