Skip to content

Commit fbe2deb

Browse files
fix(service-analytics): the ObjectQL face applies a query's order, offset and limit, as its echoed sql says (#21363)
Fixes #21316 Clause-②: no ## What this changes **The ObjectQL face applies `order`, then `offset` and `limit`, to the aggregated answer**, as its echoed `sql` and `POST /api/v1/analytics/sql` say it does (`packages/services/service-analytics/src/strategies/objectql-strategy.ts`, `orderAndWindow`). - `engine.aggregate` declares no ordering or window grammar (`EngineAggregateOptionsSchema`, `packages/spec/src/data/data-engine.zod.ts`), and neither does the `executeAggregate` contract (`StrategyContext.executeAggregate`, `packages/spec/src/contracts/analytics-service.ts`). So there is nothing to push down, and the strategy applies the three keys itself. It does this on the direct path and on the cross-object (FK-expand) path, after the re-bucket. Before the re-bucket, a limit would cut FK groups that merge into a group it keeps. - It runs on the remapped rows, which are keyed by the member spellings the caller selected. So an `order` key names a column exactly as the analytics door's order-key rule (`order-key-door.ts`, #21267) admitted it. `order` is read verbatim, in its own key order, and no implicit ordering is added. A bare `limit` slices the engine's order, the same as `LIMIT` without `ORDER BY`. - **One ordering rule.** The comparison is `applyOrdering` and the window is `applyWindow`. Both come from `dataset-executor.ts`, the package's one row comparator and window, which the dataset door's post-pass already applies to every grid. Nothing is copied. Other comparators exist in or near the package, and none was reused: `preview-evaluator.ts` has its own `compare` for draft rows, and `driver-memory`'s `applySort` is private. **The dataset door no longer applies a pushed-down `offset` twice** (`dataset-executor.ts`). For a single-query selection, `DatasetExecutor` pushes `order`, `limit` and `offset` down to the strategy and then windowed the answer again. The second slice applies `offset` a second time. This is a shipped defect on the native face, and this change would have extended it to the ObjectQL face, which was right before only because it dropped the window. The post-pass now windows only a grid it could not push down. It still re-sorts every grid. ## Measured before / after The measurement went through the real `POST /api/v1/analytics/query` and `/analytics/sql` route (`createDispatcherPlugin`), using `AnalyticsServicePlugin` with its default composition and with capabilities narrowed to the engine aggregate, a real `ObjectQL` engine and `SqlDriver`. It ran on SQLite (better-sqlite3) and PostgreSQL 16.14 (C.UTF-8 collation). | query (ObjectQL face) | `main` 97239c3, SQLite | `main`, PostgreSQL | this branch, both | |:--|:--|:--|:--| | `timeDimensions [closed_on, month]`, `order { closed_on: desc }`, `limit 1` | every month, ascending | every month, `04, 03, 05` | the newest month alone | | `dimensions [note]`, `order { note: desc }` | unordered | unordered | the native face's rows | | `order { amount_sum: desc }`, `limit 2`, `offset 1` | every group | every group | the native face's rows | On both drivers the echoed `sql` rendered `… ORDER BY "closed_on" DESC LIMIT 1` (and the others), before and after. Dataset door, `limit 2, offset 1`, ordered by a measure over five groups. Before, the native face answered one row (the third) on both drivers, and the ObjectQL face answered the second and third. After, both faces answer the second and third. ## Pins (`src/__tests__/objectql-face-order-limit.test.ts`, SQLite always, PostgreSQL where `OS_TEST_POSTGRES_URL` is set) - The card's row: a bucketed time dimension ordered `desc` with `limit 1` answers the newest bucket, on both compositions, and no raw statement runs. Also a bucketed time dimension with `offset 1, limit 2`. - These answer exactly what the native face answers: a selected dimension (the card's row), a selected measure with `offset`/`limit`, two order keys, `limit 0`, and the cross-object path ordered by a measure and by the relationship-path dimension. - The echoed `sql`, run on the same driver, answers the rows the ObjectQL face answered. - CONTROL: with no `order`/`offset`/`limit`, the answer is exactly the engine aggregate's rows in the engine's order (a selected dimension and a bucketed one). - The dataset door's pushed-down page, on both faces. - The #21267 controls in `order-key-selected.test.ts` now assert the ObjectQL face's order, not only its set. ## Ablations (committed tree, each leg through `scripts/ablation-replace.mjs` with the anchor proven to land and the restore proven by blob hash plus an empty `git diff HEAD`; the pins import the strategy from `src/`, so no `dist/` is involved) Recorded at `c6c4ae9d0`. The later merge of `main` (`a3179775d`) leaves `packages/services/service-analytics/` byte-identical: `git diff c6c4ae9 a317977` over that path is empty. The pin file has 26 cases, 13 per driver. - **Ordering removed** (`applyWindow(rows, …)`): 17 red, 9 green. Every order pin is red on SQLite. The 9 green are the controls, `limit 0`, and windows that the arrival order happens to satisfy on one driver. - **Window removed** (`applyOrdering(rows, query.order)`): 18 red. Every `limit`/`offset` pin is red on both drivers. The order-only pins and the controls stay green. - **Ordering inverted** (`.reverse()`): 20 red. Every native-equality pin and both controls are red on both drivers. The dataset-door pins stay green, because the executor re-sorts a middle window that happens to be symmetric. The next leg owns those pins. - **Executor re-windows a pushed-down page**: exactly the 4 dataset-door pins are red (2 per driver), and nothing else. - The first run of that last leg was a no-op that the tool refused (its replacement text was a substring of the anchor, so the count could not rise). It was re-run with a distinct replacement. The first runs of the other three legs, on an earlier fixture, showed that SQLite's ascending group order satisfied two order pins. The fixture was changed so that no order pin matches that arrival order, and all four legs were re-run. ## Acceptance notes - **Comparator semantics against the native face.** Both faces answer the same rows for numbers and for text of single-case ASCII letters. They can differ on NULL, `''`, numeric text and mixed case. This was measured on this branch at the route: - The native `ORDER BY` places NULL lowest on SQLite and highest on PostgreSQL, and orders text by the column collation (codepoint on SQLite, and on PostgreSQL under C.UTF-8). - `applyOrdering` keeps NULL and `''` last in both directions, compares numeric text numerically, and compares other text with `localeCompare`. - This is the dataset door's existing, documented rule ("Null / empty buckets sort last in both directions", `content/docs/data-modeling/analytics.mdx`). It is reused, not changed. The open question is in the report. - **`limit` / `offset` outside the non-negative integers.** `AnalyticsQuerySchema` declares both as `z.number()`. Measured on this branch: - `limit: -1`: native SQLite returns every row, native PostgreSQL answers 500. The ObjectQL face now answers `applyWindow`'s slice (all but the last row), as the dataset door already does for the same value. Before, it answered every row. - `limit: 1.5`: native SQLite answers 500, PostgreSQL rounds to 2 rows, the ObjectQL face answers 1 row. - `offset: -1`: the native face answers 500 on both drivers. - No answer agrees across drivers. These are in the report as a finding; this PR leaves them as they are. - **`offset` without `limit` on SQLite.** The native face renders `OFFSET n` with no `LIMIT`, which SQLite refuses (`near "OFFSET": syntax error`, 500). The ObjectQL face answers it, and its echo renders the same statement. This is in the report as a finding. - Docs: no page in `content/docs/**` (outside `releases/`) or `skills/**` says the ObjectQL face ignores `order`/`limit`, so no doc sentence becomes false. Code comments that said so, in the `dataset-executor.ts` header and post-pass and in the `order-key-door.ts` header, are corrected. ## Verification (head `a3179775d`, after merging `main`) - `pnpm --filter @objectstack/service-analytics test` with live PostgreSQL 16.14 (`OS_TEST_POSTGRES_URL`, so no cell skipped): 167 files, 3869 tests passed. Its dependency closure was rebuilt first. - `pnpm --filter @objectstack/service-analytics typecheck`: exit 0. Both touched test files are in its program (`tsc --listFiles`). - The derived gate union (`node scripts/pm/dispatch-gates.mjs --commands`, merge base `96b12b589`) has 63 commands. All 63 exited 0, and `--ran` reconciles 63 derived, 63 run, 0 NOT-MEASURED. On the earlier head, `pnpm check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET (no `dist/`). It was run again after a full `turbo run build` and answered 0, and it answered 0 again on this head. - Lint, narrowed to the 5 touched `.ts` files: `eslint --no-inline-config --format json` reads 5 files with 0 errors and 0 warnings. `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`), so this diff cannot change the verdict on any untouched file. The repo-wide `pnpm lint` is left to CI. --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9360df4 commit fbe2deb

6 files changed

Lines changed: 459 additions & 25 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: 367 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,367 @@
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 7 (1 row), x 15 (2), y 20 (1), z 1 (1). By month: 03 (2 rows,
80+
// 15), 04 (1), 05 (20), 06 (7). By owner: a@x 15, b@x 21, c@x 7. SQLite
81+
// answers a grouping in ascending key order, so every ordered pin below asks
82+
// for an order, and a window, that ascending key order does not already give.
83+
const DEALS = [
84+
{ id: 'd1', note: 'x', amount: 10, closed_on: '2026-03-01', owner: 'p1' },
85+
{ id: 'd2', note: 'x', amount: 5, closed_on: '2026-03-02', owner: 'p1' },
86+
{ id: 'd3', note: 'y', amount: 20, closed_on: '2026-05-03', owner: 'p2' },
87+
{ id: 'd4', note: 'z', amount: 1, closed_on: '2026-04-01', owner: 'p2' },
88+
{ id: 'd5', note: 'w', amount: 7, closed_on: '2026-06-01', owner: 'p3' },
89+
] as const;
90+
91+
/** It declares NO join, so `owner.email` is served by FK-expand on the ObjectQL face and by a JOIN on the native face. */
92+
const CUBE = 'os21316_cube';
93+
const CUBES = [
94+
{
95+
name: CUBE,
96+
title: 'Order window cube',
97+
sql: DEAL,
98+
public: true,
99+
measures: {
100+
count: { type: 'count', sql: '*', label: 'Rows' },
101+
amount_sum: { type: 'sum', sql: 'amount', label: 'Amount' },
102+
},
103+
dimensions: {
104+
note: { type: 'string', sql: 'note', label: 'Note' },
105+
closed_on: { type: 'time', sql: 'closed_on', label: 'Closed on' },
106+
},
107+
},
108+
] as unknown as Cube[];
109+
110+
/** The same object as a dataset, for the dataset door's pushed-down page. */
111+
const DATASET = {
112+
name: 'os21316_ds',
113+
label: 'Order window dataset',
114+
object: DEAL,
115+
dimensions: [
116+
{ name: 'note', field: 'note', type: 'string' },
117+
{ name: 'closed_on', field: 'closed_on', type: 'date' },
118+
],
119+
measures: [{ name: 'amount_sum', aggregate: 'sum', field: 'amount' }],
120+
};
121+
122+
interface Cell {
123+
id: 'sqlite' | 'pg';
124+
label: string;
125+
env: string | null;
126+
config: () => Record<string, unknown> | null;
127+
}
128+
129+
const CELLS: readonly Cell[] = [
130+
{ id: 'sqlite', label: 'sqlite', env: null, config: () => ({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) },
131+
{
132+
id: 'pg',
133+
label: 'live postgres',
134+
env: 'OS_TEST_POSTGRES_URL',
135+
config: () => (process.env.OS_TEST_POSTGRES_URL ? { client: 'pg', connection: process.env.OS_TEST_POSTGRES_URL } : null),
136+
},
137+
];
138+
139+
const FACES = ['native', 'objectql'] as const;
140+
type Face = (typeof FACES)[number];
141+
142+
const quiet = { debug() {}, info() {}, warn() {}, error() {}, child() { return quiet; } };
143+
144+
type Row = Record<string, unknown>;
145+
146+
/** Rows as tuples of the named columns, in the order they arrived; a numeric cell reads as a number on every dialect. */
147+
const tuples = (rows: unknown, columns: readonly string[]) =>
148+
(rows as Row[]).map((row) => columns.map((c) => (typeof row[c] === 'number' || /^-?\d+(\.\d+)?$/.test(String(row[c])) ? Number(row[c]) : row[c])));
149+
150+
for (const cell of CELLS) {
151+
const config = cell.config();
152+
describe.skipIf(!config)(
153+
`[#21316] the ObjectQL face applies order, offset and limit (${cell.label})${config ? '' : ` (skipped: set ${cell.env} to run this cell)`}`,
154+
() => {
155+
let driver: any;
156+
let engine: ObjectQL;
157+
/** Raw-SQL statements and engine aggregates, on any object, and the last aggregate's raw answer. */
158+
const reads = { rawSql: 0, aggregate: 0, lastAggregate: [] as Row[] };
159+
/** `native`: the plugin's own capabilities. `objectql`: narrowed to the engine-aggregate path. */
160+
const services: Partial<Record<Face, AnalyticsService>> = {};
161+
162+
const dropTables = async () => {
163+
if (cell.id !== 'pg') return;
164+
for (const table of [DEAL, PERSON]) await driver?.execute(`drop table if exists ${table}`).catch(() => {});
165+
};
166+
167+
/** One `query()` on one face, with the reads it caused. */
168+
const ask = async (face: Face, query: Record<string, unknown>) => {
169+
const before = { ...reads };
170+
const res = await services[face]!.query(query as any);
171+
return { res, rawSql: reads.rawSql - before.rawSql, aggregate: reads.aggregate - before.aggregate };
172+
};
173+
174+
/** The ObjectQL face answers `query` exactly as the native face does, served by the engine aggregate. */
175+
const equalToNative = async (query: Record<string, unknown>, columns: readonly string[], expected: unknown[][]) => {
176+
const native = await ask('native', query);
177+
expect(native.rawSql, 'the native face ran its statement').toBeGreaterThan(0);
178+
expect(tuples(native.res.rows, columns), 'native face').toEqual(expected);
179+
const objectql = await ask('objectql', query);
180+
expect(objectql.rawSql, 'the ObjectQL face ran no statement').toBe(0);
181+
expect(objectql.aggregate, 'the ObjectQL face ran the engine aggregate').toBeGreaterThan(0);
182+
expect(tuples(objectql.res.rows, columns), 'ObjectQL face').toEqual(expected);
183+
};
184+
185+
beforeAll(async () => {
186+
driver = new SqlDriver(config as any);
187+
await dropTables();
188+
engine = new ObjectQL({ logger: quiet } as any);
189+
engine.registerDriver(driver, true);
190+
await engine.init();
191+
for (const object of [PERSON_OBJECT, DEAL_OBJECT]) engine.registry.registerObject(object as any);
192+
await engine.syncSchemas();
193+
for (const row of PEOPLE) await engine.insert(PERSON, { ...row } as any);
194+
for (const row of DEALS) await engine.insert(DEAL, { ...row } as any);
195+
196+
const realExecute = (engine as any).execute.bind(engine);
197+
(engine as any).execute = (sql: unknown, opts?: unknown) => {
198+
reads.rawSql += 1;
199+
return realExecute(sql, opts);
200+
};
201+
const realAggregate = engine.aggregate.bind(engine);
202+
(engine as any).aggregate = async (...args: unknown[]) => {
203+
reads.aggregate += 1;
204+
const rows = await (realAggregate as any)(...args);
205+
reads.lastAggregate = (rows as Row[]).map((row) => ({ ...row }));
206+
return rows;
207+
};
208+
209+
for (const [face, caps] of [
210+
['native', undefined],
211+
['objectql', () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false })],
212+
] as const) {
213+
const registered: Record<string, unknown> = {};
214+
await new AnalyticsServicePlugin({ cubes: CUBES, debugSql: true, ...(caps ? { queryCapabilities: caps } : {}) } as any).init({
215+
getService: (name: string) => (name === 'data' ? engine : registered[name]),
216+
registerService: (name: string, svc: unknown) => { registered[name] = svc; },
217+
replaceService: (name: string, svc: unknown) => { registered[name] = svc; },
218+
hook: () => {},
219+
logger: quiet,
220+
} as never);
221+
services[face] = registered.analytics as AnalyticsService;
222+
}
223+
});
224+
225+
afterAll(async () => {
226+
await dropTables();
227+
try { await engine?.destroy(); } catch { /* noop */ }
228+
});
229+
230+
describe('a bucketed time dimension, which lands on the ObjectQL face on every composition', () => {
231+
it("the card's row: ordered desc and limited to 1, it answers the newest bucket alone", async () => {
232+
const query = {
233+
cube: CUBE,
234+
measures: ['count'],
235+
timeDimensions: [{ dimension: 'closed_on', granularity: 'month' }],
236+
order: { closed_on: 'desc' },
237+
limit: 1,
238+
};
239+
for (const face of FACES) {
240+
const { res, rawSql } = await ask(face, query);
241+
expect(rawSql, `${face}: the native face declines granularity`).toBe(0);
242+
expect(tuples(res.rows, ['closed_on', 'count']), face).toEqual([['2026-06', 1]]);
243+
}
244+
});
245+
246+
it('ordered desc with offset 1 and limit 2, it answers the second and third newest buckets', async () => {
247+
const { res } = await ask('objectql', {
248+
cube: CUBE,
249+
measures: ['amount_sum'],
250+
timeDimensions: [{ dimension: 'closed_on', granularity: 'month' }],
251+
order: { closed_on: 'desc' },
252+
offset: 1,
253+
limit: 2,
254+
});
255+
expect(tuples(res.rows, ['closed_on', 'amount_sum'])).toEqual([['2026-05', 20], ['2026-04', 1]]);
256+
});
257+
});
258+
259+
describe('where both faces answer, the ObjectQL face answers what the native face does', () => {
260+
it("the card's row: a selected dimension ordered desc", async () => {
261+
await equalToNative(
262+
{ cube: CUBE, measures: ['count'], dimensions: ['note'], order: { note: 'desc' } },
263+
['note', 'count'],
264+
[['z', 1], ['y', 1], ['x', 2], ['w', 1]],
265+
);
266+
});
267+
268+
it('a selected measure ordered desc, with offset 1 and limit 2', async () => {
269+
await equalToNative(
270+
{ cube: CUBE, measures: ['amount_sum'], dimensions: ['note'], order: { amount_sum: 'desc' }, offset: 1, limit: 2 },
271+
['note', 'amount_sum'],
272+
[['x', 15], ['w', 7]],
273+
);
274+
});
275+
276+
it('two order keys, the first one most significant', async () => {
277+
await equalToNative(
278+
{ cube: CUBE, measures: ['count'], dimensions: ['note'], order: { count: 'desc', note: 'asc' } },
279+
['note', 'count'],
280+
[['x', 2], ['w', 1], ['y', 1], ['z', 1]],
281+
);
282+
});
283+
284+
it('limit 0 answers no rows, as LIMIT 0 does', async () => {
285+
await equalToNative(
286+
{ cube: CUBE, measures: ['count'], dimensions: ['note'], order: { note: 'asc' }, limit: 0 },
287+
['note', 'count'],
288+
[],
289+
);
290+
});
291+
292+
it('the cross-object path: a relationship-path dimension, ordered by a measure and limited', async () => {
293+
await equalToNative(
294+
{ cube: CUBE, measures: ['amount_sum'], dimensions: ['owner.email'], order: { amount_sum: 'desc' }, limit: 2 },
295+
['owner.email', 'amount_sum'],
296+
[['b@x', 21], ['a@x', 15]],
297+
);
298+
});
299+
300+
it('the cross-object path: ordered by the relationship-path dimension itself, with offset 1 and limit 2', async () => {
301+
await equalToNative(
302+
{ cube: CUBE, measures: ['amount_sum'], dimensions: ['owner.email'], order: { 'owner.email': 'desc' }, offset: 1, limit: 2 },
303+
['owner.email', 'amount_sum'],
304+
[['b@x', 21], ['a@x', 15]],
305+
);
306+
});
307+
});
308+
309+
it('the echoed sql, run on the same driver, answers the rows the ObjectQL face answered', async () => {
310+
const query = { cube: CUBE, measures: ['amount_sum'], dimensions: ['note'], order: { amount_sum: 'desc' }, offset: 1, limit: 2 };
311+
const { res } = await ask('objectql', query);
312+
expect(res.sql).toContain('ORDER BY "amount_sum" DESC LIMIT 2 OFFSET 1');
313+
const ran = (await driver.execute(res.sql)) as unknown;
314+
const echoed = Array.isArray(ran) ? ran : (ran as { rows: Row[] }).rows;
315+
expect(tuples(res.rows, ['note', 'amount_sum'])).toEqual(tuples(echoed, ['note', 'amount_sum']));
316+
expect(tuples(res.rows, ['note', 'amount_sum'])).toEqual([['x', 15], ['w', 7]]);
317+
});
318+
319+
describe('CONTROL with no order, offset or limit the answer is the engine aggregate, unchanged', () => {
320+
it('a selected dimension', async () => {
321+
const { res } = await ask('objectql', { cube: CUBE, measures: ['amount_sum'], dimensions: ['note'] });
322+
expect(res.rows.length).toBe(4);
323+
expect(tuples(res.rows, ['note', 'amount_sum'])).toEqual(tuples(reads.lastAggregate, ['note', 'amount_sum']));
324+
});
325+
326+
it('a bucketed time dimension', async () => {
327+
const { res } = await ask('objectql', {
328+
cube: CUBE,
329+
measures: ['count'],
330+
timeDimensions: [{ dimension: 'closed_on', granularity: 'month' }],
331+
});
332+
expect(res.rows.length).toBe(4);
333+
expect(tuples(res.rows, ['closed_on', 'count'])).toEqual(tuples(reads.lastAggregate, ['closed_on', 'count']));
334+
});
335+
});
336+
337+
describe('the dataset door windows a pushed-down page once, on both faces', () => {
338+
it('a selected dimension ordered by a measure, offset 1 and limit 2', async () => {
339+
for (const face of FACES) {
340+
const res = await services[face]!.queryDataset(DATASET as any, {
341+
dimensions: ['note'],
342+
measures: ['amount_sum'],
343+
order: { amount_sum: 'desc' },
344+
offset: 1,
345+
limit: 2,
346+
} as any);
347+
expect(tuples(res.rows, ['note', 'amount_sum']), face).toEqual([['x', 15], ['w', 7]]);
348+
}
349+
});
350+
351+
it('a month-bucketed dimension ordered desc, offset 1 and limit 2', async () => {
352+
for (const face of FACES) {
353+
const res = await services[face]!.queryDataset(DATASET as any, {
354+
dimensions: ['closed_on'],
355+
measures: ['amount_sum'],
356+
dateGranularity: 'month',
357+
order: { closed_on: 'desc' },
358+
offset: 1,
359+
limit: 2,
360+
} as any);
361+
expect(tuples(res.rows, ['closed_on', 'amount_sum']), face).toEqual([['2026-05', 20], ['2026-04', 1]]);
362+
}
363+
});
364+
});
365+
},
366+
);
367+
}

0 commit comments

Comments
 (0)