diff --git a/.changeset/28019-derived-measure-fixed-currency.md b/.changeset/28019-derived-measure-fixed-currency.md new file mode 100644 index 00000000000..018c5dbcb10 --- /dev/null +++ b/.changeset/28019-derived-measure-fixed-currency.md @@ -0,0 +1,19 @@ +--- +'@objectstack/service-analytics': minor +--- + +fix(service-analytics): a dataset refuses a derived measure `currency` that contradicts the one `fixed` code its operands resolve to + +Clause-②: no (narrowing) — not a widening; a dataset that compiled before is refused now +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 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. + +No in-tree dataset declares this shape. A deployed tenant's stored dataset with it now refuses at registration and at query, and its message names the edit. + + 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 a70812a0445..9fa94d73de9 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 @@ -21,7 +21,8 @@ * column-description seam. * - The two compile-time guards (`measure-currency-coherence.ts`): * `DATASET_INVALID` / 400 for a measure `currency` over a record-mode amount - * or contradicting a `fixed` field's code, and for a derived measure + * or contradicting a `fixed` field's code or a derived measure's operands' + * one `fixed` code, and for a derived measure * combining a record-mode amount with another currency or two different * `fixed` codes. The `fixed` arms reach boot registration, `queryDataset` * and the draft preview alike (ruling 6486427200 on #8345). @@ -370,6 +371,48 @@ describe('[ADR-0144 M0-3] analytics over a record-currency amount', () => { ); }); + for (const op of ['sum', 'difference'] as const) { + it(`a derived ${op} declaring a currency other than its operands' one fixed code is refused, naming the fix`, () => { + const measures = [ + ...base, + { name: 'eur_restated', aggregate: 'sum', field: 'amt_eur', currency: 'EUR' }, + { name: 'eur_as_usd', derived: { op, of: ['eur', 'eur_restated'] }, currency: 'USD' }, + ]; + expectRefused(measures, 'eur_as_usd'); + expect(compileRefusal(measures)?.message).toContain( + `[dataset-compiler] dataset "os27187_guard" derived measure "eur_as_usd" declares currency "USD" over ` + + '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)"); + }); + } + + it('a derived measure declaring a currency over a fixed-code derived sum is judged too', () => { + expectRefused( + [...base, { name: 'eur_twice', derived: { op: 'sum', of: ['eur', 'eur'] } }, { name: 'eur_as_jpy', derived: { op: 'sum', of: ['eur_twice', 'eur'] }, currency: 'JPY' }], + 'eur_as_jpy', + ); + }); + + it("CONTROL: a derived measure restating its operands' one fixed code is accepted", () => { + expect( + compileRefusal([...base, { name: 'eur_twice', derived: { op: 'sum', of: ['eur', 'eur'] }, currency: 'EUR' }]), + ).toBeUndefined(); + }); + + it('CONTROL: a derived measure currency is not judged where its operands resolve to no one fixed code', () => { + expect( + compileRefusal([ + ...base, + // a fixed amount with the tenant currency: one currency, but not a `fixed` one + { name: 'usd_and_tenant_as_jpy', derived: { op: 'sum', of: ['usd', 'tenant'] }, currency: 'JPY' }, + // a ratio resolves to no currency + { name: 'eur_per_invoice_as_usd', derived: { op: 'ratio', of: ['eur', 'invoices'] }, currency: 'USD' }, + ]), + ).toBeUndefined(); + }); + it('CONTROL: a fixed operand restated by its measure currency compares by that one code', () => { expect( compileRefusal([...base, { name: 'usd_restated', aggregate: 'sum', field: 'amt_usd', currency: 'USD' }, { name: 'both', derived: { op: 'sum', of: ['usd', 'usd_restated'] } }]), @@ -400,6 +443,7 @@ describe('[ADR-0144 M0-3] analytics over a record-currency amount', () => { const fixedShapes = [ ['a measure currency contradicting a fixed field', [{ name: 'eur_as_jpy', aggregate: 'sum', field: 'amt_eur', currency: 'JPY' }], 'eur_as_jpy'], ['a derived measure across two different fixed codes', [...base, { name: 'mixed', derived: { op: 'difference', of: ['usd', 'eur'] } }], 'mixed'], + ["a derived measure currency contradicting its operands' fixed code", [...base, { name: 'eur_as_usd', derived: { op: 'sum', of: ['eur', 'eur'] }, currency: 'USD' }], 'eur_as_usd'], ] as const; for (const [label, measures, named] of fixedShapes) { for (const [door, options] of [['queryDataset', undefined], ['the draft preview', { previewDrafts: true }]] as const) { diff --git a/packages/services/service-analytics/src/measure-currency-coherence.ts b/packages/services/service-analytics/src/measure-currency-coherence.ts index 54b80f02b19..b070dd36b7d 100644 --- a/packages/services/service-analytics/src/measure-currency-coherence.ts +++ b/packages/services/service-analytics/src/measure-currency-coherence.ts @@ -17,7 +17,10 @@ * field): its column carries no per-row pointer, so the one code would * label every row. * - Over a `fixed` field, a code other than the field's. The same code is - * redundant, not wrong, and is accepted. + * redundant, not wrong, and is accepted. That holds for a derived measure + * whose resolved currency is one `fixed` code (a sum of `fixed` amounts in + * one code): `computeDerived` keeps the operands' amounts, so the measure's + * code would label them as another currency. * 2. **A derived measure across currencies.** `computeDerived` adds, * subtracts, multiplies and divides bare numbers, so such a value is not a * number in any currency (probe A, `m_mixed_sum`: `200` with no currency). @@ -147,7 +150,8 @@ export function assertMeasureCurrenciesCoherent( }); // Guard 1 — a measure `currency` over a record-mode amount (aggregated or - // derived), or contradicting a `fixed` field's code. + // derived), or contradicting a `fixed` code (a field's, or the one its + // derived operands resolve to). for (const m of dataset.measures) { if (m.duration || !m.currency) continue; const field = m.derived ? undefined : fieldOf(m); @@ -176,6 +180,15 @@ export function assertMeasureCurrenciesCoherent( + "currencies: delete the measure's `currency`.", ); } + if (resolved?.kind === 'code' && resolved.fixed && resolved.code !== m.currency) { + const operands = [...new Set(monetaryOperands(m).map((o) => `"${o.name}"`))].join(', '); + 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).`, + ); + } } // Guard 2 — a derived measure combining a record-mode amount with another