Skip to content

Commit 719b987

Browse files
committed
test(core,service-analytics,rest): pin measures declared number as numbers on the analytics native-SQL path (red)
Red-first pins for #20889. On PostgreSQL the analytics native-SQL path answers count / count_distinct / sum / avg and numeric min / max as strings while fields[] declares number; SQLite answers numbers. - core: aggregate-answer.test.ts pins AGGREGATE_ANSWER_KIND and the 'number' presenter (red: the module does not exist yet). - service-analytics: the cube read and the dataset door over a real engine and SqlDriver, SQLite and a named-skip PostgreSQL cell. - rest: POST /api/v1/analytics/dataset/query on both cells, and the lenient row_count pin (number or string) tightened to a number. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2f2fa11 commit 719b987

4 files changed

Lines changed: 707 additions & 4 deletions

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20889] `AGGREGATE_ANSWER_KIND` and `presentAsNumber` — what an aggregate
5+
* ANSWERS, and the one `'number'` presenter every face that hands a SQL
6+
* client's aggregate row to a caller applies: `driver-sql`'s own `aggregate()`
7+
* (#20335) and `service-analytics`' native-SQL face (`NativeSQLStrategy`).
8+
*
9+
* The wire strings below are the values measured on live PostgreSQL 16.13
10+
* through the analytics native-SQL path (node-postgres parses `bigint` and
11+
* `numeric` to strings), and the large totals are the ones #20335 pinned at the
12+
* driver door. Each face pins the same presentation through its own door, in
13+
* its own package, on SQLite and PostgreSQL.
14+
*/
15+
16+
import { describe, it, expect } from 'vitest';
17+
import { AggregationFunction } from '@objectstack/spec/data';
18+
import { AGGREGATE_ANSWER_KIND, presentAsNumber } from './aggregate-answer';
19+
20+
describe('[#20889] AGGREGATE_ANSWER_KIND — what each declared aggregate function answers', () => {
21+
it('has exactly one row per declared aggregate function', () => {
22+
expect(Object.keys(AGGREGATE_ANSWER_KIND).sort()).toEqual([...AggregationFunction.options].sort());
23+
});
24+
25+
it('count, count_distinct, sum and avg answer a number; min and max answer a value of the column', () => {
26+
expect(AGGREGATE_ANSWER_KIND).toEqual({
27+
count: 'number',
28+
count_distinct: 'number',
29+
sum: 'number',
30+
avg: 'number',
31+
min: 'column',
32+
max: 'column',
33+
});
34+
});
35+
});
36+
37+
describe("[#20889] presentAsNumber — the 'number' presenter", () => {
38+
it('turns the numeric text a SQL client hands back into the number it spells', () => {
39+
// count / count_distinct (`bigint`), sum over an integer column (`bigint`).
40+
expect(presentAsNumber('2')).toBe(2);
41+
expect(presentAsNumber('11')).toBe(11);
42+
// avg over an integer column (`numeric`, 16 places).
43+
expect(presentAsNumber('3.5000000000000000')).toBe(3.5);
44+
// sum / avg / min / max over the exact-decimal column (`numeric(65,30)`).
45+
expect(presentAsNumber('500.000000000000000000000000000000')).toBe(500);
46+
expect(presentAsNumber('20.500000000000000000000000000000')).toBe(20.5);
47+
expect(presentAsNumber('0.125000000000000000000000000000')).toBe(0.125);
48+
expect(presentAsNumber('-7.250000000000000000000000000000')).toBe(-7.25);
49+
});
50+
51+
it('precision policy: a total a double cannot hold answers the nearest double, never a string', () => {
52+
const big = presentAsNumber('9007199254740993.000000000000000000000000000000');
53+
expect(typeof big).toBe('number');
54+
expect(big).toBe(9007199254740992);
55+
expect(big).toBe(Number('9007199254740993'));
56+
const decimal = presentAsNumber('12345678901234567.123456789000000000000000000000');
57+
expect(typeof decimal).toBe('number');
58+
expect(decimal).toBe(12345678901234568);
59+
expect(decimal).toBe(Number('12345678901234567.123456789'));
60+
});
61+
62+
it('passes every value that is not numeric text through as given', () => {
63+
expect(presentAsNumber(7)).toBe(7);
64+
expect(presentAsNumber(0.30000000000000004)).toBe(0.30000000000000004);
65+
expect(presentAsNumber(null)).toBeNull();
66+
expect(presentAsNumber(undefined)).toBeUndefined();
67+
expect(presentAsNumber(true)).toBe(true);
68+
expect(presentAsNumber('')).toBe('');
69+
expect(presentAsNumber(' ')).toBe(' ');
70+
// PostgreSQL's `numeric` 'NaN', and text `Number()` cannot read, stay as written.
71+
expect(presentAsNumber('NaN')).toBe('NaN');
72+
expect(presentAsNumber('abc')).toBe('abc');
73+
const date = new Date(0);
74+
expect(presentAsNumber(date)).toBe(date);
75+
});
76+
});

‎packages/rest/src/analytics-dataset-json-dimension-door.test.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -242,16 +242,17 @@ for (const cell of CELLS) {
242242
const before = { ...reads };
243243
const res = await query({ measures: ['row_count'], dimensions: ['title_dim'] });
244244
expect(res.status, JSON.stringify(res.body)).toBe(200);
245-
const groups = (res.body.rows as Array<{ title_dim: string; row_count: number | string }>)
246-
.map((r) => [r.title_dim, Number(r.row_count)] as const)
245+
// [#20889] `row_count` is a JSON number on every dialect — never `"2"`.
246+
const groups = (res.body.rows as Array<{ title_dim: string; row_count: unknown }>)
247+
.map((r) => [r.title_dim, r.row_count] as const)
247248
.sort(([a], [b]) => a.localeCompare(b));
248249
expect(groups).toEqual([['x', 2], ['y', 1]]);
249250
expect(reads.rawSql - before.rawSql, 'the native strategy answered').toBeGreaterThanOrEqual(1);
250251

251252
const joined = await query({ measures: ['row_count'], dimensions: ['acct_name'] });
252253
expect(joined.status, JSON.stringify(joined.body)).toBe(200);
253-
const joinedGroups = (joined.body.rows as Array<{ acct_name: string; row_count: number | string }>)
254-
.map((r) => [r.acct_name, Number(r.row_count)] as const)
254+
const joinedGroups = (joined.body.rows as Array<{ acct_name: string; row_count: unknown }>)
255+
.map((r) => [r.acct_name, r.row_count] as const)
255256
.sort(([a], [b]) => a.localeCompare(b));
256257
expect(joinedGroups).toEqual([['A', 2], ['B', 1]]);
257258
});
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20889] `POST /api/v1/analytics/dataset/query` answers every measure the
5+
* response declares `number` as a JSON number, on SQLite and on PostgreSQL —
6+
* over a real `SqlDriver`, through the native-SQL strategy.
7+
*
8+
* ## Measured on the base, through this door
9+
*
10+
* An inline dataset over three categories (2, 1 and 4 rows), its measures
11+
* answered by `NativeSQLStrategy` (one raw statement per query, no engine
12+
* aggregate):
13+
*
14+
* | measure | SQLite | PostgreSQL 16 |
15+
* |:--|:--|:--|
16+
* | `row_count` (`count`), `nd_note` (`count_distinct`) | numbers | strings (`"2"`) |
17+
* | `sum` / `avg` over a `rating` (an integer column) | numbers | strings (`"7"`, `"3.5000000000000000"`) |
18+
* | `sum` / `avg` / `min` over a `number`, `max` over a `currency` | numbers | strings (`"500.000000000000000000000000000000"`) |
19+
* | a measure-scoped `filtered_count` | numbers | `"1"` in the groups the statement answered, the number `0` in the group the executor filled |
20+
*
21+
* `fields[]` declared `number` for every one of them on both dialects. The
22+
* cube read of the same object (`POST /api/v1/analytics/query`, served by
23+
* `@objectstack/runtime`'s dispatcher, which relays `AnalyticsService.query`'s
24+
* result verbatim) answered the same strings; the strategy-level pin is
25+
* `@objectstack/service-analytics`' `native-sql-measure-number-presentation.test.ts`.
26+
*
27+
* The fixture's values are dyadic fractions and its groups hold 1, 2 or 4 rows,
28+
* so a JS double holds every sum and average EXACTLY: the expected values are
29+
* computed from the rows and asserted with `toBe`.
30+
*
31+
* ## The composition, and the dialect axis of THIS file
32+
*
33+
* The analytics service is the one `AnalyticsServicePlugin` composes over a
34+
* real `ObjectQL` engine — both auto-bridges live, so `NativeSQLStrategy`
35+
* answers on a SQL driver. The SQLite cell always runs. The PostgreSQL cell
36+
* runs where `OS_TEST_POSTGRES_URL` is set and is a named skip otherwise; no
37+
* CI step provisions that variable for this package, so the live cell is
38+
* red-capable and un-run in CI, and the PR that landed this file carries its
39+
* local PostgreSQL 16 run. The live cell owns its table, dropped before and
40+
* after.
41+
*/
42+
43+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
44+
import { ObjectQL } from '@objectstack/objectql';
45+
import { SqlDriver } from '@objectstack/driver-sql';
46+
import { AnalyticsServicePlugin, type AnalyticsService } from '@objectstack/service-analytics';
47+
import { RestServer } from './rest-server';
48+
49+
const OBJECT = 'rest_dataset_measure_number_ledger';
50+
51+
const LEDGER = {
52+
name: OBJECT,
53+
label: 'Dataset measure number ledger',
54+
fields: {
55+
category: { name: 'category', type: 'text' as const },
56+
note: { name: 'note', type: 'text' as const },
57+
stars: { name: 'stars', type: 'rating' as const },
58+
amount: { name: 'amount', type: 'number' as const },
59+
price: { name: 'price', type: 'currency' as const },
60+
},
61+
};
62+
63+
interface Row {
64+
id: string;
65+
category: string;
66+
note: string;
67+
stars: number;
68+
amount: number;
69+
price: number;
70+
}
71+
72+
const ROWS: readonly Row[] = [
73+
{ id: 'a1', category: 'a', note: 'x', stars: 3, amount: 100, price: 10.25 },
74+
{ id: 'a2', category: 'a', note: 'y', stars: 4, amount: 400, price: 20.5 },
75+
{ id: 'b1', category: 'b', note: 'x', stars: 5, amount: 900, price: 30.75 },
76+
{ id: 'c1', category: 'c', note: 'n0', stars: 1, amount: 1, price: 1.5 },
77+
{ id: 'c2', category: 'c', note: 'n1', stars: 1, amount: 2, price: 2.25 },
78+
{ id: 'c3', category: 'c', note: 'n2', stars: 2, amount: 4.5, price: 0.75 },
79+
{ id: 'c4', category: 'c', note: 'n0', stars: 2, amount: 0.5, price: 1 },
80+
];
81+
82+
const GROUPS = ['a', 'b', 'c'] as const;
83+
84+
/** The inline dataset the request carries — as a Studio preview or a widget posts it. */
85+
const DATASET = {
86+
name: 'measure_number_inline',
87+
label: 'Measure number inline',
88+
object: OBJECT,
89+
dimensions: [{ name: 'category', field: 'category', type: 'string' }],
90+
measures: [
91+
{ name: 'row_count', aggregate: 'count' },
92+
{ name: 'nd_note', aggregate: 'count_distinct', field: 'note' },
93+
{ name: 'sum_stars', aggregate: 'sum', field: 'stars' },
94+
{ name: 'avg_stars', aggregate: 'avg', field: 'stars' },
95+
{ name: 'sum_amount', aggregate: 'sum', field: 'amount' },
96+
{ name: 'avg_amount', aggregate: 'avg', field: 'amount' },
97+
{ name: 'min_amount', aggregate: 'min', field: 'amount' },
98+
{ name: 'max_price', aggregate: 'max', field: 'price' },
99+
// Group `c` holds no `x` note: the executor fills that group.
100+
{ name: 'filtered_count', aggregate: 'count', filter: { note: 'x' } },
101+
],
102+
};
103+
const MEASURES = DATASET.measures.map((m) => m.name);
104+
105+
const byGroup = (g: string) => ROWS.filter((r) => r.category === g);
106+
const sumOf = (rows: readonly Row[], f: 'stars' | 'amount') => rows.reduce((a, r) => a + r[f], 0);
107+
108+
function expected(g: string): Record<string, number> {
109+
const rows = byGroup(g);
110+
return {
111+
row_count: rows.length,
112+
nd_note: new Set(rows.map((r) => r.note)).size,
113+
sum_stars: sumOf(rows, 'stars'),
114+
avg_stars: sumOf(rows, 'stars') / rows.length,
115+
sum_amount: sumOf(rows, 'amount'),
116+
avg_amount: sumOf(rows, 'amount') / rows.length,
117+
min_amount: Math.min(...rows.map((r) => r.amount)),
118+
max_price: Math.max(...rows.map((r) => r.price)),
119+
filtered_count: rows.filter((r) => r.note === 'x').length,
120+
};
121+
}
122+
123+
interface Cell {
124+
id: 'sqlite' | 'pg';
125+
label: string;
126+
env: string | null;
127+
config: () => Record<string, unknown> | null;
128+
}
129+
130+
const CELLS: readonly Cell[] = [
131+
{ id: 'sqlite', label: 'sqlite', env: null, config: () => ({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) },
132+
{
133+
id: 'pg',
134+
label: 'live postgres',
135+
env: 'OS_TEST_POSTGRES_URL',
136+
config: () => (process.env.OS_TEST_POSTGRES_URL ? { client: 'pg', connection: process.env.OS_TEST_POSTGRES_URL } : null),
137+
},
138+
];
139+
140+
const quiet = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } };
141+
142+
function createMockServer() {
143+
const noop = () => {};
144+
return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} };
145+
}
146+
147+
function mockProtocol() {
148+
return {
149+
getDiscovery: async () => ({ version: 'v0', routes: { data: '', metadata: '' } }),
150+
getMetaTypes: async () => [],
151+
getMetaItems: async () => [],
152+
};
153+
}
154+
155+
function makeRes() {
156+
const res: any = {
157+
statusCode: 200,
158+
body: undefined as any,
159+
header: () => res,
160+
status: (code: number) => { res.statusCode = code; return res; },
161+
json: (body: unknown) => { res.body = body; return res; },
162+
end: () => res,
163+
};
164+
return res;
165+
}
166+
167+
for (const cell of CELLS) {
168+
const config = cell.config();
169+
describe.skipIf(!config)(
170+
`[#20889] POST /api/v1/analytics/dataset/query — measures declared number answer JSON numbers — ${cell.label}${config ? '' : ` (skipped: set ${cell.env} to run this cell)`}`,
171+
() => {
172+
let driver: any;
173+
let engine: ObjectQL;
174+
/** Raw-SQL statements and engine aggregates that read THIS object. */
175+
const reads = { rawSql: 0, aggregate: 0 };
176+
let query: (selection: Record<string, unknown>) => Promise<{ status: number; body: any }>;
177+
178+
const dropTable = async () => {
179+
if (cell.id === 'pg') await driver?.execute(`drop table if exists ${OBJECT}`).catch(() => {});
180+
};
181+
182+
beforeAll(async () => {
183+
driver = new SqlDriver(config as any);
184+
await dropTable();
185+
engine = new ObjectQL({ logger: quiet } as any);
186+
engine.registerDriver(driver, true);
187+
await engine.init();
188+
engine.registry.registerObject(LEDGER as any);
189+
await engine.syncSchemas();
190+
for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any);
191+
192+
const realExecute = (engine as any).execute.bind(engine);
193+
(engine as any).execute = (sql: unknown, opts?: { object?: string }) => {
194+
if (opts?.object === OBJECT) reads.rawSql += 1;
195+
return realExecute(sql, opts);
196+
};
197+
const realAggregate = engine.aggregate.bind(engine);
198+
(engine as any).aggregate = (object: string, ...rest: unknown[]) => {
199+
if (object === OBJECT) reads.aggregate += 1;
200+
return (realAggregate as any)(object, ...rest);
201+
};
202+
203+
// The plugin's own composition over the real engine: both auto-bridges.
204+
const registered: Record<string, unknown> = {};
205+
await new AnalyticsServicePlugin().init({
206+
getService: (name: string) => (name === 'data' ? engine : registered[name]),
207+
registerService: (name: string, svc: unknown) => { registered[name] = svc; },
208+
replaceService: (name: string, svc: unknown) => { registered[name] = svc; },
209+
hook: () => {},
210+
logger: quiet,
211+
} as never);
212+
const service = registered.analytics as AnalyticsService;
213+
214+
const rest = new RestServer(
215+
createMockServer() as any, mockProtocol() as any, { api: { requireAuth: false } } as any,
216+
undefined, undefined, undefined, undefined, undefined, undefined, undefined,
217+
undefined, undefined, undefined, undefined,
218+
async () => service,
219+
);
220+
(rest as any).resolveExecCtx = async () => ({ userId: 'test-user' });
221+
rest.registerRoutes();
222+
const route = rest.getRoutes().find((r: any) => r.method === 'POST' && r.path === '/api/v1/analytics/dataset/query');
223+
expect(route).toBeDefined();
224+
query = async (selection) => {
225+
const res = makeRes();
226+
// What the wire carries: JSON, both ways.
227+
const body = JSON.parse(JSON.stringify({ dataset: DATASET, selection }));
228+
await route!.handler({ method: 'POST', params: {}, headers: {}, body, query: {} } as any, res);
229+
return { status: res.statusCode, body: JSON.parse(JSON.stringify(res.body)) };
230+
};
231+
});
232+
233+
afterAll(async () => {
234+
await dropTable();
235+
try { await engine?.destroy(); } catch { /* noop */ }
236+
});
237+
238+
it('every measure is a JSON number in every group, equal to the rows; fields[] declares each one number', async () => {
239+
const before = { ...reads };
240+
const res = await query({ measures: MEASURES, dimensions: ['category'] });
241+
expect(res.status, JSON.stringify(res.body)).toBe(200);
242+
expect(reads.rawSql - before.rawSql, 'NativeSQLStrategy answered').toBeGreaterThanOrEqual(1);
243+
expect(reads.aggregate - before.aggregate, 'no engine aggregate').toBe(0);
244+
const fields = res.body.fields as Array<{ name: string; type: string }>;
245+
for (const m of MEASURES) expect(fields.find((f) => f.name === m)?.type, `fields[] declares ${m}`).toBe('number');
246+
247+
const rows = res.body.rows as Array<Record<string, unknown>>;
248+
expect(rows.map((r) => r.category).sort()).toEqual([...GROUPS]);
249+
for (const r of rows) {
250+
// The dimension is text and stays text.
251+
expect(typeof r.category).toBe('string');
252+
const want = expected(String(r.category));
253+
for (const m of MEASURES) {
254+
expect(typeof r[m], `${String(r.category)} ${m} is a number, never ${JSON.stringify(r[m])}`).toBe('number');
255+
expect(r[m], `${String(r.category)} ${m}`).toBe(want[m]);
256+
}
257+
}
258+
});
259+
},
260+
);
261+
}

0 commit comments

Comments
 (0)