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
2 changes: 1 addition & 1 deletion .changeset/28019-derived-measure-fixed-currency.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/service-analytics': minor
---
Expand All @@ -9,7 +9,7 @@

**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.
Expand Down
12 changes: 12 additions & 0 deletions .changeset/28049-derived-sum-fixed-currency-column.md
Original file line number Diff line number Diff line change
@@ -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".
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>>).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({
Expand Down Expand Up @@ -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.");
});
}

Expand Down
32 changes: 25 additions & 7 deletions packages/services/service-analytics/src/analytics-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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<string, Resolved | undefined>();
const resolving = new Set<string>();
const resolvedOf = (m: DatasetMeasure): Resolved | undefined => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}.`,
);
}
}
Expand Down
Loading