Skip to content

Commit 2a57556

Browse files
committed
test(service-analytics): pin base-column qualification beside a relationship path the cube declares no join for, on SQLite and PostgreSQL
Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
1 parent b9c394e commit 2a57556

1 file changed

Lines changed: 323 additions & 0 deletions

File tree

Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
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

Comments
 (0)