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
13 changes: 13 additions & 0 deletions .changeset/28194-count-is-not-an-amount.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/service-analytics': patch
---

fix(service-analytics): a dataset `count` that names an amount field is no amount: the cross-currency guard no longer compares it, and its result column carries no currency

Clause-②: yes (widening)
Metadata-protocol: none

A `count` counts rows, not money, also when it names a field (`{ aggregate: 'count', field: 'amount' }` counts the records with an amount). Two readers still treated such a count as an amount in the field's currency:

- **The compile-time currency guard.** A derived measure over an amount and such a count was refused `DATASET_INVALID` / 400 as "amounts in different currencies": for example `ratio(usd_total, priced_invoices)`, an average amount per priced invoice, when the counted field is a record-currency amount or fixed to another code. It now compiles. Only an amount aggregate (`sum`, `avg`, `min`, `max`) resolves a currency for the guard, so a `count` is never compared, whatever field it names or `currency` it declares. Unchanged: a derived measure over two amounts in different `fixed` codes, or a record-currency amount and another currency, is still refused, and a measure `currency` that contradicts its field's `fixed` code is still refused on a `count` too.
- **The result column.** A `count` over a `fixed`-currency or tenant-currency amount carried that currency on its column (`fields[].currency`), so a dashboard tile counting priced invoices printed `€3` instead of `3`. The column now carries no currency; a `sum` / `avg` / `min` / `max` over the same field keeps it, and a measure that declares its own `currency` keeps it as before.
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
* 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).
* - A `count` reads no amount, over an amount field or not: guard 2 does not
* compare it and its column carries no currency, in any arm of the chain.
*
* 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 @@ -347,6 +349,53 @@ describe('[ADR-0144 M0-3] analytics over a record-currency amount', () => {
});
});

// A `count` over an amount reads no amount, so its column is no money in any
// arm of the chain: neither the field's `fixed` code nor the tenant's.
describe('a count over an amount field carries no currency', () => {
const COUNTS = DatasetSchema.parse({
name: 'os28194_count_currency',
label: 'Count currency',
object: OBJECT,
dimensions: [],
measures: [
{ name: 'priced_eur', aggregate: 'count', field: 'amt_eur' },
{ name: 'priced_tenant', aggregate: 'count', field: 'amt_tenant' },
// Controls: an amount aggregate over the same fields keeps its currency.
{ name: 'eur', aggregate: 'sum', field: 'amt_eur' },
{ name: 'tenant', aggregate: 'sum', field: 'amt_tenant' },
],
});
const expectColumns = (result: AnalyticsResult) => {
expect(column(result, 'priced_eur')?.currency).toBeUndefined();
expect(column(result, 'priced_tenant')?.currency).toBeUndefined();
expect(column(result, 'eur')?.currency).toBe('EUR');
expect(column(result, 'tenant')?.currency).toBe('CNY');
};

for (const face of FACES) {
it(`${face} strategy: a count over a fixed or a tenant-currency amount is a plain number; a sum keeps the code`, async () => {
const result = await services[face]!.queryDataset(
COUNTS,
{ measures: ['priced_eur', 'priced_tenant', 'eur', 'tenant'] },
CTX,
);
expectColumns(result);
const row = (result.rows as Array<Record<string, unknown>>)[0];
expect(Number(row?.priced_eur)).toBe(3);
});
}

it('the draft-preview path labels the same columns', async () => {
const result = await services.objectql!.queryDataset(
COUNTS,
{ measures: ['priced_eur', 'priced_tenant', 'eur', 'tenant'] },
CTX,
{ previewDrafts: true },
);
expectColumns(result);
});
});

describe('compile-time guards on measure currencies', () => {
const datasetWith = (measures: unknown[]) =>
DatasetSchema.parse({
Expand Down Expand Up @@ -506,6 +555,38 @@ describe('[ADR-0144 M0-3] analytics over a record-currency amount', () => {
).toBeUndefined();
});

// A `count` counts rows, not money (ruling 6486427200 on #8345): only an
// amount aggregate resolves a currency, so a count is no operand guard 2
// compares, whatever field it names or currency it declares.
const counts = [
{ name: 'priced_invoices', aggregate: 'count', field: 'amount' },
{ name: 'priced_eur', aggregate: 'count', field: 'amt_eur' },
{ name: 'invoices_usd', aggregate: 'count', currency: 'USD' },
];
for (const op of ['ratio', 'sum', 'difference'] as const) {
it(`accepted: a derived ${op} of an amount and a count over an amount field, or a count declaring a currency`, () => {
expect(
compileRefusal([
...base,
...counts,
{ name: 'usd_by_priced', derived: { op, of: ['usd', 'priced_invoices'] } },
{ name: 'usd_by_priced_eur', derived: { op, of: ['usd', 'priced_eur'] } },
{ name: 'total_by_priced_eur', derived: { op, of: ['total', 'priced_eur'] } },
{ name: 'total_by_invoices_usd', derived: { op, of: ['total', 'invoices_usd'] } },
])?.message,
).toBeUndefined();
});
}

it('CONTROL: beside those counts, two fixed codes and a record amount against a fixed one stay refused', () => {
expectRefused([...base, ...counts, { name: 'mixed', derived: { op: 'sum', of: ['usd', 'eur'] } }], 'mixed');
expectRefused([...base, ...counts, { name: 'mixed', derived: { op: 'ratio', of: ['total', 'usd'] } }], 'mixed');
});

it("CONTROL: a count's measure currency contradicting its fixed field is still refused (guard 1 reads the field)", () => {
expectRefused([{ name: 'priced_eur_as_usd', aggregate: 'count', field: 'amt_eur', currency: 'USD' }], 'priced_eur_as_usd');
});

// The request door and the draft preview compile through the same
// `compileDataset` call as registration, so both fixed shapes refuse there too.
const fixedShapes = [
Expand Down
10 changes: 6 additions & 4 deletions packages/services/service-analytics/src/analytics-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3245,9 +3245,10 @@ export class AnalyticsService implements IAnalyticsService {
// from: explicit measure `currency` → the source field's FIXED
// currency (`sourceFieldMeta().defaultCurrency`, relayed only under
// `currencyMode: 'fixed'` — #20091) → tenant default (`ctx.currency`).
// A measure is monetary if it declares a currency OR aggregates a
// `currency`-type field; non-monetary measures (count, avg of a plain
// number) never receive a currency code.
// A measure is monetary if it declares a currency OR takes an amount
// aggregate (`AMOUNT_AGGREGATES`) over a `currency`-type field;
// non-monetary measures (a count, over an amount field too, or an avg
// of a plain number) never receive a currency code.
//
// [ADR-0144 D2] A `record`-mode amount has one currency per ROW, held
// in its code field, so no single code describes the column and no
Expand Down Expand Up @@ -3277,7 +3278,8 @@ export class AnalyticsService implements IAnalyticsService {
: undefined;
if (fc.currencyField == null && codeColumn !== undefined) fc.currencyField = codeColumn;
} else if (fc.currency == null) {
const monetary = !!mc.currency || meta?.type === 'currency' || derivedCode !== undefined;
const amount = meta?.type === 'currency' && AMOUNT_AGGREGATES.has(m.aggregate ?? '');
const monetary = !!mc.currency || amount || derivedCode !== undefined;
if (monetary) {
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 @@ -28,8 +28,11 @@
* - one is a record-mode amount, and the other is another code field, a
* code or the tenant's; or
* - both resolve to `fixed` codes, and the codes differ.
* Non-monetary operands (a `count`) are not compared, so an amount per
* invoice stays legal.
* Non-monetary operands are not compared, so an amount per invoice stays
* legal: only an amount aggregate (`AMOUNT_AGGREGATES`) resolves a
* currency, so a `count` resolves to none whatever field it names or
* currency it declares (ADR-0144 open item 12: "a `count` operand is not
* compared").
*
* ⛔ Not judged (the ruling's own list): a `fixed` code against the tenant's
* currency, which is not known at compile ("cannot answer, do not block"); a
Expand Down Expand Up @@ -59,6 +62,7 @@
*/

import type { Dataset, DatasetMeasure } from '@objectstack/spec/ui';
import { AMOUNT_AGGREGATES } from './cube-record-currency-door.js';
import { datasetInvalidError } from './dataset-refusal.js';

/**
Expand Down Expand Up @@ -129,7 +133,7 @@ export function measureCurrencyResolver(dataset: Dataset, declaredCurrency: Decl
? { ...first, fixed: common.every((c) => c.kind === 'code' && c.fixed) }
: first;
}
} else if (!m.duration) {
} else if (!m.duration && AMOUNT_AGGREGATES.has(m.aggregate ?? '')) {
const field = fieldOf(m);
if (field?.currencyField !== undefined) r = { kind: 'record', codeField: field.currencyField };
else if (m.currency) r = { kind: 'code', code: m.currency, fixed: field?.defaultCurrency === m.currency };
Expand Down
Loading