From 471405aa571fd3795329190cd620262f33fb91df Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:34:29 +0000 Subject: [PATCH 1/5] fix(driver-sql): present count / count_distinct / sum / avg as numbers on every dialect node-postgres hands bigint (count) and numeric (sum, avg) back as strings and mysql2 does the same for DECIMAL, so the native aggregate path answered "2" where SQLite and the engine's rows path answer 2. Key the 'number' read presentation on the aggregate function the query asked for, with one precision policy: a JS double, the loss beyond a double's precision declared. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude --- packages/drivers/driver-sql/src/sql-driver.ts | 81 ++++++++++++++++++- 1 file changed, 77 insertions(+), 4 deletions(-) diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index a6acc3cd35a..8dc157d5351 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -1476,6 +1476,64 @@ const SQL_AGGREGATE_FUNCTIONS: ReadonlyMap = new M ['count_distinct', { sql: 'count', distinct: true }], ]); +/** + * [#20335] What each declared aggregate function ANSWERS — a derived `number`, + * or a value OF the aggregated column — and therefore which read presentation + * {@link SqlDriver.aggregate} gives its result column. + * + * - `'number'` — `count`, `count_distinct`, `sum`, `avg`. A count or a total is + * a number whatever the column held, and it is presented as one (`'number'`, + * the presenter `formatOutput` applies to a numeric field on a `find()` row). + * - `'column'` — `min`, `max`. The answer is one of the column's own values, so + * it takes that column's presentation ({@link SqlDriver.readPresentationKind}), + * exactly as before this table existed. + * + * Why the `'number'` half needs presenting at all: the SQL client hands a + * result back as the wire type of the SQL expression, not as the platform's + * value type. Measured on live PostgreSQL 16.13 and MySQL 8.0.46 through this + * driver's own connections: node-postgres parses `bigint` (OID 20 — `count`, + * and `sum` over an integer column) and `numeric` (OID 1700 — `sum` / `avg` + * over the exact-decimal numeric family, `avg` over an integer column) to + * STRINGS (`"2"`, `"500.000000000000000000000000000000"`), and mysql2 does the + * same for `DECIMAL` (`SUM` / `AVG`; its `COUNT` arrives as a number). The + * engine's rows path (`objectql`'s `in-memory-aggregation.ts`) and SQLite answer + * numbers for the same query, so `having { n: { $in: [2] } }` kept c1, c2 on + * those and no group on PostgreSQL's native path. + * + * Keyed on the function the query ASKED for, never on whether a value looks + * numeric, and deliberately not gated by dialect: the presenter only rewrites a + * STRING, so a client that already answers a number (better-sqlite3, mysql2's + * `COUNT`) passes through untouched — measured byte-identical on SQLite — and + * no list of "string-answering dialects" exists to drift. + * + * ## The precision policy — one JS number, the loss declared + * + * The answer is `Number(text)`: an IEEE-754 double, on every dialect. A `sum` / + * `avg` over the exact-decimal column (`numeric(65,30)` / `DECIMAL(65,30)`) + * whose value needs more than a double's ~15-17 significant digits, or an + * integer at or above 2^53, is ROUNDED to the nearest double — declared, not + * silent: it is the same bound `formatOutput` already puts on a `find()` read of + * that column (#16318, `valueSchemaFor`'s `z.number().finite()`, ADR-0104 D1), + * and the bound the rows path has always had (`toNumber` sums JS doubles). A + * value-dependent type — a number when it fits, a string when it does not — was + * rejected: it would reopen this defect for exactly the large totals, where a + * `having` `$in` or a chart silently stops matching. Only a string `Number()` + * reads as NaN (PostgreSQL's `numeric` `'NaN'`) is left as written, the + * presenter's existing rule. + * + * A `Record` over `AggregationFunction` on purpose: a function that joins the + * declared vocabulary without an answer here fails `tsc` rather than reaching a + * caller unpresented. + */ +const AGGREGATE_ANSWER_KIND: Readonly> = { + count: 'number', + count_distinct: 'number', + sum: 'number', + avg: 'number', + min: 'column', + max: 'column', +}; + /** * [#5907] The aggregate vocabulary the Query Protocol DECLARES, read from the * spec rather than restated — `AggregationNodeSchema.function` is this enum, so @@ -9858,8 +9916,10 @@ export class SqlDriver implements IDataDriver { // GROUP BY bucket expression (#3773) and the result presentation (#3797). const table = this.coercionKey(builder); - // Result columns that carry a raw column VALUE (rather than a count/total - // derived from one), keyed by the column name the caller will read. + // Result columns and the presentation each takes, keyed by the column name + // the caller will read: a raw column VALUE (a group key, `min`/`max`) takes + // its column's presentation, and [#20335] a count or total derived from one + // takes the `'number'` presentation (see `AGGREGATE_ANSWER_KIND`). // Collected while the statement is built because that is the only point // where a column name and its meaning are both known: a `min()` lands under // its alias (never under the field name), and a date-BUCKETED column lands @@ -10009,6 +10069,17 @@ export class SqlDriver implements IDataDriver { // their NULL passes through. See {@link foldEmptyAggregateAnswers}. const identity = emptyGroupValueFor(funcName); if (identity !== undefined) foldedOutput.set(agg.alias, identity); + // [#20335] A count or a total is presented as the number it is, on + // every dialect: node-postgres hands `bigint` / `numeric` back as a + // string and mysql2 `DECIMAL`, so without this the native path + // answered `"2"` where the rows path and SQLite answer `2`. Keyed on + // the function asked for; the precision policy (one JS double, the + // loss above a double's precision declared) is stated on + // `AGGREGATE_ANSWER_KIND`. The fold above runs first, so a folded + // `0` is already a number and passes through. + if (AGGREGATE_ANSWER_KIND[funcName] === 'number') { + presentedOutput.set(agg.alias, 'number'); + } // `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. @@ -10016,7 +10087,7 @@ export class SqlDriver implements IDataDriver { // below lands under a dialect-dependent column name // (`max("closed_at")` on SQLite, `max` on Postgres) and is defensive // only, so it is deliberately not tracked. - if ((funcName === 'min' || funcName === 'max') && agg.field) { + if (AGGREGATE_ANSWER_KIND[funcName] === 'column' && agg.field) { // [#11152] A BOOLEAN aggregand is the ruled exception to "the // result still needs the column's presentation": the maintainer's // 2026-08-28 ruling (superseding #11249's `false`/`true`, which @@ -15196,7 +15267,9 @@ export class SqlDriver implements IDataDriver { /** * Apply {@link presentReadValue} to the result columns a caller of - * `aggregate()` will read as column VALUES — group keys, and `min`/`max`. + * `aggregate()` will read as column VALUES — group keys, and `min`/`max` — + * and [#20335] to its counts and totals (`count`, `count_distinct`, `sum`, + * `avg`), which take the `'number'` presenter (`AGGREGATE_ANSWER_KIND`). * * Which columns those are cannot be recovered from the rows — the driver has * to be told, because the mapping from column name to meaning is only From 6777b32c43738c2601098be7691cad3e96f66f01 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:37:28 +0000 Subject: [PATCH 2/5] test(driver-sql): pin aggregate counts and totals as numbers on every dialect cell Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude --- ...335-aggregate-numeric-presentation.test.ts | 208 ++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 packages/drivers/driver-sql/src/sql-driver-20335-aggregate-numeric-presentation.test.ts diff --git a/packages/drivers/driver-sql/src/sql-driver-20335-aggregate-numeric-presentation.test.ts b/packages/drivers/driver-sql/src/sql-driver-20335-aggregate-numeric-presentation.test.ts new file mode 100644 index 00000000000..e263729254e --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-20335-aggregate-numeric-presentation.test.ts @@ -0,0 +1,208 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20335] `count` / `count_distinct` / `sum` / `avg` answer a JS NUMBER from + * `SqlDriver.aggregate`, on every dialect — the value the engine's rows path + * and SQLite already answered. + * + * Measured on the base (`26daf0b036`) through this door, `engine.aggregate` and + * `POST /api/v1/data/:object/query`, `groupBy` customer, four groups: + * + * | dialect | `count` | `count_distinct` | `sum` / `avg` over number, currency, percent | `sum` / `avg` over rating (integer column) | + * |:--|:--|:--|:--|:--| + * | SQLite | number | number | number | number | + * | PostgreSQL 16.13 | `"2"` | `"2"` | `"500.000000000000000000000000000000"` | `"7"` / `"3.5000000000000000"` | + * | MySQL 8.0.46 | number | number | `"500.000000000000000000000000000000"` | `"7"` / `"3.5000"` | + * + * node-postgres parses `bigint` (OID 20) and `numeric` (OID 1700) to strings, + * and mysql2 does the same for `DECIMAL`; `min` / `max` over a declared numeric + * field were already numbers (the column's own `'number'` presentation, #16318) + * and are unchanged. The engine's `having` then compared `"2"` against `2`: + * `having { n: { $in: [2] } }` kept no group on PostgreSQL's native path and + * c1, c2 everywhere else (pinned at the engine and REST doors in + * `@objectstack/rest`'s `rest-aggregate-numeric-having.test.ts`). + * + * The fixture's values are dyadic fractions on purpose, so a JS double holds + * every sum and average EXACTLY: the expected values below are computed from the + * rows with JS arithmetic — the rows path's own arithmetic — and asserted with + * `toBe`, so a string, a boolean or a rounding difference each fail. + * + * The precision policy (one JS double, the loss beyond a double's precision + * declared — `AGGREGATE_ANSWER_KIND` in `sql-driver.ts`) is pinned by the last + * case of each cell: a total the exact-decimal column holds but a double cannot + * answers the nearest double, which is also what `find()` reads for the row. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { DriverQuery } from '@objectstack/spec/contracts'; +import { SqlDriver } from './sql-driver.js'; +import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dialect-matrix.testkit.js'; + +const TABLE = 'os20335_agg_numbers'; + +interface Row { + id: string; + customer_id: string; + amount: number; + price: number; + rate: number; + stars: number; + flag: boolean; + note: string; +} + +const ROWS: readonly Row[] = [ + { id: 'o1', customer_id: 'c1', amount: 100, price: 10.25, rate: 0.25, stars: 3, flag: true, note: 'b' }, + { id: 'o2', customer_id: 'c1', amount: 400, price: 20.5, rate: 0.5, stars: 4, flag: false, note: 'a' }, + { id: 'o3', customer_id: 'c2', amount: 900, price: 30.75, rate: 0.75, stars: 5, flag: true, note: 'c' }, + { id: 'o4', customer_id: 'c2', amount: 300, price: 40, rate: 0.125, stars: 2, flag: true, note: 'c' }, + { id: 'o5', customer_id: 'c3', amount: 50, price: 5.5, rate: 0.5, stars: 1, flag: false, note: 'e' }, + { id: 'o6', customer_id: 'c4', amount: 20, price: 1.25, rate: 0.25, stars: 5, flag: false, note: 'f' }, +]; + +/** number (integer-valued), currency, percent, and the one integer column of the family. */ +const MEASURED = ['amount', 'price', 'rate', 'stars'] as const; +const GROUPS = ['c1', 'c2', 'c3', 'c4'] as const; + +const byGroup = (g: string) => ROWS.filter((r) => r.customer_id === g); +const sumOf = (rows: readonly Row[], f: keyof Row) => rows.reduce((a, r) => a + Number(r[f]), 0); + +function grouped(): DriverQuery { + const aggregations: Array> = [ + { function: 'count', alias: 'n' }, + { function: 'count_distinct', field: 'note', alias: 'nd' }, + { function: 'sum', field: 'flag', alias: 'sum_flag' }, + { function: 'avg', field: 'flag', alias: 'avg_flag' }, + { function: 'sum', field: 'spare', alias: 'sum_spare' }, + { function: 'avg', field: 'spare', alias: 'avg_spare' }, + { function: 'min', field: 'note', alias: 'min_note' }, + ]; + for (const f of MEASURED) { + for (const fn of ['count', 'sum', 'avg', 'min', 'max']) aggregations.push({ function: fn, field: f, alias: `${fn}_${f}` }); + } + return { groupBy: ['customer_id'], aggregations } as DriverQuery; +} + +function declareCell(cell: DialectCell): void { + describe(`[#20335] driver-sql — aggregate counts and totals are numbers (${cell.label})`, () => { + let driver: SqlDriver; + let answers: Map>; + + beforeAll(async () => { + driver = new SqlDriver(cell.config()); + await driver.execute(`drop table if exists ${TABLE}`).catch(() => {}); + await driver.initObjects([ + { + name: TABLE, + fields: { + customer_id: { type: 'text' }, + amount: { type: 'number' }, + price: { type: 'currency' }, + rate: { type: 'percent' }, + stars: { type: 'rating' }, + flag: { type: 'boolean' }, + note: { type: 'text' }, + // NULL in every row: `sum` folds to 0 (#15546) and `avg` stays null. + spare: { type: 'number' }, + }, + }, + ] as never); + for (const row of ROWS) await driver.create(TABLE, { ...row }, { bypassTenantAudit: true }); + const rows = (await driver.aggregate(TABLE, grouped())) as Array>; + answers = new Map(rows.map((r) => [String(r.customer_id), r])); + }); + + afterAll(async () => { + await driver?.execute(`drop table if exists ${TABLE}`).catch(() => {}); + await driver?.disconnect(); + }); + + it('answers the four groups', () => { + expect([...answers.keys()].sort()).toEqual([...GROUPS]); + }); + + it('count and count_distinct are numbers, equal to the rows', () => { + for (const g of GROUPS) { + const a = answers.get(g)!; + const rows = byGroup(g); + expect(a.n, `${g} count(*)`).toBe(rows.length); + expect(a.nd, `${g} count_distinct(note)`).toBe(new Set(rows.map((r) => r.note)).size); + for (const f of MEASURED) expect(a[`count_${f}`], `${g} count(${f})`).toBe(rows.length); + } + }); + + it('sum and avg over number, currency, percent and rating are numbers, equal to the rows', () => { + for (const g of GROUPS) { + const a = answers.get(g)!; + const rows = byGroup(g); + for (const f of MEASURED) { + expect(a[`sum_${f}`], `${g} sum(${f})`).toBe(sumOf(rows, f)); + expect(a[`avg_${f}`], `${g} avg(${f})`).toBe(sumOf(rows, f) / rows.length); + } + } + }); + + it('sum and avg over a boolean answer the #11152 numbers strictly', () => { + for (const g of GROUPS) { + const a = answers.get(g)!; + const rows = byGroup(g); + expect(a.sum_flag, `${g} sum(flag)`).toBe(sumOf(rows, 'flag')); + expect(a.avg_flag, `${g} avg(flag)`).toBe(sumOf(rows, 'flag') / rows.length); + } + }); + + it('an all-NULL aggregand: sum folds to the number 0, avg stays null', () => { + for (const g of GROUPS) { + expect(answers.get(g)!.sum_spare, `${g} sum(spare)`).toBe(0); + expect(answers.get(g)!.avg_spare, `${g} avg(spare)`).toBeNull(); + } + }); + + it('min and max keep the column presentation — numbers for the numeric family, text for text', () => { + for (const g of GROUPS) { + const a = answers.get(g)!; + const rows = byGroup(g); + for (const f of MEASURED) { + expect(a[`min_${f}`], `${g} min(${f})`).toBe(Math.min(...rows.map((r) => Number(r[f])))); + expect(a[`max_${f}`], `${g} max(${f})`).toBe(Math.max(...rows.map((r) => Number(r[f])))); + } + expect(a.min_note, `${g} min(note)`).toBe([...rows.map((r) => r.note)].sort()[0]); + } + }); + + it('precision policy: a total a double cannot hold answers the nearest double, as find() does', async () => { + // Written by SQL, not by the driver: a JS number could not carry these + // values in the first place, which is the whole point of the case. The + // literals are numeric, so the exact-decimal column stores them exactly + // on PostgreSQL and MySQL (SQLite's REAL column rounds on write). + const EXACT = ['9007199254740993', '12345678901234567.123456789']; + for (const [i, literal] of EXACT.entries()) { + await driver.execute(`insert into ${TABLE} (id, customer_id, amount) values ('p${i}', 'p${i}', ${literal})`); + } + const rows = (await driver.aggregate(TABLE, { + where: { customer_id: { $in: ['p0', 'p1'] } }, + groupBy: ['customer_id'], + aggregations: [ + { function: 'sum', field: 'amount', alias: 'total' }, + { function: 'avg', field: 'amount', alias: 'mean' }, + ], + } as DriverQuery)) as Array>; + const found = (await driver.find(TABLE, { where: { customer_id: { $in: ['p0', 'p1'] } } })) as Array< + Record + >; + for (const [i, literal] of EXACT.entries()) { + const row = rows.find((r) => r.customer_id === `p${i}`)!; + expect(typeof row.total, `sum over ${literal} is a number, never a string`).toBe('number'); + expect(row.total, `sum over ${literal}`).toBe(Number(literal)); + expect(row.mean, `avg over ${literal}`).toBe(Number(literal)); + expect(row.total, `the same bound find() reads for ${literal}`).toBe( + found.find((r) => r.customer_id === `p${i}`)!.amount, + ); + } + }); + }); +} + +for (const cell of DIALECT_CELLS) { + declareDialectCell(cell, 'aggregate numeric presentation (#20335)', declareCell); +} From ecf0128338ec274d77629d951a6279c21147a3a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:38:35 +0000 Subject: [PATCH 3/5] test(rest): pin having $in / $eq on count, count_distinct, sum and avg across both paths Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude --- .../src/rest-aggregate-numeric-having.test.ts | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 packages/rest/src/rest-aggregate-numeric-having.test.ts diff --git a/packages/rest/src/rest-aggregate-numeric-having.test.ts b/packages/rest/src/rest-aggregate-numeric-having.test.ts new file mode 100644 index 00000000000..267a0c1d155 --- /dev/null +++ b/packages/rest/src/rest-aggregate-numeric-having.test.ts @@ -0,0 +1,200 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20335] `having` `$in` / `$eq` on `count` / `count_distinct` / `sum` / + * `avg` keep the same groups on the native and the rows path, through + * `engine.aggregate` and `POST /api/v1/data/:object/query`, over a real + * `SqlDriver` — and the response carries those aggregates as JSON numbers. + * + * Measured on the base (`26daf0b036`), `groupBy` customer, four groups c1–c4: + * + * | `having` | SQLite (both paths), PG rows, MySQL rows | PostgreSQL native | MySQL native | + * |:--|:--|:--|:--| + * | `{ n: { $in: [2] } }` | c1, c2 | no group | c1, c2 | + * | `{ total: { $in: [500, 20] } }` | c1, c4 | no group | no group | + * | `{ mean: { $in: [250, 600] } }` | c1, c2 | no group | no group | + * | `{ n: { $lt: 'not-a-date' } }` | no group | c1–c4 | no group | + * | `{ total: { $lt: 'not-a-date' } }` | no group | c1–c4 | c1–c4 | + * + * The native path handed the SQL client's strings through (`"n": "2"`, + * `"total": "500.000000000000000000000000000000"`); `SqlDriver.aggregate` now + * presents them as numbers (`sql-driver-20335-aggregate-numeric-presentation.test.ts` + * pins the value on every dialect cell of `driver-sql`'s live matrix). + * + * ## The dialect axis of THIS file + * + * The SQLite cell always runs. The PostgreSQL and MySQL cells run where + * `OS_TEST_POSTGRES_URL` / `OS_TEST_MYSQL_URL` are set and are a named skip + * otherwise; no CI job provisions them for this package today (the live + * servers are attached to `driver-sql`'s suite, where the value pins live). + * Each live cell owns one table, dropped before and after. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { EngineAggregateOptions, FilterCondition } from '@objectstack/spec/data'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { RestServer } from './rest-server'; + +const OBJECT = 'rest_agg_20335'; + +const LEDGER = { + name: OBJECT, + label: 'Ledger 20335', + fields: { + customer_id: { name: 'customer_id', type: 'text' as const }, + amount: { name: 'amount', type: 'number' as const }, + note: { name: 'note', type: 'text' as const }, + }, +}; + +const ROWS = [ + { id: 'o1', customer_id: 'c1', amount: 100, note: 'a' }, + { id: 'o2', customer_id: 'c1', amount: 400, note: 'b' }, + { id: 'o3', customer_id: 'c2', amount: 900, note: 'c' }, + { id: 'o4', customer_id: 'c2', amount: 300, note: 'd' }, + { id: 'o5', customer_id: 'c3', amount: 50, note: 'e' }, + { id: 'o6', customer_id: 'c4', amount: 20, note: 'f' }, +]; + +interface Cell { + label: string; + env: string | null; + config: () => Record | null; +} + +const CELLS: readonly Cell[] = [ + { label: 'sqlite', env: null, config: () => ({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) }, + { + label: 'live postgres', + env: 'OS_TEST_POSTGRES_URL', + config: () => (process.env.OS_TEST_POSTGRES_URL ? { client: 'pg', connection: process.env.OS_TEST_POSTGRES_URL } : null), + }, + { + label: 'live mysql', + env: 'OS_TEST_MYSQL_URL', + config: () => (process.env.OS_TEST_MYSQL_URL ? { client: 'mysql2', connection: process.env.OS_TEST_MYSQL_URL } : null), + }, +]; + +function createMockServer() { + const noop = () => {}; + return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; +} + +function makeRes() { + const res: any = { + write: () => true, end: () => {}, + header: () => res, + status: (code: number) => { res._status = code; return res; }, + json: (body: any) => { res._json = body; return res; }, + }; + return res; +} + +type Path = 'native' | 'rows'; + +/** + * `native`: SqlDriver aggregates and the engine applies `having` to its + * answer. `rows`: a filtered aggregation sends the engine to the rows path, + * where it aggregates `find()` rows itself and then applies `having`. + */ +function grouped(path: Path, having?: Record): EngineAggregateOptions { + const aggregations: NonNullable = [ + { function: 'count', alias: 'n' }, + { function: 'count_distinct', field: 'note', alias: 'nd' }, + { function: 'sum', field: 'amount', alias: 'total' }, + { function: 'avg', field: 'amount', alias: 'mean' }, + ]; + if (path === 'rows') aggregations.push({ function: 'count', alias: 'fb', filter: { customer_id: { $ne: '' } } }); + return { groupBy: ['customer_id'], aggregations, ...(having ? { having: having as FilterCondition } : {}) }; +} + +const groupsOf = (rows: any[]) => rows.map((r) => r.customer_id).sort(); + +// having · groups kept, on every path, door and dialect +const KEPT: ReadonlyArray, string[]]> = [ + ['count $in', { n: { $in: [2] } }, ['c1', 'c2']], + ['count $eq', { n: { $eq: 2 } }, ['c1', 'c2']], + ['count_distinct $in', { nd: { $in: [2] } }, ['c1', 'c2']], + ['count_distinct $eq', { nd: { $eq: 1 } }, ['c3', 'c4']], + ['sum $in', { total: { $in: [500, 20] } }, ['c1', 'c4']], + ['sum $eq', { total: { $eq: 1200 } }, ['c2']], + ['avg $in', { mean: { $in: [250, 600] } }, ['c1', 'c2']], + ['avg $eq', { mean: { $eq: 50 } }, ['c3']], + ['a string $lt on count — no group', { n: { $lt: 'not-a-date' } }, []], + ['a string $lt on sum — no group', { total: { $lt: 'not-a-date' } }, []], + ['an extended-year ISO $gt on avg — no group', { mean: { $gt: '+010000-01-01T00:00:00.000Z' } }, []], +]; + +for (const cell of CELLS) { + const config = cell.config(); + describe.skipIf(!config)( + `[#20335] having on count / sum / avg — ${cell.label}${config ? '' : ` (skipped: set ${cell.env} to run this cell)`}`, + () => { + let engine: ObjectQL; + let driver: any; + let post: (body: Record) => Promise; + + beforeAll(async () => { + driver = new SqlDriver(config as never); + if (cell.env) await driver.execute(`drop table if exists ${OBJECT}`).catch(() => {}); + engine = new ObjectQL(); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(LEDGER as any); + await engine.syncSchemas(); + // One row per call: MySQL answers no RETURNING for a batch insert. + for (const row of ROWS) await engine.insert(OBJECT, row as any); + + const protocol = new ObjectStackProtocolImplementation(engine as any); + const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); + const route = rest.getRoutes().find((r: any) => r.method === 'POST' && r.path === '/api/v1/data/:object/query'); + expect(route).toBeDefined(); + post = async (body) => { + const res = makeRes(); + // What the wire carries: JSON, both ways. + await route!.handler({ params: { object: OBJECT }, body: JSON.parse(JSON.stringify(body)) } as any, res); + if (res._json !== undefined) res._json = JSON.parse(JSON.stringify(res._json)); + return res; + }; + }); + + afterAll(async () => { + if (cell.env) await driver?.execute(`drop table if exists ${OBJECT}`).catch(() => {}); + try { await engine?.destroy(); } catch { /* noop */ } + }); + + it('the response carries count / count_distinct / sum / avg as JSON numbers, equal on both paths', async () => { + const answers: Record = {}; + for (const path of ['native', 'rows'] as const) { + const res = await post(grouped(path) as Record); + expect(res._status ?? 200, JSON.stringify(res._json)).toBe(200); + const byGroup = Object.fromEntries( + res._json.records.map((r: any) => [r.customer_id, { n: r.n, nd: r.nd, total: r.total, mean: r.mean }]), + ); + answers[path] = byGroup; + expect(byGroup.c1, `REST, ${path}`).toStrictEqual({ n: 2, nd: 2, total: 500, mean: 250 }); + expect(byGroup.c2, `REST, ${path}`).toStrictEqual({ n: 2, nd: 2, total: 1200, mean: 600 }); + expect(byGroup.c3, `REST, ${path}`).toStrictEqual({ n: 1, nd: 1, total: 50, mean: 50 }); + expect(byGroup.c4, `REST, ${path}`).toStrictEqual({ n: 1, nd: 1, total: 20, mean: 20 }); + } + expect(answers.native).toStrictEqual(answers.rows); + }); + + for (const [name, having, kept] of KEPT) { + it(`${name}: keeps ${kept.join(', ') || 'no group'} — engine and REST, native and rows`, async () => { + for (const path of ['native', 'rows'] as const) { + expect(groupsOf(await engine.aggregate(OBJECT, grouped(path, having))), `engine, ${path}`).toEqual(kept); + const res = await post(grouped(path, having) as Record); + expect(res._status ?? 200, JSON.stringify(res._json)).toBe(200); + expect(groupsOf(res._json.records), `REST, ${path}`).toEqual(kept); + } + }); + } + }, + ); +} From 06349dc973109e223dcf6cf0f4065c9f455f1675 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:41:40 +0000 Subject: [PATCH 4/5] chore(changeset): driver-sql aggregate counts and totals are numbers, with the precision policy Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude --- .changeset/20335-pg-aggregate-numbers.md | 41 ++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .changeset/20335-pg-aggregate-numbers.md diff --git a/.changeset/20335-pg-aggregate-numbers.md b/.changeset/20335-pg-aggregate-numbers.md new file mode 100644 index 00000000000..9b159061e8c --- /dev/null +++ b/.changeset/20335-pg-aggregate-numbers.md @@ -0,0 +1,41 @@ +--- +'@objectstack/driver-sql': patch +--- + +fix(driver-sql): `count` / `count_distinct` / `sum` / `avg` answer JS numbers on PostgreSQL and MySQL, as they do on SQLite and on the engine's rows path + +Clause-②: no + +`SqlDriver.aggregate` handed the SQL client's answer straight through. node-postgres parses +`bigint` (`count`, and `sum` over an integer column) and `numeric` (`sum` / `avg` over the +numeric family's exact-decimal column, `avg` over an integer column) to strings, and mysql2 +does the same for `DECIMAL` (`SUM` / `AVG`). So one grouped query answered + + { "n": "2", "total": "500.000000000000000000000000000000" } + +on PostgreSQL's native path and `{ "n": 2, "total": 500 }` on SQLite and on the rows path of +every dialect. The engine's `having` compares values as they +arrive, so `having { n: { $in: [2] } }` kept no group on PostgreSQL alone, and +`having { total: { $in: [500, 20] } }` kept no group on PostgreSQL or MySQL, while a string +comparand such as `{ total: { $lt: 'not-a-date' } }` kept every group there and none anywhere +else. + +Those four functions now answer a JS number on every dialect, through `SqlDriver.aggregate`, +`engine.aggregate` and `POST /api/v1/data/:object/query`. The presentation is keyed on the +aggregate function the query asked for; it only rewrites a string, so SQLite's answers are +byte-identical to before. `min` / `max` are unchanged: they answer a value of the column and +keep that column's presentation (a declared numeric field was already a number). +Non-aggregate reads (`find()`, `distinct()`) are unchanged, and no connection-level type +parser is touched. + +**Precision policy.** The answer is one JS number (an IEEE-754 double) on every dialect. A +`sum` / `avg` whose exact value needs more than a double's 15 to 17 significant digits, or an +integer total at or above 2^53, is rounded to the nearest double. That is the same bound +`find()` already puts on a read of the same exact-decimal column, and the bound the rows path +has always had. A total that fits keeps its exact value (`500`, `30.75`, `0.375`). The answer +is never a string, including for large totals: an answer whose type depended on its size would +break the same `having` or chart for exactly those totals. + +A consumer that read these values through `Number(...)` gets the same number it computed +before. A consumer that compared them as strings, or checked `typeof value === 'string'`, +now receives a number. From f3b9e1390c0bcea6aa49033485960a41647a48db Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 03:14:30 +0000 Subject: [PATCH 5/5] docs(driver-sql): presentReadValue's numeric coercion runs on every dialect The docblock still said the numeric coercion is SQLite-only, which the numeric-representation change had already made false and the aggregate presentation makes false a second time. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude --- packages/drivers/driver-sql/src/sql-driver.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 8dc157d5351..48253517659 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -15199,7 +15199,9 @@ export class SqlDriver implements IDataDriver { * the audit-stamp fold run everywhere (`datetime` and `audit_timestamp` * since #13973, [ADR-0053 D-F1] — the former SQLite-only and the latter * absent before, which handed the two live dialects' `Date` through), the - * numeric coercion is SQLite-only, and the boolean coercion runs on SQLite + * numeric coercion runs everywhere (#16318 for a declared numeric column; + * [#20335] for `aggregate()`'s counts and totals, `AGGREGATE_ANSWER_KIND`), + * and the boolean coercion runs on SQLite * and MySQL (#11782 — the two dialects whose stored boolean is a number). * {@link readPresentationKind} does the dialect gating for the scalar kinds, * so by the time one arrives here the dialect is settled.