Skip to content

Commit 3187952

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(analytics): dimension labels + drill-through metadata for dataset reports (#2080)
* feat(analytics): dimension labels + drill-through metadata for dataset reports 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 <noreply@anthropic.com> * chore: add changeset for dataset analytics labels + drill-through Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 01f8c5d commit 3187952

4 files changed

Lines changed: 128 additions & 6 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@objectstack/service-analytics": minor
3+
---
4+
5+
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).

examples/app-showcase/src/datasets/chart-gallery.dataset.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,11 @@ export const ShowcaseProjectDataset = defineDataset({
4141
],
4242
measures: [
4343
{ name: 'project_count', label: 'Projects', aggregate: 'count' },
44-
{ name: 'budget_sum', label: 'Total Budget', aggregate: 'sum', field: 'budget', format: '$0,0' },
45-
{ name: 'spent_sum', label: 'Total Spent', aggregate: 'sum', field: 'spent', format: '$0,0' },
44+
// `budget`/`spent` are currency fields with NO declared currency code, so a
45+
// hardcoded "$" misrepresents the amount (an amount with unspecified
46+
// currency must not show a $ symbol). Use a plain grouped-number format;
47+
// declare a `currency` on the field to get a locale-correct symbol via Intl.
48+
{ name: 'budget_sum', label: 'Total Budget', aggregate: 'sum', field: 'budget', format: '0,0' },
49+
{ name: 'spent_sum', label: 'Total Spent', aggregate: 'sum', field: 'spent', format: '0,0' },
4650
],
4751
});

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

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,4 +80,55 @@ describe('AnalyticsService.queryDataset', () => {
8080
});
8181
expect(svc.cubeRegistry.has('sales')).toBe(true);
8282
});
83+
84+
// ── ADR-0021 D2 drill-through metadata ──────────────────────────────────
85+
it('exposes drill-through metadata: object, dimensionFields, and a raw-value sidecar', async () => {
86+
const captured: { sql: string; params: unknown[] }[] = [];
87+
const result = await service(captured).queryDataset(
88+
dataset,
89+
{ dimensions: ['region'], measures: ['revenue'] },
90+
{ tenantId: 'org_A' } as ExecutionContext,
91+
) as any;
92+
// The host drills into the dataset's base object…
93+
expect(result.object).toBe('opportunity');
94+
// …mapping the drillable dimension name to its underlying field…
95+
expect(result.dimensionFields).toEqual({ region: 'account.region' });
96+
// …and the RAW grouped value is preserved in a parallel array (rows are
97+
// NOT mutated — they keep exactly their measure/dimension columns).
98+
expect(result.drillRawRows).toEqual([{ region: 'NA' }]);
99+
expect(result.rows[0]).toEqual({ region: 'NA', revenue: 100 });
100+
});
101+
102+
it('enriches dimension columns with their dataset display label', async () => {
103+
const labeled = DatasetSchema.parse({
104+
name: 'sales2', label: 'Sales', object: 'opportunity', include: ['account'],
105+
dimensions: [{ name: 'region', field: 'account.region', type: 'string', label: 'Region' }],
106+
measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount', label: 'Revenue', certified: true }],
107+
});
108+
const result = await service([]).queryDataset(
109+
labeled,
110+
{ dimensions: ['region'], measures: ['revenue'] },
111+
{ tenantId: 'org_A' } as ExecutionContext,
112+
) as any;
113+
const regionField = (result.fields ?? []).find((f: any) => f.name === 'region' || f.name === 'account.region');
114+
expect(regionField?.label).toBe('Region');
115+
});
116+
117+
it('does NOT mark a date dimension drillable (a humanized bucket cannot be exact-matched)', async () => {
118+
const dated = DatasetSchema.parse({
119+
name: 'sales3', label: 'Sales', object: 'opportunity', include: [],
120+
dimensions: [{ name: 'closed', field: 'close_date', type: 'date' }],
121+
measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount', certified: true }],
122+
});
123+
const svc = new AnalyticsService({
124+
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }),
125+
executeRawSql: async () => [{ closed: 1700000000000, revenue: 100 }],
126+
getReadScope: (_o, ctx?: ExecutionContext) => (ctx?.tenantId ? { organization_id: ctx.tenantId } : undefined),
127+
});
128+
const result = await svc.queryDataset(dated, { dimensions: ['closed'], measures: ['revenue'] }, { tenantId: 'org_A' } as ExecutionContext) as any;
129+
// No drillable (non-date) dimension → no drill metadata at all.
130+
expect(result.dimensionFields).toBeUndefined();
131+
expect(result.object).toBeUndefined();
132+
expect(result.drillRawRows).toBeUndefined();
133+
});
83134
});

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

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,25 @@ import { DatasetExecutor } from './dataset-executor.js';
2121
import { resolveDimensionLabels, type DimensionLabelDeps } from './dimension-labels.js';
2222
import { evaluateAnalyticsQueryOverRows } from './preview-evaluator.js';
2323

24+
/**
25+
* Analytics result augmented with drill-through metadata (ADR-0021 D2; see
26+
* queryDataset). Carried alongside `rows` so the host can drill a clicked bucket
27+
* back to the underlying records without the renderer knowing field mappings.
28+
*/
29+
type AnalyticsResultWithDrill = AnalyticsResult & {
30+
/** The dataset's base object — the host drills into its records. */
31+
object?: string;
32+
/** Selected drillable dimension NAME → underlying object FIELD name. */
33+
dimensionFields?: Record<string, string>;
34+
/**
35+
* RAW grouped values per row, aligned to `rows` by index — each a map of
36+
* drillable dimension NAME → stored value (BEFORE label resolution rewrote
37+
* `rows[i][dim]` to the display label). The exact-match drill filter is built
38+
* from these, never from the display labels.
39+
*/
40+
drillRawRows?: Array<Record<string, unknown>>;
41+
};
42+
2443
/**
2544
* Detect the "backing object/table isn't present in this kernel" class of
2645
* error so a dataset query can degrade to an empty result instead of failing
@@ -441,14 +460,41 @@ export class AnalyticsService implements IAnalyticsService {
441460
throw err;
442461
}
443462

463+
// Selected dimensions resolved against the dataset definition — shared by
464+
// drill metadata, label resolution, and dimension field-label enrichment.
465+
const selectedDims = (selection.dimensions ?? [])
466+
.map((name) => dataset.dimensions?.find((d) => d.name === name))
467+
.filter((d): d is NonNullable<typeof d> => !!d);
468+
469+
// ADR-0021 D2 — drill-through metadata. A host (dashboard/report) drills a
470+
// clicked bucket back to the underlying records, but it only knows the
471+
// dimension NAMES, and the label resolution below OVERWRITES the raw grouped
472+
// value in each row with its display label. So before that happens, snapshot
473+
// the raw grouped values into a PARALLEL array (aligned to `rows` by index —
474+
// the result rows are NOT mutated) and expose the dataset's `object` +
475+
// dimension→field mapping so the renderer can build an exact-match filter.
476+
// Date buckets are excluded — a humanized bucket ("2026-06") can't be
477+
// exact-matched against the stored timestamp, so they are not drillable.
478+
const drillDims = selectedDims.filter((d) => !!d.field && d.type !== 'date');
479+
if (drillDims.length && result.rows.length) {
480+
(result as AnalyticsResultWithDrill).object = dataset.object;
481+
(result as AnalyticsResultWithDrill).dimensionFields = Object.fromEntries(
482+
drillDims.map((d) => [d.name, d.field as string]),
483+
);
484+
(result as AnalyticsResultWithDrill).drillRawRows = result.rows.map((row) => {
485+
const raw: Record<string, unknown> = {};
486+
for (const d of drillDims) raw[d.name] = row[d.name];
487+
return raw;
488+
});
489+
}
490+
444491
// ADR-0021 — resolve grouped dimension values to human display labels
445492
// (select option label, lookup related-record name). Charts render the
446493
// dimension key verbatim, so this is the single place that turns a stored
447494
// value / FK id into the text a user expects to read.
448-
if (this.labelResolver && selection.dimensions?.length) {
449-
const dims = selection.dimensions
450-
.map((name) => dataset.dimensions?.find((d) => d.name === name))
451-
.filter((d): d is NonNullable<typeof d> => !!d?.field)
495+
if (this.labelResolver && selectedDims.length) {
496+
const dims = selectedDims
497+
.filter((d) => !!d.field)
452498
.map((d) => ({ name: d.name, field: d.field, type: d.type, dateGranularity: d.dateGranularity }));
453499
if (dims.length) {
454500
try {
@@ -480,6 +526,22 @@ export class AnalyticsService implements IAnalyticsService {
480526
if (f.format == null && m.format) f.format = m.format;
481527
}
482528
}
529+
530+
// Enrich DIMENSION columns with their display `label` too, so a grouped
531+
// table header reads "Status" instead of the raw field name "status". The
532+
// measure-only enrichment above left dimension headers bare (the renderer
533+
// then fell back to the raw dimension name).
534+
if (result.fields?.length && selectedDims.length) {
535+
const dimByName = new Map(selectedDims.map((d) => [d.name, d]));
536+
const dimByField = new Map(selectedDims.filter((d) => !!d.field).map((d) => [d.field as string, d]));
537+
for (const f of result.fields) {
538+
if (f.label != null) continue;
539+
// Result fields may be keyed by the dataset dimension NAME or the
540+
// underlying cube FIELD depending on strategy — match either.
541+
const d = dimByName.get(f.name) ?? dimByField.get(f.name);
542+
if (d && typeof d.label === 'string') f.label = d.label;
543+
}
544+
}
483545
return result;
484546
}
485547

0 commit comments

Comments
 (0)