diff --git a/.changeset/20889-analytics-native-measure-number.md b/.changeset/20889-analytics-native-measure-number.md new file mode 100644 index 00000000000..0260ecfa236 --- /dev/null +++ b/.changeset/20889-analytics-native-measure-number.md @@ -0,0 +1,45 @@ +--- +'@objectstack/core': minor +'@objectstack/driver-sql': patch +'@objectstack/service-analytics': patch +--- + +fix: the analytics native-SQL path answers a measure its response declares `number` as a number on every dialect, presented by the one rule `driver-sql`'s `aggregate()` applies, which `@objectstack/core` now exports as `AGGREGATE_ANSWER_KIND` and `presentAsNumber` (#20889) + +Clause-②: yes (widening) + +**New exports.** `@objectstack/core` exports two names, moved here unchanged +from `@objectstack/driver-sql`, which now imports them instead of keeping them +private: + +- `AGGREGATE_ANSWER_KIND`: what each declared aggregate function answers. + `count`, `count_distinct`, `sum` and `avg` answer `'number'`; `min` and `max` + answer `'column'`, a value of the aggregated column. +- `presentAsNumber(value)`: the `'number'` presentation. A string `Number()` + reads as a number becomes that number. Any other value is returned as given: + a number, `null`, a boolean, empty or blank text, or text that reads as NaN. + +**What changed.** On PostgreSQL, `POST /api/v1/analytics/query` and +`POST /api/v1/analytics/dataset/query` answered through `NativeSQLStrategy` +returned count, count_distinct, sum, avg, and min / max over a numeric column +as strings, such as `count: "2"` and +`sum: "500.000000000000000000000000000000"`, while `fields[]` declared +`number`. A dataset's `row_count` did the same, and a measure-scoped count +mixed `"1"` with the number `0` in one column. SQLite answered numbers. The +strategy now presents each measure column by its declared aggregate function, +through the same table and presenter as `SqlDriver.aggregate()`. `min` / `max` +are presented only when their column is declared numeric, so `max` over a text +column, every dimension, and expression measures keep the value the database +returned. + +**Precision.** The answer is one JS number, the policy `driver-sql`'s +`aggregate()` already applies. A total that needs more digits than a double +holds, such as `9007199254740993`, answers the nearest double +(`9007199254740992`), which is also what SQLite and the engine path answer. + +**What did not move.** `@objectstack/driver-sql`'s behaviour is unchanged: its +`aggregate()` reads the same table, and its read presenter calls the same +function. The answers on SQLite are byte-identical. The arithmetic of the +analytics native statement did not change either. On PostgreSQL its `sum` and +`avg` still add exact decimals, so `0.1 + 0.2` answers `0.3` where the engine +path answers `0.30000000000000004`. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1888aa9f931..07769f97866 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -56,6 +56,9 @@ export * from './utils/datetime.js'; // runtime dependencies, and a copy per face is how one `sum` came to answer // two doubles. export * from './utils/compensated-sum.js'; +// [#20889] What an aggregate ANSWERS and its `'number'` presenter, moved from +// `driver-sql` so the analytics native-SQL face presents with the same rule. +export * from './utils/aggregate-answer.js'; // Export the shared batched-write helper (framework#2678) export * from './utils/bulk-write.js'; diff --git a/packages/core/src/utils/aggregate-answer.test.ts b/packages/core/src/utils/aggregate-answer.test.ts new file mode 100644 index 00000000000..687f9885480 --- /dev/null +++ b/packages/core/src/utils/aggregate-answer.test.ts @@ -0,0 +1,76 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20889] `AGGREGATE_ANSWER_KIND` and `presentAsNumber` — what an aggregate + * ANSWERS, and the one `'number'` presenter every face that hands a SQL + * client's aggregate row to a caller applies: `driver-sql`'s own `aggregate()` + * (#20335) and `service-analytics`' native-SQL face (`NativeSQLStrategy`). + * + * The wire strings below are the values measured on live PostgreSQL 16.13 + * through the analytics native-SQL path (node-postgres parses `bigint` and + * `numeric` to strings), and the large totals are the ones #20335 pinned at the + * driver door. Each face pins the same presentation through its own door, in + * its own package, on SQLite and PostgreSQL. + */ + +import { describe, it, expect } from 'vitest'; +import { AggregationFunction } from '@objectstack/spec/data'; +import { AGGREGATE_ANSWER_KIND, presentAsNumber } from './aggregate-answer'; + +describe('[#20889] AGGREGATE_ANSWER_KIND — what each declared aggregate function answers', () => { + it('has exactly one row per declared aggregate function', () => { + expect(Object.keys(AGGREGATE_ANSWER_KIND).sort()).toEqual([...AggregationFunction.options].sort()); + }); + + it('count, count_distinct, sum and avg answer a number; min and max answer a value of the column', () => { + expect(AGGREGATE_ANSWER_KIND).toEqual({ + count: 'number', + count_distinct: 'number', + sum: 'number', + avg: 'number', + min: 'column', + max: 'column', + }); + }); +}); + +describe("[#20889] presentAsNumber — the 'number' presenter", () => { + it('turns the numeric text a SQL client hands back into the number it spells', () => { + // count / count_distinct (`bigint`), sum over an integer column (`bigint`). + expect(presentAsNumber('2')).toBe(2); + expect(presentAsNumber('11')).toBe(11); + // avg over an integer column (`numeric`, 16 places). + expect(presentAsNumber('3.5000000000000000')).toBe(3.5); + // sum / avg / min / max over the exact-decimal column (`numeric(65,30)`). + expect(presentAsNumber('500.000000000000000000000000000000')).toBe(500); + expect(presentAsNumber('20.500000000000000000000000000000')).toBe(20.5); + expect(presentAsNumber('0.125000000000000000000000000000')).toBe(0.125); + expect(presentAsNumber('-7.250000000000000000000000000000')).toBe(-7.25); + }); + + it('precision policy: a total a double cannot hold answers the nearest double, never a string', () => { + const big = presentAsNumber('9007199254740993.000000000000000000000000000000'); + expect(typeof big).toBe('number'); + expect(big).toBe(9007199254740992); + expect(big).toBe(Number('9007199254740993')); + const decimal = presentAsNumber('12345678901234567.123456789000000000000000000000'); + expect(typeof decimal).toBe('number'); + expect(decimal).toBe(12345678901234568); + expect(decimal).toBe(Number('12345678901234567.123456789')); + }); + + it('passes every value that is not numeric text through as given', () => { + expect(presentAsNumber(7)).toBe(7); + expect(presentAsNumber(0.30000000000000004)).toBe(0.30000000000000004); + expect(presentAsNumber(null)).toBeNull(); + expect(presentAsNumber(undefined)).toBeUndefined(); + expect(presentAsNumber(true)).toBe(true); + expect(presentAsNumber('')).toBe(''); + expect(presentAsNumber(' ')).toBe(' '); + // PostgreSQL's `numeric` 'NaN', and text `Number()` cannot read, stay as written. + expect(presentAsNumber('NaN')).toBe('NaN'); + expect(presentAsNumber('abc')).toBe('abc'); + const date = new Date(0); + expect(presentAsNumber(date)).toBe(date); + }); +}); diff --git a/packages/core/src/utils/aggregate-answer.ts b/packages/core/src/utils/aggregate-answer.ts new file mode 100644 index 00000000000..755fe5fbcb2 --- /dev/null +++ b/packages/core/src/utils/aggregate-answer.ts @@ -0,0 +1,120 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20889] What an aggregate ANSWERS, and the `'number'` presenter that gives a + * count or a total its type — defined once, for every face that hands a SQL + * client's aggregate row to a caller. + * + * ## Why it lives here + * + * Two faces run an aggregate statement on a SQL client and must present its + * answer the same way: + * + * - `@objectstack/driver-sql`'s native `aggregate()` (#20335), where this rule + * was written: `aggregate()` reads {@link AGGREGATE_ANSWER_KIND} to choose + * the result columns that take the `'number'` presentation, and + * `presentReadValue`'s `'number'` arm is {@link presentAsNumber}; + * - `@objectstack/service-analytics`' native-SQL face + * (`NativeSQLStrategy.execute`), which runs its own statement through the + * host's raw-SQL bridge — `engine.execute`, or an embedder's own + * `executeRawSql` — and so never passes through the driver's `aggregate()`. + * On PostgreSQL it answered `count: "2"` under a `fields[]` that declared + * `number`. + * + * `service-analytics` holds `driver-sql` as a development dependency only — it + * is multi-driver, and its native path also runs over an embedder's raw SQL — + * and this is the package both already stand on, the reason `compensatedSum` + * lives here too. A second transcription is how a face comes to answer its own + * type again. + * + * The table and its docblock below moved here from `driver-sql` unchanged, so + * they speak of `SqlDriver.aggregate` and `formatOutput` in that driver's + * voice. + */ + +import type { AggregationFunction } from '@objectstack/spec/data'; + +/** + * [#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. + */ +export const AGGREGATE_ANSWER_KIND: Readonly> = { + count: 'number', + count_distinct: 'number', + sum: 'number', + avg: 'number', + min: 'column', + max: 'column', +}; + +/** + * [#20335, #20889] The `'number'` presentation: a STRING `Number()` reads as a + * number becomes that number; every other value — a number, `null`, a boolean, + * empty or blank text, text `Number()` reads as NaN (PostgreSQL's `numeric` + * `'NaN'`) — is returned as given. + * + * It is the body of `driver-sql`'s `presentReadValue` `'number'` arm, moved + * here unchanged: the presenter `formatOutput` applies to a declared numeric + * column on a `find()` row (#16318), and the one {@link AGGREGATE_ANSWER_KIND} + * names for a count or a total. The answer is one JS double — the precision + * policy that table's docblock states. + * + * ⛔ Ask it by a column's DECLARED meaning — the aggregate function the query + * asked for, or a column declared numeric — never because a value looks + * numeric: a text column holding `'007'` is text. + */ +export function presentAsNumber(value: unknown): unknown { + // Only strings are repaired, exactly as in `formatOutput`: a fresh + // REAL/INTEGER column already yields a number, and genuinely + // non-numeric legacy junk is left intact rather than turned into NaN. + if (typeof value === 'string' && value.trim() !== '') { + const n = Number(value); + if (!Number.isNaN(n)) return n; + } + return value; +} diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 0af59824882..880baa083d3 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -21,6 +21,10 @@ import { parseAutonumberFormat, renderAutonumber, resolveAutonumberFormat, readA // "the protocol has no such function" refusal cannot drift from what // `AggregationNodeSchema.function` actually admits. import { AggregationFunction, emptyGroupValueFor } from '@objectstack/spec/data'; +// [#20335, #20889] What each aggregate function ANSWERS, and the `'number'` +// presenter its counts and totals take — defined once in core, for this +// driver's `aggregate()` and the analytics native-SQL face alike. +import { AGGREGATE_ANSWER_KIND, presentAsNumber } from '@objectstack/core'; import { STRUCTURED_JSON_TYPES, FILE_REFERENCE_TYPES, MULTI_OPTION_TYPES, NUMERIC_VALUE_TYPES, isMultiValueField } from '@objectstack/spec/data'; // [#16318] The per-field-type physical representation of the NUMERIC family. // `os generate migration` reads the SAME table, in both of its formats — that @@ -1530,63 +1534,11 @@ 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', -}; +// [#20335, #20889] `AGGREGATE_ANSWER_KIND` — what each declared aggregate +// function ANSWERS, and the one-double precision policy for that answer — lives +// in `@objectstack/core` (`utils/aggregate-answer.ts`), imported above with its +// `'number'` presenter, so the analytics native-SQL face presents a count or a +// total with this driver's own rule. /** * [#20387] What each declared aggregate function ACCUMULATES IN on PostgreSQL @@ -15839,16 +15791,11 @@ export class SqlDriver implements IDataDriver { return presentAuditTimestampOutput(value); case 'boolean': return Boolean(value); - case 'number': { - // Only strings are repaired, exactly as in `formatOutput`: a fresh - // REAL/INTEGER column already yields a number, and genuinely - // non-numeric legacy junk is left intact rather than turned into NaN. - if (typeof value === 'string' && value.trim() !== '') { - const n = Number(value); - if (!Number.isNaN(n)) return n; - } - return value; - } + case 'number': + // [#20889] The presenter lives in `@objectstack/core`, beside + // `AGGREGATE_ANSWER_KIND`, so the analytics native-SQL face presents + // a count or a total with this same function. + return presentAsNumber(value); } } diff --git a/packages/rest/src/analytics-dataset-json-dimension-door.test.ts b/packages/rest/src/analytics-dataset-json-dimension-door.test.ts index 1f807a3fa4c..ac9369f9253 100644 --- a/packages/rest/src/analytics-dataset-json-dimension-door.test.ts +++ b/packages/rest/src/analytics-dataset-json-dimension-door.test.ts @@ -242,16 +242,17 @@ for (const cell of CELLS) { const before = { ...reads }; const res = await query({ measures: ['row_count'], dimensions: ['title_dim'] }); expect(res.status, JSON.stringify(res.body)).toBe(200); - const groups = (res.body.rows as Array<{ title_dim: string; row_count: number | string }>) - .map((r) => [r.title_dim, Number(r.row_count)] as const) + // [#20889] `row_count` is a JSON number on every dialect — never `"2"`. + const groups = (res.body.rows as Array<{ title_dim: string; row_count: unknown }>) + .map((r) => [r.title_dim, r.row_count] as const) .sort(([a], [b]) => a.localeCompare(b)); expect(groups).toEqual([['x', 2], ['y', 1]]); expect(reads.rawSql - before.rawSql, 'the native strategy answered').toBeGreaterThanOrEqual(1); const joined = await query({ measures: ['row_count'], dimensions: ['acct_name'] }); expect(joined.status, JSON.stringify(joined.body)).toBe(200); - const joinedGroups = (joined.body.rows as Array<{ acct_name: string; row_count: number | string }>) - .map((r) => [r.acct_name, Number(r.row_count)] as const) + const joinedGroups = (joined.body.rows as Array<{ acct_name: string; row_count: unknown }>) + .map((r) => [r.acct_name, r.row_count] as const) .sort(([a], [b]) => a.localeCompare(b)); expect(joinedGroups).toEqual([['A', 2], ['B', 1]]); }); diff --git a/packages/rest/src/analytics-dataset-measure-number-door.test.ts b/packages/rest/src/analytics-dataset-measure-number-door.test.ts new file mode 100644 index 00000000000..775f163a1ea --- /dev/null +++ b/packages/rest/src/analytics-dataset-measure-number-door.test.ts @@ -0,0 +1,261 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20889] `POST /api/v1/analytics/dataset/query` answers every measure the + * response declares `number` as a JSON number, on SQLite and on PostgreSQL — + * over a real `SqlDriver`, through the native-SQL strategy. + * + * ## Measured on the base, through this door + * + * An inline dataset over three categories (2, 1 and 4 rows), its measures + * answered by `NativeSQLStrategy` (one raw statement per query, no engine + * aggregate): + * + * | measure | SQLite | PostgreSQL 16 | + * |:--|:--|:--| + * | `row_count` (`count`), `nd_note` (`count_distinct`) | numbers | strings (`"2"`) | + * | `sum` / `avg` over a `rating` (an integer column) | numbers | strings (`"7"`, `"3.5000000000000000"`) | + * | `sum` / `avg` / `min` over a `number`, `max` over a `currency` | numbers | strings (`"500.000000000000000000000000000000"`) | + * | a measure-scoped `filtered_count` | numbers | `"1"` in the groups the statement answered, the number `0` in the group the executor filled | + * + * `fields[]` declared `number` for every one of them on both dialects. The + * cube read of the same object (`POST /api/v1/analytics/query`, served by + * `@objectstack/runtime`'s dispatcher, which relays `AnalyticsService.query`'s + * result verbatim) answered the same strings; the strategy-level pin is + * `@objectstack/service-analytics`' `native-sql-measure-number-presentation.test.ts`. + * + * The fixture's values are dyadic fractions and its groups hold 1, 2 or 4 rows, + * so a JS double holds every sum and average EXACTLY: the expected values are + * computed from the rows and asserted with `toBe`. + * + * ## The composition, and the dialect axis of THIS file + * + * The analytics service is the one `AnalyticsServicePlugin` composes over a + * real `ObjectQL` engine — both auto-bridges live, so `NativeSQLStrategy` + * answers on a SQL driver. The SQLite cell always runs. The PostgreSQL cell + * runs where `OS_TEST_POSTGRES_URL` is set and is a named skip otherwise; no + * CI step provisions that variable for this package, so the live cell is + * red-capable and un-run in CI, and the PR that landed this file carries its + * local PostgreSQL 16 run. The live cell owns its table, dropped before and + * after. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { AnalyticsServicePlugin, type AnalyticsService } from '@objectstack/service-analytics'; +import { RestServer } from './rest-server'; + +const OBJECT = 'rest_dataset_measure_number_ledger'; + +const LEDGER = { + name: OBJECT, + label: 'Dataset measure number ledger', + fields: { + category: { name: 'category', type: 'text' as const }, + note: { name: 'note', type: 'text' as const }, + stars: { name: 'stars', type: 'rating' as const }, + amount: { name: 'amount', type: 'number' as const }, + price: { name: 'price', type: 'currency' as const }, + }, +}; + +interface Row { + id: string; + category: string; + note: string; + stars: number; + amount: number; + price: number; +} + +const ROWS: readonly Row[] = [ + { id: 'a1', category: 'a', note: 'x', stars: 3, amount: 100, price: 10.25 }, + { id: 'a2', category: 'a', note: 'y', stars: 4, amount: 400, price: 20.5 }, + { id: 'b1', category: 'b', note: 'x', stars: 5, amount: 900, price: 30.75 }, + { id: 'c1', category: 'c', note: 'n0', stars: 1, amount: 1, price: 1.5 }, + { id: 'c2', category: 'c', note: 'n1', stars: 1, amount: 2, price: 2.25 }, + { id: 'c3', category: 'c', note: 'n2', stars: 2, amount: 4.5, price: 0.75 }, + { id: 'c4', category: 'c', note: 'n0', stars: 2, amount: 0.5, price: 1 }, +]; + +const GROUPS = ['a', 'b', 'c'] as const; + +/** The inline dataset the request carries — as a Studio preview or a widget posts it. */ +const DATASET = { + name: 'measure_number_inline', + label: 'Measure number inline', + object: OBJECT, + dimensions: [{ name: 'category', field: 'category', type: 'string' }], + measures: [ + { name: 'row_count', aggregate: 'count' }, + { name: 'nd_note', aggregate: 'count_distinct', field: 'note' }, + { name: 'sum_stars', aggregate: 'sum', field: 'stars' }, + { name: 'avg_stars', aggregate: 'avg', field: 'stars' }, + { name: 'sum_amount', aggregate: 'sum', field: 'amount' }, + { name: 'avg_amount', aggregate: 'avg', field: 'amount' }, + { name: 'min_amount', aggregate: 'min', field: 'amount' }, + { name: 'max_price', aggregate: 'max', field: 'price' }, + // Group `c` holds no `x` note: the executor fills that group. + { name: 'filtered_count', aggregate: 'count', filter: { note: 'x' } }, + ], +}; +const MEASURES = DATASET.measures.map((m) => m.name); + +const byGroup = (g: string) => ROWS.filter((r) => r.category === g); +const sumOf = (rows: readonly Row[], f: 'stars' | 'amount') => rows.reduce((a, r) => a + r[f], 0); + +function expected(g: string): Record { + const rows = byGroup(g); + return { + row_count: rows.length, + nd_note: new Set(rows.map((r) => r.note)).size, + sum_stars: sumOf(rows, 'stars'), + avg_stars: sumOf(rows, 'stars') / rows.length, + sum_amount: sumOf(rows, 'amount'), + avg_amount: sumOf(rows, 'amount') / rows.length, + min_amount: Math.min(...rows.map((r) => r.amount)), + max_price: Math.max(...rows.map((r) => r.price)), + filtered_count: rows.filter((r) => r.note === 'x').length, + }; +} + +interface Cell { + id: 'sqlite' | 'pg'; + label: string; + env: string | null; + config: () => Record | null; +} + +const CELLS: readonly Cell[] = [ + { id: 'sqlite', label: 'sqlite', env: null, config: () => ({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) }, + { + id: 'pg', + 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), + }, +]; + +const quiet = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } }; + +function createMockServer() { + const noop = () => {}; + return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; +} + +function mockProtocol() { + return { + getDiscovery: async () => ({ version: 'v0', routes: { data: '', metadata: '' } }), + getMetaTypes: async () => [], + getMetaItems: async () => [], + }; +} + +function makeRes() { + const res: any = { + statusCode: 200, + body: undefined as any, + header: () => res, + status: (code: number) => { res.statusCode = code; return res; }, + json: (body: unknown) => { res.body = body; return res; }, + end: () => res, + }; + return res; +} + +for (const cell of CELLS) { + const config = cell.config(); + describe.skipIf(!config)( + `[#20889] POST /api/v1/analytics/dataset/query — measures declared number answer JSON numbers — ${cell.label}${config ? '' : ` (skipped: set ${cell.env} to run this cell)`}`, + () => { + let driver: any; + let engine: ObjectQL; + /** Raw-SQL statements and engine aggregates that read THIS object. */ + const reads = { rawSql: 0, aggregate: 0 }; + let query: (selection: Record) => Promise<{ status: number; body: any }>; + + const dropTable = async () => { + if (cell.id === 'pg') await driver?.execute(`drop table if exists ${OBJECT}`).catch(() => {}); + }; + + beforeAll(async () => { + driver = new SqlDriver(config as any); + await dropTable(); + engine = new ObjectQL({ logger: quiet } as any); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(LEDGER as any); + await engine.syncSchemas(); + for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any); + + const realExecute = (engine as any).execute.bind(engine); + (engine as any).execute = (sql: unknown, opts?: { object?: string }) => { + if (opts?.object === OBJECT) reads.rawSql += 1; + return realExecute(sql, opts); + }; + const realAggregate = engine.aggregate.bind(engine); + (engine as any).aggregate = (object: string, ...rest: unknown[]) => { + if (object === OBJECT) reads.aggregate += 1; + return (realAggregate as any)(object, ...rest); + }; + + // The plugin's own composition over the real engine: both auto-bridges. + const registered: Record = {}; + await new AnalyticsServicePlugin().init({ + getService: (name: string) => (name === 'data' ? engine : registered[name]), + registerService: (name: string, svc: unknown) => { registered[name] = svc; }, + replaceService: (name: string, svc: unknown) => { registered[name] = svc; }, + hook: () => {}, + logger: quiet, + } as never); + const service = registered.analytics as AnalyticsService; + + const rest = new RestServer( + createMockServer() as any, mockProtocol() as any, { api: { requireAuth: false } } as any, + undefined, undefined, undefined, undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, + async () => service, + ); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); + const route = rest.getRoutes().find((r: any) => r.method === 'POST' && r.path === '/api/v1/analytics/dataset/query'); + expect(route).toBeDefined(); + query = async (selection) => { + const res = makeRes(); + // What the wire carries: JSON, both ways. + const body = JSON.parse(JSON.stringify({ dataset: DATASET, selection })); + await route!.handler({ method: 'POST', params: {}, headers: {}, body, query: {} } as any, res); + return { status: res.statusCode, body: JSON.parse(JSON.stringify(res.body)) }; + }; + }); + + afterAll(async () => { + await dropTable(); + try { await engine?.destroy(); } catch { /* noop */ } + }); + + it('every measure is a JSON number in every group, equal to the rows; fields[] declares each one number', async () => { + const before = { ...reads }; + const res = await query({ measures: MEASURES, dimensions: ['category'] }); + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(reads.rawSql - before.rawSql, 'NativeSQLStrategy answered').toBeGreaterThanOrEqual(1); + expect(reads.aggregate - before.aggregate, 'no engine aggregate').toBe(0); + const fields = res.body.fields as Array<{ name: string; type: string }>; + for (const m of MEASURES) expect(fields.find((f) => f.name === m)?.type, `fields[] declares ${m}`).toBe('number'); + + const rows = res.body.rows as Array>; + expect(rows.map((r) => r.category).sort()).toEqual([...GROUPS]); + for (const r of rows) { + // The dimension is text and stays text. + expect(typeof r.category).toBe('string'); + const want = expected(String(r.category)); + for (const m of MEASURES) { + expect(typeof r[m], `${String(r.category)} ${m} is a number, never ${JSON.stringify(r[m])}`).toBe('number'); + expect(r[m], `${String(r.category)} ${m}`).toBe(want[m]); + } + } + }); + }, + ); +} diff --git a/packages/services/service-analytics/src/__tests__/native-sql-measure-number-presentation.test.ts b/packages/services/service-analytics/src/__tests__/native-sql-measure-number-presentation.test.ts new file mode 100644 index 00000000000..2ef3332d745 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/native-sql-measure-number-presentation.test.ts @@ -0,0 +1,365 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20889] A measure the analytics response declares `number` answers a JS + * NUMBER on the native-SQL path, on every dialect — the value SQLite and the + * ObjectQL path already answered. + * + * `NativeSQLStrategy` hands its statement to the host's raw-SQL bridge and used + * to return the client's rows as they came. node-postgres parses `bigint` + * (`count`, `sum` over an integer column) and `numeric` (`sum` / `avg` over the + * exact-decimal column, `avg` over an integer column, `min` / `max` over a + * decimal column) to STRINGS, so on PostgreSQL the same response that declared + * `{ name: 'row_count', type: 'number' }` carried `row_count: "2"`. + * + * ## Measured on the base, through these doors + * + * The cube read (`AnalyticsService.query`, what `POST /api/v1/analytics/query` + * relays verbatim) and the dataset door (`AnalyticsService.queryDataset`), both + * answered by `NativeSQLStrategy` (one raw statement, no engine aggregate): + * + * | measure | SQLite | PostgreSQL 16.13 | + * |:--|:--|:--| + * | `count(*)`, `count(note)`, `count_distinct(note)` | `2` | `"2"` | + * | `sum` / `avg` over `rating` (an integer column) | `7` / `3.5` | `"7"` / `"3.5000000000000000"` | + * | `sum` / `avg` over `number` (the exact-decimal column) | `500` / `250` | `"500.000000000000000000000000000000"` / `"250.0…"` | + * | `min(number)`, `max(currency)` | `100`, `20.5` | `"100.0…"`, `"20.500000000000000000000000000000"` | + * | a dataset's `row_count` | `2` | `"2"` | + * + * On PostgreSQL's dataset door one column mixed both types: a measure-scoped + * `filtered_count` read `"1"` in the groups the statement answered and the + * number `0` in the group the executor filled. + * + * ## What the fix does, and what these pins hold + * + * `NativeSQLStrategy.execute` presents each measure column whose declared + * aggregate function answers a number (`AGGREGATE_ANSWER_KIND`, `@objectstack/ + * core`), and `min` / `max` over a declared numeric column, through the same + * `presentAsNumber` `driver-sql`'s own `aggregate()` applies (#20335). It is + * keyed on the measure's declared function, never on whether a value looks + * numeric: the text dimension and the `max` over a text column below hold + * numeric-looking text and stay text. + * + * The fixture's values are dyadic fractions and its groups hold 1, 2 or 4 rows, + * so a JS double holds every sum and average EXACTLY and the expected values + * are computed from the rows with JS arithmetic and asserted with `toBe`: a + * string, a boolean or a rounding difference each fail. The precision case + * holds #20335's one-double policy — a total beyond a double answers the + * nearest double, equal to SQLite and to the ObjectQL face. ⛔ No case pins the + * SUM / AVG accumulation of non-dyadic fractions (`0.1 + 0.2`, `11 / 9`): the + * native statement does not carry #20387's double accumulation, a different + * defect shape that is tracked on its own. + * + * ## The dialect axis of THIS file + * + * The SQLite cell always runs. The PostgreSQL cell runs where + * `OS_TEST_POSTGRES_URL` is set and is a named skip otherwise. No CI step + * provisions that variable for this package, so the live cell is red-capable + * and un-run in CI; the PR that landed this file carries its local PostgreSQL + * 16 run. `@objectstack/core`'s `aggregate-answer.test.ts` pins the presenter + * itself in CI. The live cell owns its table, dropped before and after. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import type { Cube } from '@objectstack/spec/data'; +import type { AnalyticsService } from '../analytics-service.js'; +import { AnalyticsServicePlugin } from '../plugin.js'; + +const OBJECT = 'os20889_measure_ledger'; + +const LEDGER = { + name: OBJECT, + label: 'Measure number presentation ledger', + fields: { + category: { name: 'category', type: 'text' as const }, + note: { name: 'note', type: 'text' as const }, + code: { name: 'code', type: 'text' as const }, + stars: { name: 'stars', type: 'rating' as const }, + amount: { name: 'amount', type: 'number' as const }, + price: { name: 'price', type: 'currency' as const }, + frac: { name: 'frac', type: 'number' as const }, + }, +}; + +interface Row { + id: string; + category: string; + note: string; + code: string; + stars: number; + amount: number; + price: number; + frac: number; +} + +/** Groups of 2, 1 and 4 rows; every value a dyadic fraction. `code` is numeric-looking TEXT. */ +const ROWS: readonly Row[] = [ + { id: 'a1', category: 'a', note: 'x', code: '10', stars: 3, amount: 100, price: 10.25, frac: 0.25 }, + { id: 'a2', category: 'a', note: 'y', code: '9', stars: 4, amount: 400, price: 20.5, frac: 0.5 }, + { id: 'b1', category: 'b', note: 'x', code: '42', stars: 5, amount: 900, price: 30.75, frac: 0.125 }, + { id: 'c1', category: 'c', note: 'n0', code: '1', stars: 1, amount: 1, price: 1.5, frac: 0.75 }, + { id: 'c2', category: 'c', note: 'n1', code: '2', stars: 1, amount: 2, price: 2.25, frac: 0.75 }, + { id: 'c3', category: 'c', note: 'n2', code: '3', stars: 2, amount: 4.5, price: 0.75, frac: 0.75 }, + { id: 'c4', category: 'c', note: 'n0', code: '4', stars: 2, amount: 0.5, price: 1, frac: 0.75 }, +]; + +const GROUPS = ['a', 'b', 'c'] as const; + +const CUBE: Cube = { + name: 'os20889_measure_cube', + title: 'Measure number presentation cube', + sql: OBJECT, + public: true, + measures: { + row_count: { type: 'count', sql: '*', label: 'Rows' }, + cnt_note: { type: 'count', sql: 'note', label: 'Notes' }, + nd_note: { type: 'count_distinct', sql: 'note', label: 'Distinct notes' }, + sum_stars: { type: 'sum', sql: 'stars', label: 'Stars (integer column)' }, + avg_stars: { type: 'avg', sql: 'stars', label: 'Average stars' }, + sum_amount: { type: 'sum', sql: 'amount', label: 'Amount (exact-decimal column)' }, + avg_amount: { type: 'avg', sql: 'amount', label: 'Average amount' }, + min_amount: { type: 'min', sql: 'amount', label: 'Smallest amount' }, + max_price: { type: 'max', sql: 'price', label: 'Largest price' }, + sum_frac: { type: 'sum', sql: 'frac', label: 'Fractions' }, + avg_frac: { type: 'avg', sql: 'frac', label: 'Average fraction' }, + max_code: { type: 'max', sql: 'code', label: 'Largest code (text)' }, + }, + dimensions: { + category: { type: 'string', sql: 'category', label: 'Category' }, + code: { type: 'string', sql: 'code', label: 'Code' }, + }, +} as Cube; + +/** Every measure that answers a number, and the value the rows give it. */ +const NUMBER_MEASURES = [ + 'row_count', 'cnt_note', 'nd_note', + 'sum_stars', 'avg_stars', 'sum_amount', 'avg_amount', + 'min_amount', 'max_price', 'sum_frac', 'avg_frac', +] as const; +type NumberMeasure = (typeof NUMBER_MEASURES)[number]; + +const byGroup = (g: string) => ROWS.filter((r) => r.category === g); +const sumOf = (rows: readonly Row[], f: 'stars' | 'amount' | 'frac') => rows.reduce((a, r) => a + r[f], 0); + +function expected(g: string): Record & { max_code: string } { + const rows = byGroup(g); + return { + row_count: rows.length, + cnt_note: rows.length, + nd_note: new Set(rows.map((r) => r.note)).size, + sum_stars: sumOf(rows, 'stars'), + avg_stars: sumOf(rows, 'stars') / rows.length, + sum_amount: sumOf(rows, 'amount'), + avg_amount: sumOf(rows, 'amount') / rows.length, + min_amount: Math.min(...rows.map((r) => r.amount)), + max_price: Math.max(...rows.map((r) => r.price)), + sum_frac: sumOf(rows, 'frac'), + avg_frac: sumOf(rows, 'frac') / rows.length, + // Text order, not numeric: '9' sorts after '10'. + max_code: [...rows.map((r) => r.code)].sort().reverse()[0]!, + }; +} + +const DATASET = { + name: 'os20889_measure_ds', + label: 'Measure number presentation dataset', + object: OBJECT, + dimensions: [{ name: 'category', field: 'category', type: 'string' }], + measures: [ + { name: 'row_count', aggregate: 'count' }, + { name: 'nd_note', aggregate: 'count_distinct', field: 'note' }, + { name: 'sum_stars', aggregate: 'sum', field: 'stars' }, + { name: 'avg_stars', aggregate: 'avg', field: 'stars' }, + { name: 'sum_amount', aggregate: 'sum', field: 'amount' }, + { name: 'avg_amount', aggregate: 'avg', field: 'amount' }, + { name: 'min_amount', aggregate: 'min', field: 'amount' }, + { name: 'max_price', aggregate: 'max', field: 'price' }, + // Group `c` holds no `x` note: the executor fills that group. + { name: 'filtered_count', aggregate: 'count', filter: { note: 'x' } }, + ], +}; +const DATASET_MEASURES = DATASET.measures.map((m) => m.name); + +interface Cell { + id: 'sqlite' | 'pg'; + label: string; + env: string | null; + config: () => Record | null; +} + +const CELLS: readonly Cell[] = [ + { id: 'sqlite', label: 'sqlite', env: null, config: () => ({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) }, + { + id: 'pg', + 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), + }, +]; + +const quiet = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } }; + +type Answer = Record; +const byCategory = (rows: readonly Answer[]) => new Map(rows.map((r) => [String(r.category), r])); + +for (const cell of CELLS) { + const config = cell.config(); + describe.skipIf(!config)( + `[#20889] analytics native SQL — measures declared number answer numbers (${cell.label})${config ? '' : ` (skipped: set ${cell.env} to run this cell)`}`, + () => { + let driver: any; + let engine: ObjectQL; + /** Raw-SQL statements and engine aggregates that read THIS object. */ + const reads = { rawSql: 0, aggregate: 0 }; + /** `native`: the plugin's own capabilities. `objectql`: narrowed to the engine-aggregate path. */ + const services: Partial> = {}; + + const dropTable = async () => { + if (cell.id === 'pg') await driver?.execute(`drop table if exists ${OBJECT}`).catch(() => {}); + }; + + /** The cube read on one face, with the strategy that answered it counted. */ + const cubeRead = async (face: 'native' | 'objectql', measures: readonly string[], dimensions: readonly string[]) => { + const before = { ...reads }; + const res = await services[face]!.query({ cube: CUBE.name, measures: [...measures], dimensions: [...dimensions] } as any); + return { + rows: res.rows as Answer[], + fields: res.fields as Array<{ name: string; type: string }>, + rawSql: reads.rawSql - before.rawSql, + aggregate: reads.aggregate - before.aggregate, + }; + }; + + beforeAll(async () => { + driver = new SqlDriver(config as any); + await dropTable(); + engine = new ObjectQL({ logger: quiet } as any); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(LEDGER as any); + await engine.syncSchemas(); + for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any); + + const realExecute = (engine as any).execute.bind(engine); + (engine as any).execute = (sql: unknown, opts?: { object?: string }) => { + if (opts?.object === OBJECT) reads.rawSql += 1; + return realExecute(sql, opts); + }; + const realAggregate = engine.aggregate.bind(engine); + (engine as any).aggregate = (object: string, ...rest: unknown[]) => { + if (object === OBJECT) reads.aggregate += 1; + return (realAggregate as any)(object, ...rest); + }; + + // The plugin's own composition over the real engine: both auto-bridges. + for (const [face, caps] of [ + ['native', undefined], + ['objectql', () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false })], + ] as const) { + const registered: Record = {}; + await new AnalyticsServicePlugin({ cubes: [CUBE], ...(caps ? { queryCapabilities: caps } : {}) } as any).init({ + getService: (name: string) => (name === 'data' ? engine : registered[name]), + registerService: (name: string, svc: unknown) => { registered[name] = svc; }, + replaceService: (name: string, svc: unknown) => { registered[name] = svc; }, + hook: () => {}, + logger: quiet, + } as never); + services[face] = registered.analytics as AnalyticsService; + } + }); + + afterAll(async () => { + await dropTable(); + try { await engine?.destroy(); } catch { /* noop */ } + }); + + it('the cube read: every measure declared number is a number, equal to the rows, and the native strategy answered it', async () => { + const res = await cubeRead('native', [...NUMBER_MEASURES, 'max_code'], ['category']); + expect(res.rawSql, 'one raw statement: NativeSQLStrategy answered').toBe(1); + expect(res.aggregate, 'no engine aggregate').toBe(0); + for (const m of NUMBER_MEASURES) { + expect(res.fields.find((f) => f.name === m)?.type, `fields[] declares ${m} number`).toBe('number'); + } + const answers = byCategory(res.rows); + expect([...answers.keys()].sort()).toEqual([...GROUPS]); + for (const g of GROUPS) { + const a = answers.get(g)!; + const want = expected(g); + for (const m of NUMBER_MEASURES) { + expect(typeof a[m], `${g} ${m} is a number, never ${JSON.stringify(a[m])}`).toBe('number'); + expect(a[m], `${g} ${m}`).toBe(want[m]); + } + } + }); + + it('keyed on the declared function, never on the value: max over a text column and a text dimension stay text', async () => { + const res = await cubeRead('native', ['max_code'], ['category']); + for (const [g, a] of byCategory(res.rows)) { + expect(a.max_code, `${g} max(code) is the column's text`).toBe(expected(g).max_code); + } + + const byCode = await cubeRead('native', ['row_count'], ['code']); + expect(byCode.rawSql).toBe(1); + const groups = byCode.rows.map((r) => [r.code, r.row_count]).sort(([x], [y]) => String(x).localeCompare(String(y))); + expect(groups).toEqual( + [...ROWS.map((r) => r.code)].sort((x, y) => x.localeCompare(y)).map((code) => [code, 1]), + ); + }); + + it('the dataset door: row_count and every measure are numbers in every group, the executor-filled group included', async () => { + const before = { ...reads }; + const res = await services.native!.queryDataset(DATASET as any, { measures: DATASET_MEASURES, dimensions: ['category'] } as any); + expect(reads.rawSql - before.rawSql, 'NativeSQLStrategy answered').toBeGreaterThanOrEqual(1); + expect(reads.aggregate - before.aggregate, 'no engine aggregate').toBe(0); + const answers = byCategory(res.rows as Answer[]); + expect([...answers.keys()].sort()).toEqual([...GROUPS]); + for (const g of GROUPS) { + const a = answers.get(g)!; + const want = expected(g); + for (const m of DATASET_MEASURES) { + expect(typeof a[m], `${g} ${m} is a number, never ${JSON.stringify(a[m])}`).toBe('number'); + } + for (const m of ['row_count', 'nd_note', 'sum_stars', 'avg_stars', 'sum_amount', 'avg_amount', 'min_amount', 'max_price'] as const) { + expect(a[m], `${g} ${m}`).toBe(want[m]); + } + expect(a.filtered_count, `${g} filtered_count`).toBe(byGroup(g).filter((r) => r.note === 'x').length); + } + }); + + it('dyadic fractions: the native face and the ObjectQL face answer the same numbers', async () => { + const native = await cubeRead('native', NUMBER_MEASURES, ['category']); + const objectql = await cubeRead('objectql', NUMBER_MEASURES, ['category']); + expect(native.rawSql).toBe(1); + expect(objectql.rawSql, 'the ObjectQL face ran no raw statement').toBe(0); + expect(objectql.aggregate, 'the ObjectQL face asked the engine').toBeGreaterThanOrEqual(1); + const n = byCategory(native.rows); + const o = byCategory(objectql.rows); + for (const g of GROUPS) { + for (const m of NUMBER_MEASURES) expect(n.get(g)![m], `${g} ${m}`).toBe(o.get(g)![m]); + } + }); + + it('precision policy: a total a double cannot hold answers the nearest double, equal to the ObjectQL face', async () => { + // Written by SQL, not by the engine: a JS number could not carry these + // values in the first place. The literals are numeric, so PostgreSQL's + // exact-decimal column stores them exactly (SQLite's column rounds on write). + const EXACT = ['9007199254740993', '12345678901234567.123456789']; + for (const [i, literal] of EXACT.entries()) { + await driver.execute(`insert into ${OBJECT} (id, category, amount) values ('p${i}', 'p${i}', ${literal})`); + } + const native = byCategory((await cubeRead('native', ['sum_amount', 'avg_amount', 'min_amount'], ['category'])).rows); + const objectql = byCategory((await cubeRead('objectql', ['sum_amount', 'avg_amount', 'min_amount'], ['category'])).rows); + for (const [i, literal] of EXACT.entries()) { + const a = native.get(`p${i}`)!; + for (const m of ['sum_amount', 'avg_amount', 'min_amount'] as const) { + expect(typeof a[m], `${m} over ${literal} is a number, never ${JSON.stringify(a[m])}`).toBe('number'); + expect(a[m], `${m} over ${literal}`).toBe(Number(literal)); + expect(a[m], `${m} over ${literal} equals the ObjectQL face`).toBe(objectql.get(`p${i}`)![m]); + } + } + }); + }, + ); +} diff --git a/packages/services/service-analytics/src/strategies/native-sql-strategy.ts b/packages/services/service-analytics/src/strategies/native-sql-strategy.ts index 517016f5ea6..3bce7bb0734 100644 --- a/packages/services/service-analytics/src/strategies/native-sql-strategy.ts +++ b/packages/services/service-analytics/src/strategies/native-sql-strategy.ts @@ -2,6 +2,7 @@ import type { AnalyticsQuery, AnalyticsResult } from '@objectstack/spec/contracts'; import type { Cube } from '@objectstack/spec/data'; +import { NUMERIC_VALUE_TYPES, type AggregationFunction } from '@objectstack/spec/data'; import type { AnalyticsStrategy, StrategyContext, DatasetScopedStrategyContext } from './types.js'; import { declaredDatetimeLowering, @@ -21,6 +22,9 @@ import { datasetInvalidError, invalidMemberError } from '../dataset-refusal.js'; import { type LikeShape } from '../like-pattern.js'; import { textMatchPredicateSql, sqlDialectFor } from '../text-match-sql.js'; import { nextUtcCalendarDay, resolveAnalyticsDateRangeString, isUnboundedAbove } from '@objectstack/core'; +// [#20889] What each aggregate function ANSWERS, and the `'number'` presenter — +// the rule `driver-sql`'s own `aggregate()` applies, defined once in core. +import { AGGREGATE_ANSWER_KIND, presentAsNumber } from '@objectstack/core'; import { explicitDateRangeWindow } from '../date-range-array-arm.js'; /** @@ -467,6 +471,43 @@ export class NativeSQLStrategy implements AnalyticsStrategy { const rows = await ctx.executeRawSql!(objectName, sql, params); + // [#20889] A measure column `fields[]` declares `number` answers a number, + // on every dialect. The SQL client hands an aggregate back as the wire type + // of its expression: node-postgres parses `bigint` (`count`, `sum` over an + // integer column) and `numeric` (`sum` / `avg` over the exact-decimal + // column, `avg` over an integer one, `min` / `max` over a decimal one) to + // STRINGS, mysql2 does the same for `DECIMAL`, and better-sqlite3 answers + // numbers. `driver-sql`'s own `aggregate()` presents those answers + // (#20335); this statement reaches the client through the host's raw-SQL + // bridge instead, so it is presented here, with the same table and the same + // presenter (`@objectstack/core`) — never a second coercion. + // + // Keyed on each measure's DECLARED aggregate function, never on whether a + // value looks numeric: `count` / `count_distinct` / `sum` / `avg` answer a + // number whatever the column held; `min` / `max` answer a value OF the + // column, so they are presented only when that column is declared numeric + // (`driver-sql`'s `readPresentationKind` rule), asked through + // `declaredFieldType` — a host that cannot answer, or a relationship-path + // column, leaves the value as the client gave it. Expression metric types + // (`number` / `string` / `boolean`) are the author's SQL and stay as they + // are. Rows are presented in place, as the driver presents its own. + const declaredType = (ctx as DatasetScopedStrategyContext).declaredFieldType; + const numberMeasures = (query.measures ?? []).filter((member) => { + const measure = this.lookupMember(cube, member, 'measure'); + if (!measure?.type || !Object.prototype.hasOwnProperty.call(AGGREGATE_ANSWER_KIND, measure.type)) return false; + if (AGGREGATE_ANSWER_KIND[measure.type as AggregationFunction] === 'number') return true; + const sourceType = typeof declaredType === 'function' ? declaredType.call(ctx, objectName, measure.sql) : undefined; + return sourceType !== undefined && NUMERIC_VALUE_TYPES.has(sourceType); + }); + if (numberMeasures.length > 0 && Array.isArray(rows)) { + for (const row of rows) { + if (!row || typeof row !== 'object') continue; + for (const member of numberMeasures) { + if (row[member] !== undefined) row[member] = presentAsNumber(row[member]); + } + } + } + // Build field metadata const fields = this.buildFieldMeta(query, cube);