Skip to content

Commit 36cb9f2

Browse files
committed
wip(service-analytics): both-faces pins for a list at a scalar operator; moved pins follow the shape face (#21448)
Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8988ca9 commit 36cb9f2

5 files changed

Lines changed: 415 additions & 20 deletions

File tree

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

Comments
 (0)