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
5 changes: 5 additions & 0 deletions .changeset/dashboard-report-drill.md
Original file line number Diff line number Diff line change
@@ -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).
8 changes: 6 additions & 2 deletions examples/app-showcase/src/datasets/chart-gallery.dataset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
],
});
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
70 changes: 66 additions & 4 deletions packages/services/service-analytics/src/analytics-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
/**
* 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<Record<string, unknown>>;
};

/**
* 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
Expand Down Expand Up @@ -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<typeof d> => !!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<string, unknown> = {};
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<typeof d> => !!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 {
Expand Down Expand Up @@ -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;
}

Expand Down
Loading