From 8e1015e4a90d41e0744e681b798943a428308aa3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 13:54:47 +0000 Subject: [PATCH] feat(analytics): raw-value drill sidecar for totals/subtotal rows (#3214) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ADR-0021 D2 drill metadata snapshots each row's raw grouped values into a parallel `drillRawRows` array before `resolveDimensionLabels` overwrites `row[dim]` with the display label — but the snapshot only covered `result.rows`. The totals/subtotal rows (#1753) carry dimension values too and go through the same label resolution, so a lookup/select subtotal ended up label-only with the stored value lost — a subtotal drill had nothing to exact-match on. Snapshot raw values for totals rows too, in the same pre-label pass: `drillRawTotals` is aligned to `result.totals` by index and restricted to each grouping's drillable dims (the grand-total grouping `[]` yields empty maps). Purely additive result props, same as #2080 — no spec-contract change. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Vejtto9wLCMUjQ9NFrXqnK --- .changeset/analytics-drill-raw-totals.md | 7 ++ .../src/__tests__/query-dataset.test.ts | 79 +++++++++++++++++++ .../src/analytics-service.ts | 28 +++++++ 3 files changed, 114 insertions(+) create mode 100644 .changeset/analytics-drill-raw-totals.md diff --git a/.changeset/analytics-drill-raw-totals.md b/.changeset/analytics-drill-raw-totals.md new file mode 100644 index 0000000000..e05383d44d --- /dev/null +++ b/.changeset/analytics-drill-raw-totals.md @@ -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. diff --git a/packages/services/service-analytics/src/__tests__/query-dataset.test.ts b/packages/services/service-analytics/src/__tests__/query-dataset.test.ts index 6694c6469c..80015df66c 100644 --- a/packages/services/service-analytics/src/__tests__/query-dataset.test.ts +++ b/packages/services/service-analytics/src/__tests__/query-dataset.test.ts @@ -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>)[obj], + fetchRecordLabels: async (target, ids) => { + const names: Record = { acc1: 'Acme Corp', acc2: 'Globex' }; + const m = new Map(); + 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([{}]); + }); }); diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index e21970aae8..3cb871bc38 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -38,6 +38,18 @@ type AnalyticsResultWithDrill = AnalyticsResult & { * from these, never from the display labels. */ drillRawRows?: Array>; + /** + * 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>>; }; /** @@ -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 = {}; + for (const d of groupingDims) raw[d.name] = row[d.name]; + return raw; + }); + }); + } } // ADR-0021 — resolve grouped dimension values to human display labels