diff --git a/.changeset/27187-record-currency-analytics.md b/.changeset/27187-record-currency-analytics.md new file mode 100644 index 00000000000..e22a795280e --- /dev/null +++ b/.changeset/27187-record-currency-analytics.md @@ -0,0 +1,23 @@ +--- +'@objectstack/spec': minor +'@objectstack/service-analytics': minor +--- + +feat(spec,service-analytics): analytics over a record-currency amount — grouped answers name a per-row currency, an ungrouped amount aggregate is refused, and two compile-time guards on record-mode measures (ADR-0144 D2, D6) + +Clause-②: yes (widening) +Metadata-protocol: added + +A measure column over a `currencyMode: 'record'` amount now carries `fields[].currencyField` on a `queryDataset` answer: the name of the dimension column that groups by the amount's code field. Each row's currency is that row's value of it, and the column carries no `currency`. Before, a breakdown by currency labelled every group with one code. + +- `AnalyticsResult.fields[].currencyField` (`@objectstack/spec/contracts`) and the same key on `AnalyticsResultResponseSchema` (`@objectstack/spec/api`). +- The cube door refuses a `sum`, `avg`, `min` or `max` over a record-mode amount whose query does not group by the code field, with `INVALID_FIELD` / 400 naming the measure, the field and its object. It judges every path: a dataset or registered cube, an ad-hoc `query` over the object (`amount_sum`), and a draft preview. A `totals` grouping that drops the code, the grand total included, is refused the same way. `count` reads no amount and is served, and its column carries neither currency key. + +Two compile-time refusals, `DATASET_INVALID` / 400, on the record mode alone, so nothing that compiled before is refused: + +- a measure `currency` over a record-mode amount, whose currency is per row, including on a derived measure that resolves to one; +- a derived measure that combines a record-mode amount with an operand in another currency (another code field, a `fixed` code, an explicit code or the tenant's), for every op. A count operand is not compared, so an amount-per-invoice ratio still compiles. + +The same two shapes over `fixed` fields only are not judged here; ADR-0144 open item 12 refuses them on a card of its own after this release. + +⛔ Not released alone: this ships in the same release as the record currency mode, its write refusals and the per-row display. diff --git a/content/docs/api/data-api.mdx b/content/docs/api/data-api.mdx index d153285c455..2eba6a4e499 100644 --- a/content/docs/api/data-api.mdx +++ b/content/docs/api/data-api.mdx @@ -474,7 +474,7 @@ is executed. `POST /analytics/sql` refuses the same keys. To order by a member, **`fields[]` is the resolved presentation surface — read it first.** Each entry carries `name` and `type` and, when the producer declares them, `label`, `format`, `currency`, -`percentScale`, `builtinAggregate`, `aggregate`, `nullLabel` and `polarity` (which direction +`currencyField`, `percentScale`, `builtinAggregate`, `aggregate`, `nullLabel` and `polarity` (which direction of a measure's change is good news, relayed from the dataset measure): the optional members `AnalyticsResultResponseSchema` declares (`packages/spec/src/api/analytics.zod.ts`), mirrored member for member by `IAnalyticsService.query`'s `AnalyticsResult` and bound to @@ -500,6 +500,13 @@ tenant default currency (the `localization.currency` setting), if one is set. A so its column shows the tenant default. The cube query on this page carries no column `currency`. +A measure column over a `currencyMode: 'record'` amount has one currency per row, so it +carries `currencyField` instead of `currency`: the name of the dimension column, in the same +answer, that groups by the amount's code field (`currencyConfig.currencyField`). Each row's +currency is that row's value of that column. A `sum`, `avg`, `min` or `max` over such an +amount is refused `INVALID_FIELD` / 400 unless the query groups by the code field, on the +dataset query and the cube query alike; a `count` is served. + The dataset query also states each measure column's `aggregate` (`count`, `sum`, `avg`, `min`, `max`, `count_distinct`), whether or not the author labelled the measure, so a chart can tell a count from a sum. `builtinAggregate` is narrower: it is present only diff --git a/packages/services/service-analytics/src/__tests__/record-currency-analytics.test.ts b/packages/services/service-analytics/src/__tests__/record-currency-analytics.test.ts new file mode 100644 index 00000000000..1e6b88348a1 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/record-currency-analytics.test.ts @@ -0,0 +1,363 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0144, multi-currency M0-3] Analytics over a `currencyMode: 'record'` + * amount, through the plugin's own composition over a real engine. + * + * ## What these pins hold + * + * - A grouped answer carries the PER-ROW currency: the measure column names, + * by `currencyField`, the dimension column that groups by the amount's code + * field, and carries no `currency` (`AnalyticsResult.fields`, + * `@objectstack/spec/contracts`). Before, probe C of the #8345 measurement + * round saw every group of a correct breakdown labelled with ONE currency. + * - The cube door refuses a `sum` / `avg` / `min` / `max` over the amount that + * does not group by the code field — `INVALID_FIELD` / 400 with the member, + * the column and its object — before anything is read. A `totals` grand + * total is such an aggregate too. `count` reads no amount and is served. + * - Both of the above on both strategies (`NativeSQLStrategy`, the plugin's + * 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 SQLite cell runs everywhere; the strategies are the axis here, not the + * dialect (the door stands ahead of both and reads nothing). + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import type { AnalyticsResult } from '@objectstack/spec/contracts'; +import { DatasetSchema } from '@objectstack/spec/ui'; +import type { AnalyticsService } from '../analytics-service.js'; +import { AnalyticsServicePlugin } from '../plugin.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; + +const OBJECT = 'os27187_fx_invoice'; + +const INVOICE = { + name: OBJECT, + label: 'FX invoice', + fields: { + currency_code: { name: 'currency_code', type: 'text' as const, valueDomain: 'iso_4217_currency' }, + region: { name: 'region', type: 'text' as const }, + amount: { + name: 'amount', + type: 'currency' as const, + currencyConfig: { currencyMode: 'record' as const, currencyField: 'currency_code' }, + }, + amt_usd: { name: 'amt_usd', type: 'currency' as const, currencyConfig: { currencyMode: 'fixed' as const, defaultCurrency: 'USD' } }, + amt_eur: { name: 'amt_eur', type: 'currency' as const, currencyConfig: { currencyMode: 'fixed' as const, defaultCurrency: 'EUR' } }, + amt_tenant: { name: 'amt_tenant', type: 'currency' as const }, + }, +}; + +const ROWS = [ + { id: 'i1', currency_code: 'USD', region: 'amer', amount: 100, amt_usd: 100, amt_eur: 10, amt_tenant: 1 }, + { id: 'i2', currency_code: 'EUR', region: 'amer', amount: 100, amt_usd: 0, amt_eur: 90, amt_tenant: 1 }, + { id: 'i3', currency_code: 'USD', region: 'emea', amount: 50, amt_usd: 50, amt_eur: 0, amt_tenant: 1 }, +] as const; + +const DATASET = DatasetSchema.parse({ + name: 'os27187_fx_by_currency', + label: 'Invoices by currency', + object: OBJECT, + dimensions: [ + { name: 'currency', field: 'currency_code', type: 'string' }, + { name: 'region', field: 'region', type: 'string' }, + ], + measures: [ + { name: 'total', aggregate: 'sum', field: 'amount' }, + { name: 'largest', aggregate: 'max', field: 'amount' }, + { name: 'invoices', aggregate: 'count' }, + { name: 'priced_invoices', aggregate: 'count', field: 'amount' }, + { name: 'usd_total', aggregate: 'sum', field: 'amt_usd' }, + ], +}); + +const CTX = { currency: 'CNY' } as ExecutionContext; + +type Face = 'native' | 'objectql'; +const FACES: readonly Face[] = ['native', 'objectql']; + +const quiet = { debug() {}, info() {}, warn() {}, error() {}, trace() {}, fatal() {}, child() { return quiet; } }; + +type Refusal = Error & { code?: string; status?: number; member?: string; param?: string; field?: string; object?: string }; + +const refusalOf = (p: Promise): Promise => + p.then( + () => { + throw new Error('expected a refusal, but the query resolved'); + }, + (e) => e as Refusal, + ); + +const column = (result: AnalyticsResult, name: string) => + (result.fields ?? []).find((f) => f.name === name) as + | { name: string; currency?: string; currencyField?: string } + | undefined; + +/** A `'protocol'` service answering one pending seed draft, which routes `previewDrafts` onto the preview path. */ +const pendingSeedProtocol = { + getMetaItems: async () => [{ name: 'os27187_seed', object: OBJECT }], + getMetaItem: async () => ({ item: { records: ROWS } }), +}; + +describe('[ADR-0144 M0-3] analytics over a record-currency amount', () => { + let engine: ObjectQL; + /** Raw-SQL statements and engine aggregates that read THIS object. */ + const reads = { rawSql: 0, aggregate: 0 }; + const services: Partial> = {}; + + beforeAll(async () => { + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as never); + engine = new ObjectQL({ logger: quiet } as never); + engine.registerDriver(driver as never, true); + await engine.init(); + engine.registry.registerObject(INVOICE as never); + await engine.syncSchemas(); + await installDefaultOrganization(engine as never); + for (const row of ROWS) await engine.insert(OBJECT, { ...row } as never); + + const realExecute = (engine as any).execute.bind(engine); + (engine as any).execute = (sql: unknown, opts?: { object?: string }) => { + if (opts?.object === OBJECT) reads.rawSql += 1; + return realExecute(sql, opts); + }; + const realAggregate = engine.aggregate.bind(engine); + (engine as any).aggregate = (object: string, ...rest: unknown[]) => { + if (object === OBJECT) reads.aggregate += 1; + return (realAggregate as any)(object, ...rest); + }; + + for (const [face, caps] of [ + ['native', undefined], + ['objectql', () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false })], + ] as const) { + const registered: Record = {}; + const provided: Record = { data: engine, protocol: pendingSeedProtocol }; + await new AnalyticsServicePlugin({ ...(caps ? { queryCapabilities: caps } : {}) } as never).init({ + getService: (name: string) => provided[name] ?? registered[name], + registerService: (name: string, svc: unknown) => { registered[name] = svc; }, + replaceService: (name: string, svc: unknown) => { registered[name] = svc; }, + hook: () => {}, + logger: quiet, + } as never); + services[face] = registered.analytics as AnalyticsService; + } + }); + + afterAll(async () => { + try { await engine?.destroy(); } catch { /* noop */ } + }); + + for (const face of FACES) { + describe(`${face} strategy`, () => { + it('grouped by the code field: per-group totals are right, and the column names the per-row currency', async () => { + const result = await services[face]!.queryDataset( + DATASET, + { dimensions: ['currency'], measures: ['total', 'largest', 'invoices', 'priced_invoices', 'usd_total'] }, + CTX, + ); + const byCode = Object.fromEntries( + (result.rows as Array>).map((r) => [r.currency, Number(r.total)]), + ); + expect(byCode).toEqual({ USD: 150, EUR: 100 }); + expect(column(result, 'total')).toMatchObject({ currencyField: 'currency' }); + expect(column(result, 'total')?.currency).toBeUndefined(); + expect(column(result, 'largest')).toMatchObject({ currencyField: 'currency' }); + expect(column(result, 'largest')?.currency).toBeUndefined(); + // Controls: a fixed field keeps its one code, and a count is no amount. + expect(column(result, 'usd_total')?.currency).toBe('USD'); + expect(column(result, 'usd_total')?.currencyField).toBeUndefined(); + expect(column(result, 'invoices')?.currency).toBeUndefined(); + expect(column(result, 'invoices')?.currencyField).toBeUndefined(); + // …a count whose `field` is the amount reads no amount either. + expect(column(result, 'priced_invoices')?.currency).toBeUndefined(); + expect(column(result, 'priced_invoices')?.currencyField).toBeUndefined(); + }); + + it('grouped by the code field and another dimension, with a totals grouping that keeps the code: served', async () => { + const result = await services[face]!.queryDataset( + DATASET, + { dimensions: ['currency', 'region'], measures: ['total'], totals: { groupings: [['currency']] } }, + CTX, + ); + expect(column(result, 'total')?.currencyField).toBe('currency'); + const totals = Object.fromEntries( + (result.totals?.[0]?.rows as Array>).map((r) => [r.currency, Number(r.total)]), + ); + expect(totals).toEqual({ USD: 150, EUR: 100 }); + }); + + for (const [label, selection] of [ + ['grouped by another dimension only', { dimensions: ['region'], measures: ['total'] }], + ['not grouped at all (a KPI)', { measures: ['largest'] }], + ['a totals grand total over the amount', { dimensions: ['currency'], measures: ['total'], totals: { groupings: [[]] } }], + ] as const) { + it(`${label}: refused INVALID_FIELD / 400 before anything is read`, async () => { + const before = { ...reads }; + const err = await refusalOf(services[face]!.queryDataset(DATASET, selection as never, CTX)); + expect(err.code, err.message).toBe('INVALID_FIELD'); + expect(err.status).toBe(400); + expect(['total', 'largest']).toContain(err.member); + expect(err.param).toBe('measures'); + expect(err.field).toBe('amount'); + expect(err.object).toBe(OBJECT); + if (label !== 'a totals grand total over the amount') { + expect(reads.rawSql - before.rawSql).toBe(0); + expect(reads.aggregate - before.aggregate).toBe(0); + } + }); + } + + it('a count over the record-currency object is served ungrouped, with neither currency key', async () => { + const result = await services[face]!.queryDataset(DATASET, { measures: ['invoices'] }, CTX); + expect(Number((result.rows[0] as Record).invoices)).toBe(3); + expect(column(result, 'invoices')?.currency).toBeUndefined(); + expect(column(result, 'invoices')?.currencyField).toBeUndefined(); + }); + + it('the cube face refuses the same ungrouped aggregate', async () => { + services[face]!.registerDataset(DATASET); + const err = await refusalOf(services[face]!.query({ cube: DATASET.name, measures: ['total'] } as never, CTX)); + expect(err.code).toBe('INVALID_FIELD'); + expect(err.status).toBe(400); + expect(err.member).toBe('total'); + }); + + it('the inferred cube (no registered dataset, the `${field}_${fn}` members) refuses the ungrouped amount and serves it grouped', async () => { + const before = { ...reads }; + const err = await refusalOf(services[face]!.query({ cube: OBJECT, measures: ['amount_sum'] } as never, CTX)); + expect(err.code, err.message).toBe('INVALID_FIELD'); + expect(err.status).toBe(400); + expect(err.member).toBe('amount_sum'); + expect(err.field).toBe('amount'); + expect(err.object).toBe(OBJECT); + expect(reads.rawSql - before.rawSql).toBe(0); + expect(reads.aggregate - before.aggregate).toBe(0); + + const grouped = await services[face]!.query( + { cube: OBJECT, measures: ['amount_sum'], dimensions: ['currency_code'] } as never, + CTX, + ); + const byCode = Object.fromEntries( + (grouped.rows as Array>).map((r) => [r.currency_code, Number(r.amount_sum)]), + ); + expect(byCode).toEqual({ USD: 150, EUR: 100 }); + }); + }); + } + + it('the draft-preview path carries the same per-row currency column', async () => { + const result = await services.objectql!.queryDataset( + DATASET, + { dimensions: ['currency'], measures: ['total'] }, + CTX, + { previewDrafts: true }, + ); + expect(column(result, 'total')?.currencyField).toBe('currency'); + expect(column(result, 'total')?.currency).toBeUndefined(); + }); + + it('the draft-preview path refuses the ungrouped amount aggregate the published path refuses', async () => { + const err = await refusalOf( + services.objectql!.queryDataset(DATASET, { measures: ['largest'] }, CTX, { previewDrafts: true }), + ); + expect(err.code, err.message).toBe('INVALID_FIELD'); + expect(err.status).toBe(400); + expect(err.member).toBe('largest'); + expect(err.field).toBe('amount'); + expect(err.object).toBe(OBJECT); + }); + + describe('compile-time guards on measure currencies', () => { + const datasetWith = (measures: unknown[]) => + DatasetSchema.parse({ + name: 'os27187_guard', + label: 'Guard', + object: OBJECT, + dimensions: [{ name: 'currency', field: 'currency_code', type: 'string' }], + measures, + }); + const compileRefusal = (measures: unknown[]): Refusal | undefined => { + try { + services.objectql!.registerDataset(datasetWith(measures)); + return undefined; + } catch (e) { + return e as Refusal; + } + }; + const expectRefused = (measures: unknown[], named: string) => { + const err = compileRefusal(measures); + expect(err?.code, err?.message).toBe('DATASET_INVALID'); + expect(err?.status).toBe(400); + expect(err?.message).toContain(`"${named}"`); + }; + + it('a measure currency over a record-currency amount is refused', () => { + expectRefused([{ name: 'total_usd', aggregate: 'sum', field: 'amount', currency: 'USD' }], 'total_usd'); + }); + + it('a measure currency on a derived measure whose currency is the record mode is refused', () => { + expectRefused( + [ + { name: 'total', aggregate: 'sum', field: 'amount' }, + { name: 'total_twice_usd', derived: { op: 'sum', of: ['total', 'total'] }, currency: 'USD' }, + ], + 'total_twice_usd', + ); + }); + + 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(); + expect(compileRefusal([{ name: 'eur', aggregate: 'sum', field: 'amt_eur', currency: 'EUR' }])).toBeUndefined(); + }); + + const base = [ + { name: 'usd', aggregate: 'sum', field: 'amt_usd' }, + { name: 'eur', aggregate: 'sum', field: 'amt_eur' }, + { name: 'tenant', aggregate: 'sum', field: 'amt_tenant' }, + { name: 'total', aggregate: 'sum', field: 'amount' }, + { name: 'invoices', aggregate: 'count' }, + ]; + + for (const op of ['sum', 'difference', 'ratio', 'product'] as const) { + it(`a derived ${op} of a record-currency amount and a fixed one is refused`, () => { + expectRefused([...base, { name: 'mixed', derived: { op, of: ['total', 'usd'] } }], 'mixed'); + }); + } + + it('a derived measure of a record-currency amount and a tenant-currency one is refused', () => { + expectRefused([...base, { name: 'mixed', derived: { op: 'sum', of: ['tenant', 'total'] } }], 'mixed'); + }); + + it('a derived measure built on a record-currency derived sum is judged at the next level', () => { + expectRefused( + [...base, { name: 'total_twice', derived: { op: 'sum', of: ['total', 'total'] } }, { name: 'mixed', derived: { op: 'sum', of: ['total_twice', 'eur'] } }], + 'mixed', + ); + }); + + 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(); + }); + + 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_and_tenant', derived: { op: 'sum', of: ['usd', 'tenant'] } }, + { name: 'total_share', derived: { op: 'ratio', of: ['total', 'total'] } }, + ]), + ).toBeUndefined(); + }); + }); +}); diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index c49ff7eb689..07dcbe8c144 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -81,6 +81,7 @@ import { measureResultType } from './measure-result-type.js'; // `ensureCube`, ahead of both strategies, as the dataset door refuses it at // compile. import { assertCubeMeasureFieldTypesAccepted, type MeasureColumn } from './cube-measure-field-type-door.js'; +import { AMOUNT_AGGREGATES, assertRecordCurrencyAggregatesGrouped } from './cube-record-currency-door.js'; import type { AnalyticsStrategy, AnalyticsDriverCapabilities, StrategyContext, DatasetScopedStrategyContext, DatasetScope, ReadScopeFilterJudge } from './strategies/types.js'; import { NativeSQLStrategy } from './strategies/native-sql-strategy.js'; import { ObjectQLStrategy } from './strategies/objectql-strategy.js'; @@ -1134,6 +1135,12 @@ export interface AnalyticsServiceConfig { * naming no mode, `dynamic` by the spec's default — has no field-level * currency; the tenant default applies. `AnalyticsServicePlugin`'s relay * is the reference answer. + * [ADR-0144 D2] `currencyField` is the code field of a `record`-mode + * amount: answer `currencyConfig.currencyField` ONLY when + * `currencyConfig.currencyMode` is `'record'`, and `undefined` otherwise. + * Such an amount has one currency per ROW, so its column carries the + * `currencyField` pointer instead of a `currency`, and the cube door + * refuses an aggregate of it that does not group by that field. * - Percent scale (objectui#3136): a measure over a `percent` field inherits * that field's storage scale via `percentScaleOf`, so a renderer scales by * declared metadata instead of guessing from the value. @@ -1163,7 +1170,7 @@ export interface AnalyticsServiceConfig { sourceFieldMeta?: ( object: string, field: string, - ) => { type?: string; multiple?: boolean; defaultCurrency?: string; max?: number } | undefined; + ) => { type?: string; multiple?: boolean; defaultCurrency?: string; currencyField?: string; max?: number } | undefined; /** * [#15684] The SQL dialect of the datasource backing `object` — `'sqlite'`, * `'postgres'`, `'mysql'`, or `undefined` when the host cannot answer. @@ -2549,6 +2556,13 @@ export class AnalyticsService implements IAnalyticsService { if (typeof meta?.type !== 'string' || meta.type === '') return undefined; return { type: meta.type, multiple: meta.multiple === true }; }, + // [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 }; + }, }); } @@ -2711,6 +2725,10 @@ export class AnalyticsService implements IAnalyticsService { // carries the same `internal` fields the published ones do. this.assertNoInternalFieldsQueried(q, compiled.cube, previewScope); await this.assertFieldsReadable(q, compiled.cube, previewScope, context); + // [ADR-0144 D6] …and the record-currency door, which the published + // path meets in `ensureCube`: a draft preview must not serve the + // sum across currencies that the published dataset refuses. + this.assertRecordCurrencyGrouped(q, compiled.cube); return evaluateAnalyticsQueryOverRows( q, compiled.cube, @@ -3158,10 +3176,26 @@ export class AnalyticsService implements IAnalyticsService { // 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. - const fc = f as { currency?: string }; + // + // [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 + // step of the chain is asked. The column names, by `currencyField`, + // the selected dimension that groups by that code field: each row's + // currency is that row's value of it. Read off the dataset and the + // declaration like every key here, never off the rows. The cube door + // has already refused an amount aggregate that drops the grouping; a + // 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. + 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; - if (fc.currency == null) { + 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'; if (monetary) { const resolved = mc.currency ?? meta?.defaultCurrency ?? context?.currency; @@ -3483,6 +3517,10 @@ export class AnalyticsService implements IAnalyticsService { // [#21044] …and a measure's aggregate must be one the aggregate × // field-type table accepts for its column's declared type. this.assertMeasureFieldTypes(query, cube); + // [ADR-0144 D6] …and an amount aggregate over a record-currency field + // must group by its code field: this ad-hoc exit is the one + // `ObjectStackDataSource.aggregate()` reaches (`${field}_${fn}`). + this.assertRecordCurrencyGrouped(query, cube); // [#5669] …and the `where`'s, third and last of the three request keys that // carry a field name. Its members are read from the filter TREE, not from // `cube.dimensions` — which on this path was minted from this very query, @@ -3566,6 +3604,7 @@ export class AnalyticsService implements IAnalyticsService { // [#21044] Judged on the AUGMENTED cube: a suffix-inferred measure and an // authored one are one population for the aggregate × field-type table. this.assertMeasureFieldTypes(query, augmented); + this.assertRecordCurrencyGrouped(query, augmented); // [#5669] The `where` gate resolves a filter member through dimensions AND // measures (that is what the strategies do for a filter member), so it is // handed the AUGMENTED cube — a caller filtering on a suffix-inferred @@ -3582,6 +3621,7 @@ export class AnalyticsService implements IAnalyticsService { this.assertDimensionFields(query, cube, Object.keys(cube.dimensions)); this.assertDimensionsGroupScalarColumns(query, cube); this.assertMeasureFieldTypes(query, cube); + this.assertRecordCurrencyGrouped(query, cube); this.assertWhereFields(query, cube, Object.keys(cube.dimensions)); } } @@ -3624,6 +3664,46 @@ export class AnalyticsService implements IAnalyticsService { ); } + /** + * [ADR-0144 D6] Refuse a `sum` / `avg` / `min` / `max` measure over a + * `record`-mode amount whose query does not group by the amount's code field + * — `INVALID_FIELD` / 400, naming the member the caller wrote — before either + * strategy reads anything. The rule and the envelope are + * {@link assertRecordCurrencyAggregatesGrouped}'s + * (`cube-record-currency-door.ts`); this method supplies the answers only the + * service has: the measure column {@link declaredMeasureColumn} resolves + * (the #21044 door's resolver), each dimension's column by the same + * {@link declaredMemberEntry} lookup and hop resolver, and the code field off + * {@link AnalyticsServiceConfig.sourceFieldMeta}. + * + * Runs after the #21044 door, so a pair the field-type table refuses is + * answered as that first. Same stand-downs: no `sourceFieldMeta`, or a cube + * whose `sql` is not a bare object name. + */ + private assertRecordCurrencyGrouped(query: AnalyticsQuery, cube: Cube): void { + const fieldMeta = this.sourceFieldMeta; + if (!fieldMeta) return; + const object = typeof cube.sql === 'string' ? cube.sql.trim() : ''; + if (!object || !BARE_IDENTIFIER.test(object)) return; + assertRecordCurrencyAggregatesGrouped( + query, + cube.name, + (member) => declaredMeasureColumn(cube, member, object, this.hopReference), + (member) => { + const entry = declaredMemberEntry(cube, member, 'dimension'); + const sql = (entry ? (typeof entry.sql === 'string' ? entry.sql : '') : member).trim(); + if (BARE_IDENTIFIER.test(sql)) return { object, column: sql, path: sql }; + if (!IDENTIFIER_PATH.test(sql)) return null; + return { + object: columnObjectOf(cube, object, sql, this.hopReference), + column: sql.slice(sql.lastIndexOf('.') + 1), + path: sql, + }; + }, + (o, field) => fieldMeta(o, field)?.currencyField, + ); + } + /** * [#20807] Refuse a GROUPED dimension whose column is a declared * structured-JSON field — `INVALID_FIELD` / 400, naming the member the caller diff --git a/packages/services/service-analytics/src/cube-record-currency-door.ts b/packages/services/service-analytics/src/cube-record-currency-door.ts new file mode 100644 index 00000000000..db2bf8eb311 --- /dev/null +++ b/packages/services/service-analytics/src/cube-record-currency-door.ts @@ -0,0 +1,115 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0144 D6] The cube door's record-currency judgment: a `sum` / `avg` / + * `min` / `max` measure over a `currencyMode: 'record'` amount is refused + * `INVALID_FIELD` / 400 unless the same query groups by that amount's code + * field (`currencyConfig.currencyField`), ahead of strategy selection. + * + * ## The shape this closes + * + * A record-mode amount is a bare number (ADR-0104 D1) whose currency lives in a + * sibling code field, one per row. An aggregate that does not group by the code + * adds, averages or orders numbers in different currencies: the measurement + * round on #8345 (probe A) saw a USD row of 100 and an EUR row of 100 answer + * `200`, labelled with the tenant currency. Grouped by the code, every group + * holds one currency and the answer is right (probe C); the per-row label is + * the result column's `currencyField` (`AnalyticsResult.fields`, + * `@objectstack/spec/contracts`). + * + * ## Where it stands + * + * In `AnalyticsService.ensureCube`, on every path out of it (the inferred, + * augmented and declared cubes), right after the #21044 measure × field-type + * door (`cube-measure-field-type-door.ts`): ahead of both strategies, for + * `query()` and `generateSql()` alike. The ADR-0037 draft-preview branch of + * `queryDataset` evaluates in memory and never reaches `ensureCube`, so its + * per-query gate calls the same judgment. A dataset + * selection reaches it through `DatasetExecutor`, so its primary grid, every + * `totals` grouping (the grand total `[]` included) and the `compareTo` pass + * are judged one by one: a grouping that drops the code is a sum across + * currencies like any other. + * + * ## What it judges + * + * - Every `measures` entry that resolves to a cube measure of type `sum`, + * `avg`, `min` or `max` (ADR-0144 open item 10). `count` and + * `count_distinct` read no amount and are not judged. + * - The measure's column, located on the object that declares it by the + * caller's resolver (`declaredMeasureColumn`, the one the #21044 door reads). + * - "Grouped by the code" means a `dimensions` entry whose column is the code + * field on that SAME object. A time dimension is no currency group, and a + * `where` that happens to pin one code is not judged: the rule is the + * grouping, so an answer's label never depends on what a filter selected. + * + * ## Not judged — "cannot answer, do not block" + * + * - A host that wires no `sourceFieldMeta`, or a cube whose `sql` is not a + * bare object name (the caller stands down for both). + * - A member that resolves to no declared measure, a `sql` that is not a + * column reference, or a column whose declaration names no `record` mode. + * + * ## The envelope + * + * `INVALID_FIELD` / 400 through `invalidMemberError` (ADR-0112), with the + * column and its object attached, as the #21044 door attaches them: a verdict + * about one member the request named. The words carry the prescription: + * group by the code field. A consolidated total aggregates the stored home + * field instead (ADR-0144 D6), which the write-time projection (D5) fills. + */ + +import type { AnalyticsQuery } from '@objectstack/spec/contracts'; +import type { MeasureColumn } from './cube-measure-field-type-door.js'; +import { invalidMemberError } from './dataset-refusal.js'; + +/** The aggregates that read the amount itself (ADR-0144 open item 10). */ +export const AMOUNT_AGGREGATES: ReadonlySet = new Set(['sum', 'avg', 'min', 'max']); + +/** + * Refuse the first `measures` entry of `query` that aggregates a record-mode + * amount without grouping by its code field — `INVALID_FIELD` / 400. See the + * module header. + * + * @param measureOf - The cube measure a `measures` entry resolves to: its + * `type`, and the column it aggregates (`null` when its `sql` is not a column + * reference) — or `undefined` when it resolves to no declared measure. + * @param dimensionColumnOf - The column a `dimensions` entry groups by, located + * the same way, or `null` when this door cannot pin one. + * @param recordCurrencyFieldOf - The `currencyConfig.currencyField` of a column + * declared `currencyMode: 'record'`, or `undefined` for any other column. + */ +export function assertRecordCurrencyAggregatesGrouped( + query: AnalyticsQuery, + cubeName: string, + measureOf: (member: string) => { type: unknown; column: MeasureColumn | null } | undefined, + dimensionColumnOf: (member: string) => MeasureColumn | null, + recordCurrencyFieldOf: (object: string, field: string) => string | undefined, +): void { + const measures = query.measures ?? []; + if (measures.length === 0) return; + let grouped: Array | undefined; + for (const member of measures) { + const measure = measureOf(member); + if (!measure?.column || typeof measure.type !== 'string' || !AMOUNT_AGGREGATES.has(measure.type)) continue; + const target = measure.column; + const codeField = recordCurrencyFieldOf(target.object, target.column); + if (codeField === undefined) continue; + grouped ??= (query.dimensions ?? []).map(dimensionColumnOf); + if (grouped.some((d) => d?.object === target.object && d.column === codeField)) continue; + + const codePath = target.path === target.column + ? codeField + : `${target.path.slice(0, target.path.lastIndexOf('.') + 1)}${codeField}`; + const err = invalidMemberError( + `Measure '${member}' on cube '${cubeName}' takes the ${measure.type} of field '${target.path}', ` + + `a record-currency amount whose currency is each record's '${codeField}', but the query does not ` + + `group by '${codePath}', so the query was NOT run. Add the dimension over '${codePath}' to the ` + + `query, which answers one row per currency. Without the grouping the ${measure.type} would ` + + 'combine amounts in different currencies into one number.', + { member, param: 'measures', cube: cubeName }, + ) as Error & { field?: string; object?: string }; + err.field = target.path; + err.object = target.object; + throw err; + } +} diff --git a/packages/services/service-analytics/src/dataset-compiler.ts b/packages/services/service-analytics/src/dataset-compiler.ts index e938f74a3e7..04414064bac 100644 --- a/packages/services/service-analytics/src/dataset-compiler.ts +++ b/packages/services/service-analytics/src/dataset-compiler.ts @@ -18,6 +18,7 @@ import { } from '@objectstack/spec/ui'; import type { FilterCondition } from '@objectstack/spec/data'; import { datasetInvalidError } from './dataset-refusal.js'; +import { assertMeasureCurrenciesCoherent, type DeclaredCurrency } from './measure-currency-coherence.js'; import { TEMPORAL_SOURCE_FIELD_TYPES } from './measure-result-type.js'; import type { DurationMeasureSpec } from './strategies/types.js'; @@ -194,6 +195,16 @@ export interface DatasetCompileOptions { * `declaredFieldType` (the type alone) until then. */ declaredValueShape?: (objectName: string, field: string) => ValueShapeFieldDef | undefined; + /** + * [ADR-0144, M0-3] The declared CURRENCY of `field` on `objectName` — its + * type, its `fixed` code and its `record` code field — or `undefined` when + * nothing authoritative can answer. Supplied by the host from the same + * `AnalyticsServiceConfig.sourceFieldMeta` the result-column currency chain + * reads, so the compile-time guards of `measure-currency-coherence.ts` judge + * the currency the column would print. Absent → only an explicit measure + * `currency` is known, and the guards judge what that alone proves. + */ + declaredCurrency?: (objectName: string, field: string) => DeclaredCurrency | undefined; } /** Map a dataset measure's aggregate to the Cube metric `type`. */ @@ -843,6 +854,11 @@ export function compileDataset( if (m.filter) measureFilters[m.name] = m.filter; } + // [ADR-0144, M0-3] A measure `currency` over a record-mode amount, and a + // derived measure combining one with another currency, are refused HERE: both + // are properties of the document, decidable from metadata before any query runs. + assertMeasureCurrenciesCoherent(dataset, options?.declaredCurrency); + const cube: Cube = { name: dataset.name, // [#6761] The cube's own display title, same rule. `Cube.title` is optional diff --git a/packages/services/service-analytics/src/measure-currency-coherence.ts b/packages/services/service-analytics/src/measure-currency-coherence.ts new file mode 100644 index 00000000000..72f88655fa4 --- /dev/null +++ b/packages/services/service-analytics/src/measure-currency-coherence.ts @@ -0,0 +1,169 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0144, multi-currency M0-3] Two compile-time guards on a dataset's + * measures over a `currencyMode: 'record'` amount, `DATASET_INVALID` / 400 at + * compile, where the author still holds the document. Both judge the record + * mode alone, a surface that ships with them, so neither refuses anything a + * dataset could write before. + * + * 1. **A measure `currency` over a record-mode amount.** Its currency is per + * row (ADR-0144 D2), so any one code contradicts it, and the column's chain + * would let the measure's code win (`enrichResultColumns`). That holds for + * a derived measure whose resolved currency is the record mode too (a sum + * of record-mode amounts over one code field): its column carries no + * per-row pointer, so the one code would label every row. + * 2. **A derived measure that combines a record-mode amount with an operand in + * another currency.** `computeDerived` adds, subtracts, multiplies and + * divides bare numbers, so such a value is not a number in any currency + * (probe A of the #8345 measurement round, `m_mixed_sum`: `200` with no + * currency). Refused, whatever the op, when a record-mode operand and another + * monetary operand provably differ: another code field, a `fixed` code, an + * explicit measure code or the tenant's. Non-monetary operands (a `count`) + * are not compared, so an amount per invoice stays legal. + * + * ⛔ Not judged here: the same two shapes over `fixed` fields only — a + * measure `currency` contradicting a `fixed` field, a derived measure over two + * different `fixed` codes. Refusing those narrows what compiles today, so + * ADR-0144 open item 12 was ruled (6486427200 on #8345) to refuse them on a + * card of its own after R1. A measure `currency` over a `dynamic` field stays + * legal under either. + * + * ## The resolved currency, at compile + * + * The column's chain, with the request's half left symbolic: an explicit + * measure `currency`, else the field's `fixed` code, else the tenant's (one + * code per request, unknown here). A record-mode amount resolves to its code + * field. A derived `sum` / `difference` resolves to its operands' common + * currency (none when they differ), so a derived measure built on one is + * judged too; a `ratio` or `product` resolves to none. + * + * ## Not judged + * + * - No `declaredCurrency` hook (no data engine), or a field it cannot resolve. + * - A relationship-path field (`account.amount`): the hook answers for the + * base object, as for the aggregate × field-type door. + * - A duration measure, which counts time, not money. + */ + +import type { Dataset, DatasetMeasure } from '@objectstack/spec/ui'; +import { datasetInvalidError } from './dataset-refusal.js'; + +/** + * A field's declared currency, as the host's `sourceFieldMeta` relays it: + * `defaultCurrency` only under `fixed`, `currencyField` only under `record`. + */ +export interface DeclaredCurrency { + readonly type?: string; + readonly defaultCurrency?: string; + readonly currencyField?: string; +} + +type Resolved = + | { readonly kind: 'code'; readonly code: string } + | { readonly kind: 'record'; readonly codeField: string } + | { readonly kind: 'tenant' }; + +const currencyWords = (r: Resolved): string => + r.kind === 'code' + ? r.code + : r.kind === 'record' + ? `each record's '${r.codeField}' (record currency)` + : "the tenant's default currency"; + +/** Two currencies that are provably not one: two codes, two code fields, or a code field and anything else. */ +function provablyDiffer(a: Resolved, b: Resolved): boolean { + if (a.kind === 'record' || b.kind === 'record') { + return !(a.kind === 'record' && b.kind === 'record' && a.codeField === b.codeField); + } + return a.kind === 'code' && b.kind === 'code' && a.code !== b.code; +} + +/** The pair guard 2 refuses: a provable difference with a record-mode amount on one side. */ +const recordPairDiffers = (a: Resolved, b: Resolved): boolean => + (a.kind === 'record' || b.kind === 'record') && provablyDiffer(a, b); + +/** + * 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. + */ +export function assertMeasureCurrenciesCoherent( + dataset: Dataset, + declaredCurrency: ((objectName: string, field: string) => DeclaredCurrency | undefined) | undefined, +): void { + 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(); + const resolving = new Set(); + const resolvedOf = (m: DatasetMeasure): Resolved | undefined => { + if (memo.has(m.name)) return memo.get(m.name); + if (resolving.has(m.name)) return undefined; + resolving.add(m.name); + let r: Resolved | undefined; + if (m.derived) { + const common = (m.derived.op === 'sum' || m.derived.op === 'difference') + ? monetaryOperands(m).map((o) => o.resolved) + : []; + r = common.length > 0 && common.every((c) => !provablyDiffer(c, common[0]!)) ? common[0] : undefined; + } else if (!m.duration) { + const field = fieldOf(m); + if (field?.currencyField !== undefined) r = { kind: 'record', codeField: field.currencyField }; + else if (m.currency) r = { kind: 'code', code: m.currency }; + else if (field?.type === 'currency') { + r = field.defaultCurrency !== undefined ? { kind: 'code', code: field.defaultCurrency } : { kind: 'tenant' }; + } + } + resolving.delete(m.name); + memo.set(m.name, r); + return r; + }; + const monetaryOperands = (m: DatasetMeasure): Array<{ name: string; resolved: Resolved }> => + (m.derived?.of ?? []).flatMap((name) => { + const operand = byName.get(name); + const resolved = operand ? resolvedOf(operand) : undefined; + return resolved ? [{ name, resolved }] : []; + }); + + // Guard 1 — a measure `currency` over a record-mode amount, aggregated or derived. + for (const m of dataset.measures) { + if (m.duration || !m.currency) continue; + const field = m.derived ? undefined : fieldOf(m); + if (field?.currencyField !== undefined) { + throw datasetInvalidError( + `[dataset-compiler] dataset "${dataset.name}" measure "${m.name}" declares currency "${m.currency}" ` + + `over field "${m.field}", a record-currency amount of object "${dataset.object}" whose currency is ` + + `each record's "${field.currencyField}". One code cannot label amounts in several currencies: ` + + `delete the measure's \`currency\`, and select the dimension over "${field.currencyField}" so each ` + + 'row carries its own.', + ); + } + const resolved = m.derived ? resolvedOf(m) : undefined; + if (resolved?.kind === 'record') { + throw datasetInvalidError( + `[dataset-compiler] dataset "${dataset.name}" derived measure "${m.name}" declares currency ` + + `"${m.currency}" over amounts in ${currencyWords(resolved)}. One code cannot label amounts in several ` + + "currencies: delete the measure's `currency`.", + ); + } + } + + // Guard 2 — a derived measure combining a record-mode amount with another currency. + for (const m of dataset.measures) { + if (!m.derived) continue; + const operands = monetaryOperands(m); + const pair = operands.flatMap((a, i) => operands.slice(i + 1).map((b) => [a, b] as const)) + .find(([a, b]) => recordPairDiffers(a.resolved, b.resolved)); + if (!pair) continue; + const [first, other] = pair; + throw datasetInvalidError( + `[dataset-compiler] dataset "${dataset.name}" derived measure "${m.name}" (${m.derived.op}) combines ` + + `measure "${first.name}", in ${currencyWords(first.resolved)}, with measure "${other.name}", in ` + + `${currencyWords(other.resolved)}. A ${m.derived.op} of amounts in different currencies is not a number in ` + + 'any currency: derive it from measures in one currency, or drop the operand in the other.', + ); + } +} diff --git a/packages/services/service-analytics/src/plugin.ts b/packages/services/service-analytics/src/plugin.ts index 83715b24c04..805fcd1b84e 100644 --- a/packages/services/service-analytics/src/plugin.ts +++ b/packages/services/service-analytics/src/plugin.ts @@ -1440,14 +1440,26 @@ export class AnalyticsServicePlugin implements Plugin { type?: string; multiple?: boolean; max?: number; - currencyConfig?: { currencyMode?: string; defaultCurrency?: string }; + currencyConfig?: { currencyMode?: string; defaultCurrency?: string; currencyField?: string }; } | undefined; if (!f) return undefined; const fixedCurrency = f.currencyConfig?.currencyMode === 'fixed' ? f.currencyConfig.defaultCurrency : undefined; - return { type: f.type, multiple: f.multiple === true, max: f.max, defaultCurrency: fixedCurrency }; + // [ADR-0144 D2] …and `currencyField` ONLY under `currencyMode === + // 'record'`, by the same rule: the spec reads the pointer under that + // mode alone (`CurrencyConfigSchema` refuses it under the other two). + const recordCurrencyField = f.currencyConfig?.currencyMode === 'record' + ? f.currencyConfig.currencyField + : undefined; + return { + type: f.type, + multiple: f.multiple === true, + max: f.max, + defaultCurrency: fixedCurrency, + currencyField: recordCurrencyField, + }; }, // #5033 — the datasource an object is bound to, used ONLY to name the // actual cause when a dataset's SQL references a table that is not on the diff --git a/packages/spec/src/api/analytics.zod.ts b/packages/spec/src/api/analytics.zod.ts index c7f8e3bc222..e1c9c6e1623 100644 --- a/packages/spec/src/api/analytics.zod.ts +++ b/packages/spec/src/api/analytics.zod.ts @@ -102,7 +102,14 @@ export const AnalyticsResultResponseSchema = lazySchema(() => BaseResponseSchema + '`currency`, then the source field\'s fixed currency — its ' + '`currencyConfig.defaultCurrency`, read only under `currencyMode: \'fixed\'` ' + '— then the tenant default). Absent on non-monetary columns, which must ' - + 'never render a symbol.', + + 'never render a symbol, and on a column that carries `currencyField`.', + ), + currencyField: z.string().optional().describe( + '[ADR-0144 D2] The PER-ROW currency of a measure column over a `currencyMode: \'record\'` ' + + 'amount: the `name` of the dimension column in this answer that groups by the amount\'s ' + + 'code field, whose value in each row is that row\'s ISO 4217 code. `currency` is absent ' + + 'when this is present. Only on a `queryDataset` measure column that takes the `sum`, ' + + '`avg`, `min` or `max` of such an amount; never on a `count`.', ), percentScale: z.enum(['fraction', 'whole']).optional().describe( 'The column\'s percent SCALE, when it is a percentage: `fraction` for a ' diff --git a/packages/spec/src/contracts/analytics-service.ts b/packages/spec/src/contracts/analytics-service.ts index 3522a91b079..4dc111f8f53 100644 --- a/packages/spec/src/contracts/analytics-service.ts +++ b/packages/spec/src/contracts/analytics-service.ts @@ -86,9 +86,33 @@ export interface AnalyticsResult { * in `spec/data`; the amount itself stays a bare number (ADR-0104). * Absent on non-monetary columns, which must never render a symbol, * and when no step answers, in which case the amount renders as a - * plain number. + * plain number. Absent, too, on a column that carries + * `currencyField`: its currency is per row, not per column. */ currency?: string; + /** + * [ADR-0144 D2] The PER-ROW currency of a measure column over a + * `currencyMode: 'record'` amount: the `name` of another column of + * this answer — the dimension that groups by the amount's code field + * (`currencyConfig.currencyField`) — whose value in each row of `rows` + * (and of each `totals` entry that groups by it) is that row's ISO + * 4217 code. A renderer formats `row[name]` in the currency + * `row[currencyField]`. + * + * A record-mode amount holds one currency per record, so no single + * `currency` can describe its column: when this key is present + * `currency` is absent. The amount stays a bare number (ADR-0104 D1), + * and a measure-level `currency` over it is refused at compile. A + * `sum` / `avg` / `min` / `max` over such an amount is refused unless + * the query groups by its code field, so every grouped answer carries + * this column. A `count` / `count_distinct` over the amount reads no + * amount and carries neither key, grouped or not. + * + * Present only on a measure column of a `queryDataset` answer, its + * `__compare` column included; absent on dimension columns, on + * derived measures and on a `query` (cube) answer. + */ + currencyField?: string; /** * The column's percent SCALE, when it is a percentage: `fraction` for a * 0–1 ratio (`1` ⇒ "100%"), `whole` for percentage points (`1` ⇒ "1%").