|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#21448] A LIST at a scalar operator — `{ amount: { $gt: [10, 99] } }`, |
| 5 | + * `{ note: { $gt: ['a', 'z'] } }` — answers ONE refusal on both analytics |
| 6 | + * faces, at both analytics doors, on SQLite and PostgreSQL: `INVALID_FILTER` / |
| 7 | + * 400, before any statement or engine read, in the words of the shared |
| 8 | + * comparand-shape face (`assertListComparandShapes`, `@objectstack/spec/data`). |
| 9 | + * |
| 10 | + * ## Measured on `origin/main` `b94a2a727`, through these doors |
| 11 | + * |
| 12 | + * Three rows (`note` 'b' / 'm' / 'y', `amount` 5 / 12 / 30), counted by the |
| 13 | + * cube read (`AnalyticsService.query`, what `POST /api/v1/analytics/query` |
| 14 | + * relays) and the dataset door (`AnalyticsService.queryDataset`, what |
| 15 | + * `POST /api/v1/analytics/dataset/query` relays, its `runtimeFilter` merged |
| 16 | + * into the `where`), each on two faces of the plugin's own composition: the |
| 17 | + * native strategy and the ObjectQL strategy (`engine.aggregate`). SQLite and |
| 18 | + * PostgreSQL 16.14 answered alike: |
| 19 | + * |
| 20 | + * | filter | engine-aggregate face | native face | `engine.find` | |
| 21 | + * |:--|:--|:--|:--| |
| 22 | + * | `{ amount: { $gt: [10, 99] } }` | **200, 2** (the driver received `$gt: 10`) | 400, the number verdict | 400, the number verdict | |
| 23 | + * | `{ amount: { $lte: [12, 1] } }` | **200, 2** (`$lte: 12`) | 400, the number verdict | 400, the number verdict | |
| 24 | + * | `{ note: { $gt: ['a', 'z'] } }` | **200, 3** (`$gt: 'a'`) | **200, 3** (bound `'a'`) | 400, `driver-sql`'s own bind refusal | |
| 25 | + * | `[['note', '>', ['a', 'z']]]` (the FilterArray spelling) | **200, 3** | **200, 3** | 400, the driver's | |
| 26 | + * | `{ note: { $eq: ['b'] } }`, `{ note: { $ne: ['b'] } }` | 400, the face | 400, the face | 400, the face | |
| 27 | + * | `{ note: { $contains: ['b', 'm'] } }` | 400, this package's LIKE gate | the same | 400, the driver's | |
| 28 | + * |
| 29 | + * The shared analytics lowering carried the operator's whole list into one |
| 30 | + * leaf, and the leaf compilers read its first member. The engine door did not |
| 31 | + * bind the first member: on `driver-sql` it refused the text cell in the |
| 32 | + * driver's own words, so the engine door's answer was right and only its |
| 33 | + * wording was per driver. The face's new arm answers every row above, so each |
| 34 | + * cell is one refusal, the same message on both faces. |
| 35 | + * |
| 36 | + * The lowering gained no rule: `lowerAnalyticsWhere` already hands every field |
| 37 | + * entry of a `where` to the shared face (`assertWhereComparandShapes`, #20010) |
| 38 | + * before any leaf exists, so the arm reaches it with no change here. |
| 39 | + * |
| 40 | + * The PostgreSQL cells run where `OS_TEST_POSTGRES_URL` is set and are a named |
| 41 | + * skip otherwise; no CI step provisions that variable for this package. The |
| 42 | + * live cell owns its table, dropped before and after. |
| 43 | + */ |
| 44 | + |
| 45 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 46 | +import { ObjectQL } from '@objectstack/objectql'; |
| 47 | +import { SqlDriver } from '@objectstack/driver-sql'; |
| 48 | +import type { Cube } from '@objectstack/spec/data'; |
| 49 | +import { DatasetSchema } from '@objectstack/spec/ui'; |
| 50 | +import type { AnalyticsService } from '../analytics-service.js'; |
| 51 | +import { AnalyticsServicePlugin } from '../plugin.js'; |
| 52 | + |
| 53 | +const OBJECT = 'os21448_list_ledger'; |
| 54 | + |
| 55 | +const LEDGER = { |
| 56 | + name: OBJECT, |
| 57 | + label: 'List at a scalar operator ledger', |
| 58 | + fields: { |
| 59 | + note: { name: 'note', type: 'text' as const }, |
| 60 | + amount: { name: 'amount', type: 'number' as const }, |
| 61 | + }, |
| 62 | +}; |
| 63 | + |
| 64 | +const ROWS = [ |
| 65 | + { id: 'r5', note: 'b', amount: 5 }, |
| 66 | + { id: 'r12', note: 'm', amount: 12 }, |
| 67 | + { id: 'r30', note: 'y', amount: 30 }, |
| 68 | +] as const; |
| 69 | + |
| 70 | +const CUBE: Cube = { |
| 71 | + name: 'os21448_list_cube', |
| 72 | + title: 'List at a scalar operator cube', |
| 73 | + sql: OBJECT, |
| 74 | + public: true, |
| 75 | + measures: { row_count: { type: 'count', sql: '*', label: 'Rows' } }, |
| 76 | + dimensions: { |
| 77 | + note: { type: 'string', sql: 'note', label: 'Note' }, |
| 78 | + amount: { type: 'number', sql: 'amount', label: 'Amount' }, |
| 79 | + }, |
| 80 | +} as Cube; |
| 81 | + |
| 82 | +/** The inline dataset the dataset door carries. */ |
| 83 | +const INLINE = { |
| 84 | + name: 'os21448_list_inline', |
| 85 | + label: 'List at a scalar operator inline dataset', |
| 86 | + object: OBJECT, |
| 87 | + dimensions: [{ name: 'note', field: 'note', type: 'string' }], |
| 88 | + measures: [{ name: 'row_count', aggregate: 'count' }], |
| 89 | +}; |
| 90 | + |
| 91 | +/** A registered dataset: its own scope and its measure's filter are positions under test. */ |
| 92 | +const registeredDataset = (name: string, filter: unknown, measureFilter: unknown) => ({ |
| 93 | + name, |
| 94 | + label: name, |
| 95 | + object: OBJECT, |
| 96 | + ...(filter === undefined ? {} : { filter }), |
| 97 | + dimensions: [{ name: 'note', field: 'note', type: 'string' }], |
| 98 | + measures: [ |
| 99 | + { name: 'row_count', aggregate: 'count' }, |
| 100 | + ...(measureFilter === undefined ? [] : [{ name: 'scoped_count', aggregate: 'count', filter: measureFilter }]), |
| 101 | + ], |
| 102 | +}); |
| 103 | + |
| 104 | +const REGISTERED_REFUSED = [ |
| 105 | + registeredDataset('os21448_list_scope', { note: { $gt: ['a', 'z'] } }, undefined), |
| 106 | + registeredDataset('os21448_list_measure', undefined, { amount: { $lt: [20, 1] } }), |
| 107 | +] as const; |
| 108 | + |
| 109 | +type Refusal = { code: string; status: number; message: string }; |
| 110 | +type Answer = number | Refusal; |
| 111 | +type Face = 'native' | 'engine'; |
| 112 | + |
| 113 | +/** Each refused filter, and the operator and field the face must name. */ |
| 114 | +const REFUSED_CELLS: ReadonlyArray<readonly [filter: unknown, op: string, field: string, label: string]> = [ |
| 115 | + // The card's cells. |
| 116 | + [{ amount: { $gt: [10, 99] } }, '$gt', 'amount', 'a pair on a number column'], |
| 117 | + [{ amount: { $gt: [10] } }, '$gt', 'amount', 'one member on a number column'], |
| 118 | + [{ note: { $gt: ['a', 'z'] } }, '$gt', 'note', 'a pair on a text column'], |
| 119 | + [{ note: { $eq: ['b'] } }, '$eq', 'note', '$eq on a text column (the equality arm, unchanged)'], |
| 120 | + [{ note: { $ne: ['b'] } }, '$ne', 'note', '$ne on a text column (the $ne arm, unchanged)'], |
| 121 | + // The rest of the scalar set, every column type. |
| 122 | + [{ amount: { $lte: [12, 1] } }, '$lte', 'amount', '$lte on a number column'], |
| 123 | + [{ note: { $gte: ['m'] } }, '$gte', 'note', '$gte on a text column'], |
| 124 | + [{ note: { $lt: [] } }, '$lt', 'note', 'an EMPTY list — still a list in a one-value slot'], |
| 125 | + [{ note: { $contains: ['b', 'm'] } }, '$contains', 'note', 'a text operator'], |
| 126 | + [{ note: { $startsWith: ['b'] } }, '$startsWith', 'note', 'another text operator'], |
| 127 | + // Every depth, and the FilterArray spelling. |
| 128 | + [{ $or: [{ note: { $gt: ['a', 'z'] } }, { amount: 5 }] }, '$gt', 'note', 'under $or'], |
| 129 | + [{ $not: { note: { $lt: ['m'] } } }, '$lt', 'note', 'under $not'], |
| 130 | + [[['note', '>', ['a', 'z']]], '$gt', 'note', 'the FilterArray spelling, ">"'], |
| 131 | + [[['amount', 'gte', [10, 99]]], '$gte', 'amount', 'the FilterArray spelling, "gte"'], |
| 132 | +]; |
| 133 | + |
| 134 | +/** The controls the ruling names, and the scalar neighbours: each face counts alike. */ |
| 135 | +const CONTROL_CELLS: ReadonlyArray<readonly [filter: unknown, count: number, label: string]> = [ |
| 136 | + [{ note: { $in: ['b'] } }, 1, 'a list at $in'], |
| 137 | + [{ note: { $nin: ['b'] } }, 2, 'a list at $nin'], |
| 138 | + [{ amount: { $gt: 10 } }, 2, 'a scalar at $gt'], |
| 139 | + [{ note: { $gt: 'a' } }, 3, 'a scalar at $gt on a text column'], |
| 140 | + [{ amount: { $between: [10, 40] } }, 2, 'a range'], |
| 141 | +]; |
| 142 | + |
| 143 | +/** The leading sentence every one of these refusals carries — the face's, `driver-memory`'s for this condition. */ |
| 144 | +const faceSentence = (op: string, field: string) => |
| 145 | + `Operator "${op}" on field "${field}" requires a single comparable value, but received an array`; |
| 146 | + |
| 147 | +interface Cell { |
| 148 | + id: 'sqlite' | 'pg'; |
| 149 | + label: string; |
| 150 | + env: string | null; |
| 151 | + config: () => Record<string, unknown> | null; |
| 152 | +} |
| 153 | + |
| 154 | +const DRIVER_CELLS: readonly Cell[] = [ |
| 155 | + { id: 'sqlite', label: 'sqlite', env: null, config: () => ({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) }, |
| 156 | + { |
| 157 | + id: 'pg', |
| 158 | + label: 'live postgres', |
| 159 | + env: 'OS_TEST_POSTGRES_URL', |
| 160 | + config: () => (process.env.OS_TEST_POSTGRES_URL ? { client: 'pg', connection: process.env.OS_TEST_POSTGRES_URL } : null), |
| 161 | + }, |
| 162 | +]; |
| 163 | + |
| 164 | +const quiet = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } }; |
| 165 | + |
| 166 | +for (const cell of DRIVER_CELLS) { |
| 167 | + const config = cell.config(); |
| 168 | + describe.skipIf(!config)( |
| 169 | + `[#21448] a list at a scalar operator answers one 400 on both analytics faces (${cell.label})${config ? '' : ` (skipped: set ${cell.env} to run this cell)`}`, |
| 170 | + () => { |
| 171 | + let driver: any; |
| 172 | + let engine: ObjectQL; |
| 173 | + /** Raw-SQL statements, engine aggregates and driver reads of THIS object. */ |
| 174 | + const reads = { rawSql: 0, aggregate: 0, driver: 0 }; |
| 175 | + const services: Partial<Record<Face, AnalyticsService>> = {}; |
| 176 | + |
| 177 | + const dropTable = async () => { |
| 178 | + if (cell.id !== 'pg') return; |
| 179 | + await driver?.execute(`drop table if exists ${OBJECT}`).catch(() => {}); |
| 180 | + }; |
| 181 | + |
| 182 | + /** The total count a face answers, or its refusal envelope, and what read the object. */ |
| 183 | + const ask = async (face: Face, run: (svc: AnalyticsService) => Promise<{ rows: unknown[] }>) => { |
| 184 | + const before = { ...reads }; |
| 185 | + const answer = await run(services[face]!).then( |
| 186 | + (res) => (res.rows as Array<Record<string, unknown>>).reduce((sum, r) => sum + Number(r.row_count ?? 0), 0) as Answer, |
| 187 | + (e: Error & { code?: string; status?: number }) => ({ code: String(e.code), status: Number(e.status), message: e.message }) as Answer, |
| 188 | + ); |
| 189 | + return { |
| 190 | + answer, |
| 191 | + rawSql: reads.rawSql - before.rawSql, |
| 192 | + aggregate: reads.aggregate - before.aggregate, |
| 193 | + driver: reads.driver - before.driver, |
| 194 | + }; |
| 195 | + }; |
| 196 | + |
| 197 | + const cubeRead = (where: unknown) => (svc: AnalyticsService) => |
| 198 | + svc.query({ cube: CUBE.name, measures: ['row_count'], where: structuredClone(where) } as never) as Promise<{ rows: unknown[] }>; |
| 199 | + const datasetRead = (runtimeFilter: unknown) => (svc: AnalyticsService) => |
| 200 | + svc.queryDataset(INLINE as never, { measures: ['row_count'], dimensions: ['note'], runtimeFilter: structuredClone(runtimeFilter) } as never) as Promise<{ rows: unknown[] }>; |
| 201 | + |
| 202 | + /** Both faces at one door: one refusal, the same words on each, and nothing read. */ |
| 203 | + const expectOneRefusal = async (door: typeof cubeRead, filter: unknown, op: string, field: string) => { |
| 204 | + const messages: string[] = []; |
| 205 | + for (const face of ['engine', 'native'] as const) { |
| 206 | + const asked = await ask(face, door(filter)); |
| 207 | + const answer = asked.answer as Refusal; |
| 208 | + expect(typeof asked.answer, `${face}: refused, not answered`).toBe('object'); |
| 209 | + expect({ code: answer.code, status: answer.status }, face).toEqual({ code: 'INVALID_FILTER', status: 400 }); |
| 210 | + expect(answer.message, face).toContain(faceSentence(op, field)); |
| 211 | + expect(asked.rawSql, `${face}: no raw statement ran`).toBe(0); |
| 212 | + expect(asked.aggregate, `${face}: no engine aggregate read the object`).toBe(0); |
| 213 | + expect(asked.driver, `${face}: the driver read nothing`).toBe(0); |
| 214 | + messages.push(answer.message); |
| 215 | + } |
| 216 | + // One verdict: the two faces answer the same words. |
| 217 | + expect(messages[0], 'the engine face and the native face answer one message').toBe(messages[1]); |
| 218 | + }; |
| 219 | + |
| 220 | + /** Both faces at one door, counting alike — and which strategy answered. */ |
| 221 | + const expectBothCount = async (door: typeof cubeRead, filter: unknown, count: number) => { |
| 222 | + const viaEngine = await ask('engine', door(filter)); |
| 223 | + expect(viaEngine.answer, 'the engine face').toBe(count); |
| 224 | + expect(viaEngine.rawSql, 'the engine face ran no raw statement').toBe(0); |
| 225 | + const viaNative = await ask('native', door(filter)); |
| 226 | + expect(viaNative.answer, 'the native face').toBe(count); |
| 227 | + expect(viaNative.rawSql, 'NativeSQLStrategy answered').toBeGreaterThanOrEqual(1); |
| 228 | + expect(viaNative.aggregate, 'no engine aggregate on the native face').toBe(0); |
| 229 | + }; |
| 230 | + |
| 231 | + beforeAll(async () => { |
| 232 | + driver = new SqlDriver(config as any); |
| 233 | + await dropTable(); |
| 234 | + engine = new ObjectQL({ logger: quiet } as any); |
| 235 | + engine.registerDriver(driver, true); |
| 236 | + await engine.init(); |
| 237 | + engine.registry.registerObject(LEDGER as any); |
| 238 | + await engine.syncSchemas(); |
| 239 | + for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any); |
| 240 | + |
| 241 | + const realExecute = (engine as any).execute.bind(engine); |
| 242 | + (engine as any).execute = (sql: unknown, opts?: { object?: string; args?: unknown[] }) => { |
| 243 | + if (opts?.object === OBJECT) reads.rawSql += 1; |
| 244 | + return realExecute(sql, opts); |
| 245 | + }; |
| 246 | + const realAggregate = engine.aggregate.bind(engine); |
| 247 | + (engine as any).aggregate = (object: string, ...rest: unknown[]) => { |
| 248 | + if (object === OBJECT) reads.aggregate += 1; |
| 249 | + return (realAggregate as any)(object, ...rest); |
| 250 | + }; |
| 251 | + for (const verb of ['find', 'aggregate', 'count'] as const) { |
| 252 | + const real = driver[verb].bind(driver); |
| 253 | + driver[verb] = (object: string, ...rest: unknown[]) => { |
| 254 | + if (object === OBJECT) reads.driver += 1; |
| 255 | + return real(object, ...rest); |
| 256 | + }; |
| 257 | + } |
| 258 | + |
| 259 | + // The plugin's own composition over the real engine: both auto-bridges |
| 260 | + // (`native`), and the same narrowed to the engine-aggregate path (`engine`). |
| 261 | + for (const [face, caps] of [ |
| 262 | + ['native', undefined], |
| 263 | + ['engine', () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false })], |
| 264 | + ] as const) { |
| 265 | + const registered: Record<string, unknown> = {}; |
| 266 | + await new AnalyticsServicePlugin({ cubes: [CUBE], ...(caps ? { queryCapabilities: caps } : {}) } as any).init({ |
| 267 | + getService: (name: string) => (name === 'data' ? engine : registered[name]), |
| 268 | + registerService: (name: string, svc: unknown) => { registered[name] = svc; }, |
| 269 | + replaceService: (name: string, svc: unknown) => { registered[name] = svc; }, |
| 270 | + hook: () => {}, |
| 271 | + logger: quiet, |
| 272 | + } as never); |
| 273 | + services[face] = registered.analytics as AnalyticsService; |
| 274 | + for (const dataset of REGISTERED_REFUSED) services[face]!.registerDataset(dataset as never); |
| 275 | + } |
| 276 | + }); |
| 277 | + |
| 278 | + afterAll(async () => { |
| 279 | + await dropTable(); |
| 280 | + try { await engine?.destroy(); } catch { /* noop */ } |
| 281 | + }); |
| 282 | + |
| 283 | + describe('the cube read — the `where` of POST /analytics/query', () => { |
| 284 | + for (const [filter, op, field, label] of REFUSED_CELLS) { |
| 285 | + it(`${JSON.stringify(filter)} (${label}) is one 400 on both faces`, async () => { |
| 286 | + await expectOneRefusal(cubeRead, filter, op, field); |
| 287 | + }); |
| 288 | + } |
| 289 | + for (const [filter, count, label] of CONTROL_CELLS) { |
| 290 | + it(`CONTROL ${JSON.stringify(filter)} (${label}) counts ${count} on both faces`, async () => { |
| 291 | + await expectBothCount(cubeRead, filter, count); |
| 292 | + }); |
| 293 | + } |
| 294 | + }); |
| 295 | + |
| 296 | + describe('the dataset door — the `runtimeFilter` of POST /api/v1/analytics/dataset/query', () => { |
| 297 | + for (const [filter, op, field, label] of REFUSED_CELLS) { |
| 298 | + it(`${JSON.stringify(filter)} (${label}) is one 400 on both faces`, async () => { |
| 299 | + await expectOneRefusal(datasetRead, filter, op, field); |
| 300 | + }); |
| 301 | + } |
| 302 | + for (const [filter, count, label] of CONTROL_CELLS) { |
| 303 | + it(`CONTROL ${JSON.stringify(filter)} (${label}) counts ${count} on both faces`, async () => { |
| 304 | + await expectBothCount(datasetRead, filter, count); |
| 305 | + }); |
| 306 | + } |
| 307 | + }); |
| 308 | + |
| 309 | + describe("a registered dataset's own scope and measure filter, read by the cube door", () => { |
| 310 | + for (const dataset of REGISTERED_REFUSED) { |
| 311 | + it(`${dataset.name} is refused on both faces, before any read`, async () => { |
| 312 | + for (const face of ['engine', 'native'] as const) { |
| 313 | + const asked = await ask(face, (svc) => |
| 314 | + svc.query({ cube: dataset.name, measures: dataset.measures.map((m) => m.name) } as never) as Promise<{ rows: unknown[] }>); |
| 315 | + const answer = asked.answer as Refusal; |
| 316 | + expect({ code: answer.code, status: answer.status }, face).toEqual({ code: 'INVALID_FILTER', status: 400 }); |
| 317 | + expect(answer.message, face).toMatch(/requires a single comparable value, but received an array/); |
| 318 | + expect(asked.rawSql + asked.aggregate + asked.driver, `${face}: nothing read the object`).toBe(0); |
| 319 | + } |
| 320 | + }); |
| 321 | + } |
| 322 | + |
| 323 | + it('…and the save door refuses the same stored filter, in the same sentence less its location', () => { |
| 324 | + for (const dataset of REGISTERED_REFUSED) { |
| 325 | + const parsed = DatasetSchema.safeParse(dataset); |
| 326 | + expect(parsed.success, dataset.name).toBe(false); |
| 327 | + const messages = parsed.error!.issues.map((i) => i.message); |
| 328 | + expect(messages.some((m) => /requires a single comparable value, but received an array/.test(m)), dataset.name).toBe(true); |
| 329 | + expect(messages.some((m) => m.includes(' at where.')), dataset.name).toBe(false); |
| 330 | + } |
| 331 | + }); |
| 332 | + }); |
| 333 | + |
| 334 | + describe('the engine door, measured directly (`engine.find`, the real ObjectQL)', () => { |
| 335 | + it('the text cell is refused in the face\'s words now, not the driver\'s — and the driver reads nothing', async () => { |
| 336 | + const before = reads.driver; |
| 337 | + const err = await engine.find(OBJECT, { where: { note: { $gt: ['a', 'z'] } } } as never).then( |
| 338 | + () => null, |
| 339 | + (e: Error & { code?: string; status?: number }) => e, |
| 340 | + ); |
| 341 | + expect(err, 'refused').not.toBeNull(); |
| 342 | + expect({ code: err!.code, status: err!.status }).toEqual({ code: 'INVALID_FILTER', status: 400 }); |
| 343 | + expect(err!.message).toContain(`find('${OBJECT}'): ${faceSentence('$gt', 'note')}`); |
| 344 | + expect(reads.driver - before, 'the driver read nothing').toBe(0); |
| 345 | + }); |
| 346 | + |
| 347 | + it('CONTROL: a list at $in and a scalar at $gt are served', async () => { |
| 348 | + expect((await engine.find(OBJECT, { where: { note: { $in: ['b', 'y'] } } } as never)).length).toBe(2); |
| 349 | + expect((await engine.find(OBJECT, { where: { note: { $gt: 'a' } } } as never)).length).toBe(3); |
| 350 | + }); |
| 351 | + }); |
| 352 | + }, |
| 353 | + ); |
| 354 | +} |
0 commit comments