Skip to content

Commit 6c314cf

Browse files
committed
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 `<cube>.<name>`, 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 <noreply@anthropic.com>
1 parent 05f7758 commit 6c314cf

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
@@ -112,7 +112,8 @@ import {
112112
} from './dimension-labels.js';
113113
import { evaluateAnalyticsQueryOverRows } from './preview-evaluator.js';
114114
// [#25778] The one lookup of a dataset duration measure by name.
115-
import { durationMeasureIn, durationMeasureKey } from './duration-measure-sql.js';
115+
import { durationMeasureIn } from './duration-measure-sql.js';
116+
import { scopedMeasureFilter } from './scoped-measure-key.js';
116117
// [#5918] The measure mint refuses a dotted member through the SAME constructor
117118
// the strategies' member-level refusals use — `INVALID_FIELD` / 400, naming the
118119
// member as the request spelled it (see `dataset-refusal.ts`'s header for why
@@ -575,8 +576,7 @@ function namedQueryFields(
575576
for (const member of query.measures ?? []) if (!durationColumns(member, 'aggregate')) name(member, 'measure', 'aggregate');
576577
for (const member of filterMembers((query as { where?: unknown }).where)) name(member, 'any', 'predicate');
577578
for (const measure of query.measures ?? []) {
578-
const filterKey = durationMeasureKey(datasetScope?.measureDurations, measure, cube.name) ?? measure;
579-
for (const member of filterMembers(datasetScope?.measureFilters?.[filterKey])) name(member, 'any', 'predicate');
579+
for (const member of filterMembers(scopedMeasureFilter(datasetScope, measure, cube.name))) name(member, 'any', 'predicate');
580580
}
581581
for (const member of filterMembers(datasetScope?.filter)) name(member, 'any', 'predicate');
582582
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)