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. 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); +} diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index a6acc3cd35a..48253517659 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 @@ -15128,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. @@ -15196,7 +15269,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 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); + } + }); + } + }, + ); +}