|
| 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 | +}); |
0 commit comments