|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#20889] What an aggregate ANSWERS, and the `'number'` presenter that gives a |
| 5 | + * count or a total its type — defined once, for every face that hands a SQL |
| 6 | + * client's aggregate row to a caller. |
| 7 | + * |
| 8 | + * ## Why it lives here |
| 9 | + * |
| 10 | + * Two faces run an aggregate statement on a SQL client and must present its |
| 11 | + * answer the same way: |
| 12 | + * |
| 13 | + * - `@objectstack/driver-sql`'s native `aggregate()` (#20335), where this rule |
| 14 | + * was written: `aggregate()` reads {@link AGGREGATE_ANSWER_KIND} to choose |
| 15 | + * the result columns that take the `'number'` presentation, and |
| 16 | + * `presentReadValue`'s `'number'` arm is {@link presentAsNumber}; |
| 17 | + * - `@objectstack/service-analytics`' native-SQL face |
| 18 | + * (`NativeSQLStrategy.execute`), which runs its own statement through the |
| 19 | + * host's raw-SQL bridge — `engine.execute`, or an embedder's own |
| 20 | + * `executeRawSql` — and so never passes through the driver's `aggregate()`. |
| 21 | + * On PostgreSQL it answered `count: "2"` under a `fields[]` that declared |
| 22 | + * `number`. |
| 23 | + * |
| 24 | + * `service-analytics` holds `driver-sql` as a development dependency only — it |
| 25 | + * is multi-driver, and its native path also runs over an embedder's raw SQL — |
| 26 | + * and this is the package both already stand on, the reason `compensatedSum` |
| 27 | + * lives here too. A second transcription is how a face comes to answer its own |
| 28 | + * type again. |
| 29 | + * |
| 30 | + * The table and its docblock below moved here from `driver-sql` unchanged, so |
| 31 | + * they speak of `SqlDriver.aggregate` and `formatOutput` in that driver's |
| 32 | + * voice. |
| 33 | + */ |
| 34 | + |
| 35 | +import type { AggregationFunction } from '@objectstack/spec/data'; |
| 36 | + |
| 37 | +/** |
| 38 | + * [#20335] What each declared aggregate function ANSWERS — a derived `number`, |
| 39 | + * or a value OF the aggregated column — and therefore which read presentation |
| 40 | + * {@link SqlDriver.aggregate} gives its result column. |
| 41 | + * |
| 42 | + * - `'number'` — `count`, `count_distinct`, `sum`, `avg`. A count or a total is |
| 43 | + * a number whatever the column held, and it is presented as one (`'number'`, |
| 44 | + * the presenter `formatOutput` applies to a numeric field on a `find()` row). |
| 45 | + * - `'column'` — `min`, `max`. The answer is one of the column's own values, so |
| 46 | + * it takes that column's presentation ({@link SqlDriver.readPresentationKind}), |
| 47 | + * exactly as before this table existed. |
| 48 | + * |
| 49 | + * Why the `'number'` half needs presenting at all: the SQL client hands a |
| 50 | + * result back as the wire type of the SQL expression, not as the platform's |
| 51 | + * value type. Measured on live PostgreSQL 16.13 and MySQL 8.0.46 through this |
| 52 | + * driver's own connections: node-postgres parses `bigint` (OID 20 — `count`, |
| 53 | + * and `sum` over an integer column) and `numeric` (OID 1700 — `sum` / `avg` |
| 54 | + * over the exact-decimal numeric family, `avg` over an integer column) to |
| 55 | + * STRINGS (`"2"`, `"500.000000000000000000000000000000"`), and mysql2 does the |
| 56 | + * same for `DECIMAL` (`SUM` / `AVG`; its `COUNT` arrives as a number). The |
| 57 | + * engine's rows path (`objectql`'s `in-memory-aggregation.ts`) and SQLite answer |
| 58 | + * numbers for the same query, so `having { n: { $in: [2] } }` kept c1, c2 on |
| 59 | + * those and no group on PostgreSQL's native path. |
| 60 | + * |
| 61 | + * Keyed on the function the query ASKED for, never on whether a value looks |
| 62 | + * numeric, and deliberately not gated by dialect: the presenter only rewrites a |
| 63 | + * STRING, so a client that already answers a number (better-sqlite3, mysql2's |
| 64 | + * `COUNT`) passes through untouched — measured byte-identical on SQLite — and |
| 65 | + * no list of "string-answering dialects" exists to drift. |
| 66 | + * |
| 67 | + * ## The precision policy — one JS number, the loss declared |
| 68 | + * |
| 69 | + * The answer is `Number(text)`: an IEEE-754 double, on every dialect. A `sum` / |
| 70 | + * `avg` over the exact-decimal column (`numeric(65,30)` / `DECIMAL(65,30)`) |
| 71 | + * whose value needs more than a double's ~15-17 significant digits, or an |
| 72 | + * integer at or above 2^53, is ROUNDED to the nearest double — declared, not |
| 73 | + * silent: it is the same bound `formatOutput` already puts on a `find()` read of |
| 74 | + * that column (#16318, `valueSchemaFor`'s `z.number().finite()`, ADR-0104 D1), |
| 75 | + * and the bound the rows path has always had (`toNumber` sums JS doubles). A |
| 76 | + * value-dependent type — a number when it fits, a string when it does not — was |
| 77 | + * rejected: it would reopen this defect for exactly the large totals, where a |
| 78 | + * `having` `$in` or a chart silently stops matching. Only a string `Number()` |
| 79 | + * reads as NaN (PostgreSQL's `numeric` `'NaN'`) is left as written, the |
| 80 | + * presenter's existing rule. |
| 81 | + * |
| 82 | + * A `Record` over `AggregationFunction` on purpose: a function that joins the |
| 83 | + * declared vocabulary without an answer here fails `tsc` rather than reaching a |
| 84 | + * caller unpresented. |
| 85 | + */ |
| 86 | +export const AGGREGATE_ANSWER_KIND: Readonly<Record<AggregationFunction, 'number' | 'column'>> = { |
| 87 | + count: 'number', |
| 88 | + count_distinct: 'number', |
| 89 | + sum: 'number', |
| 90 | + avg: 'number', |
| 91 | + min: 'column', |
| 92 | + max: 'column', |
| 93 | +}; |
| 94 | + |
| 95 | +/** |
| 96 | + * [#20335, #20889] The `'number'` presentation: a STRING `Number()` reads as a |
| 97 | + * number becomes that number; every other value — a number, `null`, a boolean, |
| 98 | + * empty or blank text, text `Number()` reads as NaN (PostgreSQL's `numeric` |
| 99 | + * `'NaN'`) — is returned as given. |
| 100 | + * |
| 101 | + * It is the body of `driver-sql`'s `presentReadValue` `'number'` arm, moved |
| 102 | + * here unchanged: the presenter `formatOutput` applies to a declared numeric |
| 103 | + * column on a `find()` row (#16318), and the one {@link AGGREGATE_ANSWER_KIND} |
| 104 | + * names for a count or a total. The answer is one JS double — the precision |
| 105 | + * policy that table's docblock states. |
| 106 | + * |
| 107 | + * ⛔ Ask it by a column's DECLARED meaning — the aggregate function the query |
| 108 | + * asked for, or a column declared numeric — never because a value looks |
| 109 | + * numeric: a text column holding `'007'` is text. |
| 110 | + */ |
| 111 | +export function presentAsNumber(value: unknown): unknown { |
| 112 | + // Only strings are repaired, exactly as in `formatOutput`: a fresh |
| 113 | + // REAL/INTEGER column already yields a number, and genuinely |
| 114 | + // non-numeric legacy junk is left intact rather than turned into NaN. |
| 115 | + if (typeof value === 'string' && value.trim() !== '') { |
| 116 | + const n = Number(value); |
| 117 | + if (!Number.isNaN(n)) return n; |
| 118 | + } |
| 119 | + return value; |
| 120 | +} |
0 commit comments