diff --git a/.changeset/analytics-current-user-token.md b/.changeset/analytics-current-user-token.md new file mode 100644 index 0000000000..4d1938f7bb --- /dev/null +++ b/.changeset/analytics-current-user-token.md @@ -0,0 +1,9 @@ +--- +'@objectstack/service-analytics': minor +--- + +Resolve `{current_user_id}` (and every other filter placeholder) on the direct analytics query path, at parity with the list path and the dashboard dataset path. + +What changes for an app author: a widget or report whose filter says `owner: '{current_user_id}'` used to render `0` for every viewer whenever the query reached the SQL strategy — the literal text was bound into the `WHERE` and matched no row, silently. Now the same filter expression means the same thing on every surface: `AnalyticsService.query` and `generateSql` expand `where`, `timeDimensions[].dateRange`, and a registered dataset's own filter / measure filters against the requesting user before any strategy compiles, so each viewer gets their own rows. A placeholder that cannot be resolved — an unknown spelling, or `{current_user_id}` on an unauthenticated request — now refuses loudly with `FILTER_TOKEN_UNKNOWN` / `FILTER_TOKEN_UNRESOLVED` (HTTP 400) instead of charting a plausible zero. + +This also closes a gap on the dashboard dataset door: the dataset-scope channel used to hand strategies the registry's unresolved filter copy, which was ANDed in beside the resolved one (`owner = $viewer AND owner = '{current_user_id}'`) and selected nothing. diff --git a/packages/services/service-analytics/src/__tests__/dataset-filter-tokens.test.ts b/packages/services/service-analytics/src/__tests__/dataset-filter-tokens.test.ts index 1bcc70cc19..0373551925 100644 --- a/packages/services/service-analytics/src/__tests__/dataset-filter-tokens.test.ts +++ b/packages/services/service-analytics/src/__tests__/dataset-filter-tokens.test.ts @@ -81,6 +81,12 @@ describe('dataset filter placeholders (framework#3582)', () => { ); expect(captured[0].params).toContain('usr_1'); + // [#12230] The absence half is the load-bearing assertion: the #10298 + // dataset-scope conjunct used to AND the REGISTRY's unresolved copy in + // beside the executor's resolved one — `owner = $viewer AND + // owner = '{current_user_id}'` binds both params and selects nothing, + // and `toContain('usr_1')` alone stayed green through it. + expect(captured[0].params).not.toContain('{current_user_id}'); }); it('expands a measure-scoped filter', async () => { @@ -104,6 +110,11 @@ describe('dataset filter placeholders (framework#3582)', () => { ); expect(captured.some((c) => c.params.includes(THIS_YEAR_START))).toBe(true); + // [#12230] Same absence pin for the measure-filter channel: the strategy's + // conditional aggregate (`CASE WHEN`) used to compile the registry's + // unresolved measure filter, zeroing the measure while the resolved copy + // in `where` kept this presence assertion green. + expect(captured.every((c) => !c.params.includes('{current_year_start}'))).toBe(true); }); it('never mutates the registered dataset — it is reused across requests', async () => { @@ -125,6 +136,7 @@ describe('dataset filter placeholders (framework#3582)', () => { // Second render must scope to the SECOND user, not a baked-in first one. expect(captured[1].params).toContain('usr_2'); expect(captured[1].params).not.toContain('usr_1'); + expect(captured[1].params).not.toContain('{current_user_id}'); expect(scoped.filter).toEqual({ owner: '{current_user_id}' }); }); diff --git a/packages/services/service-analytics/src/__tests__/query-filter-tokens.test.ts b/packages/services/service-analytics/src/__tests__/query-filter-tokens.test.ts new file mode 100644 index 0000000000..f20e30b1f6 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/query-filter-tokens.test.ts @@ -0,0 +1,391 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { DatasetSchema } from '@objectstack/spec/ui'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import type { AnalyticsQuery } from '@objectstack/spec/contracts'; +import { AnalyticsService } from '../analytics-service.js'; + +/** + * #12230 — `{current_user_id}` resolves on the DIRECT analytics door, on both + * compiled surfaces, before either strategy compiles anything. + * + * framework#3582 gave the platform ONE evaluator of the `{token}` filter + * vocabulary (`resolveFilterTokens`, `@objectstack/core`) and wired it into + * the ObjectQL engine and into `DatasetExecutor` (the dashboard door). Two + * gaps survived, both the same shape — one vocabulary, two verdicts: + * + * 1. The DIRECT door (`/api/v1/analytics/query` → `AnalyticsService.query`) + * resolved nothing itself. The ObjectQL strategy's engine bridge resolves + * downstream, but `NativeSQLStrategy` compiles a raw `SELECT … WHERE` + * and BOUND THE LITERAL — `owner = '{current_user_id}'` matches no row, + * and every user-scoped widget rendered a plausible zero ("you have no + * work") for every viewer, silently. + * 2. The #10298 dataset-scope channel hands strategies the REGISTRY's + * compiled filter — shared across requests, never resolved. On the + * dashboard door the executor resolves its own copy into `where`, and + * the strategy then ANDed the unresolved twin as a conjunct: + * `owner = $viewer AND owner = '{current_user_id}'` selects nothing. + * The "redundant and idempotent" reasoning that justified the conjunct + * holds only for token-free filters. + * + * Both close at one seam: `AnalyticsService.query`/`generateSql` resolve the + * query's own token positions (`where`, `timeDimensions[].dateRange`) and the + * per-request dataset-scope getter, with one instant per call, BEFORE strategy + * selection. The two compiled surfaces are asserted separately below: + * + * - the SQL surface (`NativeSQLStrategy`) — assertions read the BOUND + * PARAMS, i.e. what the database actually compares against, and a real + * (sql.js) database returns real rows; + * - the in-memory surface (`ObjectQLStrategy` → the `executeAggregate` + * bridge to `engine.aggregate`) — assertions read the filter handed to + * the bridge, i.e. what the engine actually evaluates. + * + * ## The load-bearing pin: the per-viewer contrast + * + * This feature writes a VIEWER IDENTITY into a SQL `WHERE`. The failure that + * matters is not "it didn't resolve" — it is resolving to the WRONG viewer: a + * data leak wearing a working feature's clothes. So the pins here run the + * SAME saved filter as two different users against the same service instance + * and assert BOTH directions — A gets A's rows and none of B's, B gets B's + * and none of A's. A single-user pin asserting "rows came back" cannot tell + * correct per-request scoping from a token resolved once and served to + * everyone (the registry-mutation shape `dataset-filter-tokens.test.ts` + * already guards on the dashboard door). + * + * An unresolvable placeholder REFUSES loudly instead of charting zero, with + * the resolver's ADR-0112 envelope asserted (`code` AND `status`): a refusal + * an author can read beats a zero a rep believes. + */ + +const CTX_A = { userId: 'usr_a', tenantId: 'org_9', timezone: 'UTC' } as ExecutionContext; +const CTX_B = { userId: 'usr_b', tenantId: 'org_9', timezone: 'UTC' } as ExecutionContext; +/** Authenticated org context with NO user — `{current_user_id}` cannot resolve. */ +const CTX_ANON = { tenantId: 'org_9', timezone: 'UTC' } as ExecutionContext; + +const THIS_YEAR_START = `${new Date().getUTCFullYear()}-01-01`; +const USER_TOKEN = '{current_user_id}'; + +const dataset = DatasetSchema.parse({ + name: 'my_open_cases', + label: 'My Open Cases', + object: 'case_record', + dimensions: [ + { name: 'priority', field: 'priority', type: 'string' }, + { name: 'opened_at', field: 'opened_at', type: 'date' }, + ], + measures: [{ name: 'open_cases', aggregate: 'count' }], + filter: { owner: USER_TOKEN }, +}); + +// ───────────────────────────────────────────────────────────────────────────── +// The SQL surface — what NativeSQLStrategy binds +// ───────────────────────────────────────────────────────────────────────────── + +function sqlCaptureService(captured: { sql: string; params: unknown[] }[]) { + return new AnalyticsService({ + datasets: [dataset], + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }), + executeRawSql: async (_o, sql, params) => { + captured.push({ sql, params }); + return [{ priority: 'high', open_cases: 1 }]; + }, + }); +} + +const directQuery = (where: Record): AnalyticsQuery => ({ + cube: 'my_open_cases', + measures: ['open_cases'], + dimensions: ['priority'], + where, +}); + +describe('direct analytics door — SQL surface binds the viewer, never the literal (#12230)', () => { + it('resolves {current_user_id} in a direct query where', async () => { + const captured: { sql: string; params: unknown[] }[] = []; + await sqlCaptureService(captured).query(directQuery({ owner: USER_TOKEN }), CTX_A); + + expect(captured).toHaveLength(1); + expect(captured[0].params).toContain('usr_a'); + expect(captured[0].params).not.toContain(USER_TOKEN); + }); + + it('per-viewer contrast at the binding seam: each call binds ITS caller, both directions', async () => { + const captured: { sql: string; params: unknown[] }[] = []; + const svc = sqlCaptureService(captured); + + await svc.query(directQuery({ owner: USER_TOKEN }), CTX_A); + await svc.query(directQuery({ owner: USER_TOKEN }), CTX_B); + + expect(captured[0].params).toContain('usr_a'); + expect(captured[0].params).not.toContain('usr_b'); + expect(captured[1].params).toContain('usr_b'); + expect(captured[1].params).not.toContain('usr_a'); + for (const c of captured) expect(c.params).not.toContain(USER_TOKEN); + }); + + it('resolves a date macro in a direct query timeDimensions dateRange', async () => { + const captured: { sql: string; params: unknown[] }[] = []; + await sqlCaptureService(captured).query( + { + cube: 'my_open_cases', + measures: ['open_cases'], + timeDimensions: [ + { dimension: 'opened_at', dateRange: ['{current_year_start}', '{today}'] }, + ], + } as AnalyticsQuery, + CTX_A, + ); + + const all = captured.flatMap((c) => c.params); + expect(all).toContain(THIS_YEAR_START); + expect(all).not.toContain('{current_year_start}'); + expect(all).not.toContain('{today}'); + }); + + it("the #10298 dataset-scope conjunct binds the VIEWER's id on the direct door", async () => { + // No `where` at all: the only token in play is the registered dataset's + // own intrinsic filter, which reaches the strategy through + // `getDatasetScope` — the channel that used to hand out the raw registry + // copy. + const captured: { sql: string; params: unknown[] }[] = []; + const svc = sqlCaptureService(captured); + + await svc.query({ cube: 'my_open_cases', measures: ['open_cases'], dimensions: ['priority'] }, CTX_A); + await svc.query({ cube: 'my_open_cases', measures: ['open_cases'], dimensions: ['priority'] }, CTX_B); + + expect(captured[0].params).toContain('usr_a'); + expect(captured[1].params).toContain('usr_b'); + expect(captured[1].params).not.toContain('usr_a'); + for (const c of captured) expect(c.params).not.toContain(USER_TOKEN); + }); + + it('the dashboard door never binds the literal beside the resolved id (the double-predicate hole)', async () => { + // Before the fix this door bound BOTH: the executor's resolved copy in + // `where` and the registry's literal through the dataset-scope conjunct — + // `owner = $viewer AND owner = '{current_user_id}'`, zero rows, silently. + // `toContain('usr_a')` alone stayed green through that; the absence + // assertion is the pin. + const captured: { sql: string; params: unknown[] }[] = []; + await sqlCaptureService(captured).queryDataset( + dataset, + { dimensions: ['priority'], measures: ['open_cases'] }, + CTX_A, + ); + + expect(captured.length).toBeGreaterThan(0); + for (const c of captured) { + expect(c.params).toContain('usr_a'); + expect(c.params).not.toContain(USER_TOKEN); + } + }); + + it('generateSql (the dry-run door) shows the statement that would really run', async () => { + const captured: { sql: string; params: unknown[] }[] = []; + const { params } = await sqlCaptureService(captured).generateSql( + directQuery({ owner: USER_TOKEN }), + CTX_A, + ); + + expect(params).toContain('usr_a'); + expect(params).not.toContain(USER_TOKEN); + }); +}); + +describe('direct analytics door — unresolvable placeholders refuse loudly (#12230, ADR-0112)', () => { + it('an unknown token refuses with FILTER_TOKEN_UNKNOWN / 400 before any SQL runs', async () => { + const captured: { sql: string; params: unknown[] }[] = []; + const err = await sqlCaptureService(captured) + .query(directQuery({ owner: '{current_user}' }), CTX_A) + .catch((e: unknown) => e); + + expect(err).toBeInstanceOf(Error); + expect((err as { code?: string }).code).toBe('FILTER_TOKEN_UNKNOWN'); + expect((err as { status?: number }).status).toBe(400); + // The near-miss suggestion is the actionable half of the message. + expect((err as Error).message).toContain('{current_user_id}'); + expect(captured).toHaveLength(0); + }); + + it('a vocabulary token with no value refuses with FILTER_TOKEN_UNRESOLVED / 400, never IS NULL', async () => { + const captured: { sql: string; params: unknown[] }[] = []; + const err = await sqlCaptureService(captured) + .query(directQuery({ owner: USER_TOKEN }), CTX_ANON) + .catch((e: unknown) => e); + + expect(err).toBeInstanceOf(Error); + expect((err as { code?: string }).code).toBe('FILTER_TOKEN_UNRESOLVED'); + expect((err as { status?: number }).status).toBe(400); + expect(captured).toHaveLength(0); + }); + + it('generateSql refuses identically — the dry run answers what the real run would', async () => { + const err = await sqlCaptureService([]) + .generateSql(directQuery({ owner: '{current_user}' }), CTX_A) + .catch((e: unknown) => e); + + expect((err as { code?: string }).code).toBe('FILTER_TOKEN_UNKNOWN'); + expect((err as { status?: number }).status).toBe(400); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// The in-memory surface — what the ObjectQL aggregate bridge receives +// ───────────────────────────────────────────────────────────────────────────── + +interface CaseRow { + id: string; + owner: string; + priority: string; + [key: string]: unknown; +} + +const ROWS: CaseRow[] = [ + { id: 'c1', owner: 'usr_a', priority: 'high' }, + { id: 'c2', owner: 'usr_a', priority: 'high' }, + { id: 'c3', owner: 'usr_b', priority: 'low' }, +]; + +/** + * Just enough of the engine's filter semantics for this fixture: `$and` + * arrays, direct equality, `$eq`. Every other combinator or operator REFUSES + * loudly rather than being misread as a field name — the where-matcher + * conformance convention (`check:where-matcher`), so a query shape this + * fixture does not model fails the test instead of silently matching. + */ +function rowMatches(row: CaseRow, cond: unknown): boolean { + if (cond == null || typeof cond !== 'object') return true; + return Object.entries(cond as Record).every(([k, v]) => { + if (k === '$and') return (v as unknown[]).every((c) => rowMatches(row, c)); + if (k.startsWith('$')) { + throw new Error(`rowMatches: combinator "${k}" is not implemented by this fixture — refusing rather than reading it as a field name`); + } + if (v !== null && typeof v === 'object') { + return Object.entries(v as Record).every(([op, operand]) => { + if (op === '$eq') return row[k] === operand; + throw new Error(`rowMatches: operator "${op}" is not implemented by this fixture — refusing rather than guessing`); + }); + } + return row[k] === v; + }); +} + +describe('direct analytics door — in-memory surface (ObjectQL aggregate bridge) (#12230)', () => { + function bridgeService(captured: { object: string; filter: unknown }[]) { + return new AnalyticsService({ + datasets: [dataset], + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + executeAggregate: async (object, opts) => { + captured.push({ object, filter: opts.filter }); + const matched = ROWS.filter((r) => rowMatches(r, opts.filter)); + const buckets = new Map(); + for (const r of matched) buckets.set(r.priority, (buckets.get(r.priority) ?? 0) + 1); + return [...buckets].map(([priority, n]) => ({ priority, open_cases: n })); + }, + }); + } + + it('per-viewer contrast over real rows: each viewer sees exactly their own, both directions', async () => { + const captured: { object: string; filter: unknown }[] = []; + const svc = bridgeService(captured); + + const asA = await svc.query(directQuery({ owner: USER_TOKEN }), CTX_A); + const asB = await svc.query(directQuery({ owner: USER_TOKEN }), CTX_B); + + // The bridge — what `engine.aggregate` would evaluate — carries the + // resolved viewer, never the literal, and never the OTHER viewer. + expect(JSON.stringify(captured[0].filter)).toContain('usr_a'); + expect(JSON.stringify(captured[0].filter)).not.toContain('usr_b'); + expect(JSON.stringify(captured[1].filter)).toContain('usr_b'); + expect(JSON.stringify(captured[1].filter)).not.toContain('usr_a'); + for (const c of captured) expect(JSON.stringify(c.filter)).not.toContain(USER_TOKEN); + + // Row-level, both directions: A's two high-priority cases and nothing of + // B's; B's one low-priority case and nothing of A's. + expect(asA.rows).toEqual([{ priority: 'high', open_cases: 2 }]); + expect(asA.rows.some((r) => r.priority === 'low')).toBe(false); + expect(asB.rows).toEqual([{ priority: 'low', open_cases: 1 }]); + expect(asB.rows.some((r) => r.priority === 'high')).toBe(false); + }); + + it('refuses an unknown token before the bridge is reached', async () => { + const captured: { object: string; filter: unknown }[] = []; + const err = await bridgeService(captured) + .query(directQuery({ owner: '{current_user}' }), CTX_A) + .catch((e: unknown) => e); + + expect((err as { code?: string }).code).toBe('FILTER_TOKEN_UNKNOWN'); + expect((err as { status?: number }).status).toBe(400); + expect(captured).toHaveLength(0); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// End to end — a real database, real rows, two real viewers +// ───────────────────────────────────────────────────────────────────────────── + +/** Point sql.js at the `.wasm` shipped inside its own package (Node-safe). */ +async function locateWasm(): Promise<((file: string) => string) | undefined> { + try { + const { createRequire } = await import('node:module'); + const require = createRequire(import.meta.url); + const pkgJsonPath = require.resolve('sql.js/package.json'); + const { dirname, join } = await import('node:path'); + return (file: string) => join(dirname(pkgJsonPath), 'dist', file); + } catch { + return undefined; + } +} + +describe('per-viewer contrast on a real database — the same saved filter, two users (#12230)', () => { + let db: any; + let svc: AnalyticsService; + + beforeAll(async () => { + const mod: any = await import('sql.js'); + const initSqlJs = mod.default ?? mod; + const locateFile = await locateWasm(); + const SQL = await initSqlJs(locateFile ? { locateFile } : undefined); + + db = new SQL.Database(); + db.run(`CREATE TABLE "case_record" ("id" TEXT PRIMARY KEY, "owner" TEXT, "priority" TEXT);`); + const insert = db.prepare(`INSERT INTO "case_record" ("id","owner","priority") VALUES (?,?,?)`); + for (const r of ROWS) insert.run([r.id, r.owner, r.priority]); + insert.free(); + + svc = new AnalyticsService({ + datasets: [dataset], + queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }), + executeRawSql: async (_object, sql, params) => { + const stmt = db.prepare(sql.replace(/\$\d+/g, '?')); + stmt.bind(params as any[]); + const out: Record[] = []; + while (stmt.step()) out.push(stmt.getAsObject()); + stmt.free(); + return out; + }, + }); + }); + + afterAll(() => { + db?.close(); + }); + + it('the dashboard door: one saved dataset, each viewer gets their rows and none of the other viewer\'s', async () => { + const asA = await svc.queryDataset(dataset, { dimensions: ['priority'], measures: ['open_cases'] }, CTX_A); + const asB = await svc.queryDataset(dataset, { dimensions: ['priority'], measures: ['open_cases'] }, CTX_B); + + expect(asA.rows).toEqual([{ priority: 'high', open_cases: 2 }]); + expect(asA.rows.some((r) => r.priority === 'low')).toBe(false); + expect(asB.rows).toEqual([{ priority: 'low', open_cases: 1 }]); + expect(asB.rows.some((r) => r.priority === 'high')).toBe(false); + }); + + it('the direct door: same contrast through AnalyticsService.query', async () => { + const asA = await svc.query(directQuery({ owner: USER_TOKEN }), CTX_A); + const asB = await svc.query(directQuery({ owner: USER_TOKEN }), CTX_B); + + expect(asA.rows).toEqual([{ priority: 'high', open_cases: 2 }]); + expect(asB.rows).toEqual([{ priority: 'low', open_cases: 1 }]); + }); +}); diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index f1e9ae5431..d665696195 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -16,13 +16,24 @@ import type { Dataset } from '@objectstack/spec/ui'; // differently from objectui's `pickLocalized` with neither end erroring. import { resolveI18nLabel } from '@objectstack/spec/ui'; import type { Logger } from '@objectstack/spec/contracts'; -import { createLogger, getEnv, bucketKeyToCalendarRange, zonedDateStartToUtcMs } from '@objectstack/core'; +import { + createLogger, + getEnv, + bucketKeyToCalendarRange, + zonedDateStartToUtcMs, + // [#12230] The ONE evaluator of the `{token}` filter vocabulary + // (framework#3582) — imported, never re-implemented, so the analytics doors + // and the ObjectQL engine cannot disagree about what a placeholder means. + filterTokenContextFrom, + resolveFilterTokens, + type FilterTokenResolutionContext, +} from '@objectstack/core'; // [#6615] The Postgres `"x" of relation "y"` phrase, owned once. This is the // only reason this package depends on `@objectstack/types` — see the module's // docblock for why the edge is acyclic and why it was worth adding. import { matchMissingColumnOfRelation } from '@objectstack/types'; import { CubeRegistry } from './cube-registry.js'; -import type { AnalyticsStrategy, AnalyticsDriverCapabilities, StrategyContext, DatasetScopedStrategyContext } from './strategies/types.js'; +import type { AnalyticsStrategy, AnalyticsDriverCapabilities, StrategyContext, DatasetScopedStrategyContext, DatasetScope } from './strategies/types.js'; import { NativeSQLStrategy } from './strategies/native-sql-strategy.js'; import { ObjectQLStrategy } from './strategies/objectql-strategy.js'; // [#5669] The `where` source-field gate reads the filter tree through the SAME @@ -778,14 +789,25 @@ export class AnalyticsService implements IAnalyticsService { */ private async callCtx( query: AnalyticsQuery, - context?: ExecutionContext, + context: ExecutionContext | undefined, + tokenCtx: FilterTokenResolutionContext, ): Promise { + // [#12230] The dataset-scope channel (#10298) hands the strategy the + // REGISTRY's compiled filter/measureFilters — shared across requests, so + // it still carries the authored `{current_user_id}` literally. On the + // dashboard door `DatasetExecutor` resolves its OWN copy into `where`, and + // the strategy then ANDed the registry's unresolved twin as a conjunct: + // `owner = $viewer AND owner = '{current_user_id}'` selects nothing — + // #10298's "redundant and idempotent" claim holds only for token-free + // filters. Resolve the channel per request, with the SAME instant as the + // query's own fields. + const getDatasetScope = this.resolvedDatasetScopeGetter(tokenCtx); // #3602 — `context` rides along unconditionally. It is the ENGINE-side belt // (forwarded to `engine.aggregate`, where the middleware chain applies its // own RLS), so it must not be gated on the analytics-side belt being wired: // a deployment with no `getReadScope` provider is exactly the one that most // needs the engine to scope for it. - if (!this.readScopeProvider) return { ...this.baseCtx, context }; + if (!this.readScopeProvider) return { ...this.baseCtx, context, getDatasetScope }; // Pre-resolve the read scope for every object the strategy will scan (base // + all declared joins) BEFORE the synchronous SQL builder runs, since the // provider may be async (the production `security.getReadFilter` bridge). @@ -794,10 +816,64 @@ export class AnalyticsService implements IAnalyticsService { return { ...this.baseCtx, context, + getDatasetScope, getReadScope: (objectName: string) => scopes.get(objectName) ?? null, }; } + /** + * [#12230] Copy-on-write expansion of filter placeholders across everything + * a DIRECT analytics query compares on: `where` and each time dimension's + * `dateRange` — the same positions `DatasetExecutor.resolveSelectionTokens` + * covers for the dashboard door, minus the dataset-only channels it alone + * carries (measure filters ride the dataset-scope getter below). + * + * The input is never mutated: a query object can be caller-owned metadata + * (a saved report definition, a flow node's config) reused across requests, + * and resolving in place would bake one request's user id into every later + * render. Returns the SAME object when nothing resolved. + */ + private resolveQueryTokens( + query: AnalyticsQuery, + tokenCtx: FilterTokenResolutionContext, + ): AnalyticsQuery { + const where = resolveFilterTokens(query.where, tokenCtx); + const timeDimensions = query.timeDimensions?.map((td) => { + if (td.dateRange == null) return td; + const dateRange = resolveFilterTokens(td.dateRange, tokenCtx); + return dateRange === td.dateRange ? td : { ...td, dateRange }; + }); + const tdChanged = + timeDimensions !== undefined && + timeDimensions.some((td, i) => td !== query.timeDimensions![i]); + if (where === query.where && !tdChanged) return query; + const out = { ...query }; + if (where !== query.where) out.where = where; + if (tdChanged) out.timeDimensions = timeDimensions; + return out; + } + + /** + * [#12230] A per-request `getDatasetScope` whose answers have their filter + * placeholders resolved against THIS caller. See `callCtx` for why the + * registry's copy cannot be handed out raw. Token-free scopes pass through + * by reference — `resolveFilterTokens` returns its input unchanged when the + * tree holds no placeholder, so the common case allocates nothing. + */ + private resolvedDatasetScopeGetter( + tokenCtx: FilterTokenResolutionContext, + ): (cubeName: string) => DatasetScope | undefined { + return (cubeName: string) => { + const scope = this.baseCtx.getDatasetScope?.(cubeName); + if (!scope) return scope; + const filter = resolveFilterTokens(scope.filter, tokenCtx); + const measureFilters = resolveFilterTokens(scope.measureFilters, tokenCtx); + return filter === scope.filter && measureFilters === scope.measureFilters + ? scope + : { filter, measureFilters }; + }; + } + /** * Resolve the read scope (tenant + RLS `FilterCondition`) for the base object * AND every joined object of the query's cube, keyed by object name. This is @@ -863,13 +939,26 @@ export class AnalyticsService implements IAnalyticsService { * aggregate bridge) instead of failing — or worse, fabricating empty rows. * Any other error propagates untouched. */ - async query(query: AnalyticsQuery, context?: ExecutionContext): Promise { - if (!query.cube) { + async query(queryInput: AnalyticsQuery, context?: ExecutionContext): Promise { + if (!queryInput.cube) { throw new Error('Cube name is required in analytics query'); } + // [#12230] Expand `{current_user_id}` / date-macro placeholders at THIS + // seam — before strategy selection — so every strategy compiles the same + // resolved values. The ObjectQL strategy's engine bridge resolves tokens + // itself (framework#3582), but `NativeSQLStrategy` compiles a raw + // `SELECT … WHERE` and bound the literal text: one vocabulary, two + // verdicts, and the losing verdict was a plausible zero ("you have no + // work") on every user-scoped widget. One instant for the whole call — + // query fields and the dataset-scope channel below must not straddle a + // period boundary. An unresolvable placeholder throws the resolver's + // `FILTER_TOKEN_*` 400 instead of charting zero. + const tokenCtx = filterTokenContextFrom(context, new Date()); + const query = this.resolveQueryTokens(queryInput, tokenCtx); + this.ensureCube(query); - const ctx = await this.callCtx(query, context); + const ctx = await this.callCtx(query, context, tokenCtx); let skip: Set | undefined; for (;;) { const strategy = this.resolveStrategy(query, ctx, skip); @@ -1368,13 +1457,20 @@ export class AnalyticsService implements IAnalyticsService { /** * Generate SQL for a query without executing it (dry-run). */ - async generateSql(query: AnalyticsQuery, context?: ExecutionContext): Promise<{ sql: string; params: unknown[] }> { - if (!query.cube) { + async generateSql(queryInput: AnalyticsQuery, context?: ExecutionContext): Promise<{ sql: string; params: unknown[] }> { + if (!queryInput.cube) { throw new Error('Cube name is required for SQL generation'); } + // [#12230] Same token seam as `query()` — the dry-run door must show the + // statement that would actually run (a resolved user id in the params, or + // the same `FILTER_TOKEN_*` refusal), never a literal `{current_user_id}` + // the real execution would not bind. + const tokenCtx = filterTokenContextFrom(context, new Date()); + const query = this.resolveQueryTokens(queryInput, tokenCtx); + this.ensureCube(query); - const ctx = await this.callCtx(query, context); + const ctx = await this.callCtx(query, context, tokenCtx); const strategy = this.resolveStrategy(query, ctx); this.logger.debug(`[Analytics] generateSql on cube "${query.cube}" → ${strategy.name}`);