Skip to content

Commit 6d830bf

Browse files
os-zhuangclaude
andcommitted
feat(analytics): server-side totals for matrix reports (#1753)
queryDataset selections accept totals.groupings — dimension subsets the executor re-runs the full selection against (measure-scoped filters, derived measures, compareTo included), returning marginal aggregates on AnalyticsResult.totals. Each subtotal/grand total is the measure's true aggregate over the underlying rows, never re-derived from bucketed values (ADR-0021 governance line). Dimension display labels resolve on totals rows the same way as the primary grid. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent c08b527 commit 6d830bf

5 files changed

Lines changed: 166 additions & 1 deletion

File tree

packages/services/service-analytics/src/__tests__/dataset-executor.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,78 @@ describe('DatasetExecutor', () => {
110110
expect(res.rows[0].win_rate).toBe(0.6);
111111
});
112112

113+
it('totals groupings re-run the selection per dimension subset (#1753)', async () => {
114+
const matrix = DatasetSchema.parse({
115+
name: 'hours', label: 'Hours', object: 'task',
116+
dimensions: [
117+
{ name: 'owner', field: 'owner', type: 'string' },
118+
{ name: 'status', field: 'status', type: 'string' },
119+
],
120+
measures: [{ name: 'avg_hours', aggregate: 'avg', field: 'est_hours' }],
121+
});
122+
const seen: AnalyticsQuery[] = [];
123+
const svc = fakeService((q) => {
124+
seen.push(q);
125+
const dims = (q.dimensions ?? []).join(',');
126+
if (dims === 'owner,status') return { rows: [{ owner: 'alice', status: 'done', avg_hours: 2 }], fields: [] };
127+
if (dims === 'owner') return { rows: [{ owner: 'alice', avg_hours: 3 }], fields: [] };
128+
if (dims === 'status') return { rows: [{ status: 'done', avg_hours: 4 }], fields: [] };
129+
// grand total — the TRUE avg over all rows, not an avg of bucket avgs
130+
return { rows: [{ avg_hours: 3.5 }], fields: [] };
131+
});
132+
const compiled = compileDataset(matrix);
133+
const res = await new DatasetExecutor(svc).execute(compiled, {
134+
dimensions: ['owner', 'status'], measures: ['avg_hours'],
135+
order: { avg_hours: 'desc' }, limit: 50,
136+
totals: { groupings: [['owner'], ['status'], []] },
137+
});
138+
expect(res.rows).toEqual([{ owner: 'alice', status: 'done', avg_hours: 2 }]);
139+
expect(res.totals).toEqual([
140+
{ dimensions: ['owner'], rows: [{ owner: 'alice', avg_hours: 3 }] },
141+
{ dimensions: ['status'], rows: [{ status: 'done', avg_hours: 4 }] },
142+
{ dimensions: [], rows: [{ avg_hours: 3.5 }] },
143+
]);
144+
// primary query keeps order/limit; totals queries drop them (totals cover
145+
// the full selection, and an order key may reference a dropped dimension)
146+
const primary = seen.find((q) => (q.dimensions ?? []).length === 2)!;
147+
expect(primary.order).toEqual({ avg_hours: 'desc' });
148+
expect(primary.limit).toBe(50);
149+
for (const q of seen.filter((s) => (s.dimensions ?? []).length < 2)) {
150+
expect(q.order).toBeUndefined();
151+
expect(q.limit).toBeUndefined();
152+
}
153+
});
154+
155+
it('totals carry measure-scoped filters and derived measures', async () => {
156+
const svc = fakeService((q) => {
157+
const grandTotal = (q.dimensions ?? []).length === 0;
158+
if (q.measures.includes('won_amount')) {
159+
// measure-scoped filter must also reach the totals query
160+
expect(JSON.stringify(q.where)).toContain('"stage":"won"');
161+
return { rows: [grandTotal ? { won_amount: 120 } : { region: 'NA', won_amount: 60 }], fields: [] };
162+
}
163+
return { rows: [grandTotal ? { revenue: 200 } : { region: 'NA', revenue: 100 }], fields: [] };
164+
});
165+
const compiled = compileDataset(dataset);
166+
const res = await new DatasetExecutor(svc).execute(compiled, {
167+
dimensions: ['region'], measures: ['win_rate'],
168+
totals: { groupings: [[]] },
169+
});
170+
expect(res.rows[0].win_rate).toBe(0.6);
171+
expect(res.totals).toEqual([{ dimensions: [], rows: [{ revenue: 200, won_amount: 120, win_rate: 0.6 }] }]);
172+
});
173+
174+
it('rejects a totals grouping that is not a subset of the selected dimensions', async () => {
175+
const svc = fakeService(() => ({ rows: [], fields: [] }));
176+
const compiled = compileDataset(dataset);
177+
await expect(
178+
new DatasetExecutor(svc).execute(compiled, {
179+
dimensions: ['region'], measures: ['revenue'],
180+
totals: { groupings: [['stage']] },
181+
}),
182+
).rejects.toThrow(/not a subset of the selected dimensions/);
183+
});
184+
113185
it('compareTo runs a shifted query and attaches <measure>__compare', async () => {
114186
const seen: AnalyticsQuery[] = [];
115187
const svc = fakeService((q) => {

packages/services/service-analytics/src/__tests__/dimension-labels.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,21 @@ describe('AnalyticsService.queryDataset — label resolution (integration)', ()
221221
]);
222222
});
223223

224+
it('resolves labels on server-side totals rows (#1753)', async () => {
225+
const res = await service().queryDataset(dataset, {
226+
dimensions: ['status', 'account'],
227+
measures: ['task_count'],
228+
totals: { groupings: [['account']] },
229+
});
230+
expect(res.totals).toEqual([{
231+
dimensions: ['account'],
232+
rows: [
233+
{ account: 'Acme Corp', task_count: 4 },
234+
{ account: 'Globex', task_count: 2 },
235+
],
236+
}]);
237+
});
238+
224239
it('enriches measure fields with their display label + format', async () => {
225240
const labelledDataset = DatasetSchema.parse({
226241
name: 'sales_metrics',

packages/services/service-analytics/src/analytics-service.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,14 @@ export class AnalyticsService implements IAnalyticsService {
404404
if (dims.length) {
405405
try {
406406
await resolveDimensionLabels(dataset.object, dims, result.rows, this.labelResolver);
407+
// Totals rows (#1753) carry dimension values too (a row subtotal is
408+
// keyed by its row bucket) — resolve each grouping's own subset.
409+
for (const total of result.totals ?? []) {
410+
const subset = dims.filter((d) => total.dimensions.includes(d.name));
411+
if (subset.length) {
412+
await resolveDimensionLabels(dataset.object, subset, total.rows, this.labelResolver);
413+
}
414+
}
407415
} catch (e) {
408416
this.logger?.warn?.(`[Analytics] dimension label resolution failed for "${dataset.name}": ${String((e as Error)?.message ?? e)}`);
409417
}

packages/services/service-analytics/src/dataset-executor.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@ export type CompareTo = DatasetCompareTo;
2626
* - applies measure-scoped filters via supplementary grouped queries,
2727
* - evaluates derived measures (ratio/sum/difference/product) row-by-row (Q1),
2828
* - shifts the query for `compareTo` (previousPeriod / previousYear) and
29-
* attaches `<measure>__compare` columns.
29+
* attaches `<measure>__compare` columns,
30+
* - computes server-side totals (`selection.totals.groupings`, #1753) by
31+
* re-running the selection per dimension subset, so matrix subtotals and
32+
* the grand total use each measure's true aggregate.
3033
*
3134
* RLS/tenant scoping is NOT handled here — it is enforced inside the strategy
3235
* via the StrategyContext read-scope hook (D-C). This layer is pure query
@@ -136,6 +139,47 @@ export class DatasetExecutor {
136139
compiled: CompiledDataset,
137140
selection: DatasetSelection,
138141
context?: ExecutionContext,
142+
): Promise<AnalyticsResult> {
143+
const result = await this.executeSelection(compiled, selection, context);
144+
145+
// Server-side totals (#1753) — re-run the selection grouped by each
146+
// requested dimension subset, so a subtotal/grand total is the measure's
147+
// TRUE aggregate over the underlying rows (an avg total is the average of
148+
// all rows, not of bucket averages). Re-running the full pipeline keeps
149+
// measure-scoped filters, derived measures, and compareTo consistent with
150+
// the primary grid. order/limit/offset are dropped: totals cover the whole
151+
// selection, and an order key may reference a dimension the grouping drops.
152+
const groupings = selection.totals?.groupings;
153+
if (groupings?.length) {
154+
const selected = new Set(selection.dimensions ?? []);
155+
const totals: NonNullable<AnalyticsResult['totals']> = [];
156+
for (const grouping of groupings) {
157+
const unknown = grouping.filter((d) => !selected.has(d));
158+
if (unknown.length) {
159+
throw new Error(
160+
`[dataset-executor] totals grouping [${grouping.join(', ')}] is not a subset of the selected dimensions — unknown: ${unknown.join(', ')}.`,
161+
);
162+
}
163+
const sub = await this.executeSelection(compiled, {
164+
...selection,
165+
dimensions: grouping,
166+
totals: undefined,
167+
order: undefined,
168+
limit: undefined,
169+
offset: undefined,
170+
}, context);
171+
totals.push({ dimensions: grouping, rows: sub.rows });
172+
}
173+
result.totals = totals;
174+
}
175+
176+
return result;
177+
}
178+
179+
private async executeSelection(
180+
compiled: CompiledDataset,
181+
selection: DatasetSelection,
182+
context?: ExecutionContext,
139183
): Promise<AnalyticsResult> {
140184
const derivedByName = new Map(compiled.derived.map((d) => [d.name, d]));
141185
const selectedDerived = selection.measures

packages/spec/src/contracts/analytics-service.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,19 @@ export interface AnalyticsResult {
7575
}>;
7676
/** Generated SQL (if available) */
7777
sql?: string;
78+
/**
79+
* Marginal aggregates — one entry per `DatasetSelection.totals` grouping,
80+
* in request order. Each entry's rows carry the grouping's dimension
81+
* columns plus the same measure columns as `rows`, computed with the
82+
* measure's true aggregate over the underlying data (never re-derived
83+
* from bucketed values). The grand-total grouping (`[]`) yields a single
84+
* dimensionless row.
85+
*/
86+
totals?: Array<{
87+
/** The dimension subset this marginal was grouped by ([] = grand total). */
88+
dimensions: string[];
89+
rows: Record<string, unknown>[];
90+
}>;
7891
}
7992

8093
/**
@@ -121,6 +134,19 @@ export interface DatasetSelection {
121134
offset?: number;
122135
/** Compare-to directive — runs a shifted query and attaches `<measure>__compare`. */
123136
compareTo?: DatasetCompareTo;
137+
/**
138+
* Server-side totals (matrix subtotals + grand total). Each grouping is a
139+
* subset of `dimensions` to additionally aggregate by; the selection is
140+
* re-run grouped only by those dimensions, so every total is the measure's
141+
* TRUE aggregate over the underlying rows — an `avg` total is the average
142+
* over all rows, not an average of bucket averages (the ADR-0021
143+
* governance line that forbids client-side re-aggregation). `[]` requests
144+
* the grand total. A matrix report asks for
145+
* `{ groupings: [rowDims, columnDims, []] }`. Results arrive on
146+
* `AnalyticsResult.totals` in request order. `order`/`limit`/`offset` do
147+
* not apply to totals queries — totals always cover the full selection.
148+
*/
149+
totals?: { groupings: string[][] };
124150
timezone?: string;
125151
}
126152

0 commit comments

Comments
 (0)