From bf622c3323db711ddb7bd70b1028057635b6911d Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sat, 20 Jun 2026 23:43:48 +0800 Subject: [PATCH 1/2] feat(analytics): dimension labels + drill-through metadata for dataset reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dataset-bound report/table path enriched only MEASURE result fields with their display label, leaving dimension columns bare (renderers fell back to the raw field name e.g. "status"). It also offered no way to drill a grouped bucket back to its records. - Enrich DIMENSION result fields with their dataset display label (match by dimension name or underlying field), so headers read "Status" not "status". - Attach drill-through metadata to the dataset query result: the base `object`, a drillable dimension→field map, and a PARALLEL `drillRawRows` array holding each row's RAW grouped values (captured before label resolution overwrites `row[dim]`) so a host builds an exact-match filter from the stored value, not the display label. Rows themselves are left untouched. Date dimensions are excluded (a humanized bucket can't be exact-matched). - Showcase: `budget_sum`/`spent_sum` are currency fields with no declared currency, so drop the misleading hardcoded `$0,0` format for a plain `0,0`. Co-Authored-By: Claude Opus 4.8 --- .../src/datasets/chart-gallery.dataset.ts | 8 ++- .../src/__tests__/query-dataset.test.ts | 51 ++++++++++++++ .../src/analytics-service.ts | 70 +++++++++++++++++-- 3 files changed, 123 insertions(+), 6 deletions(-) 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; } From c66d16901080eb02fb94c580da62127e936331ca Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 21 Jun 2026 00:14:32 +0800 Subject: [PATCH 2/2] chore: add changeset for dataset analytics labels + drill-through Co-Authored-By: Claude Opus 4.8 --- .changeset/dashboard-report-drill.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/dashboard-report-drill.md 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).