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
12 changes: 12 additions & 0 deletions .changeset/dataset-measure-currency.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions examples/app-crm/src/datasets/opportunity.dataset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
],
});
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
5 changes: 5 additions & 0 deletions packages/services/service-analytics/src/analytics-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down
5 changes: 5 additions & 0 deletions packages/spec/liveness/dataset.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions packages/spec/src/ui/dataset.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)'),
/**
Expand Down
Loading