Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/analytics-drill-raw-totals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@objectstack/service-analytics": minor
---

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.

`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.
Original file line number Diff line number Diff line change
Expand Up @@ -212,4 +212,83 @@ describe('AnalyticsService.queryDataset', () => {
expect(result.dimensionFields).toEqual({ account: 'account' });
expect(result.drillRawRows).toEqual([{ account: 'acc_123' }]);
});

// ── #3214 — raw-value drill sidecar for totals / subtotal rows ────────────
it('snapshots raw values for totals rows too (#3214), aligned to result.totals', async () => {
const captured: { sql: string; params: unknown[] }[] = [];
const result = await service(captured).queryDataset(
dataset,
{ dimensions: ['region'], measures: ['revenue'], totals: { groupings: [['region'], []] } },
{ tenantId: 'org_A' } as ExecutionContext,
) as any;
// drillRawTotals[i] ↔ result.totals[i]; drillRawTotals[i][j] ↔ result.totals[i].rows[j].
expect(result.drillRawTotals).toEqual([
// The ['region'] subtotal grouping snapshots its drillable dim's raw value…
[{ region: 'NA' }],
// …while the grand-total grouping ([]) has no drillable dim → an empty map
// per row, which keeps index alignment and drills the unfiltered object.
[{}],
]);
// The data-row sidecar is unchanged (regression guard).
expect(result.drillRawRows).toEqual([{ region: 'NA' }]);
});

it('preserves the raw FK for a subtotal row even after label resolution overwrites it (#3214)', async () => {
const byAccount = DatasetSchema.parse({
name: 'sales_matrix', label: 'Sales', object: 'opportunity', include: [],
dimensions: [
{ name: 'account', field: 'account', type: 'lookup', label: 'Account' },
{ name: 'stage', field: 'stage', type: 'string' },
],
measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount' }],
});
const svc = new AnalyticsService({
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
executeAggregate: async (_object, { groupBy }) => {
const g = groupBy ?? [];
// Main grid: account × stage.
if (g.includes('stage')) return [
{ account: 'acc1', stage: 'won', revenue: 30 },
{ account: 'acc2', stage: 'won', revenue: 20 },
];
// Per-account subtotal grouping (raw FK values, pre-label).
if (g.includes('account')) return [
{ account: 'acc1', revenue: 30 },
{ account: 'acc2', revenue: 20 },
];
// Grand total.
return [{ revenue: 50 }];
},
labelResolver: {
getObjectFields: (obj) => ({
opportunity: { account: { type: 'lookup', reference: 'crm_account' } },
crm_account: { name: { type: 'text' } },
} as Record<string, Record<string, { type?: string; reference?: string }>>)[obj],
fetchRecordLabels: async (target, ids) => {
const names: Record<string, string> = { acc1: 'Acme Corp', acc2: 'Globex' };
const m = new Map<unknown, string>();
if (target === 'crm_account') for (const id of ids) if (names[String(id)]) m.set(id, names[String(id)]);
return m;
},
},
});
const result = await svc.queryDataset(
byAccount,
{ dimensions: ['account', 'stage'], measures: ['revenue'], totals: { groupings: [['account'], []] } },
{ tenantId: 'org_A' } as ExecutionContext,
) as any;
// The subtotal row now reads the display NAME (label resolution ran on it)…
expect(result.totals[0].dimensions).toEqual(['account']);
expect(result.totals[0].rows).toEqual([
{ account: 'Acme Corp', revenue: 30 },
{ account: 'Globex', revenue: 20 },
]);
// …but the sidecar still carries the raw FK id, restricted to the grouping's
// drillable dim (stage is not part of the ['account'] grouping), so a drill
// from the subtotal filters by the stored value, not the record name.
expect(result.drillRawTotals[0]).toEqual([{ account: 'acc1' }, { account: 'acc2' }]);
// Grand total: no drillable dim → an empty map for its single row.
expect(result.totals[1].dimensions).toEqual([]);
expect(result.drillRawTotals[1]).toEqual([{}]);
});
});
28 changes: 28 additions & 0 deletions packages/services/service-analytics/src/analytics-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,18 @@ type AnalyticsResultWithDrill = AnalyticsResult & {
* from these, never from the display labels.
*/
drillRawRows?: Array<Record<string, unknown>>;
/**
* RAW grouped values for the totals/subtotal rows (#3214), the totals-side
* companion to `drillRawRows`: `drillRawTotals[i]` aligns to `result.totals[i]`
* and `drillRawTotals[i][j]` to `result.totals[i].rows[j]`. Each map holds that
* grouping's DRILLABLE dimension NAME → stored value, snapshotted in the SAME
* pre-label-resolution pass (the totals loop below overwrites a subtotal row's
* dimension value with its display label just like the data rows). Restricted
* to the drillable dims present in the grouping, so the grand-total grouping
* (`[]`) contributes an empty map per row — which keeps the index alignment
* intact and correctly drills the whole (unfiltered) object.
*/
drillRawTotals?: Array<Array<Record<string, unknown>>>;
};

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

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