Skip to content

Commit 06349dc

Browse files
committed
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 <noreply@anthropic.com>
1 parent ecf0128 commit 06349dc

1 file changed

Lines changed: 41 additions & 0 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
'@objectstack/driver-sql': patch
3+
---
4+
5+
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
6+
7+
Clause-②: no
8+
9+
`SqlDriver.aggregate` handed the SQL client's answer straight through. node-postgres parses
10+
`bigint` (`count`, and `sum` over an integer column) and `numeric` (`sum` / `avg` over the
11+
numeric family's exact-decimal column, `avg` over an integer column) to strings, and mysql2
12+
does the same for `DECIMAL` (`SUM` / `AVG`). So one grouped query answered
13+
14+
{ "n": "2", "total": "500.000000000000000000000000000000" }
15+
16+
on PostgreSQL's native path and `{ "n": 2, "total": 500 }` on SQLite and on the rows path of
17+
every dialect. The engine's `having` compares values as they
18+
arrive, so `having { n: { $in: [2] } }` kept no group on PostgreSQL alone, and
19+
`having { total: { $in: [500, 20] } }` kept no group on PostgreSQL or MySQL, while a string
20+
comparand such as `{ total: { $lt: 'not-a-date' } }` kept every group there and none anywhere
21+
else.
22+
23+
Those four functions now answer a JS number on every dialect, through `SqlDriver.aggregate`,
24+
`engine.aggregate` and `POST /api/v1/data/:object/query`. The presentation is keyed on the
25+
aggregate function the query asked for; it only rewrites a string, so SQLite's answers are
26+
byte-identical to before. `min` / `max` are unchanged: they answer a value of the column and
27+
keep that column's presentation (a declared numeric field was already a number).
28+
Non-aggregate reads (`find()`, `distinct()`) are unchanged, and no connection-level type
29+
parser is touched.
30+
31+
**Precision policy.** The answer is one JS number (an IEEE-754 double) on every dialect. A
32+
`sum` / `avg` whose exact value needs more than a double's 15 to 17 significant digits, or an
33+
integer total at or above 2^53, is rounded to the nearest double. That is the same bound
34+
`find()` already puts on a read of the same exact-decimal column, and the bound the rows path
35+
has always had. A total that fits keeps its exact value (`500`, `30.75`, `0.375`). The answer
36+
is never a string, including for large totals: an answer whose type depended on its size would
37+
break the same `having` or chart for exactly those totals.
38+
39+
A consumer that read these values through `Number(...)` gets the same number it computed
40+
before. A consumer that compared them as strings, or checked `typeof value === 'string'`,
41+
now receives a number.

0 commit comments

Comments
 (0)