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
11 changes: 11 additions & 0 deletions .changeset/analytics-dimension-labels.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
@@ -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<string, FieldMetaLite> = {
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<string, FieldMetaLite> = {
name: { type: 'text' },
region: { type: 'text' },
};

function deps(overrides: Partial<DimensionLabelDeps> = {}): DimensionLabelDeps {
return {
getObjectFields: (obj) =>
obj === 'task' ? TASK_FIELDS : obj === 'crm_account' ? ACCOUNT_FIELDS : undefined,
fetchRecordLabels: async (target, ids) => {
const names: Record<string, string> = { acc1: 'Acme Corp', acc2: 'Globex' };
const m = new Map<unknown, string>();
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<unknown, string>([['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<string, string> = { acc1: 'Acme Corp', acc2: 'Globex' };
const m = new Map<unknown, string>();
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 },
]);
});
});
33 changes: 32 additions & 1 deletion packages/services/service-analytics/src/analytics-service.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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;
}

/**
Expand DownExpand Up@@ -131,6 +140,8 @@ export class AnalyticsService implements IAnalyticsService {
private readonly datasetRegistry = new Map<string, CompiledDataset>();
/** 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;

Expand All@@ -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) {
Expand DownExpand Up@@ -311,7 +323,26 @@ export class AnalyticsService implements IAnalyticsService {
): Promise<AnalyticsResult> {
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<typeof d> => !!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;
}

/**
Expand Down
117 changes: 117 additions & 0 deletions packages/services/service-analytics/src/dimension-labels.ts
Original file line numberDiff line numberDiff line change
@@ -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<string, FieldMetaLite> | 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<Map<unknown, string>>;
}

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<string, unknown>[],
deps: DimensionLabelDeps,
): Promise<void> {
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<unknown, string>();
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<string, FieldMetaLite> | 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;
}
3 changes: 3 additions & 0 deletions packages/services/service-analytics/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
Loading