|
| 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