Skip to content

Commit fc0db22

Browse files
fix(driver-sql): sum / avg accumulate in double on PostgreSQL and MySQL, as on SQLite and the rows path (#20486)
Fixes #20387 Clause-②: no ## What changed `SqlDriver.aggregate` (`packages/drivers/driver-sql/src/sql-driver.ts`) now makes PostgreSQL and MySQL accumulate `sum` / `avg` in double. That is the arithmetic SQLite and the engine's rows path (`objectql`'s `in-memory-aggregation.ts`) already use. This is route (a) of triage 5865067775, under the one-double policy that PR #20372's changeset stated for the answer's type. - `AGGREGATE_ACCUMULATION` is a `Record` over the spec's `AggregationFunction`, a sibling of `AGGREGATE_ANSWER_KIND`. Its entries are `avg: 'double'`, `sum: 'double-over-fractional'`, and `count` / `count_distinct` / `min` / `max`: `'as-stored'`. A function added to the enum without an entry fails `tsc`. - `fractionalNumericFields` is a registry filled beside `numericFields` in both fills and aliased for shards. It holds the declared columns whose type stores fractions: `numericColumnFor(type).kind === 'exact'` (`number`, `currency`, `percent`, `slider`, `progress`, `summary`) and the driver's `float` alias. - In `aggregate()`, on PostgreSQL and MySQL only, two cases take a double operand: `avg` over a declared numeric or boolean column, and `sum` over a fractional column. The operand is `cast(cast(x as text) as double precision)` on PostgreSQL and `cast(cast(x as char) as double)` on MySQL. The column stays one `??` binding. - A `sum` over an integer-valued column (`rating`, the `integer` / `int` aliases, a boolean) keeps the database's exact total. A column with no numeric or boolean declaration keeps the database's own arithmetic. SQLite is untouched. **Why the operand is the column's text, not a plain cast.** The text is what the SQL client hands `find()`, so it is what the rows path adds. - On the exact-decimal columns the two are identical. Both servers turn a decimal into a double through its text. Measured on 20,000 random `numeric(65,30)` / `DECIMAL(65,30)` values per server: 0 differ. - On a binary32 column they are not identical. A table created before the exact-decimal columns (landed in `9cdffbe36`) still has `real` (PostgreSQL) / `FLOAT(8,2)` (MySQL) under a `number` declaration. There a plain cast adds the widened binary value, `0.30000000447034836` for `0.1 + 0.2`, where `find()` reads `0.1` and `0.2`. - Ablation 2 below pins this difference. ## Route picked by measurement: (a) **H1: reproduced at base `75b216924`.** Measured through `engine.aggregate` and `POST /api/v1/data/:object/query` on SQLite, a private live PostgreSQL 16.13 (`Asia/Shanghai`) and a private live MySQL 8.0.46 (`+08:00`). The rows path was forced with a filtered sibling aggregation. The two doors agree cell for cell. | `having` (object with `number` w, `rating` st, `boolean` flag) | SQLite native | SQLite rows | PG native | PG rows | MySQL native | MySQL rows | |:--|:--|:--|:--|:--|:--|:--| | `s $eq 0.3`, `s $in [0.3]` (sum w of 0.1, 0.2) | none | none | **kept** | none | **kept** | none | | `a $eq 0.15`, `a $in [0.15]` (avg w) | none | none | **kept** | none | **kept** | none | | `s $eq 0.1 + 0.2` | kept | kept | **none** | kept | **none** | kept | | `sa $eq 5 / 3` (avg st of 1, 2, 2) | kept | kept | kept | kept | **none** (`1.6667`) | kept | | `fa $eq 1 / 3` (avg flag of 1, 0, 0) | kept | kept | kept | kept | **none** (`0.3333`) | kept | Base values: PG and MySQL native `sum` = `0.3`, `avg` = `0.15`; every other face `0.30000000000000004` / `0.15000000000000002`. **At head** (driver-sql source identical from `07f81ea19` to `4caf9e6ef`), same doors: - `s $eq 0.3`, `s $in [0.3]`, `a $eq 0.15` and `a $in [0.15]` keep no group on any face. - `s $eq 0.1 + 0.2` and `a $eq (0.1 + 0.2) / 2` keep the group on all six. - `sa $eq 5 / 3` and `fa $eq 1 / 3` keep it on all six. - PG and MySQL native answer `0.30000000000000004` / `0.15000000000000002`. **H2: confirmed, with the order question answered.** - `sum(float8)` on PostgreSQL and `SUM(DOUBLE)` on MySQL add in scan order, one value after another, as the rows path's `reduce` does. Raw SQL over 0.1, 0.2, 0.3 answered `0.6000000000000001`, and `avg` answered `0.20000000000000004`, on both. The JS left fold gives the same. - SQLite 3.53.4, as bundled by better-sqlite3, answered `0.6` / `0.19999999999999998`. It uses compensated (Kahan-Babuska-Neumaier) summation, which SQLite has done since 3.43. - With two addends, no order and no compensation scheme can move the last place. The pin `0.1 + 0.2` is therefore deterministic across all four faces. For three or more fractions it is not deterministic on SQLite's native face: see the residual below. - `count` / `min` / `max` are untouched (pinned). - An integer column's `sum` stays exact (pinned). A `bigint` column holding 2^53 + 1 and 1 answers `9007199254740994` on all three cells, where adding doubles would give `9007199254740992`. **H3: (b) does not hold, on SQLite, for the pin itself.** SQLite's native `sum` over a REAL column adds the stored doubles, `0.30000000000000004`. An exact rows path (`0.3`) would disagree with it for exactly `0.1 + 0.2`, and changing SQLite's native face is outside route (b). The scale half of H3 also holds: in `examples/` at `4caf9e6ef`, 2 of the 70 fractional-family declarations carry a `scale` (app-todo `estimated_hours` and `actual_hours`). ### In-place fix, declared: `avg` over an integer-valued column Triage's route (a) names "a non-integer column". `avg` over an integer column is the same defect class: a native decimal quotient against the rows path's double. It sits in the same function and is closed by the same operand. No other claim holds the file, and it runs in the same gate family. So `avg` is `'double'` whatever the column holds. Evidence: - MySQL rounds a `DECIMAL` average to `div_precision_increment` (4) places: `1.6667` and `0.3333` above. - PostgreSQL's `numeric` average rounds to 16 places before the presenter rounds again. Over sums 1 to 3000 and counts 3 to 13 (27,962 non-integral pairs), 10 differ from JS division. Example: `11 / 9` answered `1.2222222222222222`, JS `1.2222222222222223`. - Pinned by the `nine` and `three` groups of the new test. ### Residual, stated and not fixed: SQLite's compensated sum For three or more fractional addends, SQLite's native face can still differ from every other face in the last place. `0.1 + 0.2 + 0.3` answers `0.6` on SQLite native and `0.6000000000000001` everywhere else, so `having { s: { $eq: 0.6 } }` keeps the group on SQLite native only, at head. - This was already true between SQLite's own two paths at base. - Neither route reaches it. SQLite's `sum` cannot be made to add naively in SQL, and PostgreSQL / MySQL cannot add with compensation in SQL. - It is reported to the seat as a separate finding. ## Tests - New: `packages/drivers/driver-sql/src/sql-driver-20387-aggregate-double-accumulation.test.ts`. It has 5 cases for each cell of the live matrix (`declareDialectCell`), so the PostgreSQL and MySQL cells run in `Temporal Conformance (live PG + MySQL)`, which runs the whole driver-sql suite with both live URLs and `OS_EXPECT_LIVE_DIALECT_MATRIX=1`: - **The pin.** `sum` / `avg` over `number` and `currency` columns holding 0.1 and 0.2 equal the rows path's arithmetic over `find()`'s own rows, and the literals `0.30000000000000004` / `0.15000000000000002`. So `having` `$eq 0.3` / `$in [0.3]` / `$eq 0.15` keep no group and `$eq 0.1 + 0.2` keeps it: `having-filter.ts` compares the value through `@objectstack/formula`'s `looseEq`, which is strict `===` for numbers (seat edit after review 5875498653). - **Binary32.** A `number` column retyped to `real` / `FLOAT(8,2)` adds what `find()` reads. - **Integer average.** `avg` over `rating` and `boolean` columns is the double quotient (5 / 3, 11 / 9, 1 / 3). - **Integer sum.** `sum` over an integer column keeps the exact total (the 2^53 + 2 case). - **Unchanged functions.** `count` / `min` / `max` are unchanged. - Result: 15 passed (5 cases × SQLite, live PostgreSQL, live MySQL), every cell exercised (verbose reporter). - **Ablations.** The fix was committed first, and each mutation went through `scripts/ablation-replace.mjs` in wrap mode. The test imports `./sql-driver.js` (source), so no `dist/` leg exists. Each run was the new file on all three cells: 1. The operand was disabled (`false &&`). Anchor 1 → 0, blob `63e5dac455` → `5e33daf1c6`. **6 failed | 9 passed:** the PG and MySQL pin (`expected 0.3 to be 0.30000000000000004`), binary32 (`0.3`) and integer average (PG `nine avg(rating): expected 1.222222222222222…`, MySQL `three avg(rating): expected 1.6667`). Every SQLite case, the integer sum and count/min/max stayed green. 2. A plain cast replaced the text operand. Blob → `4929128349`. **2 failed | 13 passed:** the binary32 case on PG and MySQL only (`expected 0.30000000447034836 to be 0.30000000000000004`). The pin stayed green, so the plain cast equals the text cast on exact decimals. 3. `sum: 'double'` dropped the integer exception. Blob → `958360a033`. **2 failed | 13 passed:** the integer sum on PG and MySQL (`expected 9007199254740992 to be 9007199254740994`). - All three failed in the predicted direction, on the predicted cells. Each restore reported blob == HEAD (`63e5dac455`) and an empty `git diff HEAD`, and `git status --porcelain` was empty afterwards. - **Whole `@objectstack/driver-sql` suite** at `4caf9e6ef`, with live PostgreSQL and MySQL, `TZ=America/New_York` and `OS_EXPECT_LIVE_DIALECT_MATRIX=1`: 207 files passed, 4720 passed | 1 skipped. The reporter confirmed that all 3 dialects were exercised; the 1 skip is for a reason other than a missing backend. - `packages/rest/src/rest-aggregate-numeric-having.test.ts`, the pins from PR #20372, ran with both live URLs against this driver: 36 passed (12 cases × 3 cells). - **Typecheck** at `4caf9e6ef`: `@objectstack/driver-sql`, and its two subclasses `@objectstack/driver-turso` and `@objectstack/driver-sqlite-wasm`, all exit 0. The new test file is in driver-sql's `tsc` program (`--listFiles`). ## Gates Measured at head `4caf9e6ef`. That head is a true merge of `origin/main` (`8e0285918`), after a full workspace build (`turbo run build`, 72 of 72 tasks). - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 63 commands. All 63 ran, each exit code captured before any pipe, and all 63 exited 0. `--ran` reconciliation: 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN. - `origin/main` moved during the run, so the three `--base` gates were also run with `--base 8e02859`, each exiting 0. `check-changeset-no-major` has no PR payload locally; its level axis reads the PR in CI. - `check:driver-conformance` before and after: 50 covered, 0 DEBT, 0 exempt; dialect axis 8 suites, 0 in the DIALECT ledger. The ledger did not move. - **Lint, a proved narrowing.** `eslint --no-inline-config --format json` over the two touched TypeScript files: 2 files, 0 errors, 0 warnings. `eslint --print-config` resolves both files with `parserOptions` `ecmaVersion` / `sourceType` only, with no `project` and no type-aware rules. So this diff cannot move the verdict on an untouched file. The repo-wide `pnpm lint` is CI's. - NOT MEASURED locally, owned by CI: - the workspace type-check lanes, the `Test Core` shards, `Dogfood`, `Build Core`, and `Temporal Conformance` as CI spells it; - the five CI-variable families dispatch-gates names: `check-issue-citations --census`, three `check-shard-attestation --emit`, and `check-test-completeness`. ## Acceptance notes - **Legacy binary32 columns change answer.** On a table created before the exact-decimal columns, the native `sum` / `avg` over a `real` / `FLOAT` column used to be computed as follows. PostgreSQL's `sum(real)` accumulated in float4 (0.1, 0.2, 1234567.9 answered `1.2345681e+06`). MySQL's `SUM(FLOAT(8,2))` answered a double shown to 2 decimals (`123457.20` for 0.1, 0.2, 123456.9). Both now answer the rows path's double, the sum of what `find()` reads: `1234568.2` on the PostgreSQL example, where a plain cast would have answered `1234568.1750000045`. - **MySQL floor.** `CAST(… AS DOUBLE)` needs MySQL 8.0.17 or later. CI's `mysql:8.0` image is newer than that. An older server refuses `sum` / `avg` over a declared numeric column with a syntax error, which is loud, not a wrong number. - **Analytics face, read and not measured.** service-analytics' `NativeSQLStrategy` (`AGGREGATE_SQL` in `native-sql-strategy.ts`) emits a raw `SUM(col)` / `AVG(col)`. `AnalyticsServicePlugin` auto-bridges it when the data engine exposes `execute()`. By reading, a cube measure on PostgreSQL / MySQL still adds exact decimals there. No door was measured, so there is no `reach:`. Carrier: none. - **Where the `having` pin lives.** The `having` pin at the engine and REST doors across dialects is this PR's local measurement (above). In CI the value pins sit in driver-sql's matrix, and those values decide `having`. The REST test's PostgreSQL / MySQL cells are still provisioned by no CI job, as PR #20372 recorded. --- _Generated by [Claude Code](https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3062e50 commit fc0db22

3 files changed

Lines changed: 401 additions & 1 deletion

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
'@objectstack/driver-sql': patch
3+
---
4+
5+
fix(driver-sql): `sum` / `avg` accumulate in double on PostgreSQL and MySQL, as they do on SQLite and on the engine's rows path
6+
7+
Clause-②: no
8+
9+
`SqlDriver.aggregate` let PostgreSQL (`numeric`) and MySQL (`DECIMAL`) add exact decimals,
10+
while SQLite and the engine's rows path add JS doubles. So a `number` column holding `0.1`
11+
and `0.2` summed to `0.3` on the PostgreSQL and MySQL native paths and to
12+
`0.30000000000000004` everywhere else, and `having { s: { $eq: 0.3 } }` kept the group on
13+
those two faces only. `avg` over an integer column diverged too: MySQL rounds a decimal
14+
average to 4 places (`avg` of 1, 2, 2 answered `1.6667`), and PostgreSQL's `numeric`
15+
average rounds to 16 places before the answer becomes a double (`11 / 9` answered
16+
`1.2222222222222222`, where every other face answers `1.2222222222222223`).
17+
18+
**The precision policy, applied to the arithmetic.** The policy already stated for the
19+
answer's type (one JS double on every dialect, the loss beyond a double's precision declared)
20+
now also decides how the answer is computed:
21+
22+
- `avg` accumulates in double on PostgreSQL and MySQL, over every declared numeric or
23+
boolean column.
24+
- `sum` accumulates in double over a column that holds fractions: `number`, `currency`,
25+
`percent`, `slider`, `progress`, `summary`, and the driver's `float` alias.
26+
- `sum` over an integer-valued column (`rating`, the `integer` / `int` aliases, a boolean)
27+
keeps the database's exact integer total, rounded once to the double.
28+
- `count`, `count_distinct`, `min` and `max` are unchanged. SQLite is unchanged.
29+
30+
Each value added is the column's text parsed as a double: the value the SQL client hands
31+
`find()`, and so the value the rows path adds. For the exact-decimal columns this equals a
32+
plain cast. For a binary `real` / `FLOAT` column, which a table created before the
33+
exact-decimal columns still has, a plain cast would add the widened binary value
34+
(`0.30000000447034836` for `0.1 + 0.2`). MySQL's `CAST(… AS DOUBLE)` needs MySQL 8.0.17 or
35+
later.
36+
37+
Route chosen: (a), accumulate in double on the native faces. The other route, (b), was to make
38+
the rows path add exact decimals and round once. It was rejected because SQLite's native `sum`
39+
adds the stored doubles (`0.30000000000000004`), so the rows path would then disagree with SQLite
40+
for exactly `0.1 + 0.2`.
41+
42+
**Residual, stated.** Double sums are added in row order, one after another, as the rows path
43+
adds them. SQLite 3.43 and later adds with compensated summation. So for a group of three or
44+
more fractions, SQLite's native answer can still differ from every other face in the last
45+
place (`0.1 + 0.2 + 0.3`: SQLite `0.6`, every other face `0.6000000000000001`). This was
46+
already true of SQLite's two paths before this change. Two addends cannot differ.
47+
48+
A consumer that compared `sum` / `avg` over a fractional column with a decimal literal on
49+
PostgreSQL or MySQL (`$eq: 0.3`) now gets the answer SQLite and the rows path already gave:
50+
compare with a range, or with the double the arithmetic produces.
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20387] `sum` / `avg` from `SqlDriver.aggregate` accumulate in double on
5+
* every dialect: the arithmetic the engine's rows path (`objectql`'s
6+
* `in-memory-aggregation.ts`) and SQLite already use, under the one-double
7+
* policy `AGGREGATE_ANSWER_KIND` states (`sql-driver.ts`,
8+
* `AGGREGATE_ACCUMULATION`).
9+
*
10+
* Measured on the base (`75b216924`) through `engine.aggregate` and
11+
* `POST /api/v1/data/:object/query`, a `number` column holding `0.1` and `0.2`:
12+
*
13+
* | face | `sum` | `avg` | `having { s: { $eq: 0.3 } }` |
14+
* |:--|:--|:--|:--|
15+
* | PostgreSQL 16.13 native (`numeric`) | `0.3` | `0.15` | keeps the group |
16+
* | MySQL 8.0.46 native (`DECIMAL`) | `0.3` | `0.15` | keeps the group |
17+
* | SQLite native (REAL) | `0.30000000000000004` | `0.15000000000000002` | keeps no group |
18+
* | the rows path, every dialect | `0.30000000000000004` | `0.15000000000000002` | keeps no group |
19+
*
20+
* `avg` over an integer-valued column diverged as well: MySQL answered
21+
* `1.6667` for a `rating` of 1, 2, 2 (every other face `5 / 3`), and
22+
* PostgreSQL's `numeric` average of nine ratings summing to 11 answered
23+
* `1.2222222222222222` (JS `11 / 9` is `1.2222222222222223`).
24+
*
25+
* `having` is evaluated by the engine, over the value this door answers
26+
* (`having-filter.ts`: `$eq` compares the value with `==`), so the value pinned
27+
* here is what decides `having` `$eq` / `$in` on every face. The expected
28+
* values are computed from `find()`'s own rows with the rows path's arithmetic
29+
* ({@link rowsPathSum}), plus the literal the triage named, so a decimal
30+
* answer, a compensated sum or a string each fail.
31+
*
32+
* What stays as it was, pinned too: `count` / `min` / `max`, and a `sum` over
33+
* an integer-valued column, which keeps the database's exact total (above 2^53
34+
* that is not what adding doubles gives, and the policy declares that bound).
35+
*/
36+
37+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
38+
import type { DriverQuery } from '@objectstack/spec/contracts';
39+
import { SqlDriver } from './sql-driver.js';
40+
import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dialect-matrix.testkit.js';
41+
42+
const TABLE = 'os20387_agg_double';
43+
44+
/** The rows path's `toNumber` (`in-memory-aggregation.ts`): a number as-is, null as 0, else `Number()`. */
45+
function toNumber(v: unknown): number {
46+
if (typeof v === 'number') return v;
47+
if (v == null) return 0;
48+
const n = Number(v);
49+
return Number.isFinite(n) ? n : 0;
50+
}
51+
52+
/** The rows path's `sum`: `values.reduce((a, b) => a + toNumber(b), 0)`, in row order. */
53+
const rowsPathSum = (values: readonly unknown[]) => values.reduce<number>((a, b) => a + toNumber(b), 0);
54+
55+
/** The rows path's `avg`: the non-null values' `sum`, divided by how many there are. */
56+
function rowsPathAvg(values: readonly unknown[]): number | null {
57+
const defined = values.filter((v) => v != null);
58+
return defined.length === 0 ? null : rowsPathSum(defined) / defined.length;
59+
}
60+
61+
interface Row {
62+
id: string;
63+
g: string;
64+
w?: number;
65+
price?: number;
66+
legacy?: number;
67+
stars?: number;
68+
flag?: boolean;
69+
}
70+
71+
const ROWS: readonly Row[] = [
72+
// The triage's pin: `0.1 + 0.2`. Two addends, so no summation order or
73+
// compensation scheme can move the last place.
74+
{ id: 'p1', g: 'pin', w: 0.1, price: 0.1, legacy: 0.1, stars: 1, flag: true },
75+
{ id: 'p2', g: 'pin', w: 0.2, price: 0.2, legacy: 0.2, stars: 2, flag: false },
76+
// `avg` over integer-valued columns: 5 / 3 (MySQL's `DECIMAL` average rounds
77+
// it to 1.6667) and 1 / 3.
78+
{ id: 't1', g: 'three', stars: 1, flag: true },
79+
{ id: 't2', g: 'three', stars: 2, flag: false },
80+
{ id: 't3', g: 'three', stars: 2, flag: false },
81+
// 11 / 9: PostgreSQL's `numeric` average rounds it to 16 places first.
82+
...[1, 1, 1, 1, 1, 1, 1, 2, 2].map((stars, i) => ({ id: `n${i}`, g: 'nine', stars })),
83+
];
84+
85+
type Aggregation = NonNullable<DriverQuery['aggregations']>[number];
86+
const agg = (fn: Aggregation['function'], field: string, alias: string): Aggregation => ({ function: fn, field, alias });
87+
88+
function declareCell(cell: DialectCell): void {
89+
describe(`[#20387] driver-sql — sum / avg accumulate in double (${cell.label})`, () => {
90+
let driver: SqlDriver;
91+
92+
async function answer(g: string, aggregations: Aggregation[]) {
93+
const query: DriverQuery = { where: { g }, groupBy: ['g'], aggregations };
94+
const rows = (await driver.aggregate(TABLE, query)) as Array<Record<string, unknown>>;
95+
expect(rows, `one group for ${g}`).toHaveLength(1);
96+
return rows[0];
97+
}
98+
99+
async function column(g: string, field: string): Promise<unknown[]> {
100+
const rows = (await driver.find(TABLE, { where: { g }, orderBy: [{ field: 'id', order: 'asc' }] })) as Array<
101+
Record<string, unknown>
102+
>;
103+
return rows.map((r) => r[field]);
104+
}
105+
106+
beforeAll(async () => {
107+
driver = new SqlDriver(cell.config());
108+
await driver.execute(`drop table if exists ${TABLE}`).catch(() => {});
109+
await driver.initObjects([
110+
{
111+
name: TABLE,
112+
fields: {
113+
g: { type: 'text' },
114+
w: { type: 'number' },
115+
price: { type: 'currency' },
116+
// A `number` column as a table created before the exact-decimal
117+
// columns carries it: binary32 `real` / `FLOAT` (retyped below).
118+
legacy: { type: 'number' },
119+
stars: { type: 'rating' },
120+
flag: { type: 'boolean' },
121+
// The driver's `integer` alias — how an introspected `bigint`
122+
// reaches it (retyped to `bigint` below on the servers).
123+
big: { type: 'integer' },
124+
},
125+
},
126+
] as never);
127+
if (cell.id === 'pg') {
128+
await driver.execute(`alter table ${TABLE} alter column legacy type real`);
129+
await driver.execute(`alter table ${TABLE} alter column big type bigint`);
130+
} else if (cell.id === 'mysql') {
131+
await driver.execute(`alter table ${TABLE} modify legacy float(8,2)`);
132+
await driver.execute(`alter table ${TABLE} modify big bigint`);
133+
}
134+
for (const row of ROWS) await driver.create(TABLE, { ...row }, { bypassTenantAudit: true });
135+
// Written by SQL: a JS number cannot carry 2^53 + 1 to the column.
136+
await driver.execute(`insert into ${TABLE} (id, g, big) values ('b1', 'big', 9007199254740993)`);
137+
await driver.execute(`insert into ${TABLE} (id, g, big) values ('b2', 'big', 1)`);
138+
});
139+
140+
afterAll(async () => {
141+
await driver?.execute(`drop table if exists ${TABLE}`).catch(() => {});
142+
await driver?.disconnect();
143+
});
144+
145+
it('the pin: sum / avg over 0.1 and 0.2 answer 0.1 + 0.2 and its half, as the rows path adds them', async () => {
146+
const a = await answer('pin', [
147+
agg('sum', 'w', 's'),
148+
agg('avg', 'w', 'a'),
149+
agg('sum', 'price', 'sp'),
150+
agg('avg', 'price', 'ap'),
151+
]);
152+
const w = await column('pin', 'w');
153+
expect(w, 'find() reads the numbers written').toStrictEqual([0.1, 0.2]);
154+
expect(a.s, 'sum(number)').toBe(rowsPathSum(w));
155+
expect(a.a, 'avg(number)').toBe(rowsPathAvg(w));
156+
expect(a.sp, 'sum(currency)').toBe(rowsPathSum(await column('pin', 'price')));
157+
expect(a.ap, 'avg(currency)').toBe(rowsPathAvg(await column('pin', 'price')));
158+
// The literal the triage named, so the oracle above cannot drift with it.
159+
expect(a.s).toBe(0.30000000000000004);
160+
expect(a.a).toBe(0.15000000000000002);
161+
// `having { s: { $eq: 0.3 } }` / `{ $in: [0.3] }` therefore keep no group
162+
// here, as on SQLite and the rows path; `0.1 + 0.2` keeps it everywhere.
163+
expect(a.s == 0.3, 'having s $eq 0.3').toBe(false);
164+
expect([0.3].some((t) => a.s == t), 'having s $in [0.3]').toBe(false);
165+
expect(a.a == 0.15, 'having a $eq 0.15').toBe(false);
166+
expect(a.s == 0.1 + 0.2, 'having s $eq 0.1 + 0.2').toBe(true);
167+
});
168+
169+
it('a binary32 column is added as the client reads it, not as its widened binary value', async () => {
170+
// `find()` reads `0.1`; a plain cast to double would add
171+
// 0.10000000149011612 (sum 0.30000000447034836) on PostgreSQL `real`
172+
// and MySQL `FLOAT`. The text of the column is what the rows path adds.
173+
const a = await answer('pin', [agg('sum', 'legacy', 's'), agg('avg', 'legacy', 'a')]);
174+
const legacy = await column('pin', 'legacy');
175+
expect(legacy, 'find() reads the numbers written').toStrictEqual([0.1, 0.2]);
176+
expect(a.s, 'sum(legacy)').toBe(rowsPathSum(legacy));
177+
expect(a.a, 'avg(legacy)').toBe(rowsPathAvg(legacy));
178+
});
179+
180+
it('avg over an integer-valued column is the double quotient, as the rows path divides', async () => {
181+
for (const g of ['three', 'nine']) {
182+
const a = await answer(g, [agg('avg', 'stars', 'as'), agg('avg', 'flag', 'af')]);
183+
expect(a.as, `${g} avg(rating)`).toBe(rowsPathAvg(await column(g, 'stars')));
184+
const flags = (await column(g, 'flag')).map((f) => (f == null ? null : Number(f)));
185+
expect(a.af, `${g} avg(boolean)`).toBe(rowsPathAvg(flags));
186+
}
187+
expect((await answer('three', [agg('avg', 'stars', 'as')])).as).toBe(5 / 3);
188+
expect((await answer('nine', [agg('avg', 'stars', 'as')])).as).toBe(11 / 9);
189+
});
190+
191+
it('sum over an integer-valued column keeps the exact total, rounded once', async () => {
192+
const a = await answer('big', [agg('sum', 'big', 's')]);
193+
// 2^53 + 1 + 1 = 2^53 + 2, a double. Adding the doubles would answer
194+
// 2^53: the bound the one-double policy declares, not taken here.
195+
expect(a.s).toBe(9007199254740994);
196+
const three = await answer('three', [agg('sum', 'stars', 's')]);
197+
expect(three.s, 'sum(rating)').toBe(5);
198+
});
199+
200+
it('count, min and max are untouched', async () => {
201+
const a = await answer('pin', [
202+
{ function: 'count', alias: 'n' },
203+
agg('count', 'w', 'nw'),
204+
agg('min', 'w', 'mn'),
205+
agg('max', 'w', 'mx'),
206+
agg('min', 'stars', 'smn'),
207+
agg('max', 'stars', 'smx'),
208+
]);
209+
expect(a).toMatchObject({ n: 2, nw: 2, mn: 0.1, mx: 0.2, smn: 1, smx: 2 });
210+
});
211+
});
212+
}
213+
214+
for (const cell of DIALECT_CELLS) {
215+
declareDialectCell(cell, 'aggregate double accumulation (#20387)', declareCell);
216+
}

0 commit comments

Comments
 (0)