diff --git a/.changeset/dataset-measure-currency.md b/.changeset/dataset-measure-currency.md new file mode 100644 index 0000000000..06d9d7516d --- /dev/null +++ b/.changeset/dataset-measure-currency.md @@ -0,0 +1,12 @@ +--- +"@objectstack/spec": minor +"@objectstack/service-analytics": minor +--- + +Propagate a dataset measure's declared currency to the analytics result field. + +Adds an optional `DatasetMeasure.currency` (ISO 4217) on the semantic layer and +carries it onto each measure result field alongside `label`/`format`, so a +currency-aware client (Intl symbol) can render `¥1,234` / `$616,000` from a real +currency code instead of a plain number or a `$` baked into `format`. Additive +and optional — existing datasets are unaffected. diff --git a/examples/app-crm/src/datasets/opportunity.dataset.ts b/examples/app-crm/src/datasets/opportunity.dataset.ts index c7756926d9..993d0422ed 100644 --- a/examples/app-crm/src/datasets/opportunity.dataset.ts +++ b/examples/app-crm/src/datasets/opportunity.dataset.ts @@ -25,7 +25,7 @@ export const OpportunityDataset = defineDataset({ measures: [ { name: 'opp_count', label: 'Opportunities', aggregate: 'count' }, - { name: 'total_amount', label: 'Total Amount', aggregate: 'sum', field: 'amount', format: '$0,0' }, - { name: 'avg_amount', label: 'Avg Deal Size', aggregate: 'avg', field: 'amount', format: '$0,0' }, + { name: 'total_amount', label: 'Total Amount', aggregate: 'sum', field: 'amount', format: '0,0', currency: 'USD' }, + { name: 'avg_amount', label: 'Avg Deal Size', aggregate: 'avg', field: 'amount', format: '0,0', currency: 'USD' }, ], }); 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 99a8d24c90..2a8c9ad399 100644 --- a/packages/services/service-analytics/src/__tests__/query-dataset.test.ts +++ b/packages/services/service-analytics/src/__tests__/query-dataset.test.ts @@ -99,6 +99,29 @@ describe('AnalyticsService.queryDataset', () => { expect(result.rows[0]).toEqual({ region: 'NA', revenue: 100 }); }); + it('enriches a measure column with its declared currency (ISO 4217)', async () => { + const priced = DatasetSchema.parse({ + name: 'sales_priced', label: 'Sales', object: 'opportunity', include: [], + dimensions: [{ name: 'stage', field: 'stage', type: 'string' }], + measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount', label: 'Revenue', format: '0,0', currency: 'USD', certified: true }], + }); + const svc = new AnalyticsService({ + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }), + executeRawSql: async () => [{ stage: 'Won', revenue: 1000 }], + getReadScope: (_o, ctx?: ExecutionContext) => (ctx?.tenantId ? { organization_id: ctx.tenantId } : undefined), + }); + const result = await svc.queryDataset( + priced, + { dimensions: ['stage'], measures: ['revenue'] }, + { tenantId: 'org_A' } as ExecutionContext, + ) as any; + // The measure's declared currency rides onto the result field so the client + // renders a locale-correct symbol via Intl (not a "$" baked into `format`). + const revenueField = (result.fields ?? []).find((f: any) => f.name === 'revenue'); + expect(revenueField?.currency).toBe('USD'); + expect(revenueField?.format).toBe('0,0'); + }); + it('enriches dimension columns with their dataset display label', async () => { const labeled = DatasetSchema.parse({ name: 'sales2', label: 'Sales', object: 'opportunity', include: ['account'], diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index a5975209b4..c60f9d47d1 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -524,6 +524,11 @@ export class AnalyticsService implements IAnalyticsService { if (!m) continue; if (f.label == null && typeof m.label === 'string') f.label = m.label; if (f.format == null && m.format) f.format = m.format; + // Carry the measure's declared currency so the renderer can render a + // locale-correct symbol via Intl (never a "$" baked into `format`). + const fc = f as { currency?: string }; + const mc = m as { currency?: string }; + if (fc.currency == null && mc.currency) fc.currency = mc.currency; } } diff --git a/packages/spec/liveness/dataset.json b/packages/spec/liveness/dataset.json index 5070f362de..e59efb8fc3 100644 --- a/packages/spec/liveness/dataset.json +++ b/packages/spec/liveness/dataset.json @@ -93,6 +93,11 @@ "evidence": "packages/services/service-analytics/src/analytics-service.ts:480", "note": "compiled into Cube metric + enriched onto result fields for the renderer." }, + "currency": { + "status": "live", + "evidence": "packages/services/service-analytics/src/analytics-service.ts:531", + "note": "measure-declared currency (ISO 4217) enriched onto result fields alongside label/format, so the renderer formats the amount with a locale-correct Intl symbol rather than a '$' baked into format." + }, "certified": { "status": "dead", "evidence": "no runtime consumer — analytics execution never reads it; not compiled into the Cube", diff --git a/packages/spec/src/ui/dataset.zod.ts b/packages/spec/src/ui/dataset.zod.ts index 8de31ed226..9a9ea54a92 100644 --- a/packages/spec/src/ui/dataset.zod.ts +++ b/packages/spec/src/ui/dataset.zod.ts @@ -72,6 +72,13 @@ export const DatasetMeasureSchema = lazySchema(() => z.object({ filter: FilterConditionSchema.optional(), /** Display format, e.g. "$0,0.00", "0.0%". */ format: z.string().optional(), + /** + * Display currency (ISO 4217, e.g. "USD", "CNY"). Carried onto the result + * field so presentations render a locale-correct symbol via `Intl` rather + * than a "$" baked into `format`. Declare it on the measure (the semantic + * layer) when the aggregated field is a fixed-currency amount. + */ + currency: z.string().length(3).optional().describe('Display currency code (ISO 4217)'), /** Governance: a human-blessed metric — the review checkpoint. */ certified: z.boolean().default(false).describe('Blessed metric (governance checkpoint)'), /**