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/27579-fixed-currency-measure-guards.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 measure `currency` that contradicts its field's `fixed` code, and a derived measure across two different `fixed` codes (ADR-0144 open item 12)

Clause-②: yes (narrowing)
Metadata-protocol: narrowed

**BREAKING** (`@objectstack/service-analytics`): two compile-time refusals, `DATASET_INVALID` / 400, for dataset metadata that compiled before and printed a wrong label or a wrong number. Both reach boot registration, `queryDataset` and the Studio draft preview, which compile through the one `compileDataset` call.

- A measure `currency` whose code differs from the aggregated field's `fixed` code. The column printed the field's amounts under the measure's code, such as euros labelled `USD`. The message names the fix: delete the measure's `currency`, and the column takes the field's code. A measure `currency` that restates the `fixed` code is still accepted.
- A derived measure, for every op, whose monetary operands resolve to two different `fixed` codes, directly or through a derived `sum` / `difference` built on them. The value combined bare numbers in two currencies and carried no currency at all. The message names the fix: derive it from measures in one currency, or drop the operand in the other. A `count` operand is not compared, so an amount per invoice still compiles.

Not judged, unchanged: a `fixed` code against the tenant currency, which is not known at compile; a measure `currency` over a `dynamic` field. The record-mode arms of both guards are unchanged.

No in-tree dataset declares either shape. A deployed tenant's stored datasets cannot be surveyed from the repository: a stored dataset with either shape now refuses at registration and at query, and its message names the edit.

<!-- adr-0087: not-required (no-migration-prescription) the two refusals reject a contradiction in a dataset's own measures at compile and name the fix in their 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 @@ -98,10 +98,12 @@ const DATASET = DatasetSchema.parse({
{ name: 'm_nomode', aggregate: 'sum', field: 'amt_nomode' },
{ name: 'm_emptycfg', aggregate: 'sum', field: 'amt_emptycfg' },
{ name: 'm_bare', aggregate: 'sum', field: 'amt_bare' },
// An explicit measure `currency` wins over every field-side answer,
// the field's FIXED currency included.
// An explicit measure `currency` wins over a dynamic field's tenant
// default. [ADR-0144 M0-3] Over a FIXED field it may only restate the
// field's code: a contradicting one is refused at compile
// (`measure-currency-coherence.ts`, record-currency-analytics.test.ts).
{ name: 'm_explicit_dynamic', aggregate: 'sum', field: 'amt_dynamic', currency: 'JPY' },
{ name: 'm_explicit_fixed', aggregate: 'sum', field: 'amt_fixed', currency: 'JPY' },
{ name: 'm_explicit_fixed', aggregate: 'sum', field: 'amt_fixed', currency: 'EUR' },
{ name: 'm_units', aggregate: 'sum', field: 'units' },
],
});
Expand Down Expand Up @@ -220,11 +222,11 @@ describe.each<Shape>(['raw', 'parsed'])('[#20091] result-column currency honours
expect(preview.m_bare).toBe('USD');
});

it('an explicit measure currency wins, over a dynamic field and over a fixed one', () => {
it("an explicit measure currency wins over a dynamic field, and restates a fixed one's code", () => {
expect(live.m_explicit_dynamic).toBe('JPY');
expect(preview.m_explicit_dynamic).toBe('JPY');
expect(live.m_explicit_fixed).toBe('JPY');
expect(preview.m_explicit_fixed).toBe('JPY');
expect(live.m_explicit_fixed).toBe('EUR');
expect(preview.m_explicit_fixed).toBe('EUR');
});

it('CONTROL: a non-monetary measure carries no currency', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,11 @@ describe('AnalyticsService.queryDataset', () => {
expect(r.fields.find((f: any) => f.name === 'revenue')?.currency).toBe('GBP');
});

it('chain: an explicit measure currency wins over the field default and the tenant ctx', async () => {
const svc = pricedSvc([{ stage: 'Won', revenue: 1000 }], (_o, f) => f === 'amount' ? { type: 'currency', defaultCurrency: 'EUR' } : undefined);
// [ADR-0144 M0-3] Over a FIXED field a measure currency may only restate the
// field's code — a contradicting one is refused at compile
// (`measure-currency-coherence.ts`, pinned in record-currency-analytics.test.ts).
it('chain: an explicit measure currency wins over the tenant ctx on a dynamic field', async () => {
const svc = pricedSvc([{ stage: 'Won', revenue: 1000 }], (_o, f) => f === 'amount' ? { type: 'currency' } : undefined);
const r = await svc.queryDataset(moneyDataset({ currency: 'JPY' }), { dimensions: ['stage'], measures: ['revenue'] }, { tenantId: 'o', currency: 'GBP' } as ExecutionContext) as any;
expect(r.fields.find((f: any) => f.name === 'revenue')?.currency).toBe('JPY');
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,12 @@
* own capabilities; `ObjectQLStrategy`, narrowed to the engine aggregate),
* and the column on the draft-preview path, which shares the one
* column-description seam.
* - The two compile-time guards (`measure-currency-coherence.ts`), both on
* the record mode alone: `DATASET_INVALID` / 400 for a measure `currency`
* over a record-mode amount, and for a derived measure combining one with an
* operand in another currency. The same shapes over `fixed` fields only
* still compile (controls below).
* - 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
* 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).
*
* The SQLite cell runs everywhere; the strategies are the axis here, not the
* dialect (the door stands ahead of both and reads nothing).
Expand Down Expand Up @@ -315,11 +316,20 @@ describe('[ADR-0144 M0-3] analytics over a record-currency amount', () => {
);
});

it('CONTROL: a measure currency over a fixed field is not judged, whatever its code (ADR-0144 open item 12, its own card after R1)', () => {
expect(compileRefusal([{ name: 'eur_as_jpy', aggregate: 'sum', field: 'amt_eur', currency: 'JPY' }])).toBeUndefined();
it("a measure currency contradicting a fixed field's code is refused, naming the fix", () => {
expectRefused([{ name: 'eur_as_jpy', aggregate: 'sum', field: 'amt_eur', currency: 'JPY' }], 'eur_as_jpy');
expect(compileRefusal([{ name: 'eur_as_jpy', aggregate: 'sum', field: 'amt_eur', currency: 'JPY' }])?.message)
.toContain("delete the measure's `currency`");
});

it("CONTROL: a measure currency restating a fixed field's code is accepted", () => {
expect(compileRefusal([{ name: 'eur', aggregate: 'sum', field: 'amt_eur', currency: 'EUR' }])).toBeUndefined();
});

it('CONTROL: a measure currency over a dynamic field is not judged (the tenant currency is unknown at compile)', () => {
expect(compileRefusal([{ name: 'tenant_as_usd', aggregate: 'sum', field: 'amt_tenant', currency: 'USD' }])).toBeUndefined();
});

const base = [
{ name: 'usd', aggregate: 'sum', field: 'amt_usd' },
{ name: 'eur', aggregate: 'sum', field: 'amt_eur' },
Expand All @@ -345,19 +355,75 @@ describe('[ADR-0144 M0-3] analytics over a record-currency amount', () => {
);
});

it('CONTROL: a derived measure over two different fixed codes is not judged (ADR-0144 open item 12, its own card after R1)', () => {
expect(compileRefusal([...base, { name: 'mixed', derived: { op: 'sum', of: ['usd', 'eur'] } }])).toBeUndefined();
for (const op of ['sum', 'difference', 'ratio', 'product'] as const) {
it(`a derived ${op} across two different fixed codes is refused, naming the fix`, () => {
expectRefused([...base, { name: 'mixed', derived: { op, of: ['usd', 'eur'] } }], 'mixed');
expect(compileRefusal([...base, { name: 'mixed', derived: { op, of: ['usd', 'eur'] } }])?.message)
.toContain('derive it from measures in one currency, or drop the operand in the other');
});
}

it('a derived measure built on a fixed-code derived sum is judged at the next level', () => {
expectRefused(
[...base, { name: 'usd_twice', derived: { op: 'sum', of: ['usd', 'usd'] } }, { name: 'mixed', derived: { op: 'sum', of: ['usd_twice', 'eur'] } }],
'mixed',
);
});

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'] } }]),
).toBeUndefined();
});

it('CONTROL: an explicit measure currency over a dynamic field is not a fixed code, so a derived measure over it and a fixed one is not judged', () => {
expect(
compileRefusal([...base, { name: 'tenant_as_jpy', aggregate: 'sum', field: 'amt_tenant', currency: 'JPY' }, { name: 'mixed', derived: { op: 'sum', of: ['tenant_as_jpy', 'usd'] } }]),
).toBeUndefined();
});

it('accepted: an amount per invoice, an amount with itself, a fixed amount with the tenant currency', () => {
expect(
compileRefusal([
...base,
{ name: 'per_invoice', derived: { op: 'ratio', of: ['total', 'invoices'] } },
{ name: 'usd_per_invoice', derived: { op: 'ratio', of: ['usd', 'invoices'] } },
{ name: 'eur_per_invoice', derived: { op: 'ratio', of: ['eur', 'invoices'] } },
{ name: 'usd_and_tenant', derived: { op: 'sum', of: ['usd', 'tenant'] } },
{ name: 'total_share', derived: { op: 'ratio', of: ['total', 'total'] } },
]),
).toBeUndefined();
});

// The request door and the draft preview compile through the same
// `compileDataset` call as registration, so both fixed shapes refuse there too.
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'],
] as const;
for (const [label, measures, named] of fixedShapes) {
for (const [door, options] of [['queryDataset', undefined], ['the draft preview', { previewDrafts: true }]] as const) {
it(`${door} refuses ${label}`, async () => {
const err = await refusalOf(
services.objectql!.queryDataset(datasetWith([...measures]), { measures: [named] }, CTX, options as never),
);
expect(err.code, err.message).toBe('DATASET_INVALID');
expect(err.status).toBe(400);
expect(err.message).toContain(`"${named}"`);
});
}
}

it(`CONTROL: queryDataset and the draft preview serve a measure currency restating a fixed field's code`, async () => {
for (const options of [undefined, { previewDrafts: true }]) {
const result = await services.objectql!.queryDataset(
datasetWith([{ name: 'eur', aggregate: 'sum', field: 'amt_eur', currency: 'EUR' }]),
{ measures: ['eur'] },
CTX,
options as never,
);
expect(column(result, 'eur')?.currency).toBe('EUR');
}
});
});
});
Loading
Loading