diff --git a/.changeset/analytics-dimension-labels.md b/.changeset/analytics-dimension-labels.md new file mode 100644 index 0000000000..87623ca464 --- /dev/null +++ b/.changeset/analytics-dimension-labels.md @@ -0,0 +1,11 @@ +--- +"@objectstack/service-analytics": minor +--- + +Analytics dimensions now render human display labels instead of raw stored +values. A `select` dimension shows its option `label` (e.g. `Backlog` rather than +`backlog`), and a `lookup`/`master_detail` dimension shows the related record's +display name (e.g. an account's name rather than its FK id). `queryDataset` +resolves these server-side, so every dashboard/report chart benefits with no +frontend change. Date/number/string dimensions are unaffected, and unresolved +values are left as-is. diff --git a/packages/services/service-analytics/src/__tests__/dimension-labels.test.ts b/packages/services/service-analytics/src/__tests__/dimension-labels.test.ts new file mode 100644 index 0000000000..ee90d9663b --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/dimension-labels.test.ts @@ -0,0 +1,187 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { DatasetSchema } from '@objectstack/spec/ui'; +import { AnalyticsService } from '../analytics-service.js'; +import { + resolveDimensionLabels, + pickDisplayField, + type DimensionLabelDeps, + type FieldMetaLite, +} from '../dimension-labels.js'; + +// ── Field maps the fake engine exposes ────────────────────────────────── +const TASK_FIELDS: Record = { + status: { + type: 'select', + options: [ + { value: 'backlog', label: 'Backlog' }, + { value: 'in_review', label: 'In Review' }, + { value: 'done', label: 'Done' }, + ], + }, + account: { type: 'lookup', reference: 'crm_account' }, + created_at: { type: 'date' }, +}; +const ACCOUNT_FIELDS: Record = { + name: { type: 'text' }, + region: { type: 'text' }, +}; + +function deps(overrides: Partial = {}): DimensionLabelDeps { + return { + getObjectFields: (obj) => + obj === 'task' ? TASK_FIELDS : obj === 'crm_account' ? ACCOUNT_FIELDS : undefined, + 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; + }, + ...overrides, + }; +} + +describe('resolveDimensionLabels', () => { + it('maps a select dimension value → option label', async () => { + const rows = [ + { status: 'backlog', task_count: 5 }, + { status: 'done', task_count: 3 }, + ]; + await resolveDimensionLabels('task', [{ name: 'status', field: 'status' }], rows, deps()); + expect(rows).toEqual([ + { status: 'Backlog', task_count: 5 }, + { status: 'Done', task_count: 3 }, + ]); + }); + + it('maps a lookup dimension id → related record display name', async () => { + const rows = [ + { account: 'acc1', budget_sum: 800000 }, + { account: 'acc2', budget_sum: 200000 }, + ]; + await resolveDimensionLabels('task', [{ name: 'account', field: 'account' }], rows, deps()); + expect(rows).toEqual([ + { account: 'Acme Corp', budget_sum: 800000 }, + { account: 'Globex', budget_sum: 200000 }, + ]); + }); + + it('leaves an unresolved lookup id untouched (no blanks)', async () => { + const rows = [{ account: 'orphan', budget_sum: 1 }]; + await resolveDimensionLabels('task', [{ name: 'account', field: 'account' }], rows, deps()); + expect(rows).toEqual([{ account: 'orphan', budget_sum: 1 }]); + }); + + it('is a no-op for date / plain dimensions', async () => { + const rows = [{ created_at: '2026-01', task_count: 2 }]; + await resolveDimensionLabels('task', [{ name: 'created_at', field: 'created_at' }], rows, deps()); + expect(rows).toEqual([{ created_at: '2026-01', task_count: 2 }]); + }); + + it('does nothing when the object is unknown to the engine', async () => { + const rows = [{ status: 'backlog', n: 1 }]; + await resolveDimensionLabels('mystery', [{ name: 'status', field: 'status' }], rows, deps()); + expect(rows).toEqual([{ status: 'backlog', n: 1 }]); + }); + + it('only fetches lookup labels once per distinct id set', async () => { + let calls = 0; + const rows = [ + { account: 'acc1', n: 1 }, + { account: 'acc1', n: 2 }, + { account: 'acc2', n: 3 }, + ]; + const d = deps({ + fetchRecordLabels: async (_t, ids) => { + calls++; + expect(ids.sort()).toEqual(['acc1', 'acc2']); // de-duped + return new Map([['acc1', 'Acme Corp'], ['acc2', 'Globex']]); + }, + }); + await resolveDimensionLabels('task', [{ name: 'account', field: 'account' }], rows, d); + expect(calls).toBe(1); + expect(rows.map((r) => r.account)).toEqual(['Acme Corp', 'Acme Corp', 'Globex']); + }); +}); + +describe('pickDisplayField', () => { + it('prefers name > title > label', () => { + expect(pickDisplayField({ title: { type: 'text' }, name: { type: 'text' } })).toBe('name'); + expect(pickDisplayField({ label: { type: 'text' }, title: { type: 'text' } })).toBe('title'); + }); + it('falls back to the first text-like field', () => { + expect(pickDisplayField({ amount: { type: 'number' }, code: { type: 'text' } })).toBe('code'); + }); + it('returns undefined when nothing suitable exists', () => { + expect(pickDisplayField({ amount: { type: 'number' } })).toBeUndefined(); + expect(pickDisplayField(undefined)).toBeUndefined(); + }); +}); + +describe('AnalyticsService.queryDataset — label resolution (integration)', () => { + const dataset = DatasetSchema.parse({ + name: 'task_metrics', + label: 'Task Metrics', + object: 'task', + dimensions: [ + { name: 'status', field: 'status', type: 'string' }, + { name: 'account', field: 'account', type: 'lookup' }, + ], + measures: [{ name: 'task_count', aggregate: 'count' }], + }); + + function service() { + return new AnalyticsService({ + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + executeAggregate: async (object, { groupBy }) => { + // Lookup name fetch: group by id + display field → return (id, name) rows. + if (object === 'crm_account' && groupBy?.includes('name')) { + return [ + { id: 'acc1', name: 'Acme Corp', _c: 1 }, + { id: 'acc2', name: 'Globex', _c: 1 }, + ]; + } + // Primary aggregate: grouped by the selected dimension (raw values). + if (groupBy?.includes('status')) { + return [ + { status: 'backlog', task_count: 5 }, + { status: 'done', task_count: 3 }, + ]; + } + return [ + { account: 'acc1', task_count: 4 }, + { account: 'acc2', task_count: 2 }, + ]; + }, + labelResolver: { + getObjectFields: (obj) => + obj === 'task' ? TASK_FIELDS : obj === 'crm_account' ? ACCOUNT_FIELDS : undefined, + // Reuse the real plugin shape: fetch via the same executeAggregate path is + // exercised by the e2e build; here we resolve directly for a focused unit. + fetchRecordLabels: async (_t, ids) => { + const names: Record = { acc1: 'Acme Corp', acc2: 'Globex' }; + const m = new Map(); + for (const id of ids) if (names[String(id)]) m.set(id, names[String(id)]); + return m; + }, + }, + }); + } + + it('returns select option labels for a select dimension', async () => { + const res = await service().queryDataset(dataset, { dimensions: ['status'], measures: ['task_count'] }); + expect(res.rows).toEqual([ + { status: 'Backlog', task_count: 5 }, + { status: 'Done', task_count: 3 }, + ]); + }); + + it('returns related-record names for a lookup dimension', async () => { + const res = await service().queryDataset(dataset, { dimensions: ['account'], measures: ['task_count'] }); + expect(res.rows).toEqual([ + { account: 'Acme Corp', task_count: 4 }, + { account: 'Globex', task_count: 2 }, + ]); + }); +}); diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index 21a15d8054..e23f61c892 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -18,6 +18,7 @@ import { NativeSQLStrategy } from './strategies/native-sql-strategy.js'; import { ObjectQLStrategy } from './strategies/objectql-strategy.js'; import { compileDataset, type CompiledDataset, type RelationshipResolver } from './dataset-compiler.js'; import { DatasetExecutor } from './dataset-executor.js'; +import { resolveDimensionLabels, type DimensionLabelDeps } from './dimension-labels.js'; /** * Configuration for AnalyticsService. @@ -92,6 +93,14 @@ export interface AnalyticsServiceConfig { relationshipResolver?: RelationshipResolver; /** Pre-defined datasets to compile + register at construction (ADR-0021). */ datasets?: Dataset[]; + /** + * ADR-0021 — resolve raw dimension values to human display labels. When + * provided, `queryDataset` post-processes result rows so a `select` dimension + * shows its option label (not the stored value) and a `lookup`/`master_detail` + * dimension shows the related record's display name (not the FK id). Injected + * by the plugin from the `data` engine; omit to keep raw values. + */ + labelResolver?: DimensionLabelDeps; } /** @@ -131,6 +140,8 @@ export class AnalyticsService implements IAnalyticsService { private readonly datasetRegistry = new Map(); /** Optional object-graph resolver used when compiling datasets. */ private readonly relationshipResolver?: RelationshipResolver; + /** Optional dimension display-label resolver (select options / lookup names). */ + private readonly labelResolver?: DimensionLabelDeps; readonly cubeRegistry: CubeRegistry; private readonly logger: Logger; @@ -145,6 +156,7 @@ export class AnalyticsService implements IAnalyticsService { this.readScopeProvider = config.getReadScope; this.relationshipResolver = config.relationshipResolver; + this.labelResolver = config.labelResolver; // Compile + register pre-defined datasets (ADR-0021). if (config.datasets) { @@ -311,7 +323,26 @@ export class AnalyticsService implements IAnalyticsService { ): Promise { const compiled = this.registerDataset(dataset); this.logger.debug(`[Analytics] queryDataset "${dataset.name}" (object=${dataset.object}, include=${(dataset.include ?? []).join(',') || '—'})`); - return new DatasetExecutor(this).execute(compiled, selection, context); + const result = await new DatasetExecutor(this).execute(compiled, selection, context); + + // 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) + .map((d) => ({ name: d.name, field: d.field })); + if (dims.length) { + try { + await resolveDimensionLabels(dataset.object, dims, result.rows, this.labelResolver); + } catch (e) { + this.logger?.warn?.(`[Analytics] dimension label resolution failed for "${dataset.name}": ${String((e as Error)?.message ?? e)}`); + } + } + } + return result; } /** diff --git a/packages/services/service-analytics/src/dimension-labels.ts b/packages/services/service-analytics/src/dimension-labels.ts new file mode 100644 index 0000000000..dbe28d32a5 --- /dev/null +++ b/packages/services/service-analytics/src/dimension-labels.ts @@ -0,0 +1,117 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Dimension display-label resolution (ADR-0021). + * + * Analytics groups by the raw stored value of a dimension field. For two field + * kinds that value is NOT human-readable: + * + * - **select** — grouped by the stored option `value` (e.g. `backlog`), but the + * user-facing text is the option `label` (e.g. `Backlog`). + * - **lookup / master_detail** — grouped by the foreign-key `id` (e.g. + * `8eqtuKI4G9IhUsPS`), but the user-facing text is the related record's + * display field (its name/title). + * + * `resolveDimensionLabels` post-processes the result rows IN PLACE, replacing the + * raw value at `row[dimension.name]` with its display label when one is found. + * Unresolved values are left untouched so an orphaned id still renders as itself + * rather than blanking out. Date / number / plain-string dimensions are no-ops. + * + * The resolution LOGIC lives here (and is unit-tested); the low-level capabilities + * — reading an object's field map and fetching id→label pairs — are injected via + * {@link DimensionLabelDeps} so this module stays free of any engine dependency. + */ + +/** The minimal field shape this resolver needs. */ +export interface FieldMetaLite { + type?: string; + /** Lookup / master_detail target object name. */ + reference?: string; + /** Select options — the value→label source. */ + options?: Array<{ value: unknown; label?: string }>; +} + +/** Capabilities the resolver needs from the runtime (injected by the plugin). */ +export interface DimensionLabelDeps { + /** Return the field map for an object, or `undefined` if unknown. */ + getObjectFields(objectName: string): Record | undefined; + /** + * Fetch a map of `id → display label` for the given ids of a target object. + * The implementation chooses the target's display field. Returning an empty + * map (e.g. no display field, no data access) leaves the ids unresolved. + */ + fetchRecordLabels(targetObject: string, ids: unknown[]): Promise>; +} + +const LOOKUP_TYPES = new Set(['lookup', 'master_detail']); + +/** + * Replace raw dimension values with display labels, in place. + * + * @param baseObject - the dataset's base object (where the dimension fields live) + * @param dims - selected dimensions as `{ name, field }` (row key = `name`) + * @param rows - result rows, mutated in place + * @param deps - injected runtime capabilities + */ +export async function resolveDimensionLabels( + baseObject: string, + dims: Array<{ name: string; field: string }>, + rows: Record[], + deps: DimensionLabelDeps, +): Promise { + if (!rows.length || !dims.length) return; + const fields = deps.getObjectFields(baseObject); + if (!fields) return; + + for (const dim of dims) { + const meta = fields[dim.field]; + if (!meta) continue; + + // ── select: value → option label ────────────────────────────────── + if (Array.isArray(meta.options) && meta.options.length > 0) { + const labelByValue = new Map(); + for (const opt of meta.options) { + if (opt && opt.label != null) labelByValue.set(opt.value, String(opt.label)); + } + if (labelByValue.size === 0) continue; + for (const row of rows) { + const raw = row[dim.name]; + const label = labelByValue.get(raw); + if (label != null) row[dim.name] = label; + } + continue; + } + + // ── lookup / master_detail: id → related record display name ─────── + if (meta.type && LOOKUP_TYPES.has(meta.type) && meta.reference) { + const ids = Array.from( + new Set(rows.map((r) => r[dim.name]).filter((v) => v != null)), + ); + if (ids.length === 0) continue; + const labelById = await deps.fetchRecordLabels(meta.reference, ids); + if (!labelById || labelById.size === 0) continue; + for (const row of rows) { + const label = labelById.get(row[dim.name]); + if (label != null) row[dim.name] = label; + } + } + } +} + +/** + * Pick the display field for an object from its field map, by convention: + * an explicit `name`/`title`/`label` field, else the first text-like field. + * Returns `undefined` when nothing suitable exists. + */ +export function pickDisplayField( + fields: Record | undefined, +): string | undefined { + if (!fields) return undefined; + for (const preferred of ['name', 'title', 'label']) { + if (fields[preferred]) return preferred; + } + for (const [name, meta] of Object.entries(fields)) { + if (meta.type === 'text' || meta.type === 'string') return name; + } + return undefined; +} diff --git a/packages/services/service-analytics/src/index.ts b/packages/services/service-analytics/src/index.ts index f94cfcf720..7a147b5061 100644 --- a/packages/services/service-analytics/src/index.ts +++ b/packages/services/service-analytics/src/index.ts @@ -14,6 +14,9 @@ export { CubeRegistry } from './cube-registry.js'; // Dataset semantic layer (ADR-0021) export { compileDataset } from './dataset-compiler.js'; export type { CompiledDataset, DerivedMeasureSpec, RelationshipResolver } from './dataset-compiler.js'; + +export { resolveDimensionLabels, pickDisplayField } from './dimension-labels.js'; +export type { DimensionLabelDeps, FieldMetaLite } from './dimension-labels.js'; export { DatasetExecutor, evaluateDerivedMeasures, diff --git a/packages/services/service-analytics/src/plugin.ts b/packages/services/service-analytics/src/plugin.ts index 8a98f0a41e..86bc514c05 100644 --- a/packages/services/service-analytics/src/plugin.ts +++ b/packages/services/service-analytics/src/plugin.ts @@ -7,6 +7,7 @@ import type { IAnalyticsService } from '@objectstack/spec/contracts'; import { AnalyticsService } from './analytics-service.js'; import type { AnalyticsServiceConfig } from './analytics-service.js'; import type { DriverCapabilities } from './strategies/types.js'; +import { pickDisplayField, type DimensionLabelDeps } from './dimension-labels.js'; /** * Minimal IDataEngine surface required for the auto-bridge. @@ -22,8 +23,14 @@ interface DataEngineLike { aggregations?: Array<{ function: string; field: string; alias: string }>; }): Promise; execute?(command: unknown, options?: Record): Promise; - /** Return the registered object schema (for relationship → target resolution). */ - getObject?(name: string): { fields?: Record } | undefined; + /** Return the registered object schema (relationship → target + display-label resolution). */ + getObject?(name: string): { + fields?: Record; + }>; + } | undefined; } /** @@ -279,6 +286,40 @@ export class AnalyticsServicePlugin implements Plugin { return engine ? undefined : relationshipName; }; + // ADR-0021 — dimension display-label resolution. `queryDataset` groups by a + // dimension's raw stored value; for `select` fields the user-facing text is + // the option label, and for `lookup`/`master_detail` fields it's the related + // record's display name. Wire the two low-level capabilities the resolver + // needs from the 'data' engine (resolved lazily so plugin-init order is free): + // - field metadata (select options + lookup target), via getObject + // - id→name pairs, via the executeAggregate bridge (group by id + name) + const dataEngine = (): DataEngineLike | undefined => { + try { + const svc = ctx.getService('data'); + return svc && typeof svc.getObject === 'function' ? svc : undefined; + } catch { return undefined; } + }; + const labelResolver: DimensionLabelDeps = { + getObjectFields: (objectName) => dataEngine()?.getObject?.(objectName)?.fields, + fetchRecordLabels: async (targetObject, ids) => { + const map = new Map(); + const displayField = pickDisplayField(dataEngine()?.getObject?.(targetObject)?.fields); + if (!displayField || !executeAggregate || ids.length === 0) return map; + // Group by (id, displayField) — one row per record — reusing the aggregate + // bridge rather than adding a record-fetch capability. A count keeps engines + // that require ≥1 aggregation happy; the count itself is unused. + const rows = await executeAggregate(targetObject, { + groupBy: ['id', displayField], + aggregations: [{ field: 'id', method: 'count', alias: '_c' }], + filter: { id: { $in: ids } }, + }); + for (const r of rows) { + if (r.id != null && r[displayField] != null) map.set(r.id, String(r[displayField])); + } + return map; + }, + }; + const config: AnalyticsServiceConfig = { cubes: this.options.cubes, logger: ctx.logger, @@ -289,6 +330,7 @@ export class AnalyticsServicePlugin implements Plugin { getReadScope, getAllowedRelationships: this.options.getAllowedRelationships, relationshipResolver, + labelResolver, }; if (autoBridgedReadScope) {