From e83d579cd2fde1d160e8d35157d641669bcfd526 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:46:59 +0800 Subject: [PATCH 1/2] fix(analytics): render the read scope in ObjectQLStrategy.generateSql (#3654 residual 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit generateSql (the representative statement echoed to /analytics/sql and dataset responses) rendered the query's own filters but NOT the tenant/RLS read scope that execute() injects via withReadScope — so the previewed WHERE understated what actually runs. It now renders the base object's scope too (parameterized, $n placeholders), ANDed with the query filters. For consistency generateSql also applies the #3654 cross-object guard, so /analytics/sql no longer previews a dotted-column statement for a query execute() refuses to run — same clear error instead of misleading SQL. Display-only (no data leak — the string is documentation, values stay placeholders); no change when no read-scope provider is configured. Tests: generateSql now renders "opportunity"."organization_id" = $n and ANDs it with the query filter; a cross-object preview rejects; the no-provider case is unchanged. Reverting the change turns the three load-bearing assertions red. Co-Authored-By: Claude --- .changeset/objectql-generatesql-scope.md | 21 +++++++ .../src/__tests__/objectql-read-scope.test.ts | 60 +++++++++++++++++++ .../src/strategies/objectql-strategy.ts | 36 ++++++++++- 3 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 .changeset/objectql-generatesql-scope.md diff --git a/.changeset/objectql-generatesql-scope.md b/.changeset/objectql-generatesql-scope.md new file mode 100644 index 000000000..fddf6231f --- /dev/null +++ b/.changeset/objectql-generatesql-scope.md @@ -0,0 +1,21 @@ +--- +"@objectstack/service-analytics": patch +--- + +fix(analytics): show the read scope in ObjectQLStrategy's previewed SQL (#3654 residual 2) + +`ObjectQLStrategy.generateSql` (the representative statement echoed to +`/analytics/sql` and dataset responses) rendered the query's own filters but NOT +the tenant/RLS read scope that `execute()` injects. The preview therefore +understated the real WHERE — an author debugging a widget saw fewer constraints +than the query actually ran with. It now renders the base object's read scope +too, parameterized, alongside the query filters. + +For consistency, `generateSql` also applies the #3654 cross-object guard, so +`/analytics/sql` no longer previews a dotted-column statement for a cross-object +query that `execute()` refuses to run (the engine cannot join in an aggregate) — +it returns the same clear error instead. + +Display-only: the previewed string is documentation, never the executed +statement, and carries no data (filter values stay `$n` placeholders). No change +when no read-scope provider is configured. diff --git a/packages/services/service-analytics/src/__tests__/objectql-read-scope.test.ts b/packages/services/service-analytics/src/__tests__/objectql-read-scope.test.ts index 2ad14fd1a..3aa45bed5 100644 --- a/packages/services/service-analytics/src/__tests__/objectql-read-scope.test.ts +++ b/packages/services/service-analytics/src/__tests__/objectql-read-scope.test.ts @@ -267,3 +267,63 @@ describe('ObjectQLStrategy — cross-object references are fail-closed (#3654, s expect(wasExecuted()).toBe(false); }); }); + +describe('ObjectQLStrategy.generateSql — read scope in the previewed WHERE (#3654 residual 2)', () => { + it('renders the tenant/RLS predicate the query actually runs with', async () => { + const { sql, params } = await makeService([]).generateSql( + { cube: 'sales', dimensions: ['region'], measures: ['revenue'] }, + ctxA, + ); + // The previewed SQL must carry the scope, not just the SELECT/GROUP BY — + // before #3654 residual 2 the WHERE omitted it and understated what runs. + expect(sql).toMatch(/WHERE .*"opportunity"\."organization_id" = \$\d+/); + expect(params).toContain('org_A'); + }); + + it('ANDs the query filter and the scope together in the preview', async () => { + const { sql, params } = await makeService([]).generateSql( + { + cube: 'sales', + dimensions: ['region'], + measures: ['revenue'], + where: { region: 'West' }, + }, + ctxA, + ); + expect(sql).toContain('WHERE'); + expect(sql).toMatch(/region .*=.* \$\d+/); + expect(sql).toMatch(/"opportunity"\."organization_id" = \$\d+/); + expect(params).toEqual(expect.arrayContaining(['West', 'org_A'])); + }); + + it('omits the scope predicate when no provider is configured (unchanged)', async () => { + const { sql } = await makeService([], { getReadScope: undefined }).generateSql( + { cube: 'sales', dimensions: ['region'], measures: ['revenue'] }, + ctxA, + ); + expect(sql).not.toContain('organization_id'); + }); + + it('rejects a cross-object preview (no dotted-column SQL for a query execute() refuses)', async () => { + const compiled = compileDataset( + DatasetSchema.parse({ + name: 'sql_by_account', + label: 'x', + object: 'opportunity', + include: ['account'], + dimensions: [{ name: 'region', field: 'account.region', type: 'string' }], + measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount' }], + }), + ); + const svc = new AnalyticsService({ + cubes: [compiled.cube], + queryCapabilities: objectqlOnly, + executeAggregate: async () => [], + getReadScope: readScope, + getAllowedRelationships: () => compiled.allowedRelationships, + }); + await expect( + svc.generateSql({ cube: 'sql_by_account', dimensions: ['region'], measures: ['revenue'] }, ctxA), + ).rejects.toThrow(/cannot group or aggregate across a relationship/); + }); +}); diff --git a/packages/services/service-analytics/src/strategies/objectql-strategy.ts b/packages/services/service-analytics/src/strategies/objectql-strategy.ts index e6e99b9f3..df6a8d8ae 100644 --- a/packages/services/service-analytics/src/strategies/objectql-strategy.ts +++ b/packages/services/service-analytics/src/strategies/objectql-strategy.ts @@ -1,9 +1,10 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { AnalyticsQuery, AnalyticsResult } from '@objectstack/spec/contracts'; -import type { Cube } from '@objectstack/spec/data'; +import type { Cube, FilterCondition } from '@objectstack/spec/data'; import type { AnalyticsStrategy, StrategyContext } from './types.js'; import { normalizeAnalyticsFilters, coerceFilterValueForObjectQL } from './filter-normalizer.js'; +import { compileScopedFilterToSql } from '../read-scope-sql.js'; /** * ObjectQLStrategy — Priority 2 @@ -178,6 +179,21 @@ export class ObjectQLStrategy implements AnalyticsStrategy { return gran ? `date_trunc('${gran}', ${col})` : col; }; + // #3654 — reject a cross-object query HERE too, so `/analytics/sql` never + // previews a dotted-column statement that `execute()` refuses to run (the + // engine cannot join in an aggregate). Reconstruct the resolved field names + // the shared guard inspects. + const guardGroupBy = [ + ...(query.dimensions ?? []).map((d) => this.resolveFieldName(cube, d, 'dimension')), + ...[...granByDim.keys()] + .filter((d) => !query.dimensions?.includes(d)) + .map((d) => this.resolveFieldName(cube, d, 'dimension')), + ]; + const guardFilter = Object.fromEntries( + normalizeAnalyticsFilters(query).map((f) => [this.resolveFieldName(cube, f.member, 'any'), true]), + ); + this.assertNoCrossObjectReferences(cube, query, guardGroupBy, guardFilter); + if (query.dimensions) { for (const dim of query.dimensions) { const expr = dimExpr(dim); @@ -230,6 +246,24 @@ export class ObjectQLStrategy implements AnalyticsStrategy { whereParts.push(`${col} ${op} $${params.length}`); } } + + // #3654 — render the READ SCOPE (tenant + RLS) that `execute()` injects via + // `withReadScope`, so the previewed WHERE is an honest account of what runs. + // Without this the query's own filters showed but the security predicate + // silently didn't — the preview understated the real constraint set. Base + // object only: cross-object queries are already rejected above. + if (typeof ctx.getReadScope === 'function') { + const scope = ctx.getReadScope(tableName); + if (scope != null) { + const { sql: scopeSql, params: scopeParams } = compileScopedFilterToSql(scope as FilterCondition, tableName); + if (scopeSql) { + let i = 0; + const rendered = scopeSql.replace(/\?/g, () => { params.push(scopeParams[i++]); return `$${params.length}`; }); + whereParts.push(`(${rendered})`); + } + } + } + if (whereParts.length > 0) sql += ` WHERE ${whereParts.join(' AND ')}`; if (groupByParts.length > 0) { From 884fe5479a02cb2ebed4f0c269970e62233e32f7 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:07:19 +0800 Subject: [PATCH 2/2] feat(analytics): serve in-envelope cross-object grouping by FK-expand (#3654) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ObjectQL fallback path (date-granularity bucketing, in-memory driver, federated) could not join, so cross-object grouping like `revenue by account.region` was rejected (#3664 stopgap). It now serves the common case by FK-expand: 1. group the base aggregate on the lookup FK column (`account`) — which the engine CAN do — scoped to the base object; 2. resolve each FK id to the related attribute (`region`) with a read of the referenced object scoped to THAT object's own RLS; 3. re-bucket by the resolved attribute in memory, recombining measures (sum/count add, min/max take the extremum). A base row whose referenced record the caller cannot read buckets under an explicit `(restricted)` group: the measure still counts (grand totals preserved) but the hidden attribute never appears — no leak (ADR-0021 D-C / #3602). `/analytics/sql` renders the equivalent LEFT JOIN. Bounded — still rejected LOUD (never silently wrong): cross-object in a MEASURE or FILTER, multi-hop dims, and non-recombinable measures (avg/count_distinct) with a cross-object dim. NativeSQLStrategy (the normal SQL path) is unchanged. The pure re-bucketing step is isolated in cross-object-rebucket.ts and exhaustively unit-tested (recombination, restricted-total conservation, null-vs-restricted, multi-dim). Integration tests drive the real AnalyticsService with a two-call aggregate stub, asserting both the base and the FK-resolution are scoped and the restricted bucket forms; reverting the strategy turns all three red. Co-Authored-By: Claude --- .changeset/objectql-crossobj-capability.md | 30 ++ .../__tests__/cross-object-rebucket.test.ts | 120 +++++++ .../objectql-crossobj-expand.test.ts | 117 ++++++ .../src/__tests__/objectql-read-scope.test.ts | 100 +++--- .../src/strategies/cross-object-rebucket.ts | 117 ++++++ .../src/strategies/objectql-strategy.ts | 333 +++++++++++++----- 6 files changed, 695 insertions(+), 122 deletions(-) create mode 100644 .changeset/objectql-crossobj-capability.md create mode 100644 packages/services/service-analytics/src/__tests__/cross-object-rebucket.test.ts create mode 100644 packages/services/service-analytics/src/__tests__/objectql-crossobj-expand.test.ts create mode 100644 packages/services/service-analytics/src/strategies/cross-object-rebucket.ts diff --git a/.changeset/objectql-crossobj-capability.md b/.changeset/objectql-crossobj-capability.md new file mode 100644 index 000000000..56edc9bfa --- /dev/null +++ b/.changeset/objectql-crossobj-capability.md @@ -0,0 +1,30 @@ +--- +"@objectstack/service-analytics": minor +--- + +feat(analytics): serve in-envelope cross-object grouping on the ObjectQL path by FK-expand (#3654) + +`engine.aggregate()` cannot join, so the ObjectQL fallback path (date-granularity +bucketing, in-memory driver, federated objects) previously REJECTED any +cross-object grouping like `revenue by account.region` (#3664 stopgap — a loud +error instead of the earlier silent `(null)` mis-bucket). It now SERVES the +common case directly. + +For a single-hop cross-object DIMENSION with recombinable measures, the strategy: +1. groups the base aggregate on the lookup FK column (`account`) — which the + engine can do — scoped to the base object; +2. resolves each FK id to the related attribute (`region`) with a read of the + referenced object **scoped to that object's own RLS**; then +3. re-buckets by the resolved attribute in memory, recombining the measures + (sum/count add; min/max take the extremum). + +A base row whose referenced record the caller cannot read buckets under an +explicit `(restricted)` group: its measure still counts (grand totals are +preserved) but the hidden record's attribute never appears — no leak (ADR-0021 +D-C, the #3602 class). `/analytics/sql` renders the equivalent `LEFT JOIN`. + +Deliberately bounded — still REJECTED (loud, never silently wrong): cross-object +references in a MEASURE or FILTER (need a real join to evaluate), multi-hop +dimensions (`a.b.c`), and non-recombinable measures (`avg`, `count_distinct`) +with a cross-object dimension. Cross-object queries on `NativeSQLStrategy` (the +normal SQL path) are unchanged — it hand-compiles the joins. diff --git a/packages/services/service-analytics/src/__tests__/cross-object-rebucket.test.ts b/packages/services/service-analytics/src/__tests__/cross-object-rebucket.test.ts new file mode 100644 index 000000000..5ceeb0722 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/cross-object-rebucket.test.ts @@ -0,0 +1,120 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + rebucketCrossObject, + RESTRICTED_BUCKET, + type CrossObjectDim, + type MeasureRecombine, +} from '../strategies/cross-object-rebucket.js'; + +const fkToRegion = new Map([ + ['acc_w1', 'West'], + ['acc_w2', 'West'], + ['acc_e1', 'East'], +]); + +describe('rebucketCrossObject (#3654)', () => { + it('merges FK sub-buckets that resolve to the same attribute (sum)', () => { + const base = [ + { account: 'acc_w1', revenue: 100 }, + { account: 'acc_w2', revenue: 50 }, + { account: 'acc_e1', revenue: 20 }, + ]; + const dims: CrossObjectDim[] = [{ outputName: 'region', fkField: 'account', fkToAttr: fkToRegion }]; + const measures: MeasureRecombine[] = [{ alias: 'revenue', method: 'sum' }]; + + const out = rebucketCrossObject(base, [], dims, measures); + // West = 100 + 50, East = 20 — the two West accounts collapse into one region. + expect(out).toEqual([ + { region: 'West', revenue: 150 }, + { region: 'East', revenue: 20 }, + ]); + }); + + it('buckets an unreadable FK under RESTRICTED, preserving the grand total', () => { + const base = [ + { account: 'acc_w1', revenue: 100 }, + { account: 'acc_hidden', revenue: 999 }, // not in the map ⇒ referenced record hidden + ]; + const dims: CrossObjectDim[] = [{ outputName: 'region', fkField: 'account', fkToAttr: fkToRegion }]; + const out = rebucketCrossObject(base, [], dims, [{ alias: 'revenue', method: 'sum' }]); + + expect(out).toEqual([ + { region: 'West', revenue: 100 }, + { region: RESTRICTED_BUCKET, revenue: 999 }, + ]); + // Grand total is conserved (100 + 999) — no row silently dropped. + expect(out.reduce((s, r) => s + Number(r.revenue), 0)).toBe(1099); + // The hidden account's attribute value never appears. + expect(out.map((r) => r.region)).not.toContain('acc_hidden'); + }); + + it('recombines count by adding, min/max by extremum', () => { + const base = [ + { account: 'acc_w1', cnt: 3, lo: 10, hi: 7 }, + { account: 'acc_w2', cnt: 2, lo: 4, hi: 12 }, + { account: 'acc_e1', cnt: 5, lo: 1, hi: 1 }, + ]; + const dims: CrossObjectDim[] = [{ outputName: 'region', fkField: 'account', fkToAttr: fkToRegion }]; + const measures: MeasureRecombine[] = [ + { alias: 'cnt', method: 'count' }, + { alias: 'lo', method: 'min' }, + { alias: 'hi', method: 'max' }, + ]; + const out = rebucketCrossObject(base, [], dims, measures); + expect(out).toEqual([ + { region: 'West', cnt: 5, lo: 4, hi: 12 }, // 3+2, min(10,4), max(7,12) + { region: 'East', cnt: 5, lo: 1, hi: 1 }, + ]); + }); + + it('keeps a base dimension alongside the cross-object dimension', () => { + const base = [ + { stage: 'won', account: 'acc_w1', revenue: 100 }, + { stage: 'won', account: 'acc_w2', revenue: 50 }, + { stage: 'lost', account: 'acc_w1', revenue: 5 }, + ]; + const dims: CrossObjectDim[] = [{ outputName: 'region', fkField: 'account', fkToAttr: fkToRegion }]; + const out = rebucketCrossObject(base, ['stage'], dims, [{ alias: 'revenue', method: 'sum' }]); + expect(out).toEqual([ + { stage: 'won', region: 'West', revenue: 150 }, + { stage: 'lost', region: 'West', revenue: 5 }, + ]); + }); + + it('handles two cross-object dimensions', () => { + const fkToTier = new Map([['acc_w1', 'gold'], ['acc_w2', 'silver'], ['acc_e1', 'gold']]); + const base = [ + { account: 'acc_w1', revenue: 100 }, + { account: 'acc_w2', revenue: 50 }, + { account: 'acc_e1', revenue: 20 }, + ]; + const dims: CrossObjectDim[] = [ + { outputName: 'region', fkField: 'account', fkToAttr: fkToRegion }, + { outputName: 'tier', fkField: 'account', fkToAttr: fkToTier }, + ]; + const out = rebucketCrossObject(base, [], dims, [{ alias: 'revenue', method: 'sum' }]); + // (West,gold)=100, (West,silver)=50, (East,gold)=20 — no merge (all distinct pairs). + expect(out).toEqual([ + { region: 'West', tier: 'gold', revenue: 100 }, + { region: 'West', tier: 'silver', revenue: 50 }, + { region: 'East', tier: 'gold', revenue: 20 }, + ]); + }); + + it('a null attribute value is distinct from RESTRICTED', () => { + const map = new Map([['a1', null]]); // resolvable, but the attribute IS null + const base = [ + { account: 'a1', revenue: 10 }, // resolves to null region + { account: 'a2', revenue: 20 }, // unresolvable ⇒ restricted + ]; + const out = rebucketCrossObject(base, [], [{ outputName: 'region', fkField: 'account', fkToAttr: map }], [ + { alias: 'revenue', method: 'sum' }, + ]); + expect(out).toEqual([ + { region: null, revenue: 10 }, + { region: RESTRICTED_BUCKET, revenue: 20 }, + ]); + }); +}); diff --git a/packages/services/service-analytics/src/__tests__/objectql-crossobj-expand.test.ts b/packages/services/service-analytics/src/__tests__/objectql-crossobj-expand.test.ts new file mode 100644 index 000000000..27f23e9b0 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/objectql-crossobj-expand.test.ts @@ -0,0 +1,117 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #3654 capability — the ObjectQL path serves an in-envelope cross-object + * grouping (`account.region`) by FK-expand: group the base aggregate on the + * lookup FK, resolve the FK to the related attribute with a SCOPED read, and + * re-bucket in memory. A referenced record the caller cannot read buckets under + * `(restricted)` — its attribute never leaks, and the grand total is preserved. + * + * Out-of-envelope shapes (cross-object measure/filter, multi-hop, + * non-recombinable measure) stay REJECTED — see objectql-read-scope.test.ts. + */ + +import { describe, it, expect } from 'vitest'; +import { DatasetSchema } from '@objectstack/spec/ui'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import type { FilterCondition } from '@objectstack/spec/data'; +import { AnalyticsService } from '../analytics-service.js'; +import { compileDataset } from '../dataset-compiler.js'; + +const dataset = DatasetSchema.parse({ + name: 'sales_by_account', + label: 'Sales by account', + object: 'opportunity', + include: ['account'], + dimensions: [{ name: 'region', field: 'account.region', type: 'string' }], + measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount' }], +}); + +const ctxA = { tenantId: 'org_A', userId: 'u_a' } as ExecutionContext; +const objectqlOnly = () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }); + +type AggCall = { object: string; groupBy?: unknown; filter?: unknown }; + +/** + * Two-call aggregate stub: the base FK grouping on `opportunity`, then the + * FK→region resolution on the referenced object. `acc_hidden` is deliberately + * absent from the referenced result (as if RLS hid it) so the row must bucket + * under `(restricted)`. + */ +function makeService(calls: AggCall[], refObjectName: string) { + const compiled = compileDataset(dataset); + const svc = new AnalyticsService({ + cubes: [compiled.cube], + queryCapabilities: objectqlOnly, + getReadScope: (o: string): FilterCondition | undefined => + o === 'opportunity' ? { organization_id: 'org_A' } : { is_public: true }, + getAllowedRelationships: () => compiled.allowedRelationships, + executeAggregate: async (object, opts) => { + calls.push({ object, groupBy: opts.groupBy, filter: opts.filter }); + if (object === 'opportunity') { + // Base aggregate grouped by the FK column `account`. + return [ + { account: 'acc_w', revenue: 100 }, + { account: 'acc_e', revenue: 20 }, + { account: 'acc_hidden', revenue: 5 }, + ]; + } + // Referenced-object resolution: id → region (acc_hidden withheld by scope). + return [ + { id: 'acc_w', region: 'West' }, + { id: 'acc_e', region: 'East' }, + ]; + }, + }); + return { svc, compiled, refObjectName }; +} + +describe('ObjectQLStrategy — cross-object FK-expand (#3654)', () => { + it('groups by the related attribute, bucketing an unreadable ref as (restricted)', async () => { + const calls: AggCall[] = []; + const { svc } = makeService(calls, 'account'); + + const result = await svc.query( + { cube: 'sales_by_account', dimensions: ['region'], measures: ['revenue'] }, + ctxA, + ); + + // West=100, East=20, and acc_hidden (unreadable ref) → (restricted)=5. + const byRegion = Object.fromEntries(result.rows.map((r) => [r.region, r.revenue])); + expect(byRegion).toEqual({ West: 100, East: 20, '(restricted)': 5 }); + // Grand total conserved — no base row silently dropped. + expect(result.rows.reduce((s, r) => s + Number(r.revenue), 0)).toBe(125); + }); + + it('scopes BOTH the base aggregate and the FK→attribute resolution', async () => { + const calls: AggCall[] = []; + const { svc } = makeService(calls, 'account'); + await svc.query( + { cube: 'sales_by_account', dimensions: ['region'], measures: ['revenue'] }, + ctxA, + ); + + // Call 1: base grouped by the FK `account`, scoped to opportunity's tenant. + const base = calls.find((c) => c.object === 'opportunity'); + expect(base?.groupBy).toEqual(['account']); + expect(JSON.stringify(base?.filter)).toContain('organization_id'); + + // Call 2: the referenced object, grouped by (id, region), carrying ITS scope + // ANDed with the id filter — so a hidden account never yields its region. + const ref = calls.find((c) => c.object !== 'opportunity'); + expect(ref?.groupBy).toEqual(['id', 'region']); + expect(JSON.stringify(ref?.filter)).toContain('is_public'); // referenced object's own scope + expect(JSON.stringify(ref?.filter)).toContain('$in'); // restricted to the FK id set + }); + + it('renders a LEFT JOIN in the previewed SQL for the cross-object dimension', async () => { + const calls: AggCall[] = []; + const { svc } = makeService(calls, 'account'); + const { sql } = await svc.generateSql( + { cube: 'sales_by_account', dimensions: ['region'], measures: ['revenue'] }, + ctxA, + ); + expect(sql).toMatch(/LEFT JOIN "account" ON "opportunity"\."account" = "account"\."id"/); + expect(sql).toContain('"account"."region" AS "region"'); + }); +}); diff --git a/packages/services/service-analytics/src/__tests__/objectql-read-scope.test.ts b/packages/services/service-analytics/src/__tests__/objectql-read-scope.test.ts index 3aa45bed5..1ee65d4c8 100644 --- a/packages/services/service-analytics/src/__tests__/objectql-read-scope.test.ts +++ b/packages/services/service-analytics/src/__tests__/objectql-read-scope.test.ts @@ -218,53 +218,63 @@ describe('ObjectQLStrategy — read scope (ADR-0021 D-C, #3597)', () => { }); }); -describe('ObjectQLStrategy — cross-object references are fail-closed (#3654, subsumes #3597)', () => { - const joined = DatasetSchema.parse({ - name: 'sales_by_account', - label: 'Sales by account', - object: 'opportunity', - include: ['account'], - dimensions: [{ name: 'region', field: 'account.region', type: 'string' }], - measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount' }], - }); - - function makeJoinedService(scopeFor?: (o: string) => FilterCondition | undefined) { - const compiled = compileDataset(joined); - // A stub that would happily "succeed" with a garbage single-(null) bucket if - // the guard didn't fire — so a passing test proves the REJECTION, not luck. - let called = false; - const svc = new AnalyticsService({ +describe('ObjectQLStrategy — OUT-OF-ENVELOPE cross-object is fail-closed (#3654)', () => { + // In-envelope cross-object DIMENSIONS are now served by FK-expand + // (objectql-crossobj-expand.test.ts). What the engine still cannot serve — + // cross-object in a MEASURE or FILTER, multi-hop, or a non-recombinable + // measure — must fail LOUD rather than silently mis-bucket (the #3654 class). + let reached = false; + function svcFor(ds: unknown) { + const compiled = compileDataset(DatasetSchema.parse(ds)); + reached = false; + return new AnalyticsService({ cubes: [compiled.cube], queryCapabilities: objectqlOnly, - executeAggregate: async () => { called = true; return [{ 'account.region': null, revenue: 999 }]; }, - getReadScope: scopeFor ? (o: string) => scopeFor(o) : undefined, + // Would "succeed" with garbage if the guard failed to fire — so a passing + // rejection test proves the guard, not luck. + executeAggregate: async () => { reached = true; return [{ x: null, revenue: 999 }]; }, + getReadScope: (o: string) => ({ organization_id: 'org_A', _o: o }), getAllowedRelationships: () => compiled.allowedRelationships, }); - return { svc, wasExecuted: () => called }; } - const q = { cube: 'sales_by_account', dimensions: ['region'], measures: ['revenue'] }; - - it('rejects a cross-object grouping the engine cannot join — even with a joined-object scope', async () => { - const { svc, wasExecuted } = makeJoinedService(() => ({ organization_id: 'org_A' })); - await expect(svc.query(q, ctxA)).rejects.toThrow( - /cannot group or aggregate across a relationship .*"account\.region"/, - ); - expect(wasExecuted()).toBe(false); // never reached the engine + it('rejects a cross-object MEASURE (needs a real join to evaluate)', async () => { + const svc = svcFor({ + name: 'xobj_measure', label: 'X-obj measure', object: 'opportunity', include: ['account'], + dimensions: [{ name: 'stage', field: 'stage', type: 'string' }], + measures: [{ name: 'acct_rev', aggregate: 'sum', field: 'account.annual_revenue' }], + }); + await expect( + svc.query({ cube: 'xobj_measure', dimensions: ['stage'], measures: ['acct_rev'] }, ctxA), + ).rejects.toThrow(/cannot evaluate a cross-object measure .*"account\.annual_revenue"/); + expect(reached).toBe(false); }); - it('rejects when only the base object carries a scope (the query is still unjoinable)', async () => { - const { svc } = makeJoinedService((o) => (o === 'opportunity' ? { organization_id: 'org_A' } : undefined)); - await expect(svc.query(q, ctxA)).rejects.toThrow(/cannot group or aggregate across a relationship/); + it('rejects a cross-object FILTER', async () => { + const svc = svcFor({ + name: 'xobj_filter', label: 'X-obj filter', object: 'opportunity', include: ['account'], + dimensions: [{ name: 'stage', field: 'stage', type: 'string' }], + measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount' }], + }); + await expect( + svc.query( + { cube: 'xobj_filter', dimensions: ['stage'], measures: ['revenue'], where: { 'account.region': 'West' } }, + ctxA, + ), + ).rejects.toThrow(/cannot evaluate a cross-object filter/); + expect(reached).toBe(false); }); - it('rejects with NO read-scope provider at all — the #3654 silent-(null) case (security off)', async () => { - // Before #3654 this path ran unguarded (the guard early-returned without a - // getReadScope), and the member got a single mislabelled `(null)` bucket - // summing the whole table. Now it fails loud. - const { svc, wasExecuted } = makeJoinedService(/* no provider */); - await expect(svc.query(q)).rejects.toThrow(/cannot group or aggregate across a relationship/); - expect(wasExecuted()).toBe(false); + it('rejects a non-recombinable measure (avg) with a cross-object dimension', async () => { + const svc = svcFor({ + name: 'xobj_avg', label: 'X-obj avg', object: 'opportunity', include: ['account'], + dimensions: [{ name: 'region', field: 'account.region', type: 'string' }], + measures: [{ name: 'avg_amt', aggregate: 'avg', field: 'amount' }], + }); + await expect( + svc.query({ cube: 'xobj_avg', dimensions: ['region'], measures: ['avg_amt'] }, ctxA), + ).rejects.toThrow(/cannot group by a cross-object dimension with a "avg" measure/); + expect(reached).toBe(false); }); }); @@ -304,15 +314,19 @@ describe('ObjectQLStrategy.generateSql — read scope in the previewed WHERE (#3 expect(sql).not.toContain('organization_id'); }); - it('rejects a cross-object preview (no dotted-column SQL for a query execute() refuses)', async () => { + it('rejects an OUT-OF-ENVELOPE cross-object preview (cross-object measure)', async () => { + // An in-envelope cross-object DIMENSION now renders a LEFT JOIN + // (objectql-crossobj-expand.test.ts); a cross-object MEASURE still cannot be + // evaluated without a real join, so its preview rejects — /analytics/sql and + // execute() reject the same set. const compiled = compileDataset( DatasetSchema.parse({ - name: 'sql_by_account', + name: 'sql_m', label: 'x', object: 'opportunity', include: ['account'], - dimensions: [{ name: 'region', field: 'account.region', type: 'string' }], - measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount' }], + dimensions: [{ name: 'stage', field: 'stage', type: 'string' }], + measures: [{ name: 'acct_rev', aggregate: 'sum', field: 'account.annual_revenue' }], }), ); const svc = new AnalyticsService({ @@ -323,7 +337,7 @@ describe('ObjectQLStrategy.generateSql — read scope in the previewed WHERE (#3 getAllowedRelationships: () => compiled.allowedRelationships, }); await expect( - svc.generateSql({ cube: 'sql_by_account', dimensions: ['region'], measures: ['revenue'] }, ctxA), - ).rejects.toThrow(/cannot group or aggregate across a relationship/); + svc.generateSql({ cube: 'sql_m', dimensions: ['stage'], measures: ['acct_rev'] }, ctxA), + ).rejects.toThrow(/cannot evaluate a cross-object measure/); }); }); diff --git a/packages/services/service-analytics/src/strategies/cross-object-rebucket.ts b/packages/services/service-analytics/src/strategies/cross-object-rebucket.ts new file mode 100644 index 000000000..50d9fb7a9 --- /dev/null +++ b/packages/services/service-analytics/src/strategies/cross-object-rebucket.ts @@ -0,0 +1,117 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Cross-object dimension re-bucketing (#3654 capability). + * + * `engine.aggregate()` cannot join, so the ObjectQL path cannot group directly + * by a related object's attribute (`account.region`). Instead the strategy: + * 1. groups the base aggregate by the LOOKUP FK column (`account`), which the + * engine CAN do (it is a plain base column), and + * 2. resolves each FK id to the related attribute (`region`) with a SCOPED + * read of the referenced object, then + * 3. re-buckets the base aggregate by that attribute here, in memory, + * recombining the measures. + * + * This module is the pure, deterministic step (3): given the base rows, the + * FK→attribute maps, and the measures' aggregation methods, produce the rows a + * direct cross-object grouping would have. It is unit-tested in isolation + * because a wrong re-combination silently corrupts totals — exactly the class of + * bug #3654 exists to kill. + * + * A base row whose FK does not resolve (the referenced record is hidden by the + * referenced object's own RLS) buckets under {@link RESTRICTED_BUCKET}: its + * measure still counts, so grand totals are preserved, but the hidden record's + * attribute value never appears (no leak — ADR-0021 D-C, the #3602 class). + */ + +/** Only aggregation methods that re-combine across sub-buckets are supported. */ +export type RecombinableMethod = 'sum' | 'count' | 'min' | 'max'; + +export const RECOMBINABLE_METHODS: ReadonlySet = new Set([ + 'sum', + 'count', + 'min', + 'max', +]); + +/** Sentinel bucket for base rows whose referenced record the caller cannot read. */ +export const RESTRICTED_BUCKET = '(restricted)'; + +export interface CrossObjectDim { + /** Output key for the resolved attribute (the original dimension name), e.g. `region`. */ + outputName: string; + /** The base FK column the base aggregate was grouped by, e.g. `account`. */ + fkField: string; + /** `fkValue → attributeValue`. An FK absent from the map buckets as RESTRICTED. */ + fkToAttr: Map; +} + +export interface MeasureRecombine { + /** The measure's output key in each base row. */ + alias: string; + method: RecombinableMethod; +} + +/** Combine two measure values under an aggregation method (either may be undefined). */ +function recombine(method: RecombinableMethod, acc: unknown, next: unknown): unknown { + const n = Number(next ?? 0); + if (acc === undefined) return method === 'min' || method === 'max' ? Number(next ?? 0) : n; + const a = Number(acc); + switch (method) { + case 'sum': + case 'count': + return a + n; + case 'min': + return Math.min(a, n); + case 'max': + return Math.max(a, n); + } +} + +/** + * Re-bucket base aggregate rows by resolved cross-object attributes. + * + * @param baseRows rows grouped by `baseDimFields` + every `crossDims[*].fkField`. + * @param baseDimFields the NON-cross-object group keys carried through unchanged + * (base columns and date buckets), keyed as in `baseRows`. + * @param crossDims one entry per cross-object dimension (its FK→attr map). + * @param measures measure keys + their (recombinable) aggregation method. + * @returns rows keyed by `baseDimFields` + each `crossDims[*].outputName` + measures. + */ +export function rebucketCrossObject( + baseRows: Record[], + baseDimFields: string[], + crossDims: CrossObjectDim[], + measures: MeasureRecombine[], +): Record[] { + const buckets = new Map>(); + + for (const row of baseRows) { + // Resolve each cross-object FK to its attribute (or RESTRICTED). + const resolved: Record = {}; + for (const cd of crossDims) { + const fk = row[cd.fkField]; + resolved[cd.outputName] = cd.fkToAttr.has(fk) ? cd.fkToAttr.get(fk) : RESTRICTED_BUCKET; + } + + // Bucket key = base dims (unchanged) + resolved attributes. `` is a + // separator no group value contains, matching the engine's own convention. + const keyParts: string[] = []; + for (const f of baseDimFields) keyParts.push(`${f}=${String(row[f] ?? '(null)')}`); + for (const cd of crossDims) keyParts.push(`${cd.outputName}=${String(resolved[cd.outputName])}`); + const key = keyParts.join(''); + + let bucket = buckets.get(key); + if (!bucket) { + bucket = {}; + for (const f of baseDimFields) bucket[f] = row[f]; + for (const cd of crossDims) bucket[cd.outputName] = resolved[cd.outputName]; + buckets.set(key, bucket); + } + for (const m of measures) { + bucket[m.alias] = recombine(m.method, bucket[m.alias], row[m.alias]); + } + } + + return [...buckets.values()]; +} diff --git a/packages/services/service-analytics/src/strategies/objectql-strategy.ts b/packages/services/service-analytics/src/strategies/objectql-strategy.ts index df6a8d8ae..d699bf360 100644 --- a/packages/services/service-analytics/src/strategies/objectql-strategy.ts +++ b/packages/services/service-analytics/src/strategies/objectql-strategy.ts @@ -5,6 +5,29 @@ import type { Cube, FilterCondition } from '@objectstack/spec/data'; import type { AnalyticsStrategy, StrategyContext } from './types.js'; import { normalizeAnalyticsFilters, coerceFilterValueForObjectQL } from './filter-normalizer.js'; import { compileScopedFilterToSql } from '../read-scope-sql.js'; +import { + rebucketCrossObject, + RECOMBINABLE_METHODS, + type CrossObjectDim, + type MeasureRecombine, + type RecombinableMethod, +} from './cross-object-rebucket.js'; + +/** One cross-object grouping dimension planned for FK-expand (#3654). */ +interface CrossObjectPlanDim { + /** The caller's dimension name (output key), e.g. `region`. */ + outputName: string; + /** The base lookup FK column to group the base aggregate by, e.g. `account`. */ + fkField: string; + /** The related object's attribute to resolve the FK to, e.g. `region`. */ + attr: string; + /** The related object name (join target), e.g. `crm_account`. */ + refObject: string; +} + +interface CrossObjectPlan { + crossDims: CrossObjectPlanDim[]; +} /** * ObjectQLStrategy — Priority 2 @@ -80,18 +103,20 @@ export class ObjectQLStrategy implements AnalyticsStrategy { } } - // Reject cross-object grouping/aggregation this path cannot perform — the - // engine has no join, so it would silently mis-bucket or error (#3654). This - // runs BEFORE scope injection and subsumes the #3597 joined-scope concern: - // a rejected query never loads the joined object, so nothing is left - // unscoped. Independent of read scope, so it fires with security off too. - this.assertNoCrossObjectReferences(cube, query, groupBy, filter); + // #3654 — classify cross-object references. A cross-object DIMENSION within + // the supported envelope is served by an FK-expand (`executeCrossObject`); + // everything the engine cannot serve (cross-object measures/filters, + // multi-hop, non-recombinable measures) is REJECTED by `planCrossObject` — + // the engine has no join, and a silent mis-bucket is worse than a loud + // error. `null` ⇒ the query is base-only and takes the direct path below. + const plan = this.planCrossObject(cube, query, filter); + if (plan) { + return this.executeCrossObject(cube, query, aggregations, filter, plan, ctx); + } // ADR-0021 D-C — the base object's read scope (tenant + RLS) MUST be ANDed - // in before the query leaves the strategy (#3597). - // (`assertNoCrossObjectReferences` above guarantees `objectName` is the only - // object in play, so a single base-object scope is sufficient here.) - + // in before the query leaves the strategy (#3597). A base-only query has a + // single object in play, so one base-object scope is sufficient here. const rows = await ctx.executeAggregate!(objectName, { // Structured groupBy items ({field, dateGranularity}) pass through the // executeAggregate bridge to engine.aggregate, which buckets them. The @@ -173,27 +198,31 @@ export class ObjectQLStrategy implements AnalyticsStrategy { for (const td of query.timeDimensions ?? []) { if (td.granularity) granByDim.set(td.dimension, td.granularity); } + const tableName = this.extractObjectName(cube); + + // #3654 — a cross-object DIMENSION renders as a LEFT JOIN (its logical shape; + // `execute()` serves it via FK-expand). Out-of-envelope cross-object refs + // (measures/filters/multi-hop/non-recombinable) throw here too, so + // `/analytics/sql` and `execute()` accept and reject the SAME set. + const guardFilter = Object.fromEntries( + normalizeAnalyticsFilters(query).map((f) => [this.resolveFieldName(cube, f.member, 'any'), true]), + ); + const plan = this.planCrossObject(cube, query, guardFilter); + const crossByDim = new Map((plan?.crossDims ?? []).map((cd) => [cd.outputName, cd])); + const joinClauses: string[] = []; const dimExpr = (dim: string): string => { + const cd = crossByDim.get(dim); + if (cd) { + joinClauses.push( + `LEFT JOIN "${cd.refObject}" ON "${tableName}"."${cd.fkField}" = "${cd.refObject}"."id"`, + ); + return `"${cd.refObject}"."${cd.attr}"`; + } const col = this.resolveFieldName(cube, dim, 'dimension'); const gran = granByDim.get(dim); return gran ? `date_trunc('${gran}', ${col})` : col; }; - // #3654 — reject a cross-object query HERE too, so `/analytics/sql` never - // previews a dotted-column statement that `execute()` refuses to run (the - // engine cannot join in an aggregate). Reconstruct the resolved field names - // the shared guard inspects. - const guardGroupBy = [ - ...(query.dimensions ?? []).map((d) => this.resolveFieldName(cube, d, 'dimension')), - ...[...granByDim.keys()] - .filter((d) => !query.dimensions?.includes(d)) - .map((d) => this.resolveFieldName(cube, d, 'dimension')), - ]; - const guardFilter = Object.fromEntries( - normalizeAnalyticsFilters(query).map((f) => [this.resolveFieldName(cube, f.member, 'any'), true]), - ); - this.assertNoCrossObjectReferences(cube, query, guardGroupBy, guardFilter); - if (query.dimensions) { for (const dim of query.dimensions) { const expr = dimExpr(dim); @@ -221,8 +250,8 @@ export class ObjectQLStrategy implements AnalyticsStrategy { } } - const tableName = this.extractObjectName(cube); let sql = `SELECT ${selectParts.join(', ')} FROM "${tableName}"`; + if (joinClauses.length > 0) sql += ' ' + joinClauses.join(' '); const SQL_OPERATORS: Record = { equals: '=', notEquals: '<>', gt: '>', gte: '>=', lt: '<', lte: '<=', @@ -310,69 +339,215 @@ export class ObjectQLStrategy implements AnalyticsStrategy { return { $and: [userFilter, scopeFilter] }; } + /** Is `field` a resolved cross-object (relationship-traversal) reference? */ + private isCrossObjectField(cube: Cube, field: string, baseObject: string): boolean { + if (!field.includes('.')) return false; + const alias = field.split('.')[0]; + const joinedObject = cube.joins?.[alias]?.name ?? alias; + return joinedObject !== baseObject; + } + /** - * Fail-closed guard for cross-object references on the ObjectQL path - * (#3654, subsuming #3597). + * Plan how to serve cross-object references on this join-less path (#3654). * - * `engine.aggregate()` has NO join: it never expands a lookup, and the SQL - * driver's aggregate emits no `JOIN`. A dotted member like `account.region` - * therefore reaches the engine as a bare column that no table in the query - * provides. The failure is SILENT and wrong, not loud: - * - native SQL path → "column account.region does not exist" (a hard error); - * - in-memory path → `row['account.region']` is always `undefined`, so every - * row collapses into ONE `(null)` bucket and the measure is summed across - * the whole table — a plausible-looking number that is actually a - * full-table total mislabelled `(null)`. + * `engine.aggregate()` cannot join. Rather than the old blanket rejection, + * a cross-object DIMENSION within a supported envelope is served by an + * FK-expand (`executeCrossObject`): group the base aggregate on the lookup FK, + * resolve the FK to the related attribute with a SCOPED read, re-bucket in + * memory. Returns `null` for a base-only query (direct path), a plan for an + * in-envelope cross-object query. * - * So we reject any cross-object reference OUTRIGHT — regardless of read scope. - * This also subsumes the #3597 concern: because the joined object is never - * loaded on this path, there is nothing to leave unscoped. Cross-object - * datasets are served by `NativeSQLStrategy`, which hand-compiles the LEFT - * JOINs (and scopes each one); this path is the fallback NativeSQL declines - * (date-granularity bucketing, in-memory driver, federated objects), and it - * cannot join, so "loud rejection" beats "silent wrong answer". + * THROWS for anything outside the envelope — a cross-object MEASURE or FILTER + * (needs a real join to evaluate), a MULTI-HOP dimension (`a.b.c`), or a + * non-recombinable measure (`avg`/`count_distinct`, whose sub-bucket values + * cannot be merged). A loud error beats the silent mis-bucket #3654 kills. * - * Detection is on the RESOLVED field names (post-`resolveFieldName`), so a - * dotted dimension the cube flattens to a real column is NOT flagged — only - * genuinely-unresolved relationship traversals are. + * Detection is on RESOLVED field names, so a dotted dimension the cube + * flattens to a real column is treated as base, not cross-object. */ - private assertNoCrossObjectReferences( + private planCrossObject( cube: Cube, query: AnalyticsQuery, - groupBy: Array, filter: Record, - ): void { - // Field names as they will reach the engine. A dotted name whose first - // segment names a DIFFERENT object is a relationship traversal the engine - // cannot perform in an aggregate. - const referenced = [ - ...groupBy.map((g) => (typeof g === 'string' ? g : g.field)), - ...Object.keys(filter), - ...(query.measures ?? []).map((m) => this.resolveMeasureAggregation(cube, m).field), - ]; + ): CrossObjectPlan | null { + const baseObject = this.extractObjectName(cube); + // A cross-object MEASURE or FILTER can only be evaluated with a real join. + const nonDim = [ + ...(query.measures ?? []).map((m) => ({ where: 'measure', field: this.resolveMeasureAggregation(cube, m).field })), + ...Object.keys(filter).map((f) => ({ where: 'filter', field: f })), + ].filter((r) => this.isCrossObjectField(cube, r.field, baseObject)); + if (nonDim.length > 0) { + throw new Error( + `[Analytics] ObjectQLStrategy cannot evaluate a cross-object ${nonDim[0].where} ` + + `("${nonDim[0].field}") — the engine cannot join in an aggregate. Run this ` + + `query on a native-SQL driver, or remove the cross-object ${nonDim[0].where}.`, + ); + } + + // Collect cross-object DIMENSIONS (single-hop only). + const crossDims: CrossObjectPlanDim[] = []; + for (const dim of query.dimensions ?? []) { + const field = this.resolveFieldName(cube, dim, 'dimension'); + if (!this.isCrossObjectField(cube, field, baseObject)) continue; + const [alias, ...rest] = field.split('.'); + const attr = rest.join('.'); + if (attr.includes('.')) { + throw new Error( + `[Analytics] ObjectQLStrategy supports only single-hop cross-object ` + + `dimensions; "${field}" traverses more than one relationship.`, + ); + } + crossDims.push({ outputName: dim, fkField: alias, attr, refObject: cube.joins?.[alias]?.name ?? alias }); + } + // A date bucket over a related object's field is not supported. + for (const td of query.timeDimensions ?? []) { + const field = this.resolveFieldName(cube, td.dimension, 'dimension'); + if (this.isCrossObjectField(cube, field, baseObject)) { + throw new Error( + `[Analytics] ObjectQLStrategy cannot bucket a cross-object time dimension ("${field}").`, + ); + } + } + + if (crossDims.length === 0) return null; + + // Every measure must re-combine across the intermediate FK sub-buckets. + for (const m of query.measures ?? []) { + const { method } = this.resolveMeasureAggregation(cube, m); + if (!RECOMBINABLE_METHODS.has(method)) { + throw new Error( + `[Analytics] ObjectQLStrategy cannot group by a cross-object dimension ` + + `with a "${method}" measure ("${m}") — its value cannot be recombined ` + + `across the intermediate FK grouping. Use sum/count/min/max, or run on ` + + `a native-SQL driver.`, + ); + } + } + + return { crossDims }; + } + + /** + * Serve a cross-object-dimension query by FK-expand (#3654). The pure + * re-bucketing step lives in `cross-object-rebucket.ts`. + */ + private async executeCrossObject( + cube: Cube, + query: AnalyticsQuery, + aggregations: Array<{ field: string; method: string; alias: string }>, + filter: Record, + plan: CrossObjectPlan, + ctx: StrategyContext, + ): Promise { const baseObject = this.extractObjectName(cube); - const offending = new Set(); - for (const fieldName of referenced) { - if (!fieldName.includes('.')) continue; - const alias = fieldName.split('.')[0]; - const joinedObject = cube.joins?.[alias]?.name ?? alias; - if (joinedObject === baseObject) continue; - offending.add(fieldName); - } - if (offending.size === 0) return; - - throw new Error( - `[Analytics] ObjectQLStrategy cannot group or aggregate across a ` + - `relationship (cross-object reference(s): ` + - `${[...offending].map((f) => `"${f}"`).join(', ')}). ` + - `engine.aggregate() does not join, so the referenced object is never ` + - `loaded — this path would silently bucket every row under a single ` + - `"(null)" group (or error outright on a SQL driver), and could not scope ` + - `the joined object's rows. Run this query where NativeSQLStrategy handles ` + - `it (a raw-SQL/JOIN-capable driver with no date-granularity bucketing), or ` + - `drop the cross-object dimension/measure from the query.`, - ); + const crossByDim = new Map(plan.crossDims.map((cd) => [cd.outputName, cd])); + + // Rewrite group-by: a cross-object dim becomes its base FK column; base and + // time dims pass through. `baseDimFields` are the group keys carried into + // the re-bucket verbatim (the FK columns are replaced by resolved attrs). + type GroupByItem = string | { field: string; dateGranularity: string }; + const granByDim = new Map(); + for (const td of query.timeDimensions ?? []) { + if (td.granularity) granByDim.set(td.dimension, td.granularity); + } + const groupBy: GroupByItem[] = []; + const baseDimFields: string[] = []; + for (const dim of query.dimensions ?? []) { + const cd = crossByDim.get(dim); + if (cd) { + groupBy.push(cd.fkField); + continue; + } + const field = this.resolveFieldName(cube, dim, 'dimension'); + const gran = granByDim.get(dim); + groupBy.push(gran ? { field, dateGranularity: gran } : field); + baseDimFields.push(field); + granByDim.delete(dim); + } + for (const [dim, gran] of granByDim) { + const field = this.resolveFieldName(cube, dim, 'dimension'); + groupBy.push({ field, dateGranularity: gran }); + baseDimFields.push(field); + } + + // Base aggregate, grouped by the FK, scoped to the base object. + const baseRows = await ctx.executeAggregate!(baseObject, { + groupBy: groupBy.length > 0 ? (groupBy as unknown as string[]) : undefined, + aggregations: aggregations.length > 0 ? aggregations : undefined, + filter: this.withReadScope(baseObject, filter, ctx), + timezone: query.timezone, + }); + + // Resolve each cross-object dim's FK → attribute, SCOPED to the referenced + // object: a related record the caller cannot read never yields its + // attribute, so it buckets as RESTRICTED (no leak; ADR-0021 D-C / #3602). + const resolvedDims: CrossObjectDim[] = []; + for (const cd of plan.crossDims) { + const fkValues = [...new Set(baseRows.map((r) => r[cd.fkField]).filter((v) => v != null))]; + const fkToAttr = await this.resolveFkAttr(cd.refObject, cd.attr, fkValues, ctx); + resolvedDims.push({ outputName: cd.outputName, fkField: cd.fkField, fkToAttr }); + } + + const measures: MeasureRecombine[] = (query.measures ?? []).map((m) => ({ + alias: m, + // planCrossObject already asserted every measure is recombinable. + method: this.resolveMeasureAggregation(cube, m).method as RecombinableMethod, + })); + + const merged = rebucketCrossObject(baseRows, baseDimFields, resolvedDims, measures); + + // Map resolved group keys back to the caller's dimension names. + const mappedRows = merged.map((row) => { + const out: Record = {}; + for (const dim of query.dimensions ?? []) { + if (crossByDim.has(dim)) { + if (dim in row) out[dim] = row[dim]; + } else { + const field = this.resolveFieldName(cube, dim, 'dimension'); + if (field in row) out[dim] = row[field]; + } + } + for (const td of query.timeDimensions ?? []) { + if (query.dimensions?.includes(td.dimension)) continue; + const field = this.resolveFieldName(cube, td.dimension, 'dimension'); + if (field in row) out[td.dimension] = row[field]; + } + for (const m of query.measures ?? []) { + if (m in row) out[m] = row[m]; + } + return out; + }); + + return { rows: mappedRows, fields: this.buildFieldMeta(query, cube) }; + } + + /** + * Resolve `fkValues` (ids of `refObject`) to their `attr` values, applying the + * referenced object's OWN read scope (#3654 / #3602). Reuses the aggregate + * bridge — `group by (id, attr)` is one row per record. Ids the scope hides + * are simply absent from the map (⇒ RESTRICTED bucket downstream). + */ + private async resolveFkAttr( + refObject: string, + attr: string, + fkValues: unknown[], + ctx: StrategyContext, + ): Promise> { + const map = new Map(); + if (fkValues.length === 0 || typeof ctx.executeAggregate !== 'function') return map; + const idFilter: Record = { id: { $in: fkValues } }; + const scope = typeof ctx.getReadScope === 'function' ? ctx.getReadScope(refObject) : null; + const filter = scope != null ? { $and: [idFilter, scope] } : idFilter; + const rows = await ctx.executeAggregate(refObject, { + groupBy: ['id', attr], + aggregations: [{ field: 'id', method: 'count', alias: '_c' }], + filter, + }); + for (const r of rows) { + if (r.id != null) map.set(r.id, r[attr]); + } + return map; } /**