Skip to content

Commit c06b33c

Browse files
committed
wip(analytics): serve dataset duration measures on the native SQL face
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AKPixnkg91qCkQCVFDJuqW
1 parent f183745 commit c06b33c

8 files changed

Lines changed: 962 additions & 31 deletions

File tree

‎packages/services/service-analytics/src/__tests__/dataset-duration-measure.test.ts‎

Lines changed: 339 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25778] The dataset duration measure on the native-SQL face, as statements,
5+
* and on the service's gates.
6+
*
7+
* `dataset-duration-measure.test.ts` measures the numbers on live SQLite and
8+
* PostgreSQL. This file pins what that cannot reach: the statement per dialect
9+
* (MySQL included, which has no live cell in this package), the routing on a
10+
* dialect nobody named, the presentation of a client's string answer, the
11+
* draft-preview evaluator, and the service gates a duration measure passes
12+
* through — the field-level read gate, the measure mint, the inline-dataset
13+
* door and the compile guards for a dataset that reaches the service unparsed.
14+
*/
15+
16+
import { describe, it, expect } from 'vitest';
17+
import type { Cube } from '@objectstack/spec/data';
18+
import type { AnalyticsQuery, StrategyContext } from '@objectstack/spec/contracts';
19+
import type { ExecutionContext } from '@objectstack/spec/kernel';
20+
import { DatasetSchema } from '@objectstack/spec/ui';
21+
import { AnalyticsService } from '../analytics-service.js';
22+
import { NativeSQLStrategy } from '../strategies/native-sql-strategy.js';
23+
import type { DatasetScope, DurationMeasureSpec } from '../strategies/types.js';
24+
import { durationMeasureIn } from '../duration-measure-sql.js';
25+
import { evaluateAnalyticsQueryOverRows } from '../preview-evaluator.js';
26+
27+
const OBJECT = 'ticket';
28+
29+
const CUBE: Cube = {
30+
name: 'tickets',
31+
sql: OBJECT,
32+
measures: { n: { label: 'Rows', type: 'count', sql: '*' } },
33+
dimensions: { category: { label: 'Category', type: 'string', sql: 'category' } },
34+
joins: { owner: { name: 'owner_obj', relationship: 'many_to_one', sql: 'owner' } },
35+
public: true,
36+
} as unknown as Cube;
37+
38+
const AVG_HOURS: DurationMeasureSpec = { aggregate: 'avg', from: 'opened_at', to: 'resolved_at', unit: 'hours' };
39+
const TOTAL_SECONDS: DurationMeasureSpec = { aggregate: 'sum', from: 'opened_at', to: 'resolved_at', unit: 'seconds' };
40+
41+
const SCOPE: DatasetScope = {
42+
measureDurations: { avg_hours: AVG_HOURS, total_seconds: TOTAL_SECONDS },
43+
measureFilters: { total_seconds: { priority: 'high' } },
44+
};
45+
46+
function nativeCtx(knobs: {
47+
dialect?: string;
48+
rows?: Record<string, unknown>[];
49+
coerce?: (object: string, field: string, col: string) => string;
50+
} = {}): StrategyContext {
51+
return {
52+
getCube: (name: string) => (name === CUBE.name ? CUBE : undefined),
53+
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: true, inMemory: false }),
54+
executeRawSql: async () => knobs.rows ?? [],
55+
getDatasetScope: () => SCOPE,
56+
...(knobs.dialect ? { sqlDialect: () => knobs.dialect } : {}),
57+
...(knobs.coerce ? { coerceTemporalFilterColumn: knobs.coerce } : {}),
58+
} as unknown as StrategyContext;
59+
}
60+
61+
const q = (extra: Partial<AnalyticsQuery> = {}): AnalyticsQuery =>
62+
({ cube: CUBE.name, measures: ['avg_hours', 'total_seconds'], dimensions: ['category'], ...extra }) as AnalyticsQuery;
63+
64+
describe('[#25778] NativeSQLStrategy — the duration measure as a statement', () => {
65+
const native = new NativeSQLStrategy();
66+
67+
it.each([
68+
[
69+
'sqlite',
70+
'AVG(((ROUND((julianday(resolved_at) - julianday(opened_at)) * 86400000.0) / 1000.0) / 3600.0)) AS "avg_hours"',
71+
'SUM(CASE WHEN priority = $1 THEN (ROUND((julianday(resolved_at) - julianday(opened_at)) * 86400000.0) / 1000.0) END) AS "total_seconds"',
72+
],
73+
[
74+
'postgres',
75+
'AVG((CAST(EXTRACT(EPOCH FROM (resolved_at - opened_at)) AS DOUBLE PRECISION) / 3600.0)) AS "avg_hours"',
76+
'SUM(CASE WHEN priority = $1 THEN CAST(EXTRACT(EPOCH FROM (resolved_at - opened_at)) AS DOUBLE PRECISION) END) AS "total_seconds"',
77+
],
78+
[
79+
'mysql',
80+
'AVG(((TIMESTAMPDIFF(MICROSECOND, opened_at, resolved_at) / 1000000.0) / 3600.0)) AS "avg_hours"',
81+
'SUM(CASE WHEN priority = $1 THEN (TIMESTAMPDIFF(MICROSECOND, opened_at, resolved_at) / 1000000.0) END) AS "total_seconds"',
82+
],
83+
])('%s: the per-dialect difference, divided by the unit, under the aggregate and its measure filter', async (dialect, avg, filteredSum) => {
84+
const { sql, params } = await native.generateSql(q(), nativeCtx({ dialect }));
85+
expect(sql).toBe(`SELECT category AS "category", ${avg}, ${filteredSum} FROM "${OBJECT}" GROUP BY category`);
86+
expect(params).toEqual(['high']);
87+
});
88+
89+
it('a statement that joins qualifies both columns with the base table', async () => {
90+
const { sql } = await native.generateSql(q({ measures: ['avg_hours'], dimensions: ['owner.region'] }), nativeCtx({ dialect: 'postgres' }));
91+
expect(sql).toContain(`EXTRACT(EPOCH FROM ("${OBJECT}"."resolved_at" - "${OBJECT}"."opened_at"))`);
92+
expect(sql).toContain('LEFT JOIN "owner_obj" "owner"');
93+
});
94+
95+
it('each column reads through the host\'s temporal repair, as a filter on it does', async () => {
96+
const seen: string[] = [];
97+
const coerce = (object: string, field: string, col: string) => {
98+
seen.push(`${object}.${field}`);
99+
return `canon(${col})`;
100+
};
101+
const { sql } = await native.generateSql(q({ measures: ['avg_hours'] }), nativeCtx({ dialect: 'sqlite', coerce }));
102+
expect(sql).toContain('julianday(canon(resolved_at)) - julianday(canon(opened_at))');
103+
expect(seen).toEqual([`${OBJECT}.opened_at`, `${OBJECT}.resolved_at`]);
104+
});
105+
106+
it('declines a duration measure on a dialect nobody named, and only then', () => {
107+
expect(native.canHandle(q(), nativeCtx())).toBe(false);
108+
expect(native.canHandle(q(), nativeCtx({ dialect: 'oracle' }))).toBe(false);
109+
expect(native.canHandle(q({ measures: ['n'] }), nativeCtx()), 'CONTROL a query with no duration measure').toBe(true);
110+
for (const dialect of ['sqlite', 'postgres', 'mysql']) expect(native.canHandle(q(), nativeCtx({ dialect })), dialect).toBe(true);
111+
});
112+
113+
it('presents a client string as a number, and folds an empty sum to 0 while an empty avg stays null', async () => {
114+
const rows = [
115+
{ category: 'a', avg_hours: '12.75', total_seconds: '91800' },
116+
{ category: 'c', avg_hours: null, total_seconds: null },
117+
];
118+
const res = await native.execute(q(), nativeCtx({ dialect: 'postgres', rows }));
119+
expect(res.rows).toEqual([
120+
{ category: 'a', avg_hours: 12.75, total_seconds: 91800 },
121+
{ category: 'c', avg_hours: null, total_seconds: 0 },
122+
]);
123+
expect(res.fields).toEqual([
124+
{ name: 'category', type: 'string' },
125+
{ name: 'avg_hours', type: 'number' },
126+
{ name: 'total_seconds', type: 'number' },
127+
]);
128+
});
129+
130+
it('a measure is a duration only by an own key: a name on Object.prototype is never one', () => {
131+
expect(durationMeasureIn(SCOPE.measureDurations, 'avg_hours')).toBe(AVG_HOURS);
132+
expect(durationMeasureIn(SCOPE.measureDurations, 'constructor')).toBeUndefined();
133+
expect(durationMeasureIn(SCOPE.measureDurations, 'toString')).toBeUndefined();
134+
expect(durationMeasureIn(undefined, 'avg_hours')).toBeUndefined();
135+
});
136+
});
137+
138+
describe('[#25778] the draft preview computes a duration measure from the rows', () => {
139+
const rows = [
140+
{ category: 'a', opened_at: '2026-03-01T08:00:00.000Z', resolved_at: '2026-03-01T09:30:00.000Z' },
141+
{ category: 'a', opened_at: '2026-03-02T10:00:00.000Z', resolved_at: '2026-03-03T10:00:00.000Z' },
142+
{ category: 'a', opened_at: '2026-03-04T00:00:00.000Z', resolved_at: null },
143+
{ category: 'b', opened_at: '2026-03-05T12:00:00.250Z', resolved_at: '2026-03-05T12:00:01.000Z' },
144+
{ category: 'c', opened_at: null, resolved_at: '2026-03-07T00:00:00.000Z' },
145+
];
146+
const durations = {
147+
avg_hours: AVG_HOURS,
148+
total_seconds: TOTAL_SECONDS,
149+
fastest_minutes: { ...AVG_HOURS, aggregate: 'min', unit: 'minutes' },
150+
slowest_days: { ...AVG_HOURS, aggregate: 'max', unit: 'days' },
151+
} satisfies Record<string, DurationMeasureSpec>;
152+
153+
it('the native face\'s numbers over the same rows, nulls excluded', () => {
154+
const res = evaluateAnalyticsQueryOverRows(
155+
{ cube: CUBE.name, measures: Object.keys(durations), dimensions: ['category'] } as AnalyticsQuery,
156+
CUBE,
157+
rows,
158+
undefined,
159+
durations,
160+
);
161+
expect(res.rows).toEqual([
162+
{ category: 'a', avg_hours: 12.75, total_seconds: 91800, fastest_minutes: 90, slowest_days: 1 },
163+
{ category: 'b', avg_hours: 0.75 / 3600, total_seconds: 0.75, fastest_minutes: 0.75 / 60, slowest_days: 0.75 / 86400 },
164+
{ category: 'c', avg_hours: null, total_seconds: 0, fastest_minutes: null, slowest_days: null },
165+
]);
166+
});
167+
});
168+
169+
// ── The service's gates ─────────────────────────────────────────────────────
170+
171+
const FIELDS = ['id', 'category', 'priority', 'opened_at', 'resolved_at', 'effort'];
172+
const MEMBER = { userId: 'u_member', tenantId: 'org_a' } as ExecutionContext;
173+
174+
const DATASET_BASE = {
175+
name: 'resolution',
176+
label: 'Resolution',
177+
object: OBJECT,
178+
dimensions: [{ name: 'category', field: 'category', type: 'string' }],
179+
measures: [
180+
{ name: 'tickets', aggregate: 'count' },
181+
// A name the suffix mint would read as `sum(resolution_seconds)`.
182+
{ name: 'resolution_seconds_sum', aggregate: 'sum', duration: { from: 'opened_at', to: 'resolved_at', unit: 'seconds' } },
183+
],
184+
};
185+
186+
function makeService(opts: {
187+
capabilities?: () => { nativeSql: boolean; objectqlAggregate: boolean; inMemory: boolean };
188+
readable?: readonly string[];
189+
draftRows?: Record<string, unknown>[];
190+
} = {}) {
191+
const executed: string[] = [];
192+
const service = new AnalyticsService({
193+
queryCapabilities: opts.capabilities ?? (() => ({ nativeSql: true, objectqlAggregate: true, inMemory: false })),
194+
getObjectFieldNames: (object: string) => (object === OBJECT ? FIELDS : undefined),
195+
sourceFieldMeta: (object: string, field: string) =>
196+
object === OBJECT && FIELDS.includes(field)
197+
? { type: field.endsWith('_at') ? 'datetime' : field === 'effort' ? 'number' : 'text' }
198+
: undefined,
199+
sqlDialect: () => 'sqlite',
200+
...(opts.readable ? { getReadableFields: () => opts.readable } : {}),
201+
...(opts.draftRows ? { draftRowsResolver: async () => opts.draftRows } : {}),
202+
executeRawSql: async (_object: string, sql: string) => {
203+
executed.push(sql);
204+
return [];
205+
},
206+
executeAggregate: async (object: string) => {
207+
executed.push(`aggregate:${object}`);
208+
return [];
209+
},
210+
} as never);
211+
return { service, executed };
212+
}
213+
214+
type Refusal = Error & { code?: string; status?: number; member?: string };
215+
const refusalOf = (run: Promise<unknown>) => run.then(() => undefined, (e: Refusal) => e);
216+
217+
describe('[#25778] AnalyticsService — the gates a duration measure passes through', () => {
218+
const dataset = DatasetSchema.parse(DATASET_BASE);
219+
220+
it('is served as a duration, never minted from its name by the suffix rule', async () => {
221+
const { service, executed } = makeService();
222+
await service.queryDataset(dataset, { measures: ['resolution_seconds_sum'], dimensions: ['category'] } as never, MEMBER);
223+
expect(executed).toHaveLength(1);
224+
expect(executed[0]).toContain('SUM((ROUND((julianday(resolved_at) - julianday(opened_at))');
225+
expect(executed[0]).not.toContain('SUM(resolution_seconds)');
226+
});
227+
228+
it('the field-level read gate judges both columns: an unreadable one refuses the measure, nothing runs', async () => {
229+
const { service, executed } = makeService({ readable: FIELDS.filter((f) => f !== 'resolved_at') });
230+
const err = await refusalOf(service.queryDataset(dataset, { measures: ['resolution_seconds_sum'], dimensions: ['category'] } as never, MEMBER));
231+
expect(err?.code, err?.message).toBe('PERMISSION_DENIED');
232+
expect(err?.status).toBe(403);
233+
expect(err?.message).toContain('resolved_at');
234+
expect(executed).toEqual([]);
235+
236+
const control = makeService({ readable: FIELDS });
237+
await control.service.queryDataset(dataset, { measures: ['resolution_seconds_sum'], dimensions: ['category'] } as never, MEMBER);
238+
expect(control.executed).toHaveLength(1);
239+
});
240+
241+
it('the dry-run door refuses it off the native strategy too', async () => {
242+
const { service } = makeService({ capabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }) });
243+
service.registerDataset(dataset);
244+
const err = await refusalOf(service.generateSql({ cube: dataset.name, measures: ['resolution_seconds_sum'], dimensions: ['category'] } as never, MEMBER));
245+
expect(err?.code, err?.message).toBe('INVALID_FIELD');
246+
expect(err?.status).toBe(400);
247+
expect(err?.member).toBe('resolution_seconds_sum');
248+
});
249+
250+
it('the draft preview serves it over the drafted rows', async () => {
251+
const draftRows = [
252+
{ category: 'a', opened_at: '2026-03-01T08:00:00.000Z', resolved_at: '2026-03-01T09:30:00.000Z' },
253+
{ category: 'a', opened_at: '2026-03-02T10:00:00.000Z', resolved_at: null },
254+
];
255+
const { service, executed } = makeService({ draftRows });
256+
const res = await service.queryDataset(dataset, { measures: ['resolution_seconds_sum'], dimensions: ['category'] } as never, MEMBER, { previewDrafts: true });
257+
expect(res.rows).toEqual([{ category: 'a', resolution_seconds_sum: 5400 }]);
258+
expect(executed).toEqual([]);
259+
});
260+
261+
describe('a dataset that reaches the service without meeting the spec\'s parse', () => {
262+
const stored = (duration: Record<string, unknown>) => ({
263+
...DATASET_BASE,
264+
measures: [{ name: 'leaked', aggregate: 'avg', duration }],
265+
});
266+
267+
it('the spec refuses each of these shapes at parse', () => {
268+
for (const duration of [
269+
{ from: '(SELECT secret FROM other)', to: 'resolved_at', unit: 'hours' },
270+
{ from: 'owner.created_at', to: 'resolved_at', unit: 'hours' },
271+
{ from: 'opened_at', to: 'resolved_at', unit: 'weeks' },
272+
]) {
273+
expect(DatasetSchema.safeParse(stored(duration)).success, JSON.stringify(duration)).toBe(false);
274+
}
275+
});
276+
277+
it('text that is no column reference is refused at the door: PERMISSION_DENIED / 403, nothing runs', async () => {
278+
const { service, executed } = makeService();
279+
const err = await refusalOf(
280+
service.queryDataset(stored({ from: '(SELECT secret FROM other)', to: 'resolved_at', unit: 'hours' }) as never, { measures: ['leaked'] } as never, MEMBER),
281+
);
282+
expect(err?.code, err?.message).toBe('PERMISSION_DENIED');
283+
expect(err?.status).toBe(403);
284+
expect(err?.member).toBe('leaked');
285+
expect(err?.message).not.toContain('SELECT');
286+
expect(executed).toEqual([]);
287+
});
288+
289+
it.each([
290+
[{ from: 'owner.created_at', to: 'resolved_at', unit: 'hours' }, '`duration.from` "owner.created_at", which names no column'],
291+
[{ from: 'opened_at', unit: 'hours' }, '`duration.to` undefined, which names no column'],
292+
[{ from: 'opened_at', to: 'resolved_at', unit: 'weeks' }, '`duration.unit` seconds / minutes / hours / days; it declares "weeks"'],
293+
])('%j is refused at compile: DATASET_INVALID / 400, nothing runs', async (duration, words) => {
294+
const { service, executed } = makeService();
295+
const err = await refusalOf(service.queryDataset(stored(duration) as never, { measures: ['leaked'] } as never, MEMBER));
296+
expect(err?.code, err?.message).toBe('DATASET_INVALID');
297+
expect(err?.status).toBe(400);
298+
expect(err?.message).toContain(words);
299+
expect(executed).toEqual([]);
300+
// The configuration door compiles through the same guard.
301+
expect(() => service.registerDataset(stored(duration) as never)).toThrow(words);
302+
});
303+
});
304+
});

0 commit comments

Comments
 (0)