|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#21249] On the native-SQL face, a base-table column is qualified with the |
| 5 | + * base table exactly when the STATEMENT joins something — read off the joins |
| 6 | + * the one hop resolver (`hop-object.ts`) registered for the query, never off |
| 7 | + * whether the cube declares `joins`. |
| 8 | + * |
| 9 | + * ## The shape this closes |
| 10 | + * |
| 11 | + * Measured through `POST /api/v1/analytics/query` on the real dispatcher route |
| 12 | + * at `4727fcb2`, SQLite and PostgreSQL 16.14. A configured cube over `deal` |
| 13 | + * declares NO join; `owner` is a lookup whose `reference` is the person |
| 14 | + * object, which declares `note`, `amount` and `closed_on` too: |
| 15 | + * |
| 16 | + * | query | native face | ObjectQL face | |
| 17 | + * |:--|:--|:--| |
| 18 | + * | dimensions `note` + `owner.email` | 500 (SQLite "ambiguous column name: note"; PostgreSQL 42702) | 200 | |
| 19 | + * | the same with `where: { note }` and `order: { note }` | 500 | 200 | |
| 20 | + * | `sum(amount)` by `owner.email` | 500 (42702 on `amount`) | 200 | |
| 21 | + * | a `closed_on` time-dimension window by `owner.email` | 500 (42702 on `closed_on`) | 200 | |
| 22 | + * | `where: { id }` (a member the cube does not declare) by `owner.email` | 500 (42702 on `id`) | 200 | |
| 23 | + * | control: the same pair on a cube that declares the join | 200 | 200 | |
| 24 | + * |
| 25 | + * The statement joined `owner` through the lookup's declared `reference` |
| 26 | + * (the resolver's tier 2) while `qualifyAndRegisterJoin` qualified base |
| 27 | + * columns only for a cube declaring `joins`: `SELECT note AS "note", … LEFT |
| 28 | + * JOIN "<person>" "owner" … GROUP BY note, …`. |
| 29 | + * |
| 30 | + * ## Why the statement is compiled twice |
| 31 | + * |
| 32 | + * Joins are registered lazily — by whichever member walks a relationship path |
| 33 | + * first — and an absorbed `$or` takes back the joins its branches registered, |
| 34 | + * so "does this statement join anything" is known only after every member |
| 35 | + * has been resolved. In the card's own pair `note` resolves before |
| 36 | + * `owner.email` registers the join. `generateSql` compiles once with bare base |
| 37 | + * columns and, when that compile registered a join, once more with every base |
| 38 | + * column qualified; the "join registered by a later member" pin below is the |
| 39 | + * one a call-time read of the join set fails. |
| 40 | + * |
| 41 | + * ## The dialect axis of THIS file |
| 42 | + * |
| 43 | + * The SQLite cell always runs. The PostgreSQL cell runs where |
| 44 | + * `OS_TEST_POSTGRES_URL` is set and is a named skip otherwise; no CI step |
| 45 | + * provisions that variable for this package, so the live cell is red-capable |
| 46 | + * and un-run in CI, and the PR that landed this file carries its local |
| 47 | + * PostgreSQL 16 run. The live cell owns its tables, dropped before and after. |
| 48 | + */ |
| 49 | + |
| 50 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 51 | +import { ObjectQL } from '@objectstack/objectql'; |
| 52 | +import { SqlDriver } from '@objectstack/driver-sql'; |
| 53 | +import type { Cube } from '@objectstack/spec/data'; |
| 54 | +import type { AnalyticsService } from '../analytics-service.js'; |
| 55 | +import { AnalyticsServicePlugin } from '../plugin.js'; |
| 56 | + |
| 57 | +const PERSON = 'os21249_person'; |
| 58 | +const DEAL = 'os21249_deal'; |
| 59 | + |
| 60 | +/** The lookup's target declares a column of every name the base columns below use. */ |
| 61 | +const PERSON_OBJECT = { |
| 62 | + name: PERSON, |
| 63 | + label: 'Base-column qualification person', |
| 64 | + fields: { |
| 65 | + email: { name: 'email', type: 'text' as const }, |
| 66 | + note: { name: 'note', type: 'text' as const }, |
| 67 | + amount: { name: 'amount', type: 'number' as const }, |
| 68 | + closed_on: { name: 'closed_on', type: 'date' as const }, |
| 69 | + }, |
| 70 | +}; |
| 71 | + |
| 72 | +const DEAL_OBJECT = { |
| 73 | + name: DEAL, |
| 74 | + label: 'Base-column qualification deal', |
| 75 | + fields: { |
| 76 | + note: { name: 'note', type: 'text' as const }, |
| 77 | + amount: { name: 'amount', type: 'number' as const }, |
| 78 | + closed_on: { name: 'closed_on', type: 'date' as const }, |
| 79 | + // Named after nothing the cube joins: only the declared `reference` reaches the person. |
| 80 | + owner: { name: 'owner', type: 'lookup' as const, reference: PERSON }, |
| 81 | + }, |
| 82 | +}; |
| 83 | + |
| 84 | +// The person rows carry values that would change every answer below if the |
| 85 | +// statement read the PERSON's `note` / `amount` / `closed_on` instead. |
| 86 | +const PEOPLE = [ |
| 87 | + { id: 'p1', email: 'a@x', note: 'pn1', amount: 100, closed_on: '2026-01-01' }, |
| 88 | + { id: 'p2', email: 'b@x', note: 'pn2', amount: 200, closed_on: '2026-01-01' }, |
| 89 | +] as const; |
| 90 | +const DEALS = [ |
| 91 | + { id: 'd1', note: 'x', amount: 10, closed_on: '2026-03-01', owner: 'p1' }, |
| 92 | + { id: 'd2', note: 'x', amount: 5, closed_on: '2026-03-02', owner: 'p1' }, |
| 93 | + { id: 'd3', note: 'y', amount: 7, closed_on: '2026-03-03', owner: 'p2' }, |
| 94 | + { id: 'd4', note: 'x', amount: 1, closed_on: '2026-05-01', owner: 'p2' }, |
| 95 | +] as const; |
| 96 | + |
| 97 | +const MEMBERS = { |
| 98 | + measures: { |
| 99 | + count: { type: 'count', sql: '*', label: 'Rows' }, |
| 100 | + amount_sum: { type: 'sum', sql: 'amount', label: 'Amount' }, |
| 101 | + }, |
| 102 | + dimensions: { |
| 103 | + note: { type: 'string', sql: 'note', label: 'Note' }, |
| 104 | + closed_on: { type: 'time', sql: 'closed_on', label: 'Closed on' }, |
| 105 | + }, |
| 106 | +}; |
| 107 | + |
| 108 | +/** The card's cube: it declares NO join. */ |
| 109 | +const NO_JOIN = 'os21249_no_join'; |
| 110 | +/** The control: the same members, with the join declared. */ |
| 111 | +const DECLARED = 'os21249_declared'; |
| 112 | + |
| 113 | +const CUBES = [ |
| 114 | + { name: NO_JOIN, title: 'No declared join', sql: DEAL, public: true, ...MEMBERS }, |
| 115 | + { name: DECLARED, title: 'Declared join', sql: DEAL, public: true, ...MEMBERS, joins: { owner: { name: PERSON } } }, |
| 116 | +] as unknown as Cube[]; |
| 117 | + |
| 118 | +/** The card's pair, grouped by the DEAL's `note`: (note, owner email, rows). */ |
| 119 | +const PAIR_GROUPS = [['x', 'a@x', 2], ['x', 'b@x', 1], ['y', 'b@x', 1]]; |
| 120 | + |
| 121 | +interface Cell { |
| 122 | + id: 'sqlite' | 'pg'; |
| 123 | + label: string; |
| 124 | + env: string | null; |
| 125 | + config: () => Record<string, unknown> | null; |
| 126 | +} |
| 127 | + |
| 128 | +const CELLS: readonly Cell[] = [ |
| 129 | + { id: 'sqlite', label: 'sqlite', env: null, config: () => ({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) }, |
| 130 | + { |
| 131 | + id: 'pg', |
| 132 | + label: 'live postgres', |
| 133 | + env: 'OS_TEST_POSTGRES_URL', |
| 134 | + config: () => (process.env.OS_TEST_POSTGRES_URL ? { client: 'pg', connection: process.env.OS_TEST_POSTGRES_URL } : null), |
| 135 | + }, |
| 136 | +]; |
| 137 | + |
| 138 | +const FACES = ['native', 'objectql'] as const; |
| 139 | +type Face = (typeof FACES)[number]; |
| 140 | + |
| 141 | +const quiet = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } }; |
| 142 | + |
| 143 | +type Row = Record<string, unknown>; |
| 144 | + |
| 145 | +/** Tuples in one order, so two faces' row orders compare equal. */ |
| 146 | +const sorted = (tuples: unknown[][]) => [...tuples].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); |
| 147 | + |
| 148 | +/** Rows as sorted tuples of the named columns; a measure reads as a number on every dialect. */ |
| 149 | +const tuples = (rows: unknown, columns: readonly string[]) => |
| 150 | + sorted((rows as Row[]).map((row) => columns.map((c) => (typeof row[c] === 'number' || /^-?\d+(\.\d+)?$/.test(String(row[c])) ? Number(row[c]) : row[c])))); |
| 151 | + |
| 152 | +for (const cell of CELLS) { |
| 153 | + const config = cell.config(); |
| 154 | + describe.skipIf(!config)( |
| 155 | + `[#21249] a base column beside a relationship path the cube declares no join for (${cell.label})${config ? '' : ` (skipped: set ${cell.env} to run this cell)`}`, |
| 156 | + () => { |
| 157 | + let driver: any; |
| 158 | + let engine: ObjectQL; |
| 159 | + /** Raw-SQL statements and engine aggregates, on any object. */ |
| 160 | + const reads = { rawSql: 0, aggregate: 0 }; |
| 161 | + /** `native`: the plugin's own capabilities. `objectql`: narrowed to the engine-aggregate path. */ |
| 162 | + const services: Partial<Record<Face, AnalyticsService>> = {}; |
| 163 | + |
| 164 | + const dropTables = async () => { |
| 165 | + if (cell.id !== 'pg') return; |
| 166 | + for (const table of [DEAL, PERSON]) await driver?.execute(`drop table if exists ${table}`).catch(() => {}); |
| 167 | + }; |
| 168 | + |
| 169 | + /** One query on one face: its rows, or its error, and the reads it caused. */ |
| 170 | + const read = async (face: Face, query: Record<string, unknown>) => { |
| 171 | + const before = { ...reads }; |
| 172 | + const outcome = await services[face]!.query(query as any).then( |
| 173 | + (res) => ({ rows: res.rows as unknown, err: undefined as Error | undefined }), |
| 174 | + (err) => ({ rows: undefined as unknown, err: err as Error }), |
| 175 | + ); |
| 176 | + return { ...outcome, rawSql: reads.rawSql - before.rawSql, aggregate: reads.aggregate - before.aggregate }; |
| 177 | + }; |
| 178 | + |
| 179 | + /** The statement the native face runs for a query — the dry-run door. */ |
| 180 | + const statement = async (query: Record<string, unknown>) => (await services.native!.generateSql(query as any)).sql; |
| 181 | + |
| 182 | + /** Both faces serve `query` with the same `expected` groups; the native face in one raw statement. */ |
| 183 | + const servedOnBothFaces = async (query: Record<string, unknown>, columns: readonly string[], expected: unknown[][]) => { |
| 184 | + for (const face of FACES) { |
| 185 | + const { rows, err, rawSql, aggregate } = await read(face, query); |
| 186 | + expect(err, `${face}: ${err?.message}`).toBeUndefined(); |
| 187 | + expect(tuples(rows, columns), face).toEqual(sorted(expected)); |
| 188 | + if (face === 'native') { |
| 189 | + expect(rawSql, 'the native face answered: one raw statement').toBe(1); |
| 190 | + expect(aggregate, 'the native face asked no engine aggregate').toBe(0); |
| 191 | + } else { |
| 192 | + expect(rawSql, 'the ObjectQL face ran no raw statement').toBe(0); |
| 193 | + } |
| 194 | + } |
| 195 | + }; |
| 196 | + |
| 197 | + beforeAll(async () => { |
| 198 | + driver = new SqlDriver(config as any); |
| 199 | + await dropTables(); |
| 200 | + engine = new ObjectQL({ logger: quiet } as any); |
| 201 | + engine.registerDriver(driver, true); |
| 202 | + await engine.init(); |
| 203 | + for (const object of [PERSON_OBJECT, DEAL_OBJECT]) engine.registry.registerObject(object as any); |
| 204 | + await engine.syncSchemas(); |
| 205 | + for (const row of PEOPLE) await engine.insert(PERSON, { ...row } as any); |
| 206 | + for (const row of DEALS) await engine.insert(DEAL, { ...row } as any); |
| 207 | + |
| 208 | + const realExecute = (engine as any).execute.bind(engine); |
| 209 | + (engine as any).execute = (sql: unknown, opts?: unknown) => { |
| 210 | + reads.rawSql += 1; |
| 211 | + return realExecute(sql, opts); |
| 212 | + }; |
| 213 | + const realAggregate = engine.aggregate.bind(engine); |
| 214 | + (engine as any).aggregate = (...args: unknown[]) => { |
| 215 | + reads.aggregate += 1; |
| 216 | + return (realAggregate as any)(...args); |
| 217 | + }; |
| 218 | + |
| 219 | + // The plugin's own composition over the real engine — the relationship |
| 220 | + // resolver wired from the engine's registry, so `owner` reaches the person. |
| 221 | + for (const [face, caps] of [ |
| 222 | + ['native', undefined], |
| 223 | + ['objectql', () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false })], |
| 224 | + ] as const) { |
| 225 | + const registered: Record<string, unknown> = {}; |
| 226 | + await new AnalyticsServicePlugin({ cubes: CUBES, ...(caps ? { queryCapabilities: caps } : {}) } as any).init({ |
| 227 | + getService: (name: string) => (name === 'data' ? engine : registered[name]), |
| 228 | + registerService: (name: string, svc: unknown) => { registered[name] = svc; }, |
| 229 | + replaceService: (name: string, svc: unknown) => { registered[name] = svc; }, |
| 230 | + hook: () => {}, |
| 231 | + logger: quiet, |
| 232 | + } as never); |
| 233 | + services[face] = registered.analytics as AnalyticsService; |
| 234 | + } |
| 235 | + }); |
| 236 | + |
| 237 | + afterAll(async () => { |
| 238 | + await dropTables(); |
| 239 | + try { await engine?.destroy(); } catch { /* noop */ } |
| 240 | + }); |
| 241 | + |
| 242 | + it('the card pair is served on the native face, grouped by the BASE column, as the ObjectQL face serves it', async () => { |
| 243 | + const query = { cube: NO_JOIN, measures: ['count'], dimensions: ['note', 'owner.email'] }; |
| 244 | + await servedOnBothFaces(query, ['note', 'owner.email', 'count'], PAIR_GROUPS); |
| 245 | + const sql = await statement(query); |
| 246 | + expect(sql).toContain(`SELECT "${DEAL}"."note" AS "note", "owner"."email" AS "owner.email"`); |
| 247 | + expect(sql).toContain(`LEFT JOIN "${PERSON}" "owner" ON "${DEAL}"."owner" = "owner"."id"`); |
| 248 | + expect(sql).toContain(`GROUP BY "${DEAL}"."note", "owner"."email"`); |
| 249 | + }); |
| 250 | + |
| 251 | + it('the pair with a `where` on the base column and an `order` by it', async () => { |
| 252 | + const query = { cube: NO_JOIN, measures: ['count'], dimensions: ['note', 'owner.email'], where: { note: 'x' }, order: { note: 'asc' } }; |
| 253 | + await servedOnBothFaces(query, ['note', 'owner.email', 'count'], [['x', 'a@x', 2], ['x', 'b@x', 1]]); |
| 254 | + const sql = await statement(query); |
| 255 | + expect(sql).toContain(`WHERE "${DEAL}"."note" = $1`); |
| 256 | + expect(sql).toContain(`GROUP BY "${DEAL}"."note", "owner"."email"`); |
| 257 | + // ORDER BY names the output column the SELECT list aliases, never a table column. |
| 258 | + expect(sql).toContain('ORDER BY "note" ASC'); |
| 259 | + }); |
| 260 | + |
| 261 | + it('every other base-column emitter beside the join: a measure, a time-dimension window, a member the cube does not declare', async () => { |
| 262 | + await servedOnBothFaces( |
| 263 | + { cube: NO_JOIN, measures: ['amount_sum'], dimensions: ['owner.email'] }, |
| 264 | + ['owner.email', 'amount_sum'], |
| 265 | + [['a@x', 15], ['b@x', 8]], |
| 266 | + ); |
| 267 | + expect(await statement({ cube: NO_JOIN, measures: ['amount_sum'], dimensions: ['owner.email'] })).toContain(`"${DEAL}"."amount"`); |
| 268 | + |
| 269 | + const window = { cube: NO_JOIN, measures: ['count'], dimensions: ['owner.email'], timeDimensions: [{ dimension: 'closed_on', dateRange: ['2026-03-01', '2026-03-31'] }] }; |
| 270 | + await servedOnBothFaces(window, ['owner.email', 'count'], [['a@x', 2], ['b@x', 1]]); |
| 271 | + expect(await statement(window)).toContain(`"${DEAL}"."closed_on" >= $1`); |
| 272 | + |
| 273 | + const undeclared = { cube: NO_JOIN, measures: ['count'], dimensions: ['owner.email'], where: { id: 'd1' } }; |
| 274 | + await servedOnBothFaces(undeclared, ['owner.email', 'count'], [['a@x', 1]]); |
| 275 | + expect(await statement(undeclared)).toContain(`WHERE "${DEAL}"."id" = $1`); |
| 276 | + }); |
| 277 | + |
| 278 | + it('the join is known only once every member is resolved: a base column compiled BEFORE a later member registers the join is qualified too', async () => { |
| 279 | + // The SELECT list is compiled before the `where`, so `note` is resolved |
| 280 | + // while nothing is joined yet; the filter then joins `owner`. |
| 281 | + // Native face only: the ObjectQL face refuses a cross-object filter of its own accord. |
| 282 | + const byFilter = { cube: NO_JOIN, measures: ['count'], dimensions: ['note'], where: { 'owner.email': 'a@x' } }; |
| 283 | + const { rows, err } = await read('native', byFilter); |
| 284 | + expect(err, err?.message).toBeUndefined(); |
| 285 | + expect(tuples(rows, ['note', 'count'])).toEqual([['x', 2]]); |
| 286 | + const sql = await statement(byFilter); |
| 287 | + expect(sql).toContain(`SELECT "${DEAL}"."note" AS "note"`); |
| 288 | + expect(sql).toContain(`GROUP BY "${DEAL}"."note"`); |
| 289 | + |
| 290 | + // Either order of the card's pair compiles the same qualified columns. |
| 291 | + const reversed = await statement({ cube: NO_JOIN, measures: ['count'], dimensions: ['owner.email', 'note'] }); |
| 292 | + expect(reversed).toContain(`GROUP BY "owner"."email", "${DEAL}"."note"`); |
| 293 | + }); |
| 294 | + |
| 295 | + it('CONTROL the declared-join cube compiles the pair to the statement it always did, and serves the same groups', async () => { |
| 296 | + const query = { cube: DECLARED, measures: ['count'], dimensions: ['note', 'owner.email'] }; |
| 297 | + await servedOnBothFaces(query, ['note', 'owner.email', 'count'], PAIR_GROUPS); |
| 298 | + expect(await statement(query)).toBe( |
| 299 | + `SELECT "${DEAL}"."note" AS "note", "owner"."email" AS "owner.email", COUNT(*) AS "count" FROM "${DEAL}" ` + |
| 300 | + `LEFT JOIN "${PERSON}" "owner" ON "${DEAL}"."owner" = "owner"."id" GROUP BY "${DEAL}"."note", "owner"."email"`, |
| 301 | + ); |
| 302 | + // The cube without the declaration now compiles the very same statement. |
| 303 | + expect(await statement({ ...query, cube: NO_JOIN })).toBe(await statement(query)); |
| 304 | + }); |
| 305 | + |
| 306 | + it('CONTROL a statement that joins nothing keeps its bare columns — on either cube, and when an absorbed `$or` takes its join back', async () => { |
| 307 | + const bare = `SELECT note AS "note", COUNT(*) AS "count" FROM "${DEAL}" GROUP BY note`; |
| 308 | + for (const cube of [NO_JOIN, DECLARED]) { |
| 309 | + const query = { cube, measures: ['count'], dimensions: ['note'] }; |
| 310 | + await servedOnBothFaces(query, ['note', 'count'], [['x', 3], ['y', 1]]); |
| 311 | + // On the declared-join cube this statement used to read `"<deal>"."note"`: |
| 312 | + // both name one column of the one table the statement reads. |
| 313 | + expect(await statement(query), cube).toBe(bare); |
| 314 | + } |
| 315 | + // `{}` is TRUE and absorbs the disjunction, taking back the join its |
| 316 | + // other branch registered: the statement reads one table again. |
| 317 | + const absorbed = { cube: NO_JOIN, measures: ['count'], dimensions: ['note'], where: { $or: [{ 'owner.email': 'a@x' }, {}] } }; |
| 318 | + await servedOnBothFaces(absorbed, ['note', 'count'], [['x', 3], ['y', 1]]); |
| 319 | + expect(await statement(absorbed)).toBe(bare); |
| 320 | + }); |
| 321 | + }, |
| 322 | + ); |
| 323 | +} |
0 commit comments