@@ -81,6 +81,7 @@ import { measureResultType } from './measure-result-type.js';
8181// `ensureCube`, ahead of both strategies, as the dataset door refuses it at
8282// compile.
8383import { assertCubeMeasureFieldTypesAccepted , type MeasureColumn } from './cube-measure-field-type-door.js' ;
84+ import { assertRecordCurrencyAggregatesGrouped } from './cube-record-currency-door.js' ;
8485import type { AnalyticsStrategy , AnalyticsDriverCapabilities , StrategyContext , DatasetScopedStrategyContext , DatasetScope , ReadScopeFilterJudge } from './strategies/types.js' ;
8586import { NativeSQLStrategy } from './strategies/native-sql-strategy.js' ;
8687import { ObjectQLStrategy } from './strategies/objectql-strategy.js' ;
@@ -1134,6 +1135,12 @@ export interface AnalyticsServiceConfig {
11341135 * naming no mode, `dynamic` by the spec's default — has no field-level
11351136 * currency; the tenant default applies. `AnalyticsServicePlugin`'s relay
11361137 * is the reference answer.
1138+ * [ADR-0144 D2] `currencyField` is the code field of a `record`-mode
1139+ * amount: answer `currencyConfig.currencyField` ONLY when
1140+ * `currencyConfig.currencyMode` is `'record'`, and `undefined` otherwise.
1141+ * Such an amount has one currency per ROW, so its column carries the
1142+ * `currencyField` pointer instead of a `currency`, and the cube door
1143+ * refuses an aggregate of it that does not group by that field.
11371144 * - Percent scale (objectui#3136): a measure over a `percent` field inherits
11381145 * that field's storage scale via `percentScaleOf`, so a renderer scales by
11391146 * declared metadata instead of guessing from the value.
@@ -1163,7 +1170,7 @@ export interface AnalyticsServiceConfig {
11631170 sourceFieldMeta ?: (
11641171 object : string ,
11651172 field : string ,
1166- ) => { type ?: string ; multiple ?: boolean ; defaultCurrency ?: string ; max ?: number } | undefined ;
1173+ ) => { type ?: string ; multiple ?: boolean ; defaultCurrency ?: string ; currencyField ?: string ; max ?: number } | undefined ;
11671174 /**
11681175 * [#15684] The SQL dialect of the datasource backing `object` — `'sqlite'`,
11691176 * `'postgres'`, `'mysql'`, or `undefined` when the host cannot answer.
@@ -2549,6 +2556,13 @@ export class AnalyticsService implements IAnalyticsService {
25492556 if ( typeof meta ?. type !== 'string' || meta . type === '' ) return undefined ;
25502557 return { type : meta . type , multiple : meta . multiple === true } ;
25512558 } ,
2559+ // [ADR-0144, M0-3] …and the declared currency off the same hook, so the
2560+ // compile-time currency guards judge exactly what the column would print.
2561+ declaredCurrency : ( object : string , field : string ) => {
2562+ const meta = this . sourceFieldMeta ?.( object , field ) ;
2563+ if ( ! meta ) return undefined ;
2564+ return { type : meta . type , defaultCurrency : meta . defaultCurrency , currencyField : meta . currencyField } ;
2565+ } ,
25522566 } ) ;
25532567 }
25542568
@@ -3158,10 +3172,24 @@ export class AnalyticsService implements IAnalyticsService {
31583172 // A measure is monetary if it declares a currency OR aggregates a
31593173 // `currency`-type field; non-monetary measures (count, avg of a plain
31603174 // number) never receive a currency code.
3161- const fc = f as { currency ?: string } ;
3175+ //
3176+ // [ADR-0144 D2] A `record`-mode amount has one currency per ROW, held
3177+ // in its code field, so no single code describes the column and no
3178+ // step of the chain is asked. The column names, by `currencyField`,
3179+ // the selected dimension that groups by that code field: each row's
3180+ // currency is that row's value of it. Read off the dataset and the
3181+ // declaration like every key here, never off the rows. The cube door
3182+ // has already refused an amount aggregate that drops the grouping; a
3183+ // column left without the dimension (a `count` over the amount) gets
3184+ // neither key, rather than a label that is wrong for every other row.
3185+ const fc = f as { currency ?: string ; currencyField ?: string } ;
31623186 const mc = m as { currency ?: string } ;
31633187 const meta = m . field ? this . sourceFieldMeta ?.( dataset . object , m . field ) : undefined ;
3164- if ( fc . currency == null ) {
3188+ if ( meta ?. currencyField !== undefined ) {
3189+ const codeColumn = this . selectedDimensions ( dataset , selection )
3190+ . find ( ( d ) => d . field === meta . currencyField ) ?. name ;
3191+ if ( fc . currencyField == null && codeColumn !== undefined ) fc . currencyField = codeColumn ;
3192+ } else if ( fc . currency == null ) {
31653193 const monetary = ! ! mc . currency || meta ?. type === 'currency' ;
31663194 if ( monetary ) {
31673195 const resolved = mc . currency ?? meta ?. defaultCurrency ?? context ?. currency ;
@@ -3566,6 +3594,7 @@ export class AnalyticsService implements IAnalyticsService {
35663594 // [#21044] Judged on the AUGMENTED cube: a suffix-inferred measure and an
35673595 // authored one are one population for the aggregate × field-type table.
35683596 this . assertMeasureFieldTypes ( query , augmented ) ;
3597+ this . assertRecordCurrencyGrouped ( query , augmented ) ;
35693598 // [#5669] The `where` gate resolves a filter member through dimensions AND
35703599 // measures (that is what the strategies do for a filter member), so it is
35713600 // handed the AUGMENTED cube — a caller filtering on a suffix-inferred
@@ -3582,6 +3611,7 @@ export class AnalyticsService implements IAnalyticsService {
35823611 this . assertDimensionFields ( query , cube , Object . keys ( cube . dimensions ) ) ;
35833612 this . assertDimensionsGroupScalarColumns ( query , cube ) ;
35843613 this . assertMeasureFieldTypes ( query , cube ) ;
3614+ this . assertRecordCurrencyGrouped ( query , cube ) ;
35853615 this . assertWhereFields ( query , cube , Object . keys ( cube . dimensions ) ) ;
35863616 }
35873617 }
@@ -3624,6 +3654,46 @@ export class AnalyticsService implements IAnalyticsService {
36243654 ) ;
36253655 }
36263656
3657+ /**
3658+ * [ADR-0144 D6] Refuse a `sum` / `avg` / `min` / `max` measure over a
3659+ * `record`-mode amount whose query does not group by the amount's code field
3660+ * — `INVALID_FIELD` / 400, naming the member the caller wrote — before either
3661+ * strategy reads anything. The rule and the envelope are
3662+ * {@link assertRecordCurrencyAggregatesGrouped}'s
3663+ * (`cube-record-currency-door.ts`); this method supplies the answers only the
3664+ * service has: the measure column {@link declaredMeasureColumn} resolves
3665+ * (the #21044 door's resolver), each dimension's column by the same
3666+ * {@link declaredMemberEntry} lookup and hop resolver, and the code field off
3667+ * {@link AnalyticsServiceConfig.sourceFieldMeta}.
3668+ *
3669+ * Runs after the #21044 door, so a pair the field-type table refuses is
3670+ * answered as that first. Same stand-downs: no `sourceFieldMeta`, or a cube
3671+ * whose `sql` is not a bare object name.
3672+ */
3673+ private assertRecordCurrencyGrouped ( query : AnalyticsQuery , cube : Cube ) : void {
3674+ const fieldMeta = this . sourceFieldMeta ;
3675+ if ( ! fieldMeta ) return ;
3676+ const object = typeof cube . sql === 'string' ? cube . sql . trim ( ) : '' ;
3677+ if ( ! object || ! BARE_IDENTIFIER . test ( object ) ) return ;
3678+ assertRecordCurrencyAggregatesGrouped (
3679+ query ,
3680+ cube . name ,
3681+ ( member ) => declaredMeasureColumn ( cube , member , object , this . hopReference ) ,
3682+ ( member ) => {
3683+ const entry = declaredMemberEntry ( cube , member , 'dimension' ) ;
3684+ const sql = ( entry ? ( typeof entry . sql === 'string' ? entry . sql : '' ) : member ) . trim ( ) ;
3685+ if ( BARE_IDENTIFIER . test ( sql ) ) return { object, column : sql , path : sql } ;
3686+ if ( ! IDENTIFIER_PATH . test ( sql ) ) return null ;
3687+ return {
3688+ object : columnObjectOf ( cube , object , sql , this . hopReference ) ,
3689+ column : sql . slice ( sql . lastIndexOf ( '.' ) + 1 ) ,
3690+ path : sql ,
3691+ } ;
3692+ } ,
3693+ ( o , field ) => fieldMeta ( o , field ) ?. currencyField ,
3694+ ) ;
3695+ }
3696+
36273697 /**
36283698 * [#20807] Refuse a GROUPED dimension whose column is a declared
36293699 * structured-JSON field — `INVALID_FIELD` / 400, naming the member the caller
0 commit comments