Skip to content

Commit fc5f126

Browse files
os-zhuangclaude
andauthored
feat(analytics): serve in-envelope cross-object grouping by FK-expand (#3654) (#3691)
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 outright (#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 (and, per #3651's second belt, threading the ExecutionContext to the engine too); 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, so preview and execution accept/reject the same set. 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 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 FK-resolution are scoped and the restricted bucket forms; reverting the strategy turns all three red. Re-applied cleanly on top of #3651/#3652 (executeAggregate carries context; generateSql renders scope) after those landed on main. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5487c20 commit fc5f126

7 files changed

Lines changed: 691 additions & 126 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@objectstack/service-analytics": minor
3+
---
4+
5+
feat(analytics): serve in-envelope cross-object grouping on the ObjectQL path by FK-expand (#3654)
6+
7+
`engine.aggregate()` cannot join, so the ObjectQL fallback path (date-granularity
8+
bucketing, in-memory driver, federated objects) previously REJECTED any
9+
cross-object grouping like `revenue by account.region` (#3664 stopgap — a loud
10+
error instead of the earlier silent `(null)` mis-bucket). It now SERVES the
11+
common case directly.
12+
13+
For a single-hop cross-object DIMENSION with recombinable measures, the strategy:
14+
1. groups the base aggregate on the lookup FK column (`account`) — which the
15+
engine can do — scoped to the base object;
16+
2. resolves each FK id to the related attribute (`region`) with a read of the
17+
referenced object **scoped to that object's own RLS**; then
18+
3. re-buckets by the resolved attribute in memory, recombining the measures
19+
(sum/count add; min/max take the extremum).
20+
21+
A base row whose referenced record the caller cannot read buckets under an
22+
explicit `(restricted)` group: its measure still counts (grand totals are
23+
preserved) but the hidden record's attribute never appears — no leak (ADR-0021
24+
D-C, the #3602 class). `/analytics/sql` renders the equivalent `LEFT JOIN`.
25+
26+
Deliberately bounded — still REJECTED (loud, never silently wrong): cross-object
27+
references in a MEASURE or FILTER (need a real join to evaluate), multi-hop
28+
dimensions (`a.b.c`), and non-recombinable measures (`avg`, `count_distinct`)
29+
with a cross-object dimension. Cross-object queries on `NativeSQLStrategy` (the
30+
normal SQL path) are unchanged — it hand-compiles the joins.
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import {
5+
rebucketCrossObject,
6+
RESTRICTED_BUCKET,
7+
type CrossObjectDim,
8+
type MeasureRecombine,
9+
} from '../strategies/cross-object-rebucket.js';
10+
11+
const fkToRegion = new Map<unknown, unknown>([
12+
['acc_w1', 'West'],
13+
['acc_w2', 'West'],
14+
['acc_e1', 'East'],
15+
]);
16+
17+
describe('rebucketCrossObject (#3654)', () => {
18+
it('merges FK sub-buckets that resolve to the same attribute (sum)', () => {
19+
const base = [
20+
{ account: 'acc_w1', revenue: 100 },
21+
{ account: 'acc_w2', revenue: 50 },
22+
{ account: 'acc_e1', revenue: 20 },
23+
];
24+
const dims: CrossObjectDim[] = [{ outputName: 'region', fkField: 'account', fkToAttr: fkToRegion }];
25+
const measures: MeasureRecombine[] = [{ alias: 'revenue', method: 'sum' }];
26+
27+
const out = rebucketCrossObject(base, [], dims, measures);
28+
// West = 100 + 50, East = 20 — the two West accounts collapse into one region.
29+
expect(out).toEqual([
30+
{ region: 'West', revenue: 150 },
31+
{ region: 'East', revenue: 20 },
32+
]);
33+
});
34+
35+
it('buckets an unreadable FK under RESTRICTED, preserving the grand total', () => {
36+
const base = [
37+
{ account: 'acc_w1', revenue: 100 },
38+
{ account: 'acc_hidden', revenue: 999 }, // not in the map ⇒ referenced record hidden
39+
];
40+
const dims: CrossObjectDim[] = [{ outputName: 'region', fkField: 'account', fkToAttr: fkToRegion }];
41+
const out = rebucketCrossObject(base, [], dims, [{ alias: 'revenue', method: 'sum' }]);
42+
43+
expect(out).toEqual([
44+
{ region: 'West', revenue: 100 },
45+
{ region: RESTRICTED_BUCKET, revenue: 999 },
46+
]);
47+
// Grand total is conserved (100 + 999) — no row silently dropped.
48+
expect(out.reduce((s, r) => s + Number(r.revenue), 0)).toBe(1099);
49+
// The hidden account's attribute value never appears.
50+
expect(out.map((r) => r.region)).not.toContain('acc_hidden');
51+
});
52+
53+
it('recombines count by adding, min/max by extremum', () => {
54+
const base = [
55+
{ account: 'acc_w1', cnt: 3, lo: 10, hi: 7 },
56+
{ account: 'acc_w2', cnt: 2, lo: 4, hi: 12 },
57+
{ account: 'acc_e1', cnt: 5, lo: 1, hi: 1 },
58+
];
59+
const dims: CrossObjectDim[] = [{ outputName: 'region', fkField: 'account', fkToAttr: fkToRegion }];
60+
const measures: MeasureRecombine[] = [
61+
{ alias: 'cnt', method: 'count' },
62+
{ alias: 'lo', method: 'min' },
63+
{ alias: 'hi', method: 'max' },
64+
];
65+
const out = rebucketCrossObject(base, [], dims, measures);
66+
expect(out).toEqual([
67+
{ region: 'West', cnt: 5, lo: 4, hi: 12 }, // 3+2, min(10,4), max(7,12)
68+
{ region: 'East', cnt: 5, lo: 1, hi: 1 },
69+
]);
70+
});
71+
72+
it('keeps a base dimension alongside the cross-object dimension', () => {
73+
const base = [
74+
{ stage: 'won', account: 'acc_w1', revenue: 100 },
75+
{ stage: 'won', account: 'acc_w2', revenue: 50 },
76+
{ stage: 'lost', account: 'acc_w1', revenue: 5 },
77+
];
78+
const dims: CrossObjectDim[] = [{ outputName: 'region', fkField: 'account', fkToAttr: fkToRegion }];
79+
const out = rebucketCrossObject(base, ['stage'], dims, [{ alias: 'revenue', method: 'sum' }]);
80+
expect(out).toEqual([
81+
{ stage: 'won', region: 'West', revenue: 150 },
82+
{ stage: 'lost', region: 'West', revenue: 5 },
83+
]);
84+
});
85+
86+
it('handles two cross-object dimensions', () => {
87+
const fkToTier = new Map<unknown, unknown>([['acc_w1', 'gold'], ['acc_w2', 'silver'], ['acc_e1', 'gold']]);
88+
const base = [
89+
{ account: 'acc_w1', revenue: 100 },
90+
{ account: 'acc_w2', revenue: 50 },
91+
{ account: 'acc_e1', revenue: 20 },
92+
];
93+
const dims: CrossObjectDim[] = [
94+
{ outputName: 'region', fkField: 'account', fkToAttr: fkToRegion },
95+
{ outputName: 'tier', fkField: 'account', fkToAttr: fkToTier },
96+
];
97+
const out = rebucketCrossObject(base, [], dims, [{ alias: 'revenue', method: 'sum' }]);
98+
// (West,gold)=100, (West,silver)=50, (East,gold)=20 — no merge (all distinct pairs).
99+
expect(out).toEqual([
100+
{ region: 'West', tier: 'gold', revenue: 100 },
101+
{ region: 'West', tier: 'silver', revenue: 50 },
102+
{ region: 'East', tier: 'gold', revenue: 20 },
103+
]);
104+
});
105+
106+
it('a null attribute value is distinct from RESTRICTED', () => {
107+
const map = new Map<unknown, unknown>([['a1', null]]); // resolvable, but the attribute IS null
108+
const base = [
109+
{ account: 'a1', revenue: 10 }, // resolves to null region
110+
{ account: 'a2', revenue: 20 }, // unresolvable ⇒ restricted
111+
];
112+
const out = rebucketCrossObject(base, [], [{ outputName: 'region', fkField: 'account', fkToAttr: map }], [
113+
{ alias: 'revenue', method: 'sum' },
114+
]);
115+
expect(out).toEqual([
116+
{ region: null, revenue: 10 },
117+
{ region: RESTRICTED_BUCKET, revenue: 20 },
118+
]);
119+
});
120+
});

packages/services/service-analytics/src/__tests__/execution-context-bridge.test.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -304,19 +304,20 @@ describe('ObjectQLStrategy.generateSql reflects the executed query (#3602 item 3
304304
expect(params).toEqual(['West', 'East', 10]);
305305
});
306306

307-
it('renders nothing for a cross-object query `execute()` would reject', async () => {
308-
// The rendering must not describe a query that cannot run. #3654 made the
309-
// cross-object guard unconditional in `execute()`; running the SAME guard
310-
// here is what keeps the two in step — a rendering that outlives its
311-
// execution path is the mismatch this item exists to remove.
307+
it('renders nothing for an OUT-OF-ENVELOPE cross-object query `execute()` would reject', async () => {
308+
// The rendering must not describe a query that cannot run. #3654 now SERVES
309+
// in-envelope cross-object DIMENSIONS by FK-expand (generateSql renders the
310+
// equivalent LEFT JOIN — see objectql-crossobj-expand.test.ts). What execute()
311+
// still rejects — a cross-object MEASURE — generateSql must reject too, so the
312+
// preview and the executed path stay in step over the same set.
312313
const compiled = compileDataset(
313314
DatasetSchema.parse({
314315
name: 'sales_by_account',
315316
label: 'Sales by account',
316317
object: 'opportunity',
317318
include: ['account'],
318-
dimensions: [{ name: 'region', field: 'account.region', type: 'string' }],
319-
measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount' }],
319+
dimensions: [{ name: 'stage', field: 'stage', type: 'string' }],
320+
measures: [{ name: 'acct_rev', aggregate: 'sum', field: 'account.annual_revenue' }],
320321
}),
321322
);
322323
const service = new AnalyticsService({
@@ -328,7 +329,7 @@ describe('ObjectQLStrategy.generateSql reflects the executed query (#3602 item 3
328329
});
329330

330331
await expect(
331-
service.generateSql({ cube: 'sales_by_account', dimensions: ['region'], measures: ['revenue'] }, ctxA),
332-
).rejects.toThrow(/cannot group or aggregate across a relationship .*"account\.region"/);
332+
service.generateSql({ cube: 'sales_by_account', dimensions: ['stage'], measures: ['acct_rev'] }, ctxA),
333+
).rejects.toThrow(/cannot evaluate a cross-object measure/);
333334
});
334335
});
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #3654 capability — the ObjectQL path serves an in-envelope cross-object
5+
* grouping (`account.region`) by FK-expand: group the base aggregate on the
6+
* lookup FK, resolve the FK to the related attribute with a SCOPED read, and
7+
* re-bucket in memory. A referenced record the caller cannot read buckets under
8+
* `(restricted)` — its attribute never leaks, and the grand total is preserved.
9+
*
10+
* Out-of-envelope shapes (cross-object measure/filter, multi-hop,
11+
* non-recombinable measure) stay REJECTED — see objectql-read-scope.test.ts.
12+
*/
13+
14+
import { describe, it, expect } from 'vitest';
15+
import { DatasetSchema } from '@objectstack/spec/ui';
16+
import type { ExecutionContext } from '@objectstack/spec/kernel';
17+
import type { FilterCondition } from '@objectstack/spec/data';
18+
import { AnalyticsService } from '../analytics-service.js';
19+
import { compileDataset } from '../dataset-compiler.js';
20+
21+
const dataset = DatasetSchema.parse({
22+
name: 'sales_by_account',
23+
label: 'Sales by account',
24+
object: 'opportunity',
25+
include: ['account'],
26+
dimensions: [{ name: 'region', field: 'account.region', type: 'string' }],
27+
measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount' }],
28+
});
29+
30+
const ctxA = { tenantId: 'org_A', userId: 'u_a' } as ExecutionContext;
31+
const objectqlOnly = () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false });
32+
33+
type AggCall = { object: string; groupBy?: unknown; filter?: unknown };
34+
35+
/**
36+
* Two-call aggregate stub: the base FK grouping on `opportunity`, then the
37+
* FK→region resolution on the referenced object. `acc_hidden` is deliberately
38+
* absent from the referenced result (as if RLS hid it) so the row must bucket
39+
* under `(restricted)`.
40+
*/
41+
function makeService(calls: AggCall[], refObjectName: string) {
42+
const compiled = compileDataset(dataset);
43+
const svc = new AnalyticsService({
44+
cubes: [compiled.cube],
45+
queryCapabilities: objectqlOnly,
46+
getReadScope: (o: string): FilterCondition | undefined =>
47+
o === 'opportunity' ? { organization_id: 'org_A' } : { is_public: true },
48+
getAllowedRelationships: () => compiled.allowedRelationships,
49+
executeAggregate: async (object, opts) => {
50+
calls.push({ object, groupBy: opts.groupBy, filter: opts.filter });
51+
if (object === 'opportunity') {
52+
// Base aggregate grouped by the FK column `account`.
53+
return [
54+
{ account: 'acc_w', revenue: 100 },
55+
{ account: 'acc_e', revenue: 20 },
56+
{ account: 'acc_hidden', revenue: 5 },
57+
];
58+
}
59+
// Referenced-object resolution: id → region (acc_hidden withheld by scope).
60+
return [
61+
{ id: 'acc_w', region: 'West' },
62+
{ id: 'acc_e', region: 'East' },
63+
];
64+
},
65+
});
66+
return { svc, compiled, refObjectName };
67+
}
68+
69+
describe('ObjectQLStrategy — cross-object FK-expand (#3654)', () => {
70+
it('groups by the related attribute, bucketing an unreadable ref as (restricted)', async () => {
71+
const calls: AggCall[] = [];
72+
const { svc } = makeService(calls, 'account');
73+
74+
const result = await svc.query(
75+
{ cube: 'sales_by_account', dimensions: ['region'], measures: ['revenue'] },
76+
ctxA,
77+
);
78+
79+
// West=100, East=20, and acc_hidden (unreadable ref) → (restricted)=5.
80+
const byRegion = Object.fromEntries(result.rows.map((r) => [r.region, r.revenue]));
81+
expect(byRegion).toEqual({ West: 100, East: 20, '(restricted)': 5 });
82+
// Grand total conserved — no base row silently dropped.
83+
expect(result.rows.reduce((s, r) => s + Number(r.revenue), 0)).toBe(125);
84+
});
85+
86+
it('scopes BOTH the base aggregate and the FK→attribute resolution', async () => {
87+
const calls: AggCall[] = [];
88+
const { svc } = makeService(calls, 'account');
89+
await svc.query(
90+
{ cube: 'sales_by_account', dimensions: ['region'], measures: ['revenue'] },
91+
ctxA,
92+
);
93+
94+
// Call 1: base grouped by the FK `account`, scoped to opportunity's tenant.
95+
const base = calls.find((c) => c.object === 'opportunity');
96+
expect(base?.groupBy).toEqual(['account']);
97+
expect(JSON.stringify(base?.filter)).toContain('organization_id');
98+
99+
// Call 2: the referenced object, grouped by (id, region), carrying ITS scope
100+
// ANDed with the id filter — so a hidden account never yields its region.
101+
const ref = calls.find((c) => c.object !== 'opportunity');
102+
expect(ref?.groupBy).toEqual(['id', 'region']);
103+
expect(JSON.stringify(ref?.filter)).toContain('is_public'); // referenced object's own scope
104+
expect(JSON.stringify(ref?.filter)).toContain('$in'); // restricted to the FK id set
105+
});
106+
107+
it('renders a LEFT JOIN in the previewed SQL for the cross-object dimension', async () => {
108+
const calls: AggCall[] = [];
109+
const { svc } = makeService(calls, 'account');
110+
const { sql } = await svc.generateSql(
111+
{ cube: 'sales_by_account', dimensions: ['region'], measures: ['revenue'] },
112+
ctxA,
113+
);
114+
expect(sql).toMatch(/LEFT JOIN "account" ON "opportunity"\."account" = "account"\."id"/);
115+
expect(sql).toContain('"account"."region" AS "region"');
116+
});
117+
});

0 commit comments

Comments
 (0)