Skip to content

Commit 5eef4cf

Browse files
os-zhuangclaude
andauthored
feat(analytics): multi-hop relationship joins for datasets (ADR-0071) (#2296)
Datasets can now group/aggregate by a field two or three to-one relationships away (`account.owner.region`), not just one. Implements ADR-0071's remaining gap (matrix/across was already shipped). - spec: `include` accepts dotted relationship PATHS (≤3 hops, refined at parse); dimension/measure `field` may be a multi-hop path. To-many is out of scope. - compiler: resolve each path hop-by-hop into one `cube.join` per prefix; the join alias is the path with dots → `__` (a single valid identifier — quoted dotted identifiers are rejected by the read-scope SQL guard). `RelationshipResolver` may now return `{ object, table }` to chain (string return stays back-compat). Declaring `a.b` auto-adds the intermediate `a`. allowlist = every prefix alias. - strategy: `qualifyAndRegisterJoin` registers the chain (parent→child) and the deepest alias qualifies the column; `resolveStorageTarget` resolves the owning object at the full path. Per-hop RLS comes for free (the scope loop iterates every join alias). - to-one only ⇒ no fan-out ⇒ aggregates correct with no symmetric aggregates. - single-hop unchanged (alias no-op); 27 new/updated tests; 164 analytics tests green; spec api-surface unchanged. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f1ddc98 commit 5eef4cf

7 files changed

Lines changed: 359 additions & 79 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/service-analytics': minor
4+
---
5+
6+
feat(analytics): multi-hop relationship joins for datasets (ADR-0071)
7+
8+
A dataset's `include` and dimension/measure `field` paths may now traverse up to
9+
3 to-one relationship hops (`account.owner.region`), not just one. The compiler
10+
expands each declared path into the ordered join chain (one `cube.join` per path
11+
prefix, aliased dot-free as `account__owner` so it stays a single valid SQL
12+
identifier), and the NativeSQLStrategy emits the chained `LEFT JOIN`s. Per-hop
13+
tenant/RLS read-scope is enforced for EVERY object in the chain — the
14+
alias-driven scope loop already generalizes, so no security path is rewritten.
15+
16+
Restricted to **to-one** (lookup / master_detail) relationships, which never fan
17+
out — aggregates stay correct with no symmetric-aggregate machinery; to-many
18+
traversal is out of scope. Single-hop datasets are byte-for-byte unchanged (the
19+
dot-free alias is a no-op for a single segment). Undeclared paths are still
20+
rejected (ADR-0021 D-C); paths beyond 3 hops are rejected at both parse and
21+
compile time.

packages/services/service-analytics/src/__tests__/dataset-compiler.test.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,3 +109,111 @@ describe('compileDataset', () => {
109109
expect(() => compileDataset(ds)).toThrowError(/not supported by the v1 dataset runtime/);
110110
});
111111
});
112+
113+
describe('compileDataset — multi-hop joins (ADR-0071)', () => {
114+
/** opportunity → account (crm_account) → owner (core_user). All to-one. */
115+
const chainResolver = (obj: string, rel: string) => {
116+
const graph: Record<string, Record<string, { object: string; table: string }>> = {
117+
opportunity: { account: { object: 'account', table: 'crm_account' } },
118+
account: { owner: { object: 'user', table: 'core_user' } },
119+
};
120+
return graph[obj]?.[rel];
121+
};
122+
123+
const twoHop = () =>
124+
DatasetSchema.parse({
125+
name: 'sales_by_owner_region',
126+
label: 'Sales by owner region',
127+
object: 'opportunity',
128+
include: ['account', 'account.owner'],
129+
dimensions: [
130+
{ name: 'owner_region', label: 'Owner Region', field: 'account.owner.region', type: 'string' },
131+
],
132+
measures: [{ name: 'revenue', label: 'Revenue', aggregate: 'sum', field: 'amount' }],
133+
});
134+
135+
it('emits one join per path prefix, keyed by the full dotted path', () => {
136+
const { cube } = compileDataset(twoHop(), chainResolver);
137+
expect(cube.joins?.['account']?.name).toBe('crm_account');
138+
expect(cube.joins?.['account__owner']?.name).toBe('core_user');
139+
// The deepest dimension keeps its full dotted sql for the strategy.
140+
expect(cube.dimensions.owner_region.sql).toBe('account.owner.region');
141+
});
142+
143+
it('allowlist is every prefix alias (declared path + intermediates)', () => {
144+
const { allowedRelationships } = compileDataset(twoHop(), chainResolver);
145+
expect(allowedRelationships.has('account')).toBe(true);
146+
expect(allowedRelationships.has('account__owner')).toBe(true);
147+
expect(allowedRelationships.size).toBe(2);
148+
});
149+
150+
it('auto-includes the intermediate hop when only the deep path is declared', () => {
151+
const ds = DatasetSchema.parse({
152+
name: 'deep_only',
153+
label: 'Deep only',
154+
object: 'opportunity',
155+
include: ['account.owner'], // intermediate `account` NOT explicitly declared
156+
dimensions: [{ name: 'owner_region', field: 'account.owner.region', type: 'string' }],
157+
measures: [{ name: 'cnt', aggregate: 'count' }],
158+
});
159+
const { cube, allowedRelationships } = compileDataset(ds, chainResolver);
160+
expect(cube.joins?.['account']?.name).toBe('crm_account'); // auto-added intermediate
161+
expect(cube.joins?.['account__owner']?.name).toBe('core_user');
162+
expect(allowedRelationships.size).toBe(2);
163+
});
164+
165+
it('rejects an include path beyond the 3-hop cap at parse time (spec refine)', () => {
166+
expect(() =>
167+
DatasetSchema.parse({
168+
name: 'too_deep',
169+
label: 'Too deep',
170+
object: 'opportunity',
171+
include: ['a.b.c.d'], // 4 hops
172+
dimensions: [{ name: 'deep_name', field: 'a.b.c.d.name' }],
173+
measures: [{ name: 'cnt', aggregate: 'count' }],
174+
}),
175+
).toThrowError(/3-hop limit/);
176+
});
177+
178+
it('the compiler also rejects an over-deep include path (defense in depth)', () => {
179+
// Bypass the spec refine to exercise the compiler's OWN guard (for datasets
180+
// built programmatically, not parsed through the schema).
181+
const raw = {
182+
name: 'too_deep',
183+
label: 'Too deep',
184+
object: 'opportunity',
185+
include: ['a.b.c.d'],
186+
dimensions: [{ name: 'deep_name', label: 'Deep', type: 'string', field: 'a.b.c.d.name' }],
187+
measures: [{ name: 'cnt', label: 'Count', aggregate: 'count' }],
188+
} as unknown as Parameters<typeof compileDataset>[0];
189+
expect(() => compileDataset(raw)).toThrowError(/exceeds the 3-hop limit/);
190+
});
191+
192+
it('rejects a deep field whose relationship path was not declared (D-C)', () => {
193+
const bad = DatasetSchema.parse({
194+
name: 'bad_deep',
195+
label: 'Bad deep',
196+
object: 'opportunity',
197+
include: ['account'], // declared `account` but NOT `account.owner`
198+
dimensions: [{ name: 'owner_region', field: 'account.owner.region' }],
199+
measures: [{ name: 'cnt', aggregate: 'count' }],
200+
});
201+
expect(() => compileDataset(bad, chainResolver)).toThrowError(/relationship path "account.owner".*not declared/s);
202+
});
203+
204+
it('accepts a resolver that returns a bare table string (object assumed equal)', () => {
205+
const ds = DatasetSchema.parse({
206+
name: 'str_resolver',
207+
label: 'String resolver',
208+
object: 'opportunity',
209+
include: ['account.owner'],
210+
dimensions: [{ name: 'owner_region', field: 'account.owner.region' }],
211+
measures: [{ name: 'cnt', aggregate: 'count' }],
212+
});
213+
// Legacy string-returning resolver: object name == table name at each hop.
214+
const stringResolver = (_obj: string, rel: string) => rel;
215+
const { cube } = compileDataset(ds, stringResolver);
216+
expect(cube.joins?.['account']?.name).toBe('account');
217+
expect(cube.joins?.['account__owner']?.name).toBe('owner');
218+
});
219+
});

packages/services/service-analytics/src/__tests__/native-sql-rls.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,3 +156,67 @@ describe('NativeSQLStrategy — base-column qualification under joins', () => {
156156
expect(sql).not.toContain('"task"."status"');
157157
});
158158
});
159+
160+
describe('NativeSQLStrategy — multi-hop joins (ADR-0071)', () => {
161+
// opportunity → account (crm_account) → owner (core_user); dimension two hops deep.
162+
const mhCube: Cube = {
163+
name: 'sales',
164+
title: 'Sales',
165+
sql: 'opportunity',
166+
measures: { revenue: { name: 'revenue', label: 'Revenue', type: 'sum', sql: 'amount' } },
167+
dimensions: {
168+
owner_region: { name: 'owner_region', label: 'Owner Region', type: 'string', sql: 'account.owner.region' },
169+
},
170+
joins: {
171+
account: { name: 'crm_account', relationship: 'many_to_one', sql: 'opportunity.account = account.id' },
172+
'account__owner': { name: 'core_user', relationship: 'many_to_one', sql: 'account.owner = account__owner.id' },
173+
},
174+
public: false,
175+
};
176+
const mhQuery: AnalyticsQuery = {
177+
cube: 'sales',
178+
measures: ['revenue'],
179+
dimensions: ['owner_region'],
180+
timezone: 'UTC',
181+
};
182+
const mhCtx = (overrides: Partial<StrategyContext> = {}): StrategyContext => ({
183+
getCube: (n) => (n === 'sales' ? mhCube : undefined),
184+
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }),
185+
executeRawSql: async () => [],
186+
getAllowedRelationships: () => new Set(['account', 'account__owner']),
187+
...overrides,
188+
});
189+
190+
it('chains a LEFT JOIN per hop, each aliased by its full path prefix', async () => {
191+
const { sql } = await new NativeSQLStrategy().generateSql(mhQuery, mhCtx());
192+
// hop 1: base → account
193+
expect(sql).toContain('LEFT JOIN "crm_account" "account" ON "opportunity"."account" = "account"."id"');
194+
// hop 2: account → owner, parent is the hop-1 alias, child alias is the full path
195+
expect(sql).toContain('LEFT JOIN "core_user" "account__owner" ON "account"."owner" = "account__owner"."id"');
196+
// the deep column is qualified by the deepest alias
197+
expect(sql).toContain('"account__owner"."region"');
198+
});
199+
200+
it('injects the tenant read scope for the base AND every hop object (per-hop RLS)', async () => {
201+
const { sql, params } = await new NativeSQLStrategy().generateSql(
202+
mhQuery,
203+
mhCtx({ getReadScope: (obj) => ({ organization_id: `org:${obj}` }) }),
204+
);
205+
// base + both hop aliases are scoped
206+
expect(sql).toContain('"opportunity"."organization_id" =');
207+
expect(sql).toContain('"account"."organization_id" =');
208+
expect(sql).toContain('"account__owner"."organization_id" =');
209+
// scope params resolve against each hop's TARGET object (alias → object name)
210+
expect(params).toContain('org:opportunity');
211+
expect(params).toContain('org:crm_account');
212+
expect(params).toContain('org:core_user');
213+
});
214+
215+
it('rejects when an intermediate hop is missing from the allowlist', async () => {
216+
// `account.owner` is registered by the deep dimension but NOT allowed.
217+
const ctx = mhCtx({ getAllowedRelationships: () => new Set(['account']) });
218+
await expect(new NativeSQLStrategy().generateSql(mhQuery, ctx)).rejects.toThrow(
219+
/join "account__owner" is not backed by a declared relationship/,
220+
);
221+
});
222+
});

packages/services/service-analytics/src/dataset-compiler.ts

Lines changed: 95 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,9 @@ export interface CompiledDataset {
3333
/** The Cube the dataset compiles to (consumed by the strategy chain). */
3434
cube: Cube;
3535
/**
36-
* Relationship names declared in `include`. The join allowlist (D-C):
37-
* the NativeSQLStrategy rejects any join alias not in this set.
36+
* Every join alias the dataset may use — each declared `include` path AND its
37+
* intermediate prefixes (ADR-0071). The join allowlist (D-C): the
38+
* NativeSQLStrategy rejects any join alias not in this set.
3839
*/
3940
allowedRelationships: Set<string>;
4041
/** Derived measures, computed post-aggregation by the executor (Q1). */
@@ -46,15 +47,30 @@ export interface CompiledDataset {
4647
}
4748

4849
/**
49-
* Resolves a relationship name on a base object to the related object/table
50-
* name, using the runtime's object graph. Optional: when omitted the compiler
51-
* trusts the declared `include` names (the NativeSQLStrategy convention assumes
52-
* the relationship name equals the related table name).
50+
* The related object reached by traversing a relationship: its logical object
51+
* name (used to resolve the NEXT hop in a multi-hop chain — ADR-0071) and its
52+
* physical table name (the join target).
53+
*/
54+
export interface RelationshipTarget {
55+
object: string;
56+
table: string;
57+
}
58+
59+
/**
60+
* Resolves a relationship name on a base object to the related object/table,
61+
* using the runtime's object graph. Optional: when omitted the compiler trusts
62+
* the declared `include` names (the NativeSQLStrategy convention assumes the
63+
* relationship name equals the related table name).
64+
*
65+
* May return a bare table-name `string` (legacy single-hop: object name is
66+
* assumed equal to the table) or a {@link RelationshipTarget} (required to
67+
* traverse further along a multi-hop path, where object differs from table for
68+
* namespaced objects).
5369
*/
5470
export type RelationshipResolver = (
5571
baseObject: string,
5672
relationshipName: string,
57-
) => string | undefined;
73+
) => string | RelationshipTarget | undefined;
5874

5975
/** Map a dataset measure's aggregate to the Cube metric `type`. */
6076
function aggregateToMetricType(m: DatasetMeasure): Metric['type'] {
@@ -84,56 +100,93 @@ function dimensionType(d: DatasetDimension): CubeDimension['type'] {
84100
}
85101
}
86102

87-
/** The relationship prefix of a dotted `relationship.field` path, or null. */
88-
function relationshipPrefix(field: string): string | null {
89-
const idx = field.indexOf('.');
103+
/** The relationship PATH a dotted field traverses — all segments but the final
104+
* column — or null for a base-object field. E.g. `account.owner.region` →
105+
* `account.owner`; `account.region` → `account`; `region` → null. */
106+
function fieldRelationshipPath(field: string): string | null {
107+
const idx = field.lastIndexOf('.');
90108
return idx > 0 ? field.slice(0, idx) : null;
91109
}
92110

111+
/** Max relationship hops in one `include` path — base → 3 hops = 4 objects
112+
* (ADR-0071; Salesforce-report-type parity). To-one chains never fan out, so
113+
* this is a performance/complexity guard, not a correctness limit. */
114+
const MAX_JOIN_HOPS = 3;
115+
116+
/** SQL-safe join alias for a relationship PATH. The dotted path is the author-
117+
* facing form; the alias replaces dots with `__` (Cube.js convention) so each
118+
* prefix is one valid identifier — quoted dotted identifiers are rejected by
119+
* the read-scope SQL guard (fail-closed). Single-segment paths are unchanged,
120+
* so single-hop joins stay byte-for-byte identical. */
121+
const joinAlias = (path: string): string => path.replace(/\./g, '__');
122+
93123
export function compileDataset(
94124
dataset: Dataset,
95125
resolver?: RelationshipResolver,
96126
): CompiledDataset {
97127
const include = dataset.include ?? [];
98-
const allowedRelationships = new Set(include);
99-
100-
// Resolve each declared relationship to its TARGET TABLE and emit a Cube join.
101-
// The relationship name (a lookup/master_detail field on the base object) is
102-
// used as the join ALIAS, but the joined TABLE is the related object — these
103-
// differ when objects are namespaced (e.g. lookup field `account` →
104-
// table `crm_account`). Without resolving the table, the strategy would join a
105-
// non-existent `"account"` table. When no resolver is supplied the relationship
106-
// name is assumed to equal the table name (legacy convention / unit tests).
128+
129+
// Resolve each declared relationship PATH into its ordered join chain, emitting
130+
// one Cube join per PATH PREFIX (ADR-0071 multi-hop, to-one only). The join
131+
// ALIAS is the full dotted path (`account.owner`), which self-describes the
132+
// chain: the parent alias is the path minus its last segment, the FK column is
133+
// that last segment. So declaring `account.owner` auto-adds the intermediate
134+
// `account` join, and the strategy can rebuild every `ON` from the alias alone.
135+
// Without a resolver, each segment's relationship name is assumed to equal both
136+
// the related object and its table (legacy convention / unit tests).
137+
const resolveHop = (fromObject: string, rel: string): RelationshipTarget => {
138+
if (!resolver) return { object: rel, table: rel };
139+
const resolved = resolver(fromObject, rel);
140+
if (!resolved) {
141+
throw new Error(
142+
`[dataset-compiler] dataset "${dataset.name}" includes relationship "${rel}" ` +
143+
`which does not exist on object "${fromObject}".`,
144+
);
145+
}
146+
return typeof resolved === 'string' ? { object: resolved, table: resolved } : resolved;
147+
};
107148
const joins: Record<string, CubeJoin> = {};
108-
for (const rel of include) {
109-
let targetTable: string = rel;
110-
if (resolver) {
111-
const resolved = resolver(dataset.object, rel);
112-
if (!resolved) {
113-
throw new Error(
114-
`[dataset-compiler] dataset "${dataset.name}" includes relationship "${rel}" ` +
115-
`which does not exist on object "${dataset.object}".`,
116-
);
149+
for (const path of include) {
150+
const segments = path.split('.');
151+
if (segments.length > MAX_JOIN_HOPS) {
152+
throw new Error(
153+
`[dataset-compiler] dataset "${dataset.name}" include path "${path}" exceeds the ` +
154+
`${MAX_JOIN_HOPS}-hop limit (${segments.length} hops). Deeper traversal is not supported.`,
155+
);
156+
}
157+
let fromObject = dataset.object;
158+
let parentAlias = dataset.object;
159+
let prefix = '';
160+
for (const seg of segments) {
161+
prefix = prefix ? `${prefix}.${seg}` : seg;
162+
const target = resolveHop(fromObject, seg);
163+
const alias = joinAlias(prefix);
164+
if (!joins[alias]) {
165+
// KEY is the SQL-safe alias; `name` carries the join TABLE; the strategy
166+
// rebuilds the ON clause from the alias convention (`<parent>.<seg> = <alias>.id`).
167+
joins[alias] = {
168+
name: target.table,
169+
relationship: 'many_to_one',
170+
sql: `${parentAlias}.${seg} = ${prefix}.id`,
171+
};
117172
}
118-
targetTable = resolved;
173+
fromObject = target.object;
174+
parentAlias = prefix;
119175
}
120-
// `name` carries the join TABLE; the strategy derives the ON clause from the
121-
// relationship-name convention (`<base>.<rel> = <rel>.id`).
122-
joins[rel] = {
123-
name: targetTable,
124-
relationship: 'many_to_one',
125-
sql: `${dataset.object}.${rel} = ${rel}.id`,
126-
};
127176
}
128177

129-
// Assert any dotted field only traverses a DECLARED relationship (D-C).
178+
// The join allowlist (D-C) is every registered alias — each declared path AND
179+
// its intermediate prefixes — so a multi-hop field's intermediate joins pass.
180+
const allowedRelationships = new Set(Object.keys(joins));
181+
182+
// Assert any dotted field only traverses a DECLARED relationship PATH (D-C).
130183
const assertDeclared = (field: string, ownerKind: string, ownerName: string) => {
131-
const prefix = relationshipPrefix(field);
132-
if (prefix && !allowedRelationships.has(prefix)) {
184+
const relPath = fieldRelationshipPath(field);
185+
if (relPath && !joins[joinAlias(relPath)]) {
133186
throw new Error(
134-
`[dataset-compiler] ${ownerKind} "${ownerName}" references relationship "${prefix}" ` +
135-
`via "${field}", but "${prefix}" is not declared in the dataset's \`include\`. ` +
136-
`v1 only joins along declared relationships.`,
187+
`[dataset-compiler] ${ownerKind} "${ownerName}" references relationship path "${relPath}" ` +
188+
`via "${field}", but "${relPath}" is not declared in the dataset's \`include\`. ` +
189+
`Only fields along a declared relationship path are joinable.`,
137190
);
138191
}
139192
};

packages/services/service-analytics/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export { CubeRegistry } from './cube-registry.js';
1313

1414
// Dataset semantic layer (ADR-0021)
1515
export { compileDataset } from './dataset-compiler.js';
16-
export type { CompiledDataset, DerivedMeasureSpec, RelationshipResolver } from './dataset-compiler.js';
16+
export type { CompiledDataset, DerivedMeasureSpec, RelationshipResolver, RelationshipTarget } from './dataset-compiler.js';
1717

1818
export { resolveDimensionLabels, pickDisplayField } from './dimension-labels.js';
1919
export type { DimensionLabelDeps, FieldMetaLite } from './dimension-labels.js';

0 commit comments

Comments
 (0)