Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/25906-cube-qualified-measure-filter.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- 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). -->

**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.
Original file line number Diff line number Diff line change
@@ -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 `<cube>.` 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<string, unknown> };

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<unknown>) => 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();
});
});
6 changes: 3 additions & 3 deletions packages/services/service-analytics/src/analytics-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
11 changes: 4 additions & 7 deletions packages/services/service-analytics/src/duration-measure-sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Record<string, DurationMeasureSpec>> | 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. */
Expand Down
52 changes: 52 additions & 0 deletions packages/services/service-analytics/src/scoped-measure-key.ts
Original file line number Diff line number Diff line change
@@ -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 `<cube>.<name>`, 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 `<cubeName>.` qualifier, when the caller
* passes the cube's name.
*/
export function scopedMeasureKey(
record: Readonly<Record<string, unknown>> | 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<DatasetScope, 'measureFilters'> | undefined,
name: string,
cubeName?: string,
): FilterCondition | undefined {
const filters = scope?.measureFilters;
const key = scopedMeasureKey(filters, name, cubeName);
return key === undefined ? undefined : filters![key];
}
Loading
Loading