Skip to content

Commit 0b8f05f

Browse files
committed
test(service-analytics): pin the ObjectQL face's order, offset and limit on SQLite and PostgreSQL
The card's rows on both drivers: a bucketed time dimension ordered and limited, a selected dimension and a selected measure equal to the native face, two order keys, limit 0, the cross-object path, the echoed sql run on the same driver, the no-window control, and the dataset door's pushed-down page on both faces. The #21267 controls now assert the ObjectQL face's order. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2941644 commit 0b8f05f

4 files changed

Lines changed: 395 additions & 10 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
fix(service-analytics): the ObjectQL strategy applies a query's `order`, then its `offset` and `limit`, to the aggregated answer, as its echoed `sql` says
6+
7+
Clause-②: no
8+
9+
**Before**, the ObjectQL strategy passed none of the three keys to `engine.aggregate`, which has no ordering or window grammar, and applied none of them itself. Every date-bucketed query lands on that strategy, because the native-SQL strategy declines `granularity`. Measured through `POST /api/v1/analytics/query` on SQLite and PostgreSQL 16.14:
10+
11+
- `timeDimensions: [{ dimension: 'closed_on', granularity: 'month' }]`, `order: { closed_on: 'desc' }`, `limit: 1` answered every month, unordered (ascending on SQLite, `04, 03, 05` on PostgreSQL).
12+
- A selected dimension with `order: { note: 'desc' }`, and a selected measure with `limit: 2, offset: 1`, answered every group in the engine's order.
13+
14+
The echoed `sql` and `POST /api/v1/analytics/sql` rendered `ORDER BY … LIMIT … OFFSET …` for all three.
15+
16+
**Now** the strategy orders the answer by `order`, in the key order given, and then applies `offset` and `limit`. This happens on the direct path and on the cross-object (FK-expand) path, after the re-bucket. A bare `limit` with no `order` slices the engine's order, as `LIMIT` without `ORDER BY` does. Where the native-SQL strategy answers the same query, the two answer the same rows for numbers and for text of single-case ASCII letters. The comparison is the dataset door's own `applyOrdering`, which sorts NULL and `''` last in both directions, while SQL places NULL by driver (lowest on SQLite, highest on PostgreSQL), so the two faces can still order NULL, `''`, numeric text and mixed-case text differently.
17+
18+
**Dataset door.** `POST /api/v1/analytics/dataset/query` pushes a single query's `order`, `limit` and `offset` down to the strategy, and then windowed the answer a second time, so `offset` was applied twice. `limit: 2, offset: 1` over five groups answered one row, the third, on the native-SQL strategy. It now windows only a grid it could not push down. The ObjectQL strategy answered that page correctly before, because it dropped the window; it still does.
19+
20+
**Unchanged.** A query with no `order`, `limit` or `offset` answers exactly the engine's aggregate rows. Which `order` keys are accepted is unchanged: the analytics door still refuses a key the query does not select. The dataset door's own ordering is unchanged too: label sort keys, derived measures, the implicit dimension order for a bare `limit`, and the chronological default.
Lines changed: 366 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,366 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#21316] The ObjectQL face applies a query's `order`, then its `offset` and
5+
* `limit`, to the aggregated answer — the statement its echoed `sql` and
6+
* `/analytics/sql` render, and the clauses the native face compiles from the
7+
* same three keys.
8+
*
9+
* ## The shape this closes
10+
*
11+
* Measured through `POST /api/v1/analytics/query` on the real dispatcher route
12+
* at `origin/main` `97239c3c8`, SQLite and PostgreSQL 16.14, the ObjectQL face
13+
* (every bucketed query on the default composition, since the native face
14+
* declines `granularity`):
15+
*
16+
* | query | SQLite | PostgreSQL |
17+
* |:--|:--|:--|
18+
* | `timeDimensions [closed_on, month]`, `order { closed_on: desc }`, `limit 1` | every month, ascending | every month, 04, 03, 05 |
19+
* | `dimensions [note]`, `order { note: desc }` | the groups unordered | the groups unordered |
20+
* | `order { amount_sum: desc }`, `limit 2`, `offset 1` | every group | every group |
21+
*
22+
* The echoed `sql` rendered `ORDER BY … LIMIT … OFFSET …` for each.
23+
*
24+
* The dataset door pushes a single query's window down and then windowed the
25+
* answer again, so `offset` applied twice: `limit 2, offset 1` over five groups
26+
* answered one row on the native face. The ObjectQL face answered right only
27+
* because it dropped the window; the last block pins both faces.
28+
*
29+
* ## What "equal to the native face" covers
30+
*
31+
* The face orders with the package's one row comparator (`applyOrdering`).
32+
* The native face's `ORDER BY` follows the driver: SQLite sorts NULL lowest and
33+
* PostgreSQL highest, and text follows the column collation. The fixtures
34+
* below therefore carry no NULL, no `''`, no numeric text and no mixed case in
35+
* an ordered column, which is where the two faces agree on both drivers.
36+
*
37+
* ## The dialect axis of THIS file
38+
*
39+
* The SQLite cell always runs. The PostgreSQL cell runs where
40+
* `OS_TEST_POSTGRES_URL` is set and is a named skip otherwise; no CI step
41+
* provisions that variable for this package, so the live cell is red-capable
42+
* and un-run in CI, and the PR that landed this file carries its local
43+
* PostgreSQL 16 run. The live cell owns its tables, dropped before and after.
44+
*/
45+
46+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
47+
import { ObjectQL } from '@objectstack/objectql';
48+
import { SqlDriver } from '@objectstack/driver-sql';
49+
import type { Cube } from '@objectstack/spec/data';
50+
import type { AnalyticsService } from '../analytics-service.js';
51+
import { AnalyticsServicePlugin } from '../plugin.js';
52+
53+
const PERSON = 'os21316_person';
54+
const DEAL = 'os21316_deal';
55+
56+
const PERSON_OBJECT = {
57+
name: PERSON,
58+
label: 'Order window person',
59+
fields: { email: { name: 'email', type: 'text' as const } },
60+
};
61+
62+
const DEAL_OBJECT = {
63+
name: DEAL,
64+
label: 'Order window deal',
65+
fields: {
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+
owner: { name: 'owner', type: 'lookup' as const, reference: PERSON },
70+
},
71+
};
72+
73+
const PEOPLE = [
74+
{ id: 'p1', email: 'a@x' },
75+
{ id: 'p2', email: 'b@x' },
76+
{ id: 'p3', email: 'c@x' },
77+
] as const;
78+
79+
// By note: w 20 (1 row), x 15 (2), y 7 (1), z 1 (1). By month: 03 (2 rows),
80+
// 04, 05, 06. By owner: a@x 15, b@x 8, c@x 20. No order key's requested order
81+
// is the insertion order or its reverse.
82+
const DEALS = [
83+
{ id: 'd1', note: 'x', amount: 10, closed_on: '2026-03-01', owner: 'p1' },
84+
{ id: 'd2', note: 'x', amount: 5, closed_on: '2026-03-02', owner: 'p1' },
85+
{ id: 'd3', note: 'y', amount: 7, closed_on: '2026-05-03', owner: 'p2' },
86+
{ id: 'd4', note: 'z', amount: 1, closed_on: '2026-04-01', owner: 'p2' },
87+
{ id: 'd5', note: 'w', amount: 20, closed_on: '2026-06-01', owner: 'p3' },
88+
] as const;
89+
90+
/** It declares NO join, so `owner.email` is served by FK-expand on the ObjectQL face and by a JOIN on the native face. */
91+
const CUBE = 'os21316_cube';
92+
const CUBES = [
93+
{
94+
name: CUBE,
95+
title: 'Order window cube',
96+
sql: DEAL,
97+
public: true,
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+
] as unknown as Cube[];
108+
109+
/** The same object as a dataset, for the dataset door's pushed-down page. */
110+
const DATASET = {
111+
name: 'os21316_ds',
112+
label: 'Order window dataset',
113+
object: DEAL,
114+
dimensions: [
115+
{ name: 'note', field: 'note', type: 'string' },
116+
{ name: 'closed_on', field: 'closed_on', type: 'date' },
117+
],
118+
measures: [{ name: 'amount_sum', aggregate: 'sum', field: 'amount' }],
119+
};
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+
/** Rows as tuples of the named columns, in the order they arrived; a numeric cell reads as a number on every dialect. */
146+
const tuples = (rows: unknown, columns: readonly string[]) =>
147+
(rows as Row[]).map((row) => columns.map((c) => (typeof row[c] === 'number' || /^-?\d+(\.\d+)?$/.test(String(row[c])) ? Number(row[c]) : row[c])));
148+
149+
for (const cell of CELLS) {
150+
const config = cell.config();
151+
describe.skipIf(!config)(
152+
`[#21316] the ObjectQL face applies order, offset and limit (${cell.label})${config ? '' : ` (skipped: set ${cell.env} to run this cell)`}`,
153+
() => {
154+
let driver: any;
155+
let engine: ObjectQL;
156+
/** Raw-SQL statements and engine aggregates, on any object, and the last aggregate's raw answer. */
157+
const reads = { rawSql: 0, aggregate: 0, lastAggregate: [] as Row[] };
158+
/** `native`: the plugin's own capabilities. `objectql`: narrowed to the engine-aggregate path. */
159+
const services: Partial<Record<Face, AnalyticsService>> = {};
160+
161+
const dropTables = async () => {
162+
if (cell.id !== 'pg') return;
163+
for (const table of [DEAL, PERSON]) await driver?.execute(`drop table if exists ${table}`).catch(() => {});
164+
};
165+
166+
/** One `query()` on one face, with the reads it caused. */
167+
const ask = async (face: Face, query: Record<string, unknown>) => {
168+
const before = { ...reads };
169+
const res = await services[face]!.query(query as any);
170+
return { res, rawSql: reads.rawSql - before.rawSql, aggregate: reads.aggregate - before.aggregate };
171+
};
172+
173+
/** The ObjectQL face answers `query` exactly as the native face does, served by the engine aggregate. */
174+
const equalToNative = async (query: Record<string, unknown>, columns: readonly string[], expected: unknown[][]) => {
175+
const native = await ask('native', query);
176+
expect(native.rawSql, 'the native face ran its statement').toBeGreaterThan(0);
177+
expect(tuples(native.res.rows, columns), 'native face').toEqual(expected);
178+
const objectql = await ask('objectql', query);
179+
expect(objectql.rawSql, 'the ObjectQL face ran no statement').toBe(0);
180+
expect(objectql.aggregate, 'the ObjectQL face ran the engine aggregate').toBeGreaterThan(0);
181+
expect(tuples(objectql.res.rows, columns), 'ObjectQL face').toEqual(expected);
182+
};
183+
184+
beforeAll(async () => {
185+
driver = new SqlDriver(config as any);
186+
await dropTables();
187+
engine = new ObjectQL({ logger: quiet } as any);
188+
engine.registerDriver(driver, true);
189+
await engine.init();
190+
for (const object of [PERSON_OBJECT, DEAL_OBJECT]) engine.registry.registerObject(object as any);
191+
await engine.syncSchemas();
192+
for (const row of PEOPLE) await engine.insert(PERSON, { ...row } as any);
193+
for (const row of DEALS) await engine.insert(DEAL, { ...row } as any);
194+
195+
const realExecute = (engine as any).execute.bind(engine);
196+
(engine as any).execute = (sql: unknown, opts?: unknown) => {
197+
reads.rawSql += 1;
198+
return realExecute(sql, opts);
199+
};
200+
const realAggregate = engine.aggregate.bind(engine);
201+
(engine as any).aggregate = async (...args: unknown[]) => {
202+
reads.aggregate += 1;
203+
const rows = await (realAggregate as any)(...args);
204+
reads.lastAggregate = (rows as Row[]).map((row) => ({ ...row }));
205+
return rows;
206+
};
207+
208+
for (const [face, caps] of [
209+
['native', undefined],
210+
['objectql', () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false })],
211+
] as const) {
212+
const registered: Record<string, unknown> = {};
213+
await new AnalyticsServicePlugin({ cubes: CUBES, debugSql: true, ...(caps ? { queryCapabilities: caps } : {}) } as any).init({
214+
getService: (name: string) => (name === 'data' ? engine : registered[name]),
215+
registerService: (name: string, svc: unknown) => { registered[name] = svc; },
216+
replaceService: (name: string, svc: unknown) => { registered[name] = svc; },
217+
hook: () => {},
218+
logger: quiet,
219+
} as never);
220+
services[face] = registered.analytics as AnalyticsService;
221+
}
222+
});
223+
224+
afterAll(async () => {
225+
await dropTables();
226+
try { await engine?.destroy(); } catch { /* noop */ }
227+
});
228+
229+
describe('a bucketed time dimension, which lands on the ObjectQL face on every composition', () => {
230+
it("the card's row: ordered desc and limited to 1, it answers the newest bucket alone", async () => {
231+
const query = {
232+
cube: CUBE,
233+
measures: ['count'],
234+
timeDimensions: [{ dimension: 'closed_on', granularity: 'month' }],
235+
order: { closed_on: 'desc' },
236+
limit: 1,
237+
};
238+
for (const face of FACES) {
239+
const { res, rawSql } = await ask(face, query);
240+
expect(rawSql, `${face}: the native face declines granularity`).toBe(0);
241+
expect(tuples(res.rows, ['closed_on', 'count']), face).toEqual([['2026-06', 1]]);
242+
}
243+
});
244+
245+
it('ordered asc with offset 1 and limit 2, it answers the second and third buckets', async () => {
246+
const { res } = await ask('objectql', {
247+
cube: CUBE,
248+
measures: ['amount_sum'],
249+
timeDimensions: [{ dimension: 'closed_on', granularity: 'month' }],
250+
order: { closed_on: 'asc' },
251+
offset: 1,
252+
limit: 2,
253+
});
254+
expect(tuples(res.rows, ['closed_on', 'amount_sum'])).toEqual([['2026-04', 1], ['2026-05', 7]]);
255+
});
256+
});
257+
258+
describe('where both faces answer, the ObjectQL face answers what the native face does', () => {
259+
it("the card's row: a selected dimension ordered desc", async () => {
260+
await equalToNative(
261+
{ cube: CUBE, measures: ['count'], dimensions: ['note'], order: { note: 'desc' } },
262+
['note', 'count'],
263+
[['z', 1], ['y', 1], ['x', 2], ['w', 1]],
264+
);
265+
});
266+
267+
it('a selected measure ordered desc, with offset 1 and limit 2', async () => {
268+
await equalToNative(
269+
{ cube: CUBE, measures: ['amount_sum'], dimensions: ['note'], order: { amount_sum: 'desc' }, offset: 1, limit: 2 },
270+
['note', 'amount_sum'],
271+
[['x', 15], ['y', 7]],
272+
);
273+
});
274+
275+
it('two order keys, the first one most significant', async () => {
276+
await equalToNative(
277+
{ cube: CUBE, measures: ['count'], dimensions: ['note'], order: { count: 'desc', note: 'asc' } },
278+
['note', 'count'],
279+
[['x', 2], ['w', 1], ['y', 1], ['z', 1]],
280+
);
281+
});
282+
283+
it('limit 0 answers no rows, as LIMIT 0 does', async () => {
284+
await equalToNative(
285+
{ cube: CUBE, measures: ['count'], dimensions: ['note'], order: { note: 'asc' }, limit: 0 },
286+
['note', 'count'],
287+
[],
288+
);
289+
});
290+
291+
it('the cross-object path: a relationship-path dimension, ordered by a measure and limited', async () => {
292+
await equalToNative(
293+
{ cube: CUBE, measures: ['amount_sum'], dimensions: ['owner.email'], order: { amount_sum: 'desc' }, limit: 2 },
294+
['owner.email', 'amount_sum'],
295+
[['c@x', 20], ['a@x', 15]],
296+
);
297+
});
298+
299+
it('the cross-object path: ordered by the relationship-path dimension itself, with offset 1 and limit 2', async () => {
300+
await equalToNative(
301+
{ cube: CUBE, measures: ['amount_sum'], dimensions: ['owner.email'], order: { 'owner.email': 'desc' }, offset: 1, limit: 2 },
302+
['owner.email', 'amount_sum'],
303+
[['b@x', 8], ['a@x', 15]],
304+
);
305+
});
306+
});
307+
308+
it('the echoed sql, run on the same driver, answers the rows the ObjectQL face answered', async () => {
309+
const query = { cube: CUBE, measures: ['amount_sum'], dimensions: ['note'], order: { amount_sum: 'desc' }, offset: 1, limit: 2 };
310+
const { res } = await ask('objectql', query);
311+
expect(res.sql).toContain('ORDER BY "amount_sum" DESC LIMIT 2 OFFSET 1');
312+
const ran = (await driver.execute(res.sql)) as unknown;
313+
const echoed = Array.isArray(ran) ? ran : (ran as { rows: Row[] }).rows;
314+
expect(tuples(res.rows, ['note', 'amount_sum'])).toEqual(tuples(echoed, ['note', 'amount_sum']));
315+
expect(tuples(res.rows, ['note', 'amount_sum'])).toEqual([['x', 15], ['y', 7]]);
316+
});
317+
318+
describe('CONTROL with no order, offset or limit the answer is the engine aggregate, unchanged', () => {
319+
it('a selected dimension', async () => {
320+
const { res } = await ask('objectql', { cube: CUBE, measures: ['amount_sum'], dimensions: ['note'] });
321+
expect(res.rows.length).toBe(4);
322+
expect(tuples(res.rows, ['note', 'amount_sum'])).toEqual(tuples(reads.lastAggregate, ['note', 'amount_sum']));
323+
});
324+
325+
it('a bucketed time dimension', async () => {
326+
const { res } = await ask('objectql', {
327+
cube: CUBE,
328+
measures: ['count'],
329+
timeDimensions: [{ dimension: 'closed_on', granularity: 'month' }],
330+
});
331+
expect(res.rows.length).toBe(4);
332+
expect(tuples(res.rows, ['closed_on', 'count'])).toEqual(tuples(reads.lastAggregate, ['closed_on', 'count']));
333+
});
334+
});
335+
336+
describe('the dataset door windows a pushed-down page once, on both faces', () => {
337+
it('a selected dimension ordered by a measure, offset 1 and limit 2', async () => {
338+
for (const face of FACES) {
339+
const res = await services[face]!.queryDataset(DATASET as any, {
340+
dimensions: ['note'],
341+
measures: ['amount_sum'],
342+
order: { amount_sum: 'desc' },
343+
offset: 1,
344+
limit: 2,
345+
} as any);
346+
expect(tuples(res.rows, ['note', 'amount_sum']), face).toEqual([['x', 15], ['y', 7]]);
347+
}
348+
});
349+
350+
it('a month-bucketed dimension ordered desc, offset 1 and limit 2', async () => {
351+
for (const face of FACES) {
352+
const res = await services[face]!.queryDataset(DATASET as any, {
353+
dimensions: ['closed_on'],
354+
measures: ['amount_sum'],
355+
dateGranularity: 'month',
356+
order: { closed_on: 'desc' },
357+
offset: 1,
358+
limit: 2,
359+
} as any);
360+
expect(tuples(res.rows, ['closed_on', 'amount_sum']), face).toEqual([['2026-05', 7], ['2026-04', 1]]);
361+
}
362+
});
363+
});
364+
},
365+
);
366+
}

0 commit comments

Comments
 (0)