diff --git a/.changeset/dashboard-report-drill.md b/.changeset/dashboard-report-drill.md new file mode 100644 index 0000000000..dd04a2faad --- /dev/null +++ b/.changeset/dashboard-report-drill.md @@ -0,0 +1,5 @@ +--- +"@objectstack/service-analytics": minor +--- + +Dataset analytics enrich **dimension** result fields with their display label (so report/dashboard table headers read "Status" instead of the raw field name) and expose drill-through metadata on the dataset query result: the base `object`, a drillable dimension→field map, and a parallel `drillRawRows` array of each row's raw grouped values (captured before label resolution). This lets a host drill a grouped bucket back to its underlying records with an exact-match filter built from the stored value, not the display label. Date dimensions are excluded (a humanized bucket can't be exact-matched). diff --git a/examples/app-showcase/src/datasets/chart-gallery.dataset.ts b/examples/app-showcase/src/datasets/chart-gallery.dataset.ts index b6388a3c96..5d41ac7f51 100644 --- a/examples/app-showcase/src/datasets/chart-gallery.dataset.ts +++ b/examples/app-showcase/src/datasets/chart-gallery.dataset.ts @@ -41,7 +41,11 @@ export const ShowcaseProjectDataset = defineDataset({ ], measures: [ { name: 'project_count', label: 'Projects', aggregate: 'count' }, - { name: 'budget_sum', label: 'Total Budget', aggregate: 'sum', field: 'budget', format: '$0,0' }, - { name: 'spent_sum', label: 'Total Spent', aggregate: 'sum', field: 'spent', format: '$0,0' }, + // `budget`/`spent` are currency fields with NO declared currency code, so a + // hardcoded "$" misrepresents the amount (an amount with unspecified + // currency must not show a $ symbol). Use a plain grouped-number format; + // declare a `currency` on the field to get a locale-correct symbol via Intl. + { name: 'budget_sum', label: 'Total Budget', aggregate: 'sum', field: 'budget', format: '0,0' }, + { name: 'spent_sum', label: 'Total Spent', aggregate: 'sum', field: 'spent', format: '0,0' }, ], }); 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 643f6b3e15..99a8d24c90 100644 --- a/packages/services/service-analytics/src/__tests__/query-dataset.test.ts +++ b/packages/services/service-analytics/src/__tests__/query-dataset.test.ts @@ -80,4 +80,55 @@ describe('AnalyticsService.queryDataset', () => { }); expect(svc.cubeRegistry.has('sales')).toBe(true); }); + + // ── ADR-0021 D2 drill-through metadata ────────────────────────────────── + it('exposes drill-through metadata: object, dimensionFields, and a raw-value sidecar', async () => { + const captured: { sql: string; params: unknown[] }[] = []; + const result = await service(captured).queryDataset( + dataset, + { dimensions: ['region'], measures: ['revenue'] }, + { tenantId: 'org_A' } as ExecutionContext, + ) as any; + // The host drills into the dataset's base object… + expect(result.object).toBe('opportunity'); + // …mapping the drillable dimension name to its underlying field… + expect(result.dimensionFields).toEqual({ region: 'account.region' }); + // …and the RAW grouped value is preserved in a parallel array (rows are + // NOT mutated — they keep exactly their measure/dimension columns). + expect(result.drillRawRows).toEqual([{ region: 'NA' }]); + expect(result.rows[0]).toEqual({ region: 'NA', revenue: 100 }); + }); + + it('enriches dimension columns with their dataset display label', async () => { + const labeled = DatasetSchema.parse({ + name: 'sales2', label: 'Sales', object: 'opportunity', include: ['account'], + dimensions: [{ name: 'region', field: 'account.region', type: 'string', label: 'Region' }], + measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount', label: 'Revenue', certified: true }], + }); + const result = await service([]).queryDataset( + labeled, + { dimensions: ['region'], measures: ['revenue'] }, + { tenantId: 'org_A' } as ExecutionContext, + ) as any; + const regionField = (result.fields ?? []).find((f: any) => f.name === 'region' || f.name === 'account.region'); + expect(regionField?.label).toBe('Region'); + }); + + it('does NOT mark a date dimension drillable (a humanized bucket cannot be exact-matched)', async () => { + const dated = DatasetSchema.parse({ + name: 'sales3', label: 'Sales', object: 'opportunity', include: [], + dimensions: [{ name: 'closed', field: 'close_date', type: 'date' }], + measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount', certified: true }], + }); + const svc = new AnalyticsService({ + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }), + executeRawSql: async () => [{ closed: 1700000000000, revenue: 100 }], + getReadScope: (_o, ctx?: ExecutionContext) => (ctx?.tenantId ? { organization_id: ctx.tenantId } : undefined), + }); + const result = await svc.queryDataset(dated, { dimensions: ['closed'], measures: ['revenue'] }, { tenantId: 'org_A' } as ExecutionContext) as any; + // No drillable (non-date) dimension → no drill metadata at all. + expect(result.dimensionFields).toBeUndefined(); + expect(result.object).toBeUndefined(); + expect(result.drillRawRows).toBeUndefined(); + }); }); diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index 4f0aadd5e1..a5975209b4 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -21,6 +21,25 @@ import { DatasetExecutor } from './dataset-executor.js'; import { resolveDimensionLabels, type DimensionLabelDeps } from './dimension-labels.js'; import { evaluateAnalyticsQueryOverRows } from './preview-evaluator.js'; +/** + * Analytics result augmented with drill-through metadata (ADR-0021 D2; see + * queryDataset). Carried alongside `rows` so the host can drill a clicked bucket + * back to the underlying records without the renderer knowing field mappings. + */ +type AnalyticsResultWithDrill = AnalyticsResult & { + /** The dataset's base object — the host drills into its records. */ + object?: string; + /** Selected drillable dimension NAME → underlying object FIELD name. */ + dimensionFields?: Record; + /** + * RAW grouped values per row, aligned to `rows` by index — each a map of + * drillable dimension NAME → stored value (BEFORE label resolution rewrote + * `rows[i][dim]` to the display label). The exact-match drill filter is built + * from these, never from the display labels. + */ + drillRawRows?: Array>; +}; + /** * Detect the "backing object/table isn't present in this kernel" class of * error so a dataset query can degrade to an empty result instead of failing @@ -441,14 +460,41 @@ export class AnalyticsService implements IAnalyticsService { throw err; } + // Selected dimensions resolved against the dataset definition — shared by + // drill metadata, label resolution, and dimension field-label enrichment. + const selectedDims = (selection.dimensions ?? []) + .map((name) => dataset.dimensions?.find((d) => d.name === name)) + .filter((d): d is NonNullable => !!d); + + // ADR-0021 D2 — drill-through metadata. A host (dashboard/report) drills a + // clicked bucket back to the underlying records, but it only knows the + // dimension NAMES, and the label resolution below OVERWRITES the raw grouped + // value in each row with its display label. So before that happens, snapshot + // the raw grouped values into a PARALLEL array (aligned to `rows` by index — + // the result rows are NOT mutated) and expose the dataset's `object` + + // dimension→field mapping so the renderer can build an exact-match filter. + // Date buckets are excluded — a humanized bucket ("2026-06") can't be + // exact-matched against the stored timestamp, so they are not drillable. + const drillDims = selectedDims.filter((d) => !!d.field && d.type !== 'date'); + if (drillDims.length && result.rows.length) { + (result as AnalyticsResultWithDrill).object = dataset.object; + (result as AnalyticsResultWithDrill).dimensionFields = Object.fromEntries( + drillDims.map((d) => [d.name, d.field as string]), + ); + (result as AnalyticsResultWithDrill).drillRawRows = result.rows.map((row) => { + const raw: Record = {}; + for (const d of drillDims) raw[d.name] = row[d.name]; + return raw; + }); + } + // ADR-0021 — resolve grouped dimension values to human display labels // (select option label, lookup related-record name). Charts render the // dimension key verbatim, so this is the single place that turns a stored // value / FK id into the text a user expects to read. - if (this.labelResolver && selection.dimensions?.length) { - const dims = selection.dimensions - .map((name) => dataset.dimensions?.find((d) => d.name === name)) - .filter((d): d is NonNullable => !!d?.field) + if (this.labelResolver && selectedDims.length) { + const dims = selectedDims + .filter((d) => !!d.field) .map((d) => ({ name: d.name, field: d.field, type: d.type, dateGranularity: d.dateGranularity })); if (dims.length) { try { @@ -480,6 +526,22 @@ export class AnalyticsService implements IAnalyticsService { if (f.format == null && m.format) f.format = m.format; } } + + // Enrich DIMENSION columns with their display `label` too, so a grouped + // table header reads "Status" instead of the raw field name "status". The + // measure-only enrichment above left dimension headers bare (the renderer + // then fell back to the raw dimension name). + if (result.fields?.length && selectedDims.length) { + const dimByName = new Map(selectedDims.map((d) => [d.name, d])); + const dimByField = new Map(selectedDims.filter((d) => !!d.field).map((d) => [d.field as string, d])); + for (const f of result.fields) { + if (f.label != null) continue; + // Result fields may be keyed by the dataset dimension NAME or the + // underlying cube FIELD depending on strategy — match either. + const d = dimByName.get(f.name) ?? dimByField.get(f.name); + if (d && typeof d.label === 'string') f.label = d.label; + } + } return result; }