Skip to content

Commit 3117289

Browse files
fix(service-analytics)!: a filtered dataset measure asked as CUBE.MEASURE applies its filter, as the bare name does (#26007)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XF3gy1HC3Hh5j3w4UgZkNj5)_ Fixes #25906 Clause-②: no (narrowing) Metadata-protocol: none Contract review owed: the `(narrowing)` arm. The field-level read gate now judges the fields of a filtered measure's filter when the measure is asked as `CUBE.MEASURE`, so a caller who may not read such a field, answered `200` (with the unfiltered number) on `main`, is now refused `403 PERMISSION_DENIED`, exactly as the bare spelling already was. Nothing is widened, and no `packages/spec/**` path is touched. The seat's claim (6411271126) carries `Clause-②: yes (expected …)`; the lines above are the values measured on this diff, as the dispatch order asked. ## What was wrong A registered dataset measure can carry its own `filter`. `GET /analytics/meta` lists every cube member as `CUBE.MEASURE`, and the cube door accepts that spelling: `lookupMember` strips the cube's own qualifier, so the measure resolves under both. But every reader of the measure's scoped filter (`DatasetScope.measureFilters`) looked it up by the spelling as asked, so the listed spelling missed it. Measured at `AnalyticsService.query` on `origin/main` `05f7758f`, native SQL, SQLite dialect: | `measures` | statement run | |---|---| | `['won_amount']` | `COALESCE(SUM(CASE WHEN stage = $1 THEN amount END), 0) AS "won_amount"` | | `['sales.won_amount']` | `COALESCE(SUM(amount), 0) AS "sales.won_amount"` | The ObjectQL face dropped the aggregation's `filter` the same way, its dry-run echo rendered `SUM(amount)`, and `namedQueryFields` (the read gate) never judged the filter's fields. ## What changed One rule, `scopedMeasureKey` (new `scoped-measure-key.ts`): the bare name if it is an own key of the record, else the name without its own cube's `CUBE.` qualifier. It is the rule `durationMeasureKey` already used for duration measures (PR #25903), and `durationMeasureKey` now delegates to it, so the two records cannot drift. `scopedMeasureFilter` reads `measureFilters` through it. Every reader now calls it: | reader | file | |---|---| | `compileClauses` (the statement) | `native-sql-strategy.ts` | | `jsonConstructOnUnknownDialectIn` (routing decline) | `native-sql-strategy.ts` | | `nestedRelationConditionIn` (routing decline) | `native-sql-strategy.ts` | | `execute` aggregations | `objectql-strategy.ts` | | `generateSql` echo | `objectql-strategy.ts` | | `filterMemberView` (cross-object refusal) | `objectql-strategy.ts` | | `namedQueryFields` (the read gate) | `analytics-service.ts` | The two native routing declines had to move with the statement: once the statement compiles the qualified measure's filter, a decline that still missed it would let a nested-relation filter reach the native compiler, which refuses it as a bare fault. Not changed: `DatasetExecutor` (the `POST /analytics/dataset/query` path) builds bare names; the draft-preview evaluator runs under it. Another dotted prefix keys nothing, as before. No raw `measureFilters?.[…]` lookup is left in the package (`grep` over `src/` outside tests). ## Tests New `src/__tests__/cube-qualified-measure-filter.test.ts`, red first on unfixed readers (5 failed / 2 passed, the 2 being the helper's own unit cases): - native SQL: the qualified statement equals the bare one with only the alias changed, same bound values `['won']`; - ObjectQL: the aggregation carries the same `filter` under both spellings; the dry-run echo renders the same conditional aggregate; - read gate: with `stage` unreadable, both spellings answer `PERMISSION_DENIED` / 403 naming `stage` and nothing runs; with every field readable both run (control); - `NativeSQLStrategy.canHandle` declines a nested-relation measure filter asked either way; - `scopedMeasureKey`: bare, own-cube qualified, another prefix (none), no cube name (none), `Object.prototype` names (none). Ablation (one-time, committed fix first): `scripts/ablation-replace.mjs` put the read gate's raw lookup back in `analytics-service.ts` (anchor 1 to 0, blob `f6a5b05b` to `c366dfb4`); exactly the read-gate case went red (1 failed / 6 passed); restored blob equals HEAD, `git diff HEAD` empty. Local, at head ``6c314cfe``: - `pnpm --filter @objectstack/service-analytics test`: Test Files 189 passed (189), Tests 4570 passed, 282 skipped (os-verify-lock VERDICT command-exit 0) - `pnpm --filter @objectstack/service-analytics typecheck`: exit 0 - `eslint --no-inline-config packages/services/service-analytics`: exit 0 - derived gates (`dispatch-gates.mjs --commands`, 70 commands): 68 exit 0, 2 NOT MEASURED on a local prerequisite (exit 3): `check-plugin-teardown-shape.mjs --self-test` (shallow clone; a checker-health self-test) and `check:dual-build-cjs-loads` (needs a full workspace build); CI runs both. `dispatch-gates --ran`: 70 derived, 68 run, 2 NOT-MEASURED, 0 UNRUN, exit 0 ## Acceptance notes - The measured narrowing is the read gate only: the statement change is a correction of the number, not a refusal. A caller who relied on the unfiltered total under a filtered measure's name now gets the filtered one. - Open PRs re-measured on this file surface before opening: 22 open, none touches the five source files. --- _Generated by [Claude Code](https://claude.ai/code/session_014YyczUJLen7od4GYKjPdY2)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 169fe30 commit 3117289

7 files changed

Lines changed: 262 additions & 19 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/service-analytics': patch
3+
---
4+
5+
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
6+
7+
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.
8+
9+
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.
10+
11+
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.
12+
13+
Metadata-protocol: none (how the analytics query door resolves a requested member name; nothing a metadata author writes changes)
14+
15+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition, stored row or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is how the analytics query door reads a filtered measure asked under its listed `CUBE.MEASURE` spelling: its filter is applied, and the read gate judges the filter's fields. The remedy for a caller now refused is a permission grant or a different measure, never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
16+
17+
**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.
18+
19+
- **Before.** `measures: ['sales.won_amount']` aggregated every row, ignoring the measure's filter, and the read gate did not judge the filter's fields.
20+
- **Now.** It answers what `measures: ['won_amount']` answers: the filtered aggregate, or `403 PERMISSION_DENIED` naming the unreadable field.
21+
- **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.
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25906] A filtered dataset measure asked on the cube door as `CUBE.MEASURE`,
5+
* the name `getMeta` lists it under, answers exactly what the bare name does:
6+
* its scoped filter included, on every reader of `DatasetScope.measureFilters`.
7+
*
8+
* The member itself always resolved under both spellings (`lookupMember` strips
9+
* the `<cube>.` qualifier), but each reader of the measure's filter looked it up
10+
* by the spelling as asked, so the qualified one missed. Measured on `main`
11+
* `05f7758f`, native SQL (SQLite dialect), the dataset below:
12+
*
13+
* ```
14+
* measures: ['won_amount'] → SUM(CASE WHEN stage = $1 THEN amount END) AS "won_amount"
15+
* measures: ['sales.won_amount'] → SUM(amount) AS "sales.won_amount"
16+
* ```
17+
*
18+
* the total of every deal under the won-deals measure's name, with no error.
19+
* The ObjectQL face dropped the aggregation's `filter` the same way, and the
20+
* field-level read gate never judged the filter's fields, so a caller who may
21+
* not read `stage` was refused the bare spelling and answered the qualified one.
22+
*
23+
* Every reader now resolves the filter through `scopedMeasureKey`, the one rule
24+
* the duration measure's lookup (`durationMeasureKey`) already used. The
25+
* `DatasetExecutor` path is not here: it builds bare names.
26+
*/
27+
28+
import { describe, it, expect } from 'vitest';
29+
import type { AnalyticsQuery, StrategyContext } from '@objectstack/spec/contracts';
30+
import type { Cube, FilterCondition } from '@objectstack/spec/data';
31+
import type { ExecutionContext } from '@objectstack/spec/kernel';
32+
import { DatasetSchema } from '@objectstack/spec/ui';
33+
import { AnalyticsService } from '../analytics-service.js';
34+
import { NativeSQLStrategy } from '../strategies/native-sql-strategy.js';
35+
import { scopedMeasureKey } from '../scoped-measure-key.js';
36+
37+
const OBJECT = 'deal';
38+
const FIELDS = ['id', 'region', 'stage', 'amount'];
39+
const MEMBER = { userId: 'u_member', tenantId: 'org_a' } as ExecutionContext;
40+
41+
const DATASET = DatasetSchema.parse({
42+
name: 'sales',
43+
label: 'Sales',
44+
object: OBJECT,
45+
dimensions: [{ name: 'region', field: 'region', type: 'string' }],
46+
measures: [
47+
{ name: 'deals', aggregate: 'count' },
48+
{ name: 'won_amount', aggregate: 'sum', field: 'amount', filter: { stage: 'won' } },
49+
],
50+
});
51+
52+
type Aggregation = { field: string; method: string; alias: string; filter?: Record<string, unknown> };
53+
54+
function makeService(opts: { native?: boolean; readable?: readonly string[] } = {}) {
55+
const statements: Array<{ sql: string; params: unknown[] }> = [];
56+
const aggregations: Aggregation[][] = [];
57+
const service = new AnalyticsService({
58+
queryCapabilities: () => ({ nativeSql: opts.native ?? true, objectqlAggregate: true, inMemory: false }),
59+
getObjectFieldNames: (object: string) => (object === OBJECT ? FIELDS : undefined),
60+
sourceFieldMeta: (object: string, field: string) =>
61+
object === OBJECT && FIELDS.includes(field) ? { type: field === 'amount' ? 'number' : 'text' } : undefined,
62+
sqlDialect: () => 'sqlite',
63+
...(opts.readable ? { getReadableFields: () => opts.readable } : {}),
64+
executeRawSql: async (_object: string, sql: string, params: unknown[]) => {
65+
statements.push({ sql, params });
66+
return [];
67+
},
68+
executeAggregate: async (_object: string, options: { aggregations?: Aggregation[] }) => {
69+
aggregations.push(options.aggregations ?? []);
70+
return [];
71+
},
72+
} as never);
73+
service.registerDataset(DATASET);
74+
return { service, statements, aggregations };
75+
}
76+
77+
type Refusal = Error & { code?: string; status?: number };
78+
const refusalOf = (run: Promise<unknown>) => run.then(() => undefined, (e: Refusal) => e);
79+
const ask = (measure: string) => ({ cube: DATASET.name, measures: [measure], dimensions: ['region'] }) as never;
80+
81+
describe('[#25906] the cube door answers `CUBE.MEASURE` as the bare measure, its scoped filter included', () => {
82+
it('native SQL: the same conditional aggregate and the same bound value, under the name asked', async () => {
83+
const { service, statements } = makeService();
84+
await service.query(ask('won_amount'), MEMBER);
85+
await service.query(ask('sales.won_amount'), MEMBER);
86+
expect(statements).toHaveLength(2);
87+
const [bare, qualified] = statements;
88+
expect(bare.sql).toContain('CASE WHEN stage = $1 THEN amount END');
89+
expect(qualified.sql).toBe(bare.sql.replace('AS "won_amount"', 'AS "sales.won_amount"'));
90+
expect(qualified.params).toEqual(bare.params);
91+
expect(qualified.params).toEqual(['won']);
92+
});
93+
94+
it('ObjectQL: the aggregation carries the measure\'s filter under both spellings', async () => {
95+
const { service, aggregations } = makeService({ native: false });
96+
await service.query(ask('won_amount'), MEMBER);
97+
await service.query(ask('sales.won_amount'), MEMBER);
98+
expect(aggregations).toHaveLength(2);
99+
const [[bare], [qualified]] = aggregations;
100+
expect(bare.filter).toBeDefined();
101+
expect(qualified).toEqual({ ...bare, alias: 'sales.won_amount' });
102+
});
103+
104+
it('ObjectQL dry run: the echoed statement renders the conditional aggregate under both spellings', async () => {
105+
const { service } = makeService({ native: false });
106+
const bare = await service.generateSql(ask('won_amount'), MEMBER);
107+
const qualified = await service.generateSql(ask('sales.won_amount'), MEMBER);
108+
expect(bare.sql).toContain('stage');
109+
expect(qualified.sql).toBe(bare.sql.replace('AS "won_amount"', 'AS "sales.won_amount"'));
110+
expect(qualified.params).toEqual(bare.params);
111+
});
112+
113+
it('the field-level read gate judges the filter\'s fields under both spellings: unreadable `stage` refuses both, nothing runs', async () => {
114+
for (const measure of ['won_amount', 'sales.won_amount']) {
115+
const gated = makeService({ readable: FIELDS.filter((f) => f !== 'stage') });
116+
const err = await refusalOf(gated.service.query(ask(measure), MEMBER));
117+
expect({ code: err?.code, status: err?.status }, `${measure}: ${err?.message}`).toEqual({ code: 'PERMISSION_DENIED', status: 403 });
118+
expect(err?.message).toContain('stage');
119+
expect(gated.statements, measure).toEqual([]);
120+
121+
const control = makeService({ readable: FIELDS });
122+
await control.service.query(ask(measure), MEMBER);
123+
expect(control.statements, measure).toHaveLength(1);
124+
}
125+
});
126+
});
127+
128+
describe('[#25906] NativeSQLStrategy routes on the qualified measure\'s filter as on the bare one', () => {
129+
const CUBE: Cube = {
130+
name: 'ledger',
131+
sql: 'nested_ledger',
132+
measures: {
133+
row_count: { label: 'Rows', type: 'count', sql: '*' },
134+
na_count: { label: 'NA rows', type: 'count', sql: '*' },
135+
},
136+
dimensions: { title: { label: 'Title', type: 'string', sql: 'title' } },
137+
joins: { owner: { name: 'nested_owner', relationship: 'many_to_one', sql: 'owner' } },
138+
public: true,
139+
} as unknown as Cube;
140+
const NESTED: FilterCondition = { owner: { region: 'NA' } };
141+
const ctx = {
142+
getCube: (name: string) => (name === CUBE.name ? CUBE : undefined),
143+
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: true, inMemory: false }),
144+
executeRawSql: async () => [],
145+
getDatasetScope: () => ({ measureFilters: { na_count: NESTED } }),
146+
} as unknown as StrategyContext;
147+
const q = (measure: string) => ({ cube: CUBE.name, measures: ['row_count', measure], dimensions: ['title'] }) as AnalyticsQuery;
148+
149+
it('declines a nested-relation condition in the requested measure\'s filter, asked either way', () => {
150+
const native = new NativeSQLStrategy();
151+
expect(native.canHandle(q('na_count'), ctx)).toBe(false);
152+
expect(native.canHandle(q('ledger.na_count'), ctx)).toBe(false);
153+
});
154+
});
155+
156+
describe('[#25906] scopedMeasureKey', () => {
157+
const record = { won_amount: 1 };
158+
159+
it('the bare name, then the name under its own cube\'s qualifier; never another prefix', () => {
160+
expect(scopedMeasureKey(record, 'won_amount', 'sales')).toBe('won_amount');
161+
expect(scopedMeasureKey(record, 'sales.won_amount', 'sales')).toBe('won_amount');
162+
expect(scopedMeasureKey(record, 'other.won_amount', 'sales')).toBeUndefined();
163+
expect(scopedMeasureKey(record, 'sales.won_amount')).toBeUndefined();
164+
expect(scopedMeasureKey(undefined, 'won_amount', 'sales')).toBeUndefined();
165+
});
166+
167+
it('an own key only: a name on Object.prototype keys nothing', () => {
168+
expect(scopedMeasureKey(record, 'constructor', 'sales')).toBeUndefined();
169+
expect(scopedMeasureKey(record, 'sales.toString', 'sales')).toBeUndefined();
170+
});
171+
});

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,8 @@ import {
113113
} from './dimension-labels.js';
114114
import { evaluateAnalyticsQueryOverRows } from './preview-evaluator.js';
115115
// [#25778] The one lookup of a dataset duration measure by name.
116-
import { durationMeasureIn, durationMeasureKey } from './duration-measure-sql.js';
116+
import { durationMeasureIn } from './duration-measure-sql.js';
117+
import { scopedMeasureFilter } from './scoped-measure-key.js';
117118
// [#5918] The measure mint refuses a dotted member through the SAME constructor
118119
// the strategies' member-level refusals use — `INVALID_FIELD` / 400, naming the
119120
// member as the request spelled it (see `dataset-refusal.ts`'s header for why
@@ -576,8 +577,7 @@ function namedQueryFields(
576577
for (const member of query.measures ?? []) if (!durationColumns(member, 'aggregate')) name(member, 'measure', 'aggregate');
577578
for (const member of filterMembers((query as { where?: unknown }).where)) name(member, 'any', 'predicate');
578579
for (const measure of query.measures ?? []) {
579-
const filterKey = durationMeasureKey(datasetScope?.measureDurations, measure, cube.name) ?? measure;
580-
for (const member of filterMembers(datasetScope?.measureFilters?.[filterKey])) name(member, 'any', 'predicate');
580+
for (const member of filterMembers(scopedMeasureFilter(datasetScope, measure, cube.name))) name(member, 'any', 'predicate');
581581
}
582582
for (const member of filterMembers(datasetScope?.filter)) name(member, 'any', 'predicate');
583583
const order = (query as { order?: unknown }).order;

‎packages/services/service-analytics/src/duration-measure-sql.ts‎

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
import { DATASET_DURATION_UNIT_SECONDS, type DatasetDurationUnit } from '@objectstack/spec/ui';
5656
import type { DurationMeasureSpec } from './strategies/types.js';
5757
import type { AnalyticsSqlDialect } from './text-match-sql.js';
58+
import { scopedMeasureKey } from './scoped-measure-key.js';
5859

5960
/**
6061
* The key in a compiled dataset's `durations` (the dataset scope's
@@ -63,19 +64,15 @@ import type { AnalyticsSqlDialect } from './text-match-sql.js';
6364
* the discovery surface lists it under (`AnalyticsService.getMeta`), when the
6465
* caller passes the cube's name. Own keys only, so a measure named after an
6566
* `Object.prototype` member (`constructor`) is never read as a duration it is
66-
* not.
67+
* not. The rule is {@link scopedMeasureKey}, the one every reader of a
68+
* dataset scope's per-measure records shares (#25906).
6769
*/
6870
export function durationMeasureKey(
6971
durations: Readonly<Record<string, DurationMeasureSpec>> | undefined,
7072
name: string,
7173
cubeName?: string,
7274
): string | undefined {
73-
if (!durations || typeof name !== 'string') return undefined;
74-
const own = (key: string) => Object.prototype.hasOwnProperty.call(durations, key);
75-
if (own(name)) return name;
76-
const prefix = cubeName ? `${cubeName}.` : undefined;
77-
if (prefix && name.startsWith(prefix) && own(name.slice(prefix.length))) return name.slice(prefix.length);
78-
return undefined;
75+
return scopedMeasureKey(durations, name, cubeName);
7976
}
8077

8178
/** The duration measure {@link durationMeasureKey} finds for `name`, or `undefined`. The one lookup every reader uses. */
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25906] The one resolution of a requested measure name to the key a
5+
* dataset scope's per-measure records hold it under: `measureFilters` and
6+
* `measureDurations` (`DatasetScope`), both keyed by the measure's bare name.
7+
*
8+
* The cube door accepts a measure by its bare name or as `<cube>.<name>`, the
9+
* spelling the discovery surface lists every cube member under
10+
* (`AnalyticsService.getMeta`), and `lookupMember` resolves both to the same
11+
* cube measure. A reader of a scoped record that looks the measure up by the
12+
* spelling as asked misses under the qualified one, and the measure then
13+
* aggregates without its filter: a silent wrong number under the measure's
14+
* own name. So every reader of either record asks here, and the two spellings
15+
* cannot answer differently on any of them.
16+
*
17+
* Own keys only, so a measure named after an `Object.prototype` member
18+
* (`constructor`) is never read as an entry it is not. Only the measure's own
19+
* cube qualifies it: any other dotted prefix keys nothing here.
20+
*/
21+
22+
import type { FilterCondition } from '@objectstack/spec/data';
23+
import type { DatasetScope } from './strategies/types.js';
24+
25+
/**
26+
* The key in `record` that the member `name` names, or `undefined`: `name`
27+
* itself, else `name` without its `<cubeName>.` qualifier, when the caller
28+
* passes the cube's name.
29+
*/
30+
export function scopedMeasureKey(
31+
record: Readonly<Record<string, unknown>> | undefined,
32+
name: string,
33+
cubeName?: string,
34+
): string | undefined {
35+
if (!record || typeof name !== 'string') return undefined;
36+
const own = (key: string) => Object.prototype.hasOwnProperty.call(record, key);
37+
if (own(name)) return name;
38+
const prefix = cubeName ? `${cubeName}.` : undefined;
39+
if (prefix && name.startsWith(prefix) && own(name.slice(prefix.length))) return name.slice(prefix.length);
40+
return undefined;
41+
}
42+
43+
/** The scoped filter of the measure `name` names, or `undefined`: {@link scopedMeasureKey} over `scope.measureFilters`. */
44+
export function scopedMeasureFilter(
45+
scope: Pick<DatasetScope, 'measureFilters'> | undefined,
46+
name: string,
47+
cubeName?: string,
48+
): FilterCondition | undefined {
49+
const filters = scope?.measureFilters;
50+
const key = scopedMeasureKey(filters, name, cubeName);
51+
return key === undefined ? undefined : filters![key];
52+
}

0 commit comments

Comments
 (0)