|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#20489] On SQLite, `sum` / `avg` answer ONE double on both of |
| 5 | + * `engine.aggregate`'s paths — the native `SqlDriver.aggregate` and the rows |
| 6 | + * path (`objectql`'s `in-memory-aggregation.ts`, which a filtered sibling |
| 7 | + * aggregation forces) — through `engine.aggregate` and |
| 8 | + * `POST /api/v1/data/:object/query`, over a real `SqlDriver`. |
| 9 | + * |
| 10 | + * SQLite 3.43+ sums with Kahan-Babuska-Neumaier compensation; the rows path |
| 11 | + * used to add naively. Measured on the base (`b2b6a0643`), SQLite 3.53.4, a |
| 12 | + * `number` column, same readings through the engine and REST: |
| 13 | + * |
| 14 | + * | group | native `sum` / `avg` | rows path `sum` / `avg` (base) | |
| 15 | + * |:--|:--|:--| |
| 16 | + * | `0.1, 0.2, 0.3` | `0.6` / `0.19999999999999998` | `0.6000000000000001` / `0.20000000000000004` | |
| 17 | + * | `1e16, 1, -1e16` | `1` / `0.3333333333333333` | `0` / `0` | |
| 18 | + * | `1e16, 0.5, -1e16` | `0.5` / `0.16666666666666666` | `0` / `0` | |
| 19 | + * | `0.1, 0.2` | `0.30000000000000004` / `0.15000000000000002` | the same | |
| 20 | + * | `1, 2, 3, 40, 500` | `546` / `109.2` | the same | |
| 21 | + * |
| 22 | + * `having { s: { $eq: 0.6 } }` kept the first group on the native path and no |
| 23 | + * group on the rows path. The rows path now adds with the same compensation, |
| 24 | + * so every row of that table reads as its native column on both paths. |
| 25 | + * |
| 26 | + * ## The dialect axis of THIS file |
| 27 | + * |
| 28 | + * SQLite only, and deliberately. PostgreSQL and MySQL add their doubles |
| 29 | + * natively without compensation (`sql-driver.ts`, `AGGREGATE_ACCUMULATION`), so |
| 30 | + * over three or more fractions their native path can differ from the rows path |
| 31 | + * in the last place: that is the residual #20489 states, not a defect a pin |
| 32 | + * here should hold red. An exact `$eq` on a fractional sum compares doubles. |
| 33 | + */ |
| 34 | + |
| 35 | +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; |
| 36 | +import type { EngineAggregateOptions, FilterCondition } from '@objectstack/spec/data'; |
| 37 | +import { ObjectQL } from '@objectstack/objectql'; |
| 38 | +import { SqlDriver } from '@objectstack/driver-sql'; |
| 39 | +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; |
| 40 | +import { RestServer } from './rest-server'; |
| 41 | + |
| 42 | +const OBJECT = 'rest_agg_20489'; |
| 43 | + |
| 44 | +const LEDGER = { |
| 45 | + name: OBJECT, |
| 46 | + label: 'Ledger 20489', |
| 47 | + fields: { |
| 48 | + g: { name: 'g', type: 'text' as const }, |
| 49 | + w: { name: 'w', type: 'number' as const }, |
| 50 | + }, |
| 51 | +}; |
| 52 | + |
| 53 | +/** group → its values, and SQLite's native `sum` / `avg` over them. */ |
| 54 | +const GROUPS: Record<string, { values: number[]; s: number; a: number }> = { |
| 55 | + card: { values: [0.1, 0.2, 0.3], s: 0.6, a: 0.19999999999999998 }, |
| 56 | + cancel: { values: [1e16, 1, -1e16], s: 1, a: 0.3333333333333333 }, |
| 57 | + cancel_half: { values: [1e16, 0.5, -1e16], s: 0.5, a: 0.16666666666666666 }, |
| 58 | + two: { values: [0.1, 0.2], s: 0.30000000000000004, a: 0.15000000000000002 }, |
| 59 | + ints: { values: [1, 2, 3, 40, 500], s: 546, a: 109.2 }, |
| 60 | +}; |
| 61 | + |
| 62 | +function createMockServer() { |
| 63 | + const noop = () => {}; |
| 64 | + return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; |
| 65 | +} |
| 66 | + |
| 67 | +function makeRes() { |
| 68 | + const res: any = { |
| 69 | + write: () => true, end: () => {}, |
| 70 | + header: () => res, |
| 71 | + status: (code: number) => { res._status = code; return res; }, |
| 72 | + json: (body: any) => { res._json = body; return res; }, |
| 73 | + }; |
| 74 | + return res; |
| 75 | +} |
| 76 | + |
| 77 | +type Path = 'native' | 'rows'; |
| 78 | + |
| 79 | +/** |
| 80 | + * `native`: SqlDriver aggregates and the engine applies `having` to its |
| 81 | + * answer. `rows`: a filtered aggregation sends the engine to the rows path, |
| 82 | + * where it aggregates `find()` rows itself and then applies `having`. |
| 83 | + */ |
| 84 | +function grouped(path: Path, having?: Record<string, unknown>): EngineAggregateOptions { |
| 85 | + const aggregations: NonNullable<EngineAggregateOptions['aggregations']> = [ |
| 86 | + { function: 'sum', field: 'w', alias: 's' }, |
| 87 | + { function: 'avg', field: 'w', alias: 'a' }, |
| 88 | + ]; |
| 89 | + if (path === 'rows') aggregations.push({ function: 'count', alias: 'fb', filter: { g: { $ne: '' } } }); |
| 90 | + return { groupBy: ['g'], aggregations, ...(having ? { having: having as FilterCondition } : {}) }; |
| 91 | +} |
| 92 | + |
| 93 | +const byGroup = (rows: any[]) => Object.fromEntries(rows.map((r) => [r.g, { s: r.s, a: r.a }])); |
| 94 | +const groupsOf = (rows: any[]) => rows.map((r) => r.g).sort(); |
| 95 | + |
| 96 | +describe('[#20489] sum / avg — one double on both SQLite paths, engine and REST', () => { |
| 97 | + let engine: ObjectQL; |
| 98 | + let driver: SqlDriver; |
| 99 | + let post: (body: Record<string, unknown>) => Promise<any>; |
| 100 | + |
| 101 | + beforeAll(async () => { |
| 102 | + driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as never); |
| 103 | + engine = new ObjectQL(); |
| 104 | + engine.registerDriver(driver, true); |
| 105 | + await engine.init(); |
| 106 | + engine.registry.registerObject(LEDGER as any); |
| 107 | + await engine.syncSchemas(); |
| 108 | + let i = 0; |
| 109 | + for (const [g, { values }] of Object.entries(GROUPS)) { |
| 110 | + for (const w of values) await engine.insert(OBJECT, { id: `r${i++}`, g, w } as any); |
| 111 | + } |
| 112 | + |
| 113 | + const protocol = new ObjectStackProtocolImplementation(engine as any); |
| 114 | + const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); |
| 115 | + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); |
| 116 | + rest.registerRoutes(); |
| 117 | + const route = rest.getRoutes().find((r: any) => r.method === 'POST' && r.path === '/api/v1/data/:object/query'); |
| 118 | + expect(route).toBeDefined(); |
| 119 | + post = async (body) => { |
| 120 | + const res = makeRes(); |
| 121 | + // What the wire carries: JSON, both ways. |
| 122 | + await route!.handler({ params: { object: OBJECT }, body: JSON.parse(JSON.stringify(body)) } as any, res); |
| 123 | + if (res._json !== undefined) res._json = JSON.parse(JSON.stringify(res._json)); |
| 124 | + return res; |
| 125 | + }; |
| 126 | + }); |
| 127 | + |
| 128 | + afterAll(async () => { |
| 129 | + try { await engine?.destroy(); } catch { /* noop */ } |
| 130 | + }); |
| 131 | + |
| 132 | + it('each path is the one it names: native asks driver.aggregate, rows asks driver.find alone', async () => { |
| 133 | + const spy = vi.spyOn(driver, 'aggregate'); |
| 134 | + try { |
| 135 | + await engine.aggregate(OBJECT, grouped('native')); |
| 136 | + expect(spy, 'native').toHaveBeenCalledTimes(1); |
| 137 | + spy.mockClear(); |
| 138 | + await engine.aggregate(OBJECT, grouped('rows')); |
| 139 | + expect(spy, 'rows').not.toHaveBeenCalled(); |
| 140 | + } finally { |
| 141 | + spy.mockRestore(); |
| 142 | + } |
| 143 | + }); |
| 144 | + |
| 145 | + it("find() reads back the doubles written, so both paths add the same operands", async () => { |
| 146 | + const rows = (await engine.find(OBJECT, {})) as Array<{ g: string; w: number }>; |
| 147 | + for (const [g, { values }] of Object.entries(GROUPS)) { |
| 148 | + expect(rows.filter((r) => r.g === g).map((r) => r.w).sort((x, y) => x - y), g) |
| 149 | + .toStrictEqual([...values].sort((x, y) => x - y)); |
| 150 | + } |
| 151 | + }); |
| 152 | + |
| 153 | + it('sum / avg: SQLite native answers, equal on both paths, through the engine and REST', async () => { |
| 154 | + const expected = Object.fromEntries(Object.entries(GROUPS).map(([g, { s, a }]) => [g, { s, a }])); |
| 155 | + for (const path of ['native', 'rows'] as const) { |
| 156 | + expect(byGroup(await engine.aggregate(OBJECT, grouped(path))), `engine, ${path}`).toStrictEqual(expected); |
| 157 | + const res = await post(grouped(path) as Record<string, unknown>); |
| 158 | + expect(res._status ?? 200, JSON.stringify(res._json)).toBe(200); |
| 159 | + expect(byGroup(res._json.records), `REST, ${path}`).toStrictEqual(expected); |
| 160 | + } |
| 161 | + }); |
| 162 | + |
| 163 | + it('having $eq on the fractional sum / avg keeps the same group on both paths', async () => { |
| 164 | + const KEPT: ReadonlyArray<readonly [Record<string, unknown>, string[]]> = [ |
| 165 | + [{ s: { $eq: 0.6 } }, ['card']], |
| 166 | + [{ a: { $eq: 0.19999999999999998 } }, ['card']], |
| 167 | + [{ s: { $eq: 1 } }, ['cancel']], |
| 168 | + [{ s: { $in: [0.5, 546] } }, ['cancel_half', 'ints']], |
| 169 | + // The residual, as a double: `0.1 + 0.2` is not `0.3` on any path. |
| 170 | + [{ s: { $eq: 0.3 } }, []], |
| 171 | + [{ s: { $eq: 0.30000000000000004 } }, ['two']], |
| 172 | + ]; |
| 173 | + for (const [having, kept] of KEPT) { |
| 174 | + for (const path of ['native', 'rows'] as const) { |
| 175 | + expect(groupsOf(await engine.aggregate(OBJECT, grouped(path, having))), `engine, ${path}, ${JSON.stringify(having)}`) |
| 176 | + .toEqual(kept); |
| 177 | + const res = await post(grouped(path, having) as Record<string, unknown>); |
| 178 | + expect(res._status ?? 200, JSON.stringify(res._json)).toBe(200); |
| 179 | + expect(groupsOf(res._json.records), `REST, ${path}, ${JSON.stringify(having)}`).toEqual(kept); |
| 180 | + } |
| 181 | + } |
| 182 | + }); |
| 183 | +}); |
0 commit comments