Skip to content

Commit 96367f1

Browse files
fix(service-analytics): a derived sum or difference over one fixed currency code carries that code on its column (#28122)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DF7znmBWA8q35pxUyjMRykb)_ Fixes #28049 domain:server#3 · os-elon-musk · session_016WV4d1NUiRegpdMBrWq7FZ Clause-②: no (the response comes to say what `content/docs/data-modeling/currency.mdx` L180–181 already declares: "A derived `sum` or `difference` over one `fixed` code carries that code") Metadata-protocol: none Skills-docs: none. `git grep -n -i currency skills/` hits `skills/objectstack-ui/rules/dashboards.md` L298–306 and `skills/objectstack-data/rules/field-types.md` L180–188; both state the measure / field chain and the refusals, and neither says a derived measure carries no code, so neither becomes false. `content/docs/api/data-api.mdx` L497–509 (the currency chain) likewise says nothing about derived measures and stays true. ## 维护者速读 - 改了什么:数据集里一个衍生度量(`derived`),如果是 `sum` / `difference`,且其所有金额操作数都固定为同一个币种(例如两个固定 EUR 的度量相加),查询结果的这一列现在带上该币种(`currency: 'EUR'`)。线上查询和 Studio 草稿预览一致。 - 为什么改:文档早已写明“同一固定币种上的衍生求和/差带该币种”,但实测修复前该列在线上是不带币种的裸数字,前端只能不带货币符号显示欧元。编译期守卫早已算出这个币种,本次让结果列直接读同一个解析结果,不再另算一遍。 - 风险与回滚:只给原来缺币种的这类衍生列补上标签;比率、乘积、混合租户币种、记录币种、动态字段上的显式币种等其余衍生列都保持原答案(测试逐一钉住)。不改 spec、不改作者能写什么。回滚即 revert 本 PR。 - 先看:`packages/services/service-analytics/src/measure-currency-coherence.ts`(解析抽成 `measureCurrencyResolver`),`analytics-service.ts` 的 `enrichResultColumns` 币种链(`derivedCode`)。 - 两分钟验证:`pnpm --filter @objectstack/service-analytics exec vitest run src/__tests__/record-currency-analytics.test.ts`,预期 52 passed(新增 3 条:两种策略与草稿预览下 `eur_twice` / `eur_net` 列为 `EUR`,6 个对照列无币种)。 - 需要你做的:确认本 PR 对 #28019 尚未发布的 changeset 的更正(见 Acceptance notes 第一条;`check-empty-changeset` 因此按设计保持红,等人工确认)。 ## What changes - **`measure-currency-coherence.ts`.** The compile guard's per-measure currency resolution (`resolvedOf`, `monetaryOperands`) moves out of `assertMeasureCurrenciesCoherent` into `measureCurrencyResolver(dataset, declaredCurrency)`, unchanged, with one reader added: `fixedCodeOf(m)`, the one `fixed` code a measure resolves to, else `undefined`. The guard now destructures the same resolver. Module-internal; not exported from the package entry. - **The refusal of a derived `currency` contradicting its operands' `fixed` code** (PR #28043) said "restate the operands' EUR as the measure's `currency`, or delete it (the column then carries no currency code)". After this change that parenthetical is false, so it now reads "delete the measure's `currency` (the column takes the operands' EUR), or restate EUR." The field-level refusal beside it already spells its fix this way. - **`analytics-service.ts`.** The `declaredCurrency` adapter over `sourceFieldMeta` that `compile()` handed the compiler becomes one private member, and `enrichResultColumns` builds a `measureCurrencyResolver` over the same adapter. A derived measure's column is monetary when `fixedCodeOf` answers, and the chain is explicit measure `currency` → field `fixed` code → derived `fixed` code → tenant. The guard already refuses a derived `currency` that contradicts the resolved code, so the first and third links cannot disagree. - **`record-currency-analytics.test.ts`.** New block "a derived measure column takes the currency its operands resolve to": native and ObjectQL strategies plus the draft preview. Pins `EUR` on a `sum` over a field-fixed and a restated EUR measure, a `difference`, and a restated derived; pins no `currency` and no `currencyField` on a fixed+tenant sum, a tenant+tenant sum, a sum of explicit JPY over a `dynamic` field, a `ratio`, a `product`, and a record-mode sum. Values checked too (`{ USD: 20, EUR: 180 }`). The existing message assertion moves to the new wording. - **`.changeset/28049-derived-sum-fixed-currency-column.md`** (new, patch) and **`.changeset/28019-derived-measure-fixed-currency.md`** (one sentence corrected, see below). ## Tests <details><summary>Red on main, green after, ablation</summary> - New block on the unfixed tree (`origin/main` `8cb8c7f4`, test only): 3 failed / 49 skipped (filtered). The draft preview failed `AssertionError: eur_twice: expected undefined to be 'EUR'`. That first draft also selected a derived-over-derived measure, which failed both live strategies earlier, in the executor (second acceptance note), so it was dropped from the block. The ablation below is the clean red for the final block on all three paths. - Fixed head `670a7790`: `record-currency-analytics.test.ts` 52 passed. - Ablation on the committed head, `scripts/ablation-replace.mjs` on `analytics-service.ts` (`const derivedCode = m.derived ? currencies.fixedCodeOf(m) : undefined;` → `const derivedCode = undefined as string | undefined; // ABLATION`; anchor 1 → 0, blob `98e0c551` → `885a2a1f`): 3 failed / 49 passed. Restored: blob back to `98e0c551` = HEAD, `git diff HEAD` empty. The suite imports the service from `src` (relative imports), so no dist rebuild was in the path. - `pnpm --filter @objectstack/service-analytics test`: 196 files passed, 4762 passed / 282 skipped. `pnpm --filter @objectstack/service-analytics typecheck` (includes `src/__tests__`): exit 0. </details> ## Verification <details><summary>Gates at head 670a779</summary> - `dispatch-gates --commands` derived 62 families; all run with exit codes recorded, after `turbo run build --filter='@objectstack/dogfood^...' --filter='@objectstack/runtime^...'`. - 60 exit 0. `check:dual-build-cjs-loads` exit 3 (no dist for seven unrelated packages, e.g. `@objectstack/studio`): NOT MEASURED locally, CI runs it. `check-empty-changeset --base origin/main` exit 1: the deliberate correction below, red by design. - `--ran`: 62 derived, 61 run, 1 NOT-MEASURED (derived from the recorded exit 3), 0 unrun. - `pnpm lint`: exit 0. - The head is 6 commits behind `origin/main`; none touches `service-analytics` or the corrected changeset. </details> ## Acceptance notes - **A pending release note corrected (needs a person's confirmation).** `.changeset/28019-derived-measure-fixed-currency.md` (PR #28043, not yet released) told readers the refusal's fix was "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)". This PR makes that parenthetical false, so the sentence now reads "delete the measure's `currency` (the column takes the operands' code), or restate that code." Nothing else in that note changed. `check-empty-changeset` refuses any edit to another PR's changeset and stays red until a person confirms; restoring the old sentence would publish a false statement in the same release as this fix. - **Derived-over-derived on the live query.** The compile guard resolves a derived measure built on another derived one, and the currency docs describe that shape. A live `queryDataset` selecting such a measure fails on both strategies: "Measure 'eur_twice' on cube … aggregates field 'eur_twice', which object … does not have". The draft preview serves it. That is the executor, outside this card, and is reported to the seat. The new block does not use the shape. - The column `type` stays `number`, as for a base `sum` over a currency field. `currency` is what makes a column monetary on the wire. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (Not touched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 block is the repo's own Chinese reader section). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4d1NUiRegpdMBrWq7FZ --- _Generated by [Claude Code](https://claude.ai/code/session_016WV4d1NUiRegpdMBrWq7FZ)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2695de7 commit 96367f1

5 files changed

Lines changed: 133 additions & 20 deletions

File tree

‎.changeset/28019-derived-measure-fixed-currency.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Metadata-protocol: narrowed
99

1010
**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.
1111

12-
- 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).
12+
- 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.
1313
- A derived measure `currency` that restates the operands' `fixed` code is still accepted.
1414

1515
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.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/service-analytics': patch
3+
---
4+
5+
fix(service-analytics): a derived `sum` / `difference` over one `fixed` currency code carries that code on its result column
6+
7+
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")
8+
Metadata-protocol: none
9+
10+
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.
11+
12+
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".

‎packages/services/service-analytics/src/__tests__/record-currency-analytics.test.ts‎

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,74 @@ describe('[ADR-0144 M0-3] analytics over a record-currency amount', () => {
279279
expect(err.object).toBe(OBJECT);
280280
});
281281

282+
// The column's currency for a derived measure is the compile guard's own
283+
// resolved reading: a `sum` / `difference` whose operands all resolve to one
284+
// `fixed` code carries it, as `content/docs/data-modeling/currency.mdx` says.
285+
describe('a derived measure column takes the currency its operands resolve to', () => {
286+
const DERIVED = DatasetSchema.parse({
287+
name: 'os28049_derived_currency',
288+
label: 'Derived currency',
289+
object: OBJECT,
290+
dimensions: [{ name: 'currency', field: 'currency_code', type: 'string' }],
291+
measures: [
292+
{ name: 'eur', aggregate: 'sum', field: 'amt_eur' },
293+
{ name: 'eur_restated', aggregate: 'sum', field: 'amt_eur', currency: 'EUR' },
294+
{ name: 'usd', aggregate: 'sum', field: 'amt_usd' },
295+
{ name: 'tenant', aggregate: 'sum', field: 'amt_tenant' },
296+
{ name: 'tenant_as_jpy', aggregate: 'sum', field: 'amt_tenant', currency: 'JPY' },
297+
{ name: 'total', aggregate: 'sum', field: 'amount' },
298+
{ name: 'invoices', aggregate: 'count' },
299+
{ name: 'eur_twice', derived: { op: 'sum', of: ['eur', 'eur_restated'] } },
300+
{ name: 'eur_net', derived: { op: 'difference', of: ['eur', 'eur'] } },
301+
{ name: 'eur_twice_restated', derived: { op: 'sum', of: ['eur', 'eur'] }, currency: 'EUR' },
302+
// Controls: no one `fixed` code, so today's answer stands.
303+
{ name: 'usd_and_tenant', derived: { op: 'sum', of: ['usd', 'tenant'] } },
304+
{ name: 'tenant_twice', derived: { op: 'sum', of: ['tenant', 'tenant'] } },
305+
{ name: 'jpy_twice', derived: { op: 'sum', of: ['tenant_as_jpy', 'tenant_as_jpy'] } },
306+
{ name: 'eur_per_invoice', derived: { op: 'ratio', of: ['eur', 'invoices'] } },
307+
{ name: 'eur_product', derived: { op: 'product', of: ['eur', 'eur'] } },
308+
{ name: 'total_twice', derived: { op: 'sum', of: ['total', 'total'] } },
309+
],
310+
});
311+
const LABELLED = { eur_twice: 'EUR', eur_net: 'EUR', eur_twice_restated: 'EUR' } as const;
312+
const UNLABELLED = ['usd_and_tenant', 'tenant_twice', 'jpy_twice', 'eur_per_invoice', 'eur_product', 'total_twice'] as const;
313+
const expectColumns = (result: AnalyticsResult) => {
314+
for (const [name, code] of Object.entries(LABELLED)) {
315+
expect(column(result, name)?.currency, name).toBe(code);
316+
expect(column(result, name)?.currencyField, name).toBeUndefined();
317+
}
318+
for (const name of UNLABELLED) {
319+
expect(column(result, name)?.currency, name).toBeUndefined();
320+
expect(column(result, name)?.currencyField, name).toBeUndefined();
321+
}
322+
};
323+
324+
for (const face of FACES) {
325+
it(`${face} strategy: a sum or difference over one fixed code carries it; no other derived column gains one`, async () => {
326+
const result = await services[face]!.queryDataset(
327+
DERIVED,
328+
{ dimensions: ['currency'], measures: [...Object.keys(LABELLED), ...UNLABELLED] },
329+
CTX,
330+
);
331+
expectColumns(result);
332+
const eurByCode = Object.fromEntries(
333+
(result.rows as Array<Record<string, unknown>>).map((r) => [r.currency, Number(r.eur_twice)]),
334+
);
335+
expect(eurByCode).toEqual({ USD: 20, EUR: 180 });
336+
});
337+
}
338+
339+
it('the draft-preview path labels the same columns', async () => {
340+
const result = await services.objectql!.queryDataset(
341+
DERIVED,
342+
{ dimensions: ['currency'], measures: [...Object.keys(LABELLED), ...UNLABELLED] },
343+
CTX,
344+
{ previewDrafts: true },
345+
);
346+
expectColumns(result);
347+
});
348+
});
349+
282350
describe('compile-time guards on measure currencies', () => {
283351
const datasetWith = (measures: unknown[]) =>
284352
DatasetSchema.parse({
@@ -384,7 +452,7 @@ describe('[ADR-0144 M0-3] analytics over a record-currency amount', () => {
384452
+ 'measures "eur", "eur_restated", whose amounts are fixed to EUR.',
385453
);
386454
expect(compileRefusal(measures)?.message)
387-
.toContain("restate the operands' EUR as the measure's `currency`, or delete it (the column then carries no currency code)");
455+
.toContain("delete the measure's `currency` (the column takes the operands' EUR), or restate EUR.");
388456
});
389457
}
390458

‎packages/services/service-analytics/src/analytics-service.ts‎

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ import {
9999
} from './strategies/filter-normalizer.js';
100100
import { findCrossFieldComparand } from './comparand-shape.js';
101101
import { compileDataset, type CompiledDataset, type RelationshipResolver } from './dataset-compiler.js';
102+
import { measureCurrencyResolver, type DeclaredCurrency } from './measure-currency-coherence.js';
102103
import { bindDatasetFiltersToObjectFields } from './dataset-filter-field-binding.js';
103104
import { drillWindowBounds } from './drill-window.js';
104105
import {
@@ -2563,14 +2564,21 @@ export class AnalyticsService implements IAnalyticsService {
25632564
},
25642565
// [ADR-0144, M0-3] …and the declared currency off the same hook, so the
25652566
// compile-time currency guards judge exactly what the column would print.
2566-
declaredCurrency: (object: string, field: string) => {
2567-
const meta = this.sourceFieldMeta?.(object, field);
2568-
if (!meta) return undefined;
2569-
return { type: meta.type, defaultCurrency: meta.defaultCurrency, currencyField: meta.currencyField };
2570-
},
2567+
declaredCurrency: this.declaredCurrency,
25712568
});
25722569
}
25732570

2571+
/**
2572+
* [ADR-0144, M0-3] A field's declared currency off `sourceFieldMeta`: what
2573+
* the compile-time currency guards judge, and what a derived measure's
2574+
* column reads its resolved currency through (`enrichResultColumns`).
2575+
*/
2576+
private readonly declaredCurrency = (object: string, field: string): DeclaredCurrency | undefined => {
2577+
const meta = this.sourceFieldMeta?.(object, field);
2578+
if (!meta) return undefined;
2579+
return { type: meta.type, defaultCurrency: meta.defaultCurrency, currencyField: meta.currencyField };
2580+
};
2581+
25742582
/**
25752583
* Compile a `dataset` (ADR-0021) and register its Cube + join allowlist in the
25762584
* SHARED registry, so every caller can query it by name and `getMeta` lists
@@ -3131,6 +3139,7 @@ export class AnalyticsService implements IAnalyticsService {
31313139
// this resolves TO a string rather than widening the contract.
31323140
if (result.fields?.length && dataset.measures?.length) {
31333141
const measureByName = new Map(dataset.measures.map((m) => [m.name, m]));
3142+
const currencies = measureCurrencyResolver(dataset, this.declaredCurrency);
31343143
for (const f of result.fields) {
31353144
const m = measureByName.get(f.name) ?? measureByName.get(f.name.replace(/__compare$/, ''));
31363145
if (!m) continue;
@@ -3192,18 +3201,27 @@ export class AnalyticsService implements IAnalyticsService {
31923201
// column left without the dimension gets neither key, rather than a
31933202
// label that is wrong for every other row. A `count` over the amount
31943203
// reads no amount, so it gets neither key either, grouped or not.
3204+
//
3205+
// [#28049] A derived measure has no field to lend a code. Its column
3206+
// takes the one `fixed` code its operands resolve to (a `sum` /
3207+
// `difference` over amounts in one `fixed` code), read off the compile
3208+
// guard's own resolution, so the column says what compile judged. Any
3209+
// other derived measure (a `ratio`, a `product`, operands in the
3210+
// tenant's or a record's currency) is monetary only by its own
3211+
// `currency`, as before.
31953212
const fc = f as { currency?: string; currencyField?: string };
31963213
const mc = m as { currency?: string };
31973214
const meta = m.field ? this.sourceFieldMeta?.(dataset.object, m.field) : undefined;
3215+
const derivedCode = m.derived ? currencies.fixedCodeOf(m) : undefined;
31983216
if (meta?.currencyField !== undefined) {
31993217
const codeColumn = AMOUNT_AGGREGATES.has(m.aggregate ?? '')
32003218
? this.selectedDimensions(dataset, selection).find((d) => d.field === meta.currencyField)?.name
32013219
: undefined;
32023220
if (fc.currencyField == null && codeColumn !== undefined) fc.currencyField = codeColumn;
32033221
} else if (fc.currency == null) {
3204-
const monetary = !!mc.currency || meta?.type === 'currency';
3222+
const monetary = !!mc.currency || meta?.type === 'currency' || derivedCode !== undefined;
32053223
if (monetary) {
3206-
const resolved = mc.currency ?? meta?.defaultCurrency ?? context?.currency;
3224+
const resolved = mc.currency ?? meta?.defaultCurrency ?? derivedCode ?? context?.currency;
32073225
if (resolved) fc.currency = resolved;
32083226
}
32093227
}

‎packages/services/service-analytics/src/measure-currency-coherence.ts‎

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@
4646
* field. A derived `sum` / `difference` resolves to its operands' common
4747
* currency (none when they differ; `fixed` when every operand's is), so a
4848
* derived measure built on one is judged too; a `ratio` or `product`
49-
* resolves to none.
49+
* resolves to none. The same reading labels the result column: a derived
50+
* measure that resolves to one `fixed` code carries that code on the wire
51+
* (`enrichResultColumns`, through {@link measureCurrencyResolver}).
5052
*
5153
* ## Not judged
5254
*
@@ -97,20 +99,19 @@ const pairRefused = (a: Resolved, b: Resolved): boolean =>
9799
((a.kind === 'record' || b.kind === 'record') && provablyDiffer(a, b))
98100
|| (a.kind === 'code' && a.fixed && b.kind === 'code' && b.fixed && a.code !== b.code);
99101

102+
type DeclaredCurrencyOf = ((objectName: string, field: string) => DeclaredCurrency | undefined) | undefined;
103+
100104
/**
101-
* Refuse the first measure of `dataset` that breaks either guard — see the
102-
* module header. `declaredCurrency` answers for a bare field of the base
103-
* object, `undefined` when nothing authoritative can.
105+
* Each measure's resolved currency (module header), which both guards read and
106+
* the result column of a derived measure takes (`enrichResultColumns`), so the
107+
* column says what compile judged. `declaredCurrency` answers for a bare field
108+
* of the base object, `undefined` when nothing authoritative can.
104109
*/
105-
export function assertMeasureCurrenciesCoherent(
106-
dataset: Dataset,
107-
declaredCurrency: ((objectName: string, field: string) => DeclaredCurrency | undefined) | undefined,
108-
): void {
110+
export function measureCurrencyResolver(dataset: Dataset, declaredCurrency: DeclaredCurrencyOf) {
109111
const byName = new Map(dataset.measures.map((m) => [m.name, m]));
110112
const fieldOf = (m: DatasetMeasure): DeclaredCurrency | undefined =>
111113
m.field && !m.field.includes('.') ? declaredCurrency?.(dataset.object, m.field) : undefined;
112114

113-
// Each measure's resolved currency (module header), which both guards read.
114115
const memo = new Map<string, Resolved | undefined>();
115116
const resolving = new Set<string>();
116117
const resolvedOf = (m: DatasetMeasure): Resolved | undefined => {
@@ -148,6 +149,20 @@ export function assertMeasureCurrenciesCoherent(
148149
const resolved = operand ? resolvedOf(operand) : undefined;
149150
return resolved ? [{ name, resolved }] : [];
150151
});
152+
/** The one `fixed` code a measure resolves to, `undefined` for any other currency or none. */
153+
const fixedCodeOf = (m: DatasetMeasure): string | undefined => {
154+
const r = resolvedOf(m);
155+
return r?.kind === 'code' && r.fixed ? r.code : undefined;
156+
};
157+
return { fieldOf, resolvedOf, monetaryOperands, fixedCodeOf };
158+
}
159+
160+
/**
161+
* Refuse the first measure of `dataset` that breaks either guard — see the
162+
* module header.
163+
*/
164+
export function assertMeasureCurrenciesCoherent(dataset: Dataset, declaredCurrency: DeclaredCurrencyOf): void {
165+
const { fieldOf, resolvedOf, monetaryOperands } = measureCurrencyResolver(dataset, declaredCurrency);
151166

152167
// Guard 1 — a measure `currency` over a record-mode amount (aggregated or
153168
// derived), or contradicting a `fixed` code (a field's, or the one its
@@ -185,8 +200,8 @@ export function assertMeasureCurrenciesCoherent(
185200
throw datasetInvalidError(
186201
`[dataset-compiler] dataset "${dataset.name}" derived measure "${m.name}" declares currency `
187202
+ `"${m.currency}" over measures ${operands}, whose amounts are fixed to ${resolved.code}. The amounts are `
188-
+ `${resolved.code}, so the column would print them as ${m.currency}: restate the operands' `
189-
+ `${resolved.code} as the measure's \`currency\`, or delete it (the column then carries no currency code).`,
203+
+ `${resolved.code}, so the column would print them as ${m.currency}: delete the measure's \`currency\` `
204+
+ `(the column takes the operands' ${resolved.code}), or restate ${resolved.code}.`,
190205
);
191206
}
192207
}

0 commit comments

Comments
 (0)