diff --git a/.changeset/28019-derived-measure-fixed-currency.md b/.changeset/28019-derived-measure-fixed-currency.md index 018c5dbcb10..45fb146bf3a 100644 --- a/.changeset/28019-derived-measure-fixed-currency.md +++ b/.changeset/28019-derived-measure-fixed-currency.md @@ -9,7 +9,7 @@ Metadata-protocol: narrowed **BREAKING** (`@objectstack/service-analytics`): one more compile-time refusal, `DATASET_INVALID` / 400, for dataset metadata that compiled before and printed a wrong label. It reaches boot registration, `queryDataset` and the Studio draft preview, which compile through the one `compileDataset` call. -- A derived `sum` / `difference` whose monetary operands all resolve to one `fixed` code, directly or through a derived measure built on them, and which declares its own `currency` with another code. The column printed the operands' amounts under the declared code, such as `{ derived: { op: 'sum', of: ['eur_a', 'eur_b'] }, currency: 'USD' }` over two EUR-fixed measures printing euros labelled `USD`. The message names the dataset, the measure, its operands and their code, and the fix: restate the operands' code as the measure's `currency`, or delete it (the column then carries no currency code; a derived column takes no code from its operands on the wire). +- A derived `sum` / `difference` whose monetary operands all resolve to one `fixed` code, directly or through a derived measure built on them, and which declares its own `currency` with another code. The column printed the operands' amounts under the declared code, such as `{ derived: { op: 'sum', of: ['eur_a', 'eur_b'] }, currency: 'USD' }` over two EUR-fixed measures printing euros labelled `USD`. The message names the dataset, the measure, its operands and their code, and the fix: delete the measure's `currency` (the column takes the operands' code), or restate that code. - A derived measure `currency` that restates the operands' `fixed` code is still accepted. Not judged, unchanged: a derived measure whose operands resolve to no one `fixed` code (a `fixed` amount with a tenant-currency one, a `ratio` or `product`), and the record-mode and mixed-code refusals of both guards. diff --git a/.changeset/28049-derived-sum-fixed-currency-column.md b/.changeset/28049-derived-sum-fixed-currency-column.md new file mode 100644 index 00000000000..05485b4ec7e --- /dev/null +++ b/.changeset/28049-derived-sum-fixed-currency-column.md @@ -0,0 +1,12 @@ +--- +'@objectstack/service-analytics': patch +--- + +fix(service-analytics): a derived `sum` / `difference` over one `fixed` currency code carries that code on its result column + +Clause-②: no (the response comes to say what `content/docs/data-modeling/currency.mdx` already declares: "A derived `sum` or `difference` over one `fixed` code carries that code") +Metadata-protocol: none + +A dataset derived measure that adds or subtracts measures whose amounts are all fixed to one currency code, such as `{ derived: { op: 'sum', of: ['eur_a', 'eur_b'] } }` over two EUR-fixed amounts, reached `POST /analytics/dataset/query` as a bare number with no `currency`, so a renderer printed euros unlabelled. Its column now carries that code (`EUR`), on the live query and the Studio draft preview alike. The code is the one the dataset compiler's currency guard already resolves for the measure, read from that same resolution, so the column says what compile judged. + +Unchanged: a derived measure whose operands resolve to no one `fixed` code (a `fixed` amount with a tenant-currency one, two tenant-currency amounts, explicit codes over `dynamic` fields, record-mode amounts) and every `ratio` or `product` carry no currency unless they declare their own. The refusal of a derived measure `currency` that contradicts its operands' `fixed` code now names the fix as "delete the measure's `currency` (the column takes the operands' code), or restate that code". diff --git a/packages/services/service-analytics/src/__tests__/record-currency-analytics.test.ts b/packages/services/service-analytics/src/__tests__/record-currency-analytics.test.ts index 9fa94d73de9..4a8c2b358b1 100644 --- a/packages/services/service-analytics/src/__tests__/record-currency-analytics.test.ts +++ b/packages/services/service-analytics/src/__tests__/record-currency-analytics.test.ts @@ -279,6 +279,74 @@ describe('[ADR-0144 M0-3] analytics over a record-currency amount', () => { expect(err.object).toBe(OBJECT); }); + // The column's currency for a derived measure is the compile guard's own + // resolved reading: a `sum` / `difference` whose operands all resolve to one + // `fixed` code carries it, as `content/docs/data-modeling/currency.mdx` says. + describe('a derived measure column takes the currency its operands resolve to', () => { + const DERIVED = DatasetSchema.parse({ + name: 'os28049_derived_currency', + label: 'Derived currency', + object: OBJECT, + dimensions: [{ name: 'currency', field: 'currency_code', type: 'string' }], + measures: [ + { name: 'eur', aggregate: 'sum', field: 'amt_eur' }, + { name: 'eur_restated', aggregate: 'sum', field: 'amt_eur', currency: 'EUR' }, + { name: 'usd', aggregate: 'sum', field: 'amt_usd' }, + { name: 'tenant', aggregate: 'sum', field: 'amt_tenant' }, + { name: 'tenant_as_jpy', aggregate: 'sum', field: 'amt_tenant', currency: 'JPY' }, + { name: 'total', aggregate: 'sum', field: 'amount' }, + { name: 'invoices', aggregate: 'count' }, + { name: 'eur_twice', derived: { op: 'sum', of: ['eur', 'eur_restated'] } }, + { name: 'eur_net', derived: { op: 'difference', of: ['eur', 'eur'] } }, + { name: 'eur_twice_restated', derived: { op: 'sum', of: ['eur', 'eur'] }, currency: 'EUR' }, + // Controls: no one `fixed` code, so today's answer stands. + { name: 'usd_and_tenant', derived: { op: 'sum', of: ['usd', 'tenant'] } }, + { name: 'tenant_twice', derived: { op: 'sum', of: ['tenant', 'tenant'] } }, + { name: 'jpy_twice', derived: { op: 'sum', of: ['tenant_as_jpy', 'tenant_as_jpy'] } }, + { name: 'eur_per_invoice', derived: { op: 'ratio', of: ['eur', 'invoices'] } }, + { name: 'eur_product', derived: { op: 'product', of: ['eur', 'eur'] } }, + { name: 'total_twice', derived: { op: 'sum', of: ['total', 'total'] } }, + ], + }); + const LABELLED = { eur_twice: 'EUR', eur_net: 'EUR', eur_twice_restated: 'EUR' } as const; + const UNLABELLED = ['usd_and_tenant', 'tenant_twice', 'jpy_twice', 'eur_per_invoice', 'eur_product', 'total_twice'] as const; + const expectColumns = (result: AnalyticsResult) => { + for (const [name, code] of Object.entries(LABELLED)) { + expect(column(result, name)?.currency, name).toBe(code); + expect(column(result, name)?.currencyField, name).toBeUndefined(); + } + for (const name of UNLABELLED) { + expect(column(result, name)?.currency, name).toBeUndefined(); + expect(column(result, name)?.currencyField, name).toBeUndefined(); + } + }; + + for (const face of FACES) { + it(`${face} strategy: a sum or difference over one fixed code carries it; no other derived column gains one`, async () => { + const result = await services[face]!.queryDataset( + DERIVED, + { dimensions: ['currency'], measures: [...Object.keys(LABELLED), ...UNLABELLED] }, + CTX, + ); + expectColumns(result); + const eurByCode = Object.fromEntries( + (result.rows as Array>).map((r) => [r.currency, Number(r.eur_twice)]), + ); + expect(eurByCode).toEqual({ USD: 20, EUR: 180 }); + }); + } + + it('the draft-preview path labels the same columns', async () => { + const result = await services.objectql!.queryDataset( + DERIVED, + { dimensions: ['currency'], measures: [...Object.keys(LABELLED), ...UNLABELLED] }, + CTX, + { previewDrafts: true }, + ); + expectColumns(result); + }); + }); + describe('compile-time guards on measure currencies', () => { const datasetWith = (measures: unknown[]) => DatasetSchema.parse({ @@ -384,7 +452,7 @@ describe('[ADR-0144 M0-3] analytics over a record-currency amount', () => { + 'measures "eur", "eur_restated", whose amounts are fixed to EUR.', ); expect(compileRefusal(measures)?.message) - .toContain("restate the operands' EUR as the measure's `currency`, or delete it (the column then carries no currency code)"); + .toContain("delete the measure's `currency` (the column takes the operands' EUR), or restate EUR."); }); } diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index 34d1d846868..98e0c55113f 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -99,6 +99,7 @@ import { } from './strategies/filter-normalizer.js'; import { findCrossFieldComparand } from './comparand-shape.js'; import { compileDataset, type CompiledDataset, type RelationshipResolver } from './dataset-compiler.js'; +import { measureCurrencyResolver, type DeclaredCurrency } from './measure-currency-coherence.js'; import { bindDatasetFiltersToObjectFields } from './dataset-filter-field-binding.js'; import { drillWindowBounds } from './drill-window.js'; import { @@ -2563,14 +2564,21 @@ export class AnalyticsService implements IAnalyticsService { }, // [ADR-0144, M0-3] …and the declared currency off the same hook, so the // compile-time currency guards judge exactly what the column would print. - declaredCurrency: (object: string, field: string) => { - const meta = this.sourceFieldMeta?.(object, field); - if (!meta) return undefined; - return { type: meta.type, defaultCurrency: meta.defaultCurrency, currencyField: meta.currencyField }; - }, + declaredCurrency: this.declaredCurrency, }); } + /** + * [ADR-0144, M0-3] A field's declared currency off `sourceFieldMeta`: what + * the compile-time currency guards judge, and what a derived measure's + * column reads its resolved currency through (`enrichResultColumns`). + */ + private readonly declaredCurrency = (object: string, field: string): DeclaredCurrency | undefined => { + const meta = this.sourceFieldMeta?.(object, field); + if (!meta) return undefined; + return { type: meta.type, defaultCurrency: meta.defaultCurrency, currencyField: meta.currencyField }; + }; + /** * Compile a `dataset` (ADR-0021) and register its Cube + join allowlist in the * SHARED registry, so every caller can query it by name and `getMeta` lists @@ -3131,6 +3139,7 @@ export class AnalyticsService implements IAnalyticsService { // this resolves TO a string rather than widening the contract. if (result.fields?.length && dataset.measures?.length) { const measureByName = new Map(dataset.measures.map((m) => [m.name, m])); + const currencies = measureCurrencyResolver(dataset, this.declaredCurrency); for (const f of result.fields) { const m = measureByName.get(f.name) ?? measureByName.get(f.name.replace(/__compare$/, '')); if (!m) continue; @@ -3192,18 +3201,27 @@ export class AnalyticsService implements IAnalyticsService { // column left without the dimension gets neither key, rather than a // label that is wrong for every other row. A `count` over the amount // reads no amount, so it gets neither key either, grouped or not. + // + // [#28049] A derived measure has no field to lend a code. Its column + // takes the one `fixed` code its operands resolve to (a `sum` / + // `difference` over amounts in one `fixed` code), read off the compile + // guard's own resolution, so the column says what compile judged. Any + // other derived measure (a `ratio`, a `product`, operands in the + // tenant's or a record's currency) is monetary only by its own + // `currency`, as before. const fc = f as { currency?: string; currencyField?: string }; const mc = m as { currency?: string }; const meta = m.field ? this.sourceFieldMeta?.(dataset.object, m.field) : undefined; + const derivedCode = m.derived ? currencies.fixedCodeOf(m) : undefined; if (meta?.currencyField !== undefined) { const codeColumn = AMOUNT_AGGREGATES.has(m.aggregate ?? '') ? this.selectedDimensions(dataset, selection).find((d) => d.field === meta.currencyField)?.name : undefined; if (fc.currencyField == null && codeColumn !== undefined) fc.currencyField = codeColumn; } else if (fc.currency == null) { - const monetary = !!mc.currency || meta?.type === 'currency'; + const monetary = !!mc.currency || meta?.type === 'currency' || derivedCode !== undefined; if (monetary) { - const resolved = mc.currency ?? meta?.defaultCurrency ?? context?.currency; + const resolved = mc.currency ?? meta?.defaultCurrency ?? derivedCode ?? context?.currency; if (resolved) fc.currency = resolved; } } diff --git a/packages/services/service-analytics/src/measure-currency-coherence.ts b/packages/services/service-analytics/src/measure-currency-coherence.ts index b070dd36b7d..f8e78da6b43 100644 --- a/packages/services/service-analytics/src/measure-currency-coherence.ts +++ b/packages/services/service-analytics/src/measure-currency-coherence.ts @@ -46,7 +46,9 @@ * field. A derived `sum` / `difference` resolves to its operands' common * currency (none when they differ; `fixed` when every operand's is), so a * derived measure built on one is judged too; a `ratio` or `product` - * resolves to none. + * resolves to none. The same reading labels the result column: a derived + * measure that resolves to one `fixed` code carries that code on the wire + * (`enrichResultColumns`, through {@link measureCurrencyResolver}). * * ## Not judged * @@ -97,20 +99,19 @@ const pairRefused = (a: Resolved, b: Resolved): boolean => ((a.kind === 'record' || b.kind === 'record') && provablyDiffer(a, b)) || (a.kind === 'code' && a.fixed && b.kind === 'code' && b.fixed && a.code !== b.code); +type DeclaredCurrencyOf = ((objectName: string, field: string) => DeclaredCurrency | undefined) | undefined; + /** - * Refuse the first measure of `dataset` that breaks either guard — see the - * module header. `declaredCurrency` answers for a bare field of the base - * object, `undefined` when nothing authoritative can. + * Each measure's resolved currency (module header), which both guards read and + * the result column of a derived measure takes (`enrichResultColumns`), so the + * column says what compile judged. `declaredCurrency` answers for a bare field + * of the base object, `undefined` when nothing authoritative can. */ -export function assertMeasureCurrenciesCoherent( - dataset: Dataset, - declaredCurrency: ((objectName: string, field: string) => DeclaredCurrency | undefined) | undefined, -): void { +export function measureCurrencyResolver(dataset: Dataset, declaredCurrency: DeclaredCurrencyOf) { const byName = new Map(dataset.measures.map((m) => [m.name, m])); const fieldOf = (m: DatasetMeasure): DeclaredCurrency | undefined => m.field && !m.field.includes('.') ? declaredCurrency?.(dataset.object, m.field) : undefined; - // Each measure's resolved currency (module header), which both guards read. const memo = new Map(); const resolving = new Set(); const resolvedOf = (m: DatasetMeasure): Resolved | undefined => { @@ -148,6 +149,20 @@ export function assertMeasureCurrenciesCoherent( const resolved = operand ? resolvedOf(operand) : undefined; return resolved ? [{ name, resolved }] : []; }); + /** The one `fixed` code a measure resolves to, `undefined` for any other currency or none. */ + const fixedCodeOf = (m: DatasetMeasure): string | undefined => { + const r = resolvedOf(m); + return r?.kind === 'code' && r.fixed ? r.code : undefined; + }; + return { fieldOf, resolvedOf, monetaryOperands, fixedCodeOf }; +} + +/** + * Refuse the first measure of `dataset` that breaks either guard — see the + * module header. + */ +export function assertMeasureCurrenciesCoherent(dataset: Dataset, declaredCurrency: DeclaredCurrencyOf): void { + const { fieldOf, resolvedOf, monetaryOperands } = measureCurrencyResolver(dataset, declaredCurrency); // Guard 1 — a measure `currency` over a record-mode amount (aggregated or // derived), or contradicting a `fixed` code (a field's, or the one its @@ -185,8 +200,8 @@ export function assertMeasureCurrenciesCoherent( throw datasetInvalidError( `[dataset-compiler] dataset "${dataset.name}" derived measure "${m.name}" declares currency ` + `"${m.currency}" over measures ${operands}, whose amounts are fixed to ${resolved.code}. The amounts are ` - + `${resolved.code}, so the column would print them as ${m.currency}: restate the operands' ` - + `${resolved.code} as the measure's \`currency\`, or delete it (the column then carries no currency code).`, + + `${resolved.code}, so the column would print them as ${m.currency}: delete the measure's \`currency\` ` + + `(the column takes the operands' ${resolved.code}), or restate ${resolved.code}.`, ); } }