From 6c314cfe1bb6c4f2f7165509f4f57890d106449f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 08:23:47 +0000 Subject: [PATCH] fix(service-analytics)!: a filtered measure asked as CUBE.MEASURE applies its filter The cube door accepts a dataset measure by its bare name or as `.`, the spelling getMeta lists. The member resolved under both, but every reader of the measure's scoped filter looked it up by the spelling as asked, so the qualified one aggregated without its filter (`SUM(amount)` under the won-deals measure's name) and the read gate never judged the filter's fields. One rule, `scopedMeasureKey` (the one `durationMeasureKey` already used), now feeds every `measureFilters` reader: native SQL compileClauses and its two routing declines, ObjectQL execute, its statement echo and its cross-object member view, and the service's namedQueryFields. Claude-Session: https://claude.ai/code/session_014YyczUJLen7od4GYKjPdY2 Co-authored-by: Claude --- .../25906-cube-qualified-measure-filter.md | 21 +++ .../cube-qualified-measure-filter.test.ts | 171 ++++++++++++++++++ .../src/analytics-service.ts | 6 +- .../src/duration-measure-sql.ts | 11 +- .../src/scoped-measure-key.ts | 52 ++++++ .../src/strategies/native-sql-strategy.ts | 11 +- .../src/strategies/objectql-strategy.ts | 9 +- 7 files changed, 262 insertions(+), 19 deletions(-) create mode 100644 .changeset/25906-cube-qualified-measure-filter.md create mode 100644 packages/services/service-analytics/src/__tests__/cube-qualified-measure-filter.test.ts create mode 100644 packages/services/service-analytics/src/scoped-measure-key.ts diff --git a/.changeset/25906-cube-qualified-measure-filter.md b/.changeset/25906-cube-qualified-measure-filter.md new file mode 100644 index 00000000000..e841c9ae72b --- /dev/null +++ b/.changeset/25906-cube-qualified-measure-filter.md @@ -0,0 +1,21 @@ +--- +'@objectstack/service-analytics': patch +--- + +fix(service-analytics)!: a filtered dataset measure asked on the cube door as `CUBE.MEASURE`, the name `GET /analytics/meta` lists, applies its filter, as the bare name always did + +A registered dataset's measure can carry its own `filter`, for example `won_amount` summing `amount` only where `stage` is `won`. `GET /analytics/meta` lists every cube member as `CUBE.MEASURE` (`sales.won_amount`), and `POST /analytics/query` accepts that spelling. The measure itself resolved under both spellings, but its filter was looked up by the spelling as asked, so the listed one missed it. `measures: ['sales.won_amount']` ran `SUM(amount)`: the total of every deal, under the won-deals measure's name, with no error. That happened on both strategies (native SQL and the ObjectQL aggregate) and in the dry-run statement echo. + +Every reader of a measure's scoped filter now resolves the name through one rule, the one the duration measure's lookup already used: the bare name, or the name without its own cube's qualifier. The listed spelling answers the same conditional aggregate and binds the same values as the bare one, under the column name asked. The native strategy's routing declines (a nested-relation condition, a JSON-stored comparison on an unnamed dialect) read the same filter, so they route the qualified spelling as they route the bare one. Another dotted prefix keys nothing, as before. Queries through `POST /analytics/dataset/query` were never affected: that path builds bare names. + +Clause-②: no (narrowing) — the field-level read gate now judges the fields of the measure's filter under the qualified spelling too. A caller who may not read a field that a filtered measure's filter names was refused `403 PERMISSION_DENIED` for the bare spelling and answered (with the unfiltered number) for the qualified one. It is now refused for both. Nothing is widened. + +Metadata-protocol: none (how the analytics query door resolves a requested member name; nothing a metadata author writes changes) + + + +**BREAKING** for a caller that asked a filtered measure under its `CUBE.MEASURE` spelling: the number changes, and a caller who may not read the filter's fields is now refused. Shipped as `patch` because the metadata protocol does not move. + +- **Before.** `measures: ['sales.won_amount']` aggregated every row, ignoring the measure's filter, and the read gate did not judge the filter's fields. +- **Now.** It answers what `measures: ['won_amount']` answers: the filtered aggregate, or `403 PERMISSION_DENIED` naming the unreadable field. +- **Fix.** Nothing to change for a caller that wanted the measure as declared. A caller that relied on the unfiltered total asks for an unfiltered measure instead. diff --git a/packages/services/service-analytics/src/__tests__/cube-qualified-measure-filter.test.ts b/packages/services/service-analytics/src/__tests__/cube-qualified-measure-filter.test.ts new file mode 100644 index 00000000000..66641e5d8f7 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/cube-qualified-measure-filter.test.ts @@ -0,0 +1,171 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25906] A filtered dataset measure asked on the cube door as `CUBE.MEASURE`, + * the name `getMeta` lists it under, answers exactly what the bare name does: + * its scoped filter included, on every reader of `DatasetScope.measureFilters`. + * + * The member itself always resolved under both spellings (`lookupMember` strips + * the `.` qualifier), but each reader of the measure's filter looked it up + * by the spelling as asked, so the qualified one missed. Measured on `main` + * `05f7758f`, native SQL (SQLite dialect), the dataset below: + * + * ``` + * measures: ['won_amount'] → SUM(CASE WHEN stage = $1 THEN amount END) AS "won_amount" + * measures: ['sales.won_amount'] → SUM(amount) AS "sales.won_amount" + * ``` + * + * the total of every deal under the won-deals measure's name, with no error. + * The ObjectQL face dropped the aggregation's `filter` the same way, and the + * field-level read gate never judged the filter's fields, so a caller who may + * not read `stage` was refused the bare spelling and answered the qualified one. + * + * Every reader now resolves the filter through `scopedMeasureKey`, the one rule + * the duration measure's lookup (`durationMeasureKey`) already used. The + * `DatasetExecutor` path is not here: it builds bare names. + */ + +import { describe, it, expect } from 'vitest'; +import type { AnalyticsQuery, StrategyContext } from '@objectstack/spec/contracts'; +import type { Cube, FilterCondition } from '@objectstack/spec/data'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { DatasetSchema } from '@objectstack/spec/ui'; +import { AnalyticsService } from '../analytics-service.js'; +import { NativeSQLStrategy } from '../strategies/native-sql-strategy.js'; +import { scopedMeasureKey } from '../scoped-measure-key.js'; + +const OBJECT = 'deal'; +const FIELDS = ['id', 'region', 'stage', 'amount']; +const MEMBER = { userId: 'u_member', tenantId: 'org_a' } as ExecutionContext; + +const DATASET = DatasetSchema.parse({ + name: 'sales', + label: 'Sales', + object: OBJECT, + dimensions: [{ name: 'region', field: 'region', type: 'string' }], + measures: [ + { name: 'deals', aggregate: 'count' }, + { name: 'won_amount', aggregate: 'sum', field: 'amount', filter: { stage: 'won' } }, + ], +}); + +type Aggregation = { field: string; method: string; alias: string; filter?: Record }; + +function makeService(opts: { native?: boolean; readable?: readonly string[] } = {}) { + const statements: Array<{ sql: string; params: unknown[] }> = []; + const aggregations: Aggregation[][] = []; + const service = new AnalyticsService({ + queryCapabilities: () => ({ nativeSql: opts.native ?? true, objectqlAggregate: true, inMemory: false }), + getObjectFieldNames: (object: string) => (object === OBJECT ? FIELDS : undefined), + sourceFieldMeta: (object: string, field: string) => + object === OBJECT && FIELDS.includes(field) ? { type: field === 'amount' ? 'number' : 'text' } : undefined, + sqlDialect: () => 'sqlite', + ...(opts.readable ? { getReadableFields: () => opts.readable } : {}), + executeRawSql: async (_object: string, sql: string, params: unknown[]) => { + statements.push({ sql, params }); + return []; + }, + executeAggregate: async (_object: string, options: { aggregations?: Aggregation[] }) => { + aggregations.push(options.aggregations ?? []); + return []; + }, + } as never); + service.registerDataset(DATASET); + return { service, statements, aggregations }; +} + +type Refusal = Error & { code?: string; status?: number }; +const refusalOf = (run: Promise) => run.then(() => undefined, (e: Refusal) => e); +const ask = (measure: string) => ({ cube: DATASET.name, measures: [measure], dimensions: ['region'] }) as never; + +describe('[#25906] the cube door answers `CUBE.MEASURE` as the bare measure, its scoped filter included', () => { + it('native SQL: the same conditional aggregate and the same bound value, under the name asked', async () => { + const { service, statements } = makeService(); + await service.query(ask('won_amount'), MEMBER); + await service.query(ask('sales.won_amount'), MEMBER); + expect(statements).toHaveLength(2); + const [bare, qualified] = statements; + expect(bare.sql).toContain('CASE WHEN stage = $1 THEN amount END'); + expect(qualified.sql).toBe(bare.sql.replace('AS "won_amount"', 'AS "sales.won_amount"')); + expect(qualified.params).toEqual(bare.params); + expect(qualified.params).toEqual(['won']); + }); + + it('ObjectQL: the aggregation carries the measure\'s filter under both spellings', async () => { + const { service, aggregations } = makeService({ native: false }); + await service.query(ask('won_amount'), MEMBER); + await service.query(ask('sales.won_amount'), MEMBER); + expect(aggregations).toHaveLength(2); + const [[bare], [qualified]] = aggregations; + expect(bare.filter).toBeDefined(); + expect(qualified).toEqual({ ...bare, alias: 'sales.won_amount' }); + }); + + it('ObjectQL dry run: the echoed statement renders the conditional aggregate under both spellings', async () => { + const { service } = makeService({ native: false }); + const bare = await service.generateSql(ask('won_amount'), MEMBER); + const qualified = await service.generateSql(ask('sales.won_amount'), MEMBER); + expect(bare.sql).toContain('stage'); + expect(qualified.sql).toBe(bare.sql.replace('AS "won_amount"', 'AS "sales.won_amount"')); + expect(qualified.params).toEqual(bare.params); + }); + + it('the field-level read gate judges the filter\'s fields under both spellings: unreadable `stage` refuses both, nothing runs', async () => { + for (const measure of ['won_amount', 'sales.won_amount']) { + const gated = makeService({ readable: FIELDS.filter((f) => f !== 'stage') }); + const err = await refusalOf(gated.service.query(ask(measure), MEMBER)); + expect({ code: err?.code, status: err?.status }, `${measure}: ${err?.message}`).toEqual({ code: 'PERMISSION_DENIED', status: 403 }); + expect(err?.message).toContain('stage'); + expect(gated.statements, measure).toEqual([]); + + const control = makeService({ readable: FIELDS }); + await control.service.query(ask(measure), MEMBER); + expect(control.statements, measure).toHaveLength(1); + } + }); +}); + +describe('[#25906] NativeSQLStrategy routes on the qualified measure\'s filter as on the bare one', () => { + const CUBE: Cube = { + name: 'ledger', + sql: 'nested_ledger', + measures: { + row_count: { label: 'Rows', type: 'count', sql: '*' }, + na_count: { label: 'NA rows', type: 'count', sql: '*' }, + }, + dimensions: { title: { label: 'Title', type: 'string', sql: 'title' } }, + joins: { owner: { name: 'nested_owner', relationship: 'many_to_one', sql: 'owner' } }, + public: true, + } as unknown as Cube; + const NESTED: FilterCondition = { owner: { region: 'NA' } }; + const ctx = { + getCube: (name: string) => (name === CUBE.name ? CUBE : undefined), + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: true, inMemory: false }), + executeRawSql: async () => [], + getDatasetScope: () => ({ measureFilters: { na_count: NESTED } }), + } as unknown as StrategyContext; + const q = (measure: string) => ({ cube: CUBE.name, measures: ['row_count', measure], dimensions: ['title'] }) as AnalyticsQuery; + + it('declines a nested-relation condition in the requested measure\'s filter, asked either way', () => { + const native = new NativeSQLStrategy(); + expect(native.canHandle(q('na_count'), ctx)).toBe(false); + expect(native.canHandle(q('ledger.na_count'), ctx)).toBe(false); + }); +}); + +describe('[#25906] scopedMeasureKey', () => { + const record = { won_amount: 1 }; + + it('the bare name, then the name under its own cube\'s qualifier; never another prefix', () => { + expect(scopedMeasureKey(record, 'won_amount', 'sales')).toBe('won_amount'); + expect(scopedMeasureKey(record, 'sales.won_amount', 'sales')).toBe('won_amount'); + expect(scopedMeasureKey(record, 'other.won_amount', 'sales')).toBeUndefined(); + expect(scopedMeasureKey(record, 'sales.won_amount')).toBeUndefined(); + expect(scopedMeasureKey(undefined, 'won_amount', 'sales')).toBeUndefined(); + }); + + it('an own key only: a name on Object.prototype keys nothing', () => { + expect(scopedMeasureKey(record, 'constructor', 'sales')).toBeUndefined(); + expect(scopedMeasureKey(record, 'sales.toString', 'sales')).toBeUndefined(); + }); +}); diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index 870896c8505..f6a5b05bc3b 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -112,7 +112,8 @@ import { } from './dimension-labels.js'; import { evaluateAnalyticsQueryOverRows } from './preview-evaluator.js'; // [#25778] The one lookup of a dataset duration measure by name. -import { durationMeasureIn, durationMeasureKey } from './duration-measure-sql.js'; +import { durationMeasureIn } from './duration-measure-sql.js'; +import { scopedMeasureFilter } from './scoped-measure-key.js'; // [#5918] The measure mint refuses a dotted member through the SAME constructor // the strategies' member-level refusals use — `INVALID_FIELD` / 400, naming the // member as the request spelled it (see `dataset-refusal.ts`'s header for why @@ -575,8 +576,7 @@ function namedQueryFields( for (const member of query.measures ?? []) if (!durationColumns(member, 'aggregate')) name(member, 'measure', 'aggregate'); for (const member of filterMembers((query as { where?: unknown }).where)) name(member, 'any', 'predicate'); for (const measure of query.measures ?? []) { - const filterKey = durationMeasureKey(datasetScope?.measureDurations, measure, cube.name) ?? measure; - for (const member of filterMembers(datasetScope?.measureFilters?.[filterKey])) name(member, 'any', 'predicate'); + for (const member of filterMembers(scopedMeasureFilter(datasetScope, measure, cube.name))) name(member, 'any', 'predicate'); } for (const member of filterMembers(datasetScope?.filter)) name(member, 'any', 'predicate'); const order = (query as { order?: unknown }).order; diff --git a/packages/services/service-analytics/src/duration-measure-sql.ts b/packages/services/service-analytics/src/duration-measure-sql.ts index b3e5d3ccc2d..2b10bfae520 100644 --- a/packages/services/service-analytics/src/duration-measure-sql.ts +++ b/packages/services/service-analytics/src/duration-measure-sql.ts @@ -55,6 +55,7 @@ import { DATASET_DURATION_UNIT_SECONDS, type DatasetDurationUnit } from '@objectstack/spec/ui'; import type { DurationMeasureSpec } from './strategies/types.js'; import type { AnalyticsSqlDialect } from './text-match-sql.js'; +import { scopedMeasureKey } from './scoped-measure-key.js'; /** * The key in a compiled dataset's `durations` (the dataset scope's @@ -63,19 +64,15 @@ import type { AnalyticsSqlDialect } from './text-match-sql.js'; * the discovery surface lists it under (`AnalyticsService.getMeta`), when the * caller passes the cube's name. Own keys only, so a measure named after an * `Object.prototype` member (`constructor`) is never read as a duration it is - * not. + * not. The rule is {@link scopedMeasureKey}, the one every reader of a + * dataset scope's per-measure records shares (#25906). */ export function durationMeasureKey( durations: Readonly> | undefined, name: string, cubeName?: string, ): string | undefined { - if (!durations || typeof name !== 'string') return undefined; - const own = (key: string) => Object.prototype.hasOwnProperty.call(durations, key); - if (own(name)) return name; - const prefix = cubeName ? `${cubeName}.` : undefined; - if (prefix && name.startsWith(prefix) && own(name.slice(prefix.length))) return name.slice(prefix.length); - return undefined; + return scopedMeasureKey(durations, name, cubeName); } /** The duration measure {@link durationMeasureKey} finds for `name`, or `undefined`. The one lookup every reader uses. */ diff --git a/packages/services/service-analytics/src/scoped-measure-key.ts b/packages/services/service-analytics/src/scoped-measure-key.ts new file mode 100644 index 00000000000..778908e541f --- /dev/null +++ b/packages/services/service-analytics/src/scoped-measure-key.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25906] The one resolution of a requested measure name to the key a + * dataset scope's per-measure records hold it under: `measureFilters` and + * `measureDurations` (`DatasetScope`), both keyed by the measure's bare name. + * + * The cube door accepts a measure by its bare name or as `.`, the + * spelling the discovery surface lists every cube member under + * (`AnalyticsService.getMeta`), and `lookupMember` resolves both to the same + * cube measure. A reader of a scoped record that looks the measure up by the + * spelling as asked misses under the qualified one, and the measure then + * aggregates without its filter: a silent wrong number under the measure's + * own name. So every reader of either record asks here, and the two spellings + * cannot answer differently on any of them. + * + * Own keys only, so a measure named after an `Object.prototype` member + * (`constructor`) is never read as an entry it is not. Only the measure's own + * cube qualifies it: any other dotted prefix keys nothing here. + */ + +import type { FilterCondition } from '@objectstack/spec/data'; +import type { DatasetScope } from './strategies/types.js'; + +/** + * The key in `record` that the member `name` names, or `undefined`: `name` + * itself, else `name` without its `.` qualifier, when the caller + * passes the cube's name. + */ +export function scopedMeasureKey( + record: Readonly> | undefined, + name: string, + cubeName?: string, +): string | undefined { + if (!record || typeof name !== 'string') return undefined; + const own = (key: string) => Object.prototype.hasOwnProperty.call(record, key); + if (own(name)) return name; + const prefix = cubeName ? `${cubeName}.` : undefined; + if (prefix && name.startsWith(prefix) && own(name.slice(prefix.length))) return name.slice(prefix.length); + return undefined; +} + +/** The scoped filter of the measure `name` names, or `undefined`: {@link scopedMeasureKey} over `scope.measureFilters`. */ +export function scopedMeasureFilter( + scope: Pick | undefined, + name: string, + cubeName?: string, +): FilterCondition | undefined { + const filters = scope?.measureFilters; + const key = scopedMeasureKey(filters, name, cubeName); + return key === undefined ? undefined : filters![key]; +} diff --git a/packages/services/service-analytics/src/strategies/native-sql-strategy.ts b/packages/services/service-analytics/src/strategies/native-sql-strategy.ts index acdc2469f03..be3f6a80523 100644 --- a/packages/services/service-analytics/src/strategies/native-sql-strategy.ts +++ b/packages/services/service-analytics/src/strategies/native-sql-strategy.ts @@ -47,6 +47,7 @@ import { textMatchPredicateSql, sqlDialectFor, type AnalyticsSqlDialect } from ' import { whereContainsMembershipSql } from '../contains-membership-sql.js'; // [#25778] The per-row operand of a dataset duration measure, per dialect. import { durationMeasureIn, durationMeasureKey, durationOperandSql, hasDurationSql } from '../duration-measure-sql.js'; +import { scopedMeasureFilter } from '../scoped-measure-key.js'; import { isJsonStoredShape } from '../contains-membership-sql.js'; import { expandEmptyOperator } from '@objectstack/spec/data'; import { resolveAnalyticsDateRangeString } from '@objectstack/core'; @@ -979,7 +980,7 @@ export class NativeSQLStrategy implements AnalyticsStrategy { : undefined; if (carries(datasetScope?.filter, memberTarget)) return true; for (const measure of query.measures ?? []) { - if (carries(datasetScope?.measureFilters?.[measure], memberTarget)) return true; + if (carries(scopedMeasureFilter(datasetScope, measure, cube.name), memberTarget)) return true; } if (typeof ctx.getReadScope !== 'function') return false; const scoped = (ctx as DatasetScopedStrategyContext).readScopedObjects; @@ -1124,7 +1125,7 @@ export class NativeSQLStrategy implements AnalyticsStrategy { const inScope = findNestedRelationCondition(datasetScope?.filter, 'filter'); if (inScope) return { source: 'the dataset\'s own `filter`', ...inScope }; for (const measure of query.measures ?? []) { - const inMeasure = findNestedRelationCondition(datasetScope?.measureFilters?.[measure], 'filter'); + const inMeasure = findNestedRelationCondition(scopedMeasureFilter(datasetScope, measure, query.cube), 'filter'); if (inMeasure) return { source: `the \`filter\` of measure "${measure}"`, ...inMeasure }; } return null; @@ -1345,10 +1346,10 @@ export class NativeSQLStrategy implements AnalyticsStrategy { // loop, so its bound values are pushed onto `params` in the order the // placeholders appear in the statement — the SELECT list precedes the // WHERE clause, and `$n` is positional. - // [#25778] A duration measure named `.` reads the filter - // its dataset scoped under ``. + // [#25778, #25906] A measure named `.` reads the filter its + // dataset scoped under ``, a duration measure or any other. const durationKey = durationMeasureKey(datasetScope?.measureDurations, measure, cube.name); - const measureFilter = datasetScope?.measureFilters?.[durationKey ?? measure]; + const measureFilter = scopedMeasureFilter(datasetScope, measure, cube.name); const predicate = measureFilter ? this.compileFilterNode( normalizeAnalyticsFilterTree(judgedComparands({ where: measureFilter }, comparandTypeOf), lowering), diff --git a/packages/services/service-analytics/src/strategies/objectql-strategy.ts b/packages/services/service-analytics/src/strategies/objectql-strategy.ts index 663a79c02fd..65cd9e8483c 100644 --- a/packages/services/service-analytics/src/strategies/objectql-strategy.ts +++ b/packages/services/service-analytics/src/strategies/objectql-strategy.ts @@ -40,6 +40,7 @@ import { type LikeShape } from '../like-pattern.js'; import { textMatchPredicateSql, sqlDialectFor, type AnalyticsSqlDialect } from '../text-match-sql.js'; import { resolveAnalyticsDateRangeString } from '@objectstack/core'; import { explicitDateRangeWindow } from '../date-range-array-arm.js'; +import { scopedMeasureFilter } from '../scoped-measure-key.js'; import { rebucketCrossObject, RECOMBINABLE_METHODS, @@ -301,7 +302,7 @@ export class ObjectQLStrategy implements AnalyticsStrategy { if (query.measures && query.measures.length > 0) { for (const measure of query.measures) { const { field, method } = this.resolveMeasureAggregation(cube, measure); - const measureFilter = datasetScope?.measureFilters?.[measure]; + const measureFilter = scopedMeasureFilter(datasetScope, measure, cube.name); const filterCondition = measureFilter ? this.filterNodeToCondition(normalizeAnalyticsFilterTree({ where: measureFilter }, lowering), cube) : null; @@ -691,7 +692,7 @@ export class ObjectQLStrategy implements AnalyticsStrategy { if (query.measures) { for (const m of query.measures) { const { field, method } = this.resolveMeasureAggregation(cube, m); - const measureFilter = datasetScope?.measureFilters?.[m]; + const measureFilter = scopedMeasureFilter(datasetScope, m, cube.name); const predicate = measureFilter ? this.renderFilterNodeSql(normalizeAnalyticsFilterTree({ where: measureFilter }, echoLowering), cube, params, ctx) : null; @@ -1006,7 +1007,7 @@ export class ObjectQLStrategy implements AnalyticsStrategy { * one more producer — so it folds in HERE for the #10861 reason and not into * `dataset-compiler.ts`, which still cannot see which driver will serve the * dataset. Only the REQUESTED measures are folded: both doors' aggregation - * loops read `measureFilters[m]` for `m of query.measures` and nothing else, + * loops read `scopedMeasureFilter(…, m, …)` for `m of query.measures` and nothing else, * so a filter declared on a measure this query never asks for reaches no * engine, and refusing on it would reject a query for a member that was never * going to be evaluated. @@ -1052,7 +1053,7 @@ export class ObjectQLStrategy implements AnalyticsStrategy { // edit; `query.measures` is the iteration order both aggregation loops use, // so the view covers exactly the filters that will be lowered. const measureLeaves = (query.measures ?? []).flatMap((m) => { - const measureFilter = datasetScope?.measureFilters?.[m]; + const measureFilter = scopedMeasureFilter(datasetScope, m, cube.name); return measureFilter ? leaves( normalizeAnalyticsFilterTree({ where: measureFilter }, NO_DATETIME_COLUMNS),