Skip to content

Commit a9459e6

Browse files
authored
feat(analytics): raw-value drill sidecar for totals/subtotal rows (#3214) (#3215)
Snapshot raw grouped values for totals/subtotal rows into an additive `drillRawTotals` prop, aligned to `result.totals` and restricted to each grouping's drillable dims, captured in the same pre-label-resolution pass as `drillRawRows`. Closes #3214.
1 parent 865ccb9 commit a9459e6

3 files changed

Lines changed: 114 additions & 0 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@objectstack/service-analytics": minor
3+
---
4+
5+
Analytics drill metadata now snapshots raw grouped values for totals/subtotal rows too (#3214). The ADR-0021 D2 drill sidecar (`drillRawRows`, #2080) only covered `result.rows`, but the totals rows added in #1753 carry dimension values and go through the same label resolution — which overwrote their stored value (select option value, lookup/master_detail FK id) with the display label, leaving a subtotal drill nothing to exact-match on.
6+
7+
`queryDataset` now also emits `drillRawTotals`, aligned to `result.totals` by index (`drillRawTotals[i][j]``result.totals[i].rows[j]`), captured in the same pre-label-resolution pass. Each map is restricted to the drillable dimensions the grouping actually groups by, so the grand-total grouping (`[]`) contributes an empty map per row. Purely additive result props (same as #2080) — no spec-contract change.

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

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,4 +212,83 @@ describe('AnalyticsService.queryDataset', () => {
212212
expect(result.dimensionFields).toEqual({ account: 'account' });
213213
expect(result.drillRawRows).toEqual([{ account: 'acc_123' }]);
214214
});
215+
216+
// ── #3214 — raw-value drill sidecar for totals / subtotal rows ────────────
217+
it('snapshots raw values for totals rows too (#3214), aligned to result.totals', async () => {
218+
const captured: { sql: string; params: unknown[] }[] = [];
219+
const result = await service(captured).queryDataset(
220+
dataset,
221+
{ dimensions: ['region'], measures: ['revenue'], totals: { groupings: [['region'], []] } },
222+
{ tenantId: 'org_A' } as ExecutionContext,
223+
) as any;
224+
// drillRawTotals[i] ↔ result.totals[i]; drillRawTotals[i][j] ↔ result.totals[i].rows[j].
225+
expect(result.drillRawTotals).toEqual([
226+
// The ['region'] subtotal grouping snapshots its drillable dim's raw value…
227+
[{ region: 'NA' }],
228+
// …while the grand-total grouping ([]) has no drillable dim → an empty map
229+
// per row, which keeps index alignment and drills the unfiltered object.
230+
[{}],
231+
]);
232+
// The data-row sidecar is unchanged (regression guard).
233+
expect(result.drillRawRows).toEqual([{ region: 'NA' }]);
234+
});
235+
236+
it('preserves the raw FK for a subtotal row even after label resolution overwrites it (#3214)', async () => {
237+
const byAccount = DatasetSchema.parse({
238+
name: 'sales_matrix', label: 'Sales', object: 'opportunity', include: [],
239+
dimensions: [
240+
{ name: 'account', field: 'account', type: 'lookup', label: 'Account' },
241+
{ name: 'stage', field: 'stage', type: 'string' },
242+
],
243+
measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount' }],
244+
});
245+
const svc = new AnalyticsService({
246+
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
247+
executeAggregate: async (_object, { groupBy }) => {
248+
const g = groupBy ?? [];
249+
// Main grid: account × stage.
250+
if (g.includes('stage')) return [
251+
{ account: 'acc1', stage: 'won', revenue: 30 },
252+
{ account: 'acc2', stage: 'won', revenue: 20 },
253+
];
254+
// Per-account subtotal grouping (raw FK values, pre-label).
255+
if (g.includes('account')) return [
256+
{ account: 'acc1', revenue: 30 },
257+
{ account: 'acc2', revenue: 20 },
258+
];
259+
// Grand total.
260+
return [{ revenue: 50 }];
261+
},
262+
labelResolver: {
263+
getObjectFields: (obj) => ({
264+
opportunity: { account: { type: 'lookup', reference: 'crm_account' } },
265+
crm_account: { name: { type: 'text' } },
266+
} as Record<string, Record<string, { type?: string; reference?: string }>>)[obj],
267+
fetchRecordLabels: async (target, ids) => {
268+
const names: Record<string, string> = { acc1: 'Acme Corp', acc2: 'Globex' };
269+
const m = new Map<unknown, string>();
270+
if (target === 'crm_account') for (const id of ids) if (names[String(id)]) m.set(id, names[String(id)]);
271+
return m;
272+
},
273+
},
274+
});
275+
const result = await svc.queryDataset(
276+
byAccount,
277+
{ dimensions: ['account', 'stage'], measures: ['revenue'], totals: { groupings: [['account'], []] } },
278+
{ tenantId: 'org_A' } as ExecutionContext,
279+
) as any;
280+
// The subtotal row now reads the display NAME (label resolution ran on it)…
281+
expect(result.totals[0].dimensions).toEqual(['account']);
282+
expect(result.totals[0].rows).toEqual([
283+
{ account: 'Acme Corp', revenue: 30 },
284+
{ account: 'Globex', revenue: 20 },
285+
]);
286+
// …but the sidecar still carries the raw FK id, restricted to the grouping's
287+
// drillable dim (stage is not part of the ['account'] grouping), so a drill
288+
// from the subtotal filters by the stored value, not the record name.
289+
expect(result.drillRawTotals[0]).toEqual([{ account: 'acc1' }, { account: 'acc2' }]);
290+
// Grand total: no drillable dim → an empty map for its single row.
291+
expect(result.totals[1].dimensions).toEqual([]);
292+
expect(result.drillRawTotals[1]).toEqual([{}]);
293+
});
215294
});

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,18 @@ type AnalyticsResultWithDrill = AnalyticsResult & {
3838
* from these, never from the display labels.
3939
*/
4040
drillRawRows?: Array<Record<string, unknown>>;
41+
/**
42+
* RAW grouped values for the totals/subtotal rows (#3214), the totals-side
43+
* companion to `drillRawRows`: `drillRawTotals[i]` aligns to `result.totals[i]`
44+
* and `drillRawTotals[i][j]` to `result.totals[i].rows[j]`. Each map holds that
45+
* grouping's DRILLABLE dimension NAME → stored value, snapshotted in the SAME
46+
* pre-label-resolution pass (the totals loop below overwrites a subtotal row's
47+
* dimension value with its display label just like the data rows). Restricted
48+
* to the drillable dims present in the grouping, so the grand-total grouping
49+
* (`[]`) contributes an empty map per row — which keeps the index alignment
50+
* intact and correctly drills the whole (unfiltered) object.
51+
*/
52+
drillRawTotals?: Array<Array<Record<string, unknown>>>;
4153
};
4254

4355
/**
@@ -505,6 +517,22 @@ export class AnalyticsService implements IAnalyticsService {
505517
for (const d of drillDims) raw[d.name] = row[d.name];
506518
return raw;
507519
});
520+
// #3214 — the totals/subtotal rows (#1753) carry dimension values too and
521+
// go through the SAME label resolution below, so snapshot their raw
522+
// grouped values here in the same pre-label pass. Aligned to `result.totals`
523+
// by index; each grouping is restricted to the drillable dims it actually
524+
// groups by (the grand-total grouping `[]` keeps empty maps, so a subtotal
525+
// drill filters by the stored value while the grand total drills unfiltered).
526+
if (result.totals?.length) {
527+
(result as AnalyticsResultWithDrill).drillRawTotals = result.totals.map((total) => {
528+
const groupingDims = drillDims.filter((d) => total.dimensions.includes(d.name));
529+
return total.rows.map((row) => {
530+
const raw: Record<string, unknown> = {};
531+
for (const d of groupingDims) raw[d.name] = row[d.name];
532+
return raw;
533+
});
534+
});
535+
}
508536
}
509537

510538
// ADR-0021 — resolve grouped dimension values to human display labels

0 commit comments

Comments
 (0)