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
19 changes: 19 additions & 0 deletions .changeset/28019-derived-measure-fixed-currency.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- adr-0087: not-required (no-migration-prescription) the refusal rejects a contradiction in a dataset's own measures at compile and names the fix in its message; there is no stored shape to convert and no key is removed -->
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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'] } }]),
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
Loading