Skip to content

Commit 670a779

Browse files
committed
fix(service-analytics): a derived sum or difference over one fixed currency code carries that code on its column
A derived measure has no field to lend a currency code, so enrichResultColumns minted its column monetary only when the measure restated `currency`. A sum of two EUR-fixed measures reached the wire as a bare number, though the currency docs say it carries EUR. The compile guard's resolution of each measure's currency is lifted into measureCurrencyResolver, which both the guard and the column chain read: a derived measure that resolves to one `fixed` code now carries it, on the live query and the draft preview. Every other derived measure keeps its answer. The guard's refusal of a contradicting derived `currency` no longer says the column would carry no code once the `currency` is deleted. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4d1NUiRegpdMBrWq7FZ
1 parent 8cb8c7f commit 670a779

5 files changed

Lines changed: 133 additions & 20 deletions

File tree

‎.changeset/28019-derived-measure-fixed-currency.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Metadata-protocol: narrowed
99

1010
**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.
1111

12-
- 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).
12+
- 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.
1313
- A derived measure `currency` that restates the operands' `fixed` code is still accepted.
1414

1515
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.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/service-analytics': patch
3+
---
4+
5+
fix(service-analytics): a derived `sum` / `difference` over one `fixed` currency code carries that code on its result column
6+
7+
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")
8+
Metadata-protocol: none
9+
10+
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.
11+
12+
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".

‎packages/services/service-analytics/src/__tests__/record-currency-analytics.test.ts‎

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,74 @@ describe('[ADR-0144 M0-3] analytics over a record-currency amount', () => {
279279
expect(err.object).toBe(OBJECT);
280280
});
281281

282+
// The column's currency for a derived measure is the compile guard's own
283+
// resolved reading: a `sum` / `difference` whose operands all resolve to one
284+
// `fixed` code carries it, as `content/docs/data-modeling/currency.mdx` says.
285+
describe('a derived measure column takes the currency its operands resolve to', () => {
286+
const DERIVED = DatasetSchema.parse({
287+
name: 'os28049_derived_currency',
288+
label: 'Derived currency',
289+
object: OBJECT,
290+
dimensions: [{ name: 'currency', field: 'currency_code', type: 'string' }],
291+
measures: [
292+
{ name: 'eur', aggregate: 'sum', field: 'amt_eur' },
293+
{ name: 'eur_restated', aggregate: 'sum', field: 'amt_eur', currency: 'EUR' },
294+
{ name: 'usd', aggregate: 'sum', field: 'amt_usd' },
295+
{ name: 'tenant', aggregate: 'sum', field: 'amt_tenant' },
296+
{ name: 'tenant_as_jpy', aggregate: 'sum', field: 'amt_tenant', currency: 'JPY' },
297+
{ name: 'total', aggregate: 'sum', field: 'amount' },
298+
{ name: 'invoices', aggregate: 'count' },
299+
{ name: 'eur_twice', derived: { op: 'sum', of: ['eur', 'eur_restated'] } },
300+
{ name: 'eur_net', derived: { op: 'difference', of: ['eur', 'eur'] } },
301+
{ name: 'eur_twice_restated', derived: { op: 'sum', of: ['eur', 'eur'] }, currency: 'EUR' },
302+
// Controls: no one `fixed` code, so today's answer stands.
303+
{ name: 'usd_and_tenant', derived: { op: 'sum', of: ['usd', 'tenant'] } },
304+
{ name: 'tenant_twice', derived: { op: 'sum', of: ['tenant', 'tenant'] } },
305+
{ name: 'jpy_twice', derived: { op: 'sum', of: ['tenant_as_jpy', 'tenant_as_jpy'] } },
306+
{ name: 'eur_per_invoice', derived: { op: 'ratio', of: ['eur', 'invoices'] } },
307+
{ name: 'eur_product', derived: { op: 'product', of: ['eur', 'eur'] } },
308+
{ name: 'total_twice', derived: { op: 'sum', of: ['total', 'total'] } },
309+
],
310+
});
311+
const LABELLED = { eur_twice: 'EUR', eur_net: 'EUR', eur_twice_restated: 'EUR' } as const;
312+
const UNLABELLED = ['usd_and_tenant', 'tenant_twice', 'jpy_twice', 'eur_per_invoice', 'eur_product', 'total_twice'] as const;
313+
const expectColumns = (result: AnalyticsResult) => {
314+
for (const [name, code] of Object.entries(LABELLED)) {
315+
expect(column(result, name)?.currency, name).toBe(code);
316+
expect(column(result, name)?.currencyField, name).toBeUndefined();
317+
}
318+
for (const name of UNLABELLED) {
319+
expect(column(result, name)?.currency, name).toBeUndefined();
320+
expect(column(result, name)?.currencyField, name).toBeUndefined();
321+
}
322+
};
323+
324+
for (const face of FACES) {
325+
it(`${face} strategy: a sum or difference over one fixed code carries it; no other derived column gains one`, async () => {
326+
const result = await services[face]!.queryDataset(
327+
DERIVED,
328+
{ dimensions: ['currency'], measures: [...Object.keys(LABELLED), ...UNLABELLED] },
329+
CTX,
330+
);
331+
expectColumns(result);
332+
const eurByCode = Object.fromEntries(
333+
(result.rows as Array<Record<string, unknown>>).map((r) => [r.currency, Number(r.eur_twice)]),
334+
);
335+
expect(eurByCode).toEqual({ USD: 20, EUR: 180 });
336+
});
337+
}
338+
339+
it('the draft-preview path labels the same columns', async () => {
340+
const result = await services.objectql!.queryDataset(
341+
DERIVED,
342+
{ dimensions: ['currency'], measures: [...Object.keys(LABELLED), ...UNLABELLED] },
343+
CTX,
344+
{ previewDrafts: true },
345+
);
346+
expectColumns(result);
347+
});
348+
});
349+
282350
describe('compile-time guards on measure currencies', () => {
283351
const datasetWith = (measures: unknown[]) =>
284352
DatasetSchema.parse({
@@ -384,7 +452,7 @@ describe('[ADR-0144 M0-3] analytics over a record-currency amount', () => {
384452
+ 'measures "eur", "eur_restated", whose amounts are fixed to EUR.',
385453
);
386454
expect(compileRefusal(measures)?.message)
387-
.toContain("restate the operands' EUR as the measure's `currency`, or delete it (the column then carries no currency code)");
455+
.toContain("delete the measure's `currency` (the column takes the operands' EUR), or restate EUR.");
388456
});
389457
}
390458

‎packages/services/service-analytics/src/analytics-service.ts‎

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ import {
9999
} from './strategies/filter-normalizer.js';
100100
import { findCrossFieldComparand } from './comparand-shape.js';
101101
import { compileDataset, type CompiledDataset, type RelationshipResolver } from './dataset-compiler.js';
102+
import { measureCurrencyResolver, type DeclaredCurrency } from './measure-currency-coherence.js';
102103
import { bindDatasetFiltersToObjectFields } from './dataset-filter-field-binding.js';
103104
import { drillWindowBounds } from './drill-window.js';
104105
import {
@@ -2563,14 +2564,21 @@ export class AnalyticsService implements IAnalyticsService {
25632564
},
25642565
// [ADR-0144, M0-3] …and the declared currency off the same hook, so the
25652566
// compile-time currency guards judge exactly what the column would print.
2566-
declaredCurrency: (object: string, field: string) => {
2567-
const meta = this.sourceFieldMeta?.(object, field);
2568-
if (!meta) return undefined;
2569-
return { type: meta.type, defaultCurrency: meta.defaultCurrency, currencyField: meta.currencyField };
2570-
},
2567+
declaredCurrency: this.declaredCurrency,
25712568
});
25722569
}
25732570

2571+
/**
2572+
* [ADR-0144, M0-3] A field's declared currency off `sourceFieldMeta`: what
2573+
* the compile-time currency guards judge, and what a derived measure's
2574+
* column reads its resolved currency through (`enrichResultColumns`).
2575+
*/
2576+
private readonly declaredCurrency = (object: string, field: string): DeclaredCurrency | undefined => {
2577+
const meta = this.sourceFieldMeta?.(object, field);
2578+
if (!meta) return undefined;
2579+
return { type: meta.type, defaultCurrency: meta.defaultCurrency, currencyField: meta.currencyField };
2580+
};
2581+
25742582
/**
25752583
* Compile a `dataset` (ADR-0021) and register its Cube + join allowlist in the
25762584
* SHARED registry, so every caller can query it by name and `getMeta` lists
@@ -3131,6 +3139,7 @@ export class AnalyticsService implements IAnalyticsService {
31313139
// this resolves TO a string rather than widening the contract.
31323140
if (result.fields?.length && dataset.measures?.length) {
31333141
const measureByName = new Map(dataset.measures.map((m) => [m.name, m]));
3142+
const currencies = measureCurrencyResolver(dataset, this.declaredCurrency);
31343143
for (const f of result.fields) {
31353144
const m = measureByName.get(f.name) ?? measureByName.get(f.name.replace(/__compare$/, ''));
31363145
if (!m) continue;
@@ -3192,18 +3201,27 @@ export class AnalyticsService implements IAnalyticsService {
31923201
// column left without the dimension gets neither key, rather than a
31933202
// label that is wrong for every other row. A `count` over the amount
31943203
// reads no amount, so it gets neither key either, grouped or not.
3204+
//
3205+
// [#28049] A derived measure has no field to lend a code. Its column
3206+
// takes the one `fixed` code its operands resolve to (a `sum` /
3207+
// `difference` over amounts in one `fixed` code), read off the compile
3208+
// guard's own resolution, so the column says what compile judged. Any
3209+
// other derived measure (a `ratio`, a `product`, operands in the
3210+
// tenant's or a record's currency) is monetary only by its own
3211+
// `currency`, as before.
31953212
const fc = f as { currency?: string; currencyField?: string };
31963213
const mc = m as { currency?: string };
31973214
const meta = m.field ? this.sourceFieldMeta?.(dataset.object, m.field) : undefined;
3215+
const derivedCode = m.derived ? currencies.fixedCodeOf(m) : undefined;
31983216
if (meta?.currencyField !== undefined) {
31993217
const codeColumn = AMOUNT_AGGREGATES.has(m.aggregate ?? '')
32003218
? this.selectedDimensions(dataset, selection).find((d) => d.field === meta.currencyField)?.name
32013219
: undefined;
32023220
if (fc.currencyField == null && codeColumn !== undefined) fc.currencyField = codeColumn;
32033221
} else if (fc.currency == null) {
3204-
const monetary = !!mc.currency || meta?.type === 'currency';
3222+
const monetary = !!mc.currency || meta?.type === 'currency' || derivedCode !== undefined;
32053223
if (monetary) {
3206-
const resolved = mc.currency ?? meta?.defaultCurrency ?? context?.currency;
3224+
const resolved = mc.currency ?? meta?.defaultCurrency ?? derivedCode ?? context?.currency;
32073225
if (resolved) fc.currency = resolved;
32083226
}
32093227
}

‎packages/services/service-analytics/src/measure-currency-coherence.ts‎

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@
4646
* field. A derived `sum` / `difference` resolves to its operands' common
4747
* currency (none when they differ; `fixed` when every operand's is), so a
4848
* derived measure built on one is judged too; a `ratio` or `product`
49-
* resolves to none.
49+
* resolves to none. The same reading labels the result column: a derived
50+
* measure that resolves to one `fixed` code carries that code on the wire
51+
* (`enrichResultColumns`, through {@link measureCurrencyResolver}).
5052
*
5153
* ## Not judged
5254
*
@@ -97,20 +99,19 @@ const pairRefused = (a: Resolved, b: Resolved): boolean =>
9799
((a.kind === 'record' || b.kind === 'record') && provablyDiffer(a, b))
98100
|| (a.kind === 'code' && a.fixed && b.kind === 'code' && b.fixed && a.code !== b.code);
99101

102+
type DeclaredCurrencyOf = ((objectName: string, field: string) => DeclaredCurrency | undefined) | undefined;
103+
100104
/**
101-
* Refuse the first measure of `dataset` that breaks either guard — see the
102-
* module header. `declaredCurrency` answers for a bare field of the base
103-
* object, `undefined` when nothing authoritative can.
105+
* Each measure's resolved currency (module header), which both guards read and
106+
* the result column of a derived measure takes (`enrichResultColumns`), so the
107+
* column says what compile judged. `declaredCurrency` answers for a bare field
108+
* of the base object, `undefined` when nothing authoritative can.
104109
*/
105-
export function assertMeasureCurrenciesCoherent(
106-
dataset: Dataset,
107-
declaredCurrency: ((objectName: string, field: string) => DeclaredCurrency | undefined) | undefined,
108-
): void {
110+
export function measureCurrencyResolver(dataset: Dataset, declaredCurrency: DeclaredCurrencyOf) {
109111
const byName = new Map(dataset.measures.map((m) => [m.name, m]));
110112
const fieldOf = (m: DatasetMeasure): DeclaredCurrency | undefined =>
111113
m.field && !m.field.includes('.') ? declaredCurrency?.(dataset.object, m.field) : undefined;
112114

113-
// Each measure's resolved currency (module header), which both guards read.
114115
const memo = new Map<string, Resolved | undefined>();
115116
const resolving = new Set<string>();
116117
const resolvedOf = (m: DatasetMeasure): Resolved | undefined => {
@@ -148,6 +149,20 @@ export function assertMeasureCurrenciesCoherent(
148149
const resolved = operand ? resolvedOf(operand) : undefined;
149150
return resolved ? [{ name, resolved }] : [];
150151
});
152+
/** The one `fixed` code a measure resolves to, `undefined` for any other currency or none. */
153+
const fixedCodeOf = (m: DatasetMeasure): string | undefined => {
154+
const r = resolvedOf(m);
155+
return r?.kind === 'code' && r.fixed ? r.code : undefined;
156+
};
157+
return { fieldOf, resolvedOf, monetaryOperands, fixedCodeOf };
158+
}
159+
160+
/**
161+
* Refuse the first measure of `dataset` that breaks either guard — see the
162+
* module header.
163+
*/
164+
export function assertMeasureCurrenciesCoherent(dataset: Dataset, declaredCurrency: DeclaredCurrencyOf): void {
165+
const { fieldOf, resolvedOf, monetaryOperands } = measureCurrencyResolver(dataset, declaredCurrency);
151166

152167
// Guard 1 — a measure `currency` over a record-mode amount (aggregated or
153168
// derived), or contradicting a `fixed` code (a field's, or the one its
@@ -185,8 +200,8 @@ export function assertMeasureCurrenciesCoherent(
185200
throw datasetInvalidError(
186201
`[dataset-compiler] dataset "${dataset.name}" derived measure "${m.name}" declares currency `
187202
+ `"${m.currency}" over measures ${operands}, whose amounts are fixed to ${resolved.code}. The amounts are `
188-
+ `${resolved.code}, so the column would print them as ${m.currency}: restate the operands' `
189-
+ `${resolved.code} as the measure's \`currency\`, or delete it (the column then carries no currency code).`,
203+
+ `${resolved.code}, so the column would print them as ${m.currency}: delete the measure's \`currency\` `
204+
+ `(the column takes the operands' ${resolved.code}), or restate ${resolved.code}.`,
190205
);
191206
}
192207
}

0 commit comments

Comments
 (0)