Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/dataset-multihop-joins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@objectstack/spec': minor
'@objectstack/service-analytics': minor
---

feat(analytics): multi-hop relationship joins for datasets (ADR-0071)

A dataset's `include` and dimension/measure `field` paths may now traverse up to
3 to-one relationship hops (`account.owner.region`), not just one. The compiler
expands each declared path into the ordered join chain (one `cube.join` per path
prefix, aliased dot-free as `account__owner` so it stays a single valid SQL
identifier), and the NativeSQLStrategy emits the chained `LEFT JOIN`s. Per-hop
tenant/RLS read-scope is enforced for EVERY object in the chain — the
alias-driven scope loop already generalizes, so no security path is rewritten.

Restricted to **to-one** (lookup / master_detail) relationships, which never fan
out — aggregates stay correct with no symmetric-aggregate machinery; to-many
traversal is out of scope. Single-hop datasets are byte-for-byte unchanged (the
dot-free alias is a no-op for a single segment). Undeclared paths are still
rejected (ADR-0021 D-C); paths beyond 3 hops are rejected at both parse and
compile time.
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,111 @@ describe('compileDataset', () => {
expect(() => compileDataset(ds)).toThrowError(/not supported by the v1 dataset runtime/);
});
});

describe('compileDataset — multi-hop joins (ADR-0071)', () => {
/** opportunity → account (crm_account) → owner (core_user). All to-one. */
const chainResolver = (obj: string, rel: string) => {
const graph: Record<string, Record<string, { object: string; table: string }>> = {
opportunity: { account: { object: 'account', table: 'crm_account' } },
account: { owner: { object: 'user', table: 'core_user' } },
};
return graph[obj]?.[rel];
};

const twoHop = () =>
DatasetSchema.parse({
name: 'sales_by_owner_region',
label: 'Sales by owner region',
object: 'opportunity',
include: ['account', 'account.owner'],
dimensions: [
{ name: 'owner_region', label: 'Owner Region', field: 'account.owner.region', type: 'string' },
],
measures: [{ name: 'revenue', label: 'Revenue', aggregate: 'sum', field: 'amount' }],
});

it('emits one join per path prefix, keyed by the full dotted path', () => {
const { cube } = compileDataset(twoHop(), chainResolver);
expect(cube.joins?.['account']?.name).toBe('crm_account');
expect(cube.joins?.['account__owner']?.name).toBe('core_user');
// The deepest dimension keeps its full dotted sql for the strategy.
expect(cube.dimensions.owner_region.sql).toBe('account.owner.region');
});

it('allowlist is every prefix alias (declared path + intermediates)', () => {
const { allowedRelationships } = compileDataset(twoHop(), chainResolver);
expect(allowedRelationships.has('account')).toBe(true);
expect(allowedRelationships.has('account__owner')).toBe(true);
expect(allowedRelationships.size).toBe(2);
});

it('auto-includes the intermediate hop when only the deep path is declared', () => {
const ds = DatasetSchema.parse({
name: 'deep_only',
label: 'Deep only',
object: 'opportunity',
include: ['account.owner'], // intermediate `account` NOT explicitly declared
dimensions: [{ name: 'owner_region', field: 'account.owner.region', type: 'string' }],
measures: [{ name: 'cnt', aggregate: 'count' }],
});
const { cube, allowedRelationships } = compileDataset(ds, chainResolver);
expect(cube.joins?.['account']?.name).toBe('crm_account'); // auto-added intermediate
expect(cube.joins?.['account__owner']?.name).toBe('core_user');
expect(allowedRelationships.size).toBe(2);
});

it('rejects an include path beyond the 3-hop cap at parse time (spec refine)', () => {
expect(() =>
DatasetSchema.parse({
name: 'too_deep',
label: 'Too deep',
object: 'opportunity',
include: ['a.b.c.d'], // 4 hops
dimensions: [{ name: 'deep_name', field: 'a.b.c.d.name' }],
measures: [{ name: 'cnt', aggregate: 'count' }],
}),
).toThrowError(/3-hop limit/);
});

it('the compiler also rejects an over-deep include path (defense in depth)', () => {
// Bypass the spec refine to exercise the compiler's OWN guard (for datasets
// built programmatically, not parsed through the schema).
const raw = {
name: 'too_deep',
label: 'Too deep',
object: 'opportunity',
include: ['a.b.c.d'],
dimensions: [{ name: 'deep_name', label: 'Deep', type: 'string', field: 'a.b.c.d.name' }],
measures: [{ name: 'cnt', label: 'Count', aggregate: 'count' }],
} as unknown as Parameters<typeof compileDataset>[0];
expect(() => compileDataset(raw)).toThrowError(/exceeds the 3-hop limit/);
});

it('rejects a deep field whose relationship path was not declared (D-C)', () => {
const bad = DatasetSchema.parse({
name: 'bad_deep',
label: 'Bad deep',
object: 'opportunity',
include: ['account'], // declared `account` but NOT `account.owner`
dimensions: [{ name: 'owner_region', field: 'account.owner.region' }],
measures: [{ name: 'cnt', aggregate: 'count' }],
});
expect(() => compileDataset(bad, chainResolver)).toThrowError(/relationship path "account.owner".*not declared/s);
});

it('accepts a resolver that returns a bare table string (object assumed equal)', () => {
const ds = DatasetSchema.parse({
name: 'str_resolver',
label: 'String resolver',
object: 'opportunity',
include: ['account.owner'],
dimensions: [{ name: 'owner_region', field: 'account.owner.region' }],
measures: [{ name: 'cnt', aggregate: 'count' }],
});
// Legacy string-returning resolver: object name == table name at each hop.
const stringResolver = (_obj: string, rel: string) => rel;
const { cube } = compileDataset(ds, stringResolver);
expect(cube.joins?.['account']?.name).toBe('account');
expect(cube.joins?.['account__owner']?.name).toBe('owner');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -156,3 +156,67 @@ describe('NativeSQLStrategy — base-column qualification under joins', () => {
expect(sql).not.toContain('"task"."status"');
});
});

describe('NativeSQLStrategy — multi-hop joins (ADR-0071)', () => {
// opportunity → account (crm_account) → owner (core_user); dimension two hops deep.
const mhCube: Cube = {
name: 'sales',
title: 'Sales',
sql: 'opportunity',
measures: { revenue: { name: 'revenue', label: 'Revenue', type: 'sum', sql: 'amount' } },
dimensions: {
owner_region: { name: 'owner_region', label: 'Owner Region', type: 'string', sql: 'account.owner.region' },
},
joins: {
account: { name: 'crm_account', relationship: 'many_to_one', sql: 'opportunity.account = account.id' },
'account__owner': { name: 'core_user', relationship: 'many_to_one', sql: 'account.owner = account__owner.id' },
},
public: false,
};
const mhQuery: AnalyticsQuery = {
cube: 'sales',
measures: ['revenue'],
dimensions: ['owner_region'],
timezone: 'UTC',
};
const mhCtx = (overrides: Partial<StrategyContext> = {}): StrategyContext => ({
getCube: (n) => (n === 'sales' ? mhCube : undefined),
queryCapabilities: () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }),
executeRawSql: async () => [],
getAllowedRelationships: () => new Set(['account', 'account__owner']),
...overrides,
});

it('chains a LEFT JOIN per hop, each aliased by its full path prefix', async () => {
const { sql } = await new NativeSQLStrategy().generateSql(mhQuery, mhCtx());
// hop 1: base → account
expect(sql).toContain('LEFT JOIN "crm_account" "account" ON "opportunity"."account" = "account"."id"');
// hop 2: account → owner, parent is the hop-1 alias, child alias is the full path
expect(sql).toContain('LEFT JOIN "core_user" "account__owner" ON "account"."owner" = "account__owner"."id"');
// the deep column is qualified by the deepest alias
expect(sql).toContain('"account__owner"."region"');
});

it('injects the tenant read scope for the base AND every hop object (per-hop RLS)', async () => {
const { sql, params } = await new NativeSQLStrategy().generateSql(
mhQuery,
mhCtx({ getReadScope: (obj) => ({ organization_id: `org:${obj}` }) }),
);
// base + both hop aliases are scoped
expect(sql).toContain('"opportunity"."organization_id" =');
expect(sql).toContain('"account"."organization_id" =');
expect(sql).toContain('"account__owner"."organization_id" =');
// scope params resolve against each hop's TARGET object (alias → object name)
expect(params).toContain('org:opportunity');
expect(params).toContain('org:crm_account');
expect(params).toContain('org:core_user');
});

it('rejects when an intermediate hop is missing from the allowlist', async () => {
// `account.owner` is registered by the deep dimension but NOT allowed.
const ctx = mhCtx({ getAllowedRelationships: () => new Set(['account']) });
await expect(new NativeSQLStrategy().generateSql(mhQuery, ctx)).rejects.toThrow(
/join "account__owner" is not backed by a declared relationship/,
);
});
});
137 changes: 95 additions & 42 deletions packages/services/service-analytics/src/dataset-compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,9 @@ export interface CompiledDataset {
/** The Cube the dataset compiles to (consumed by the strategy chain). */
cube: Cube;
/**
* Relationship names declared in `include`. The join allowlist (D-C):
* the NativeSQLStrategy rejects any join alias not in this set.
* Every join alias the dataset may use — each declared `include` path AND its
* intermediate prefixes (ADR-0071). The join allowlist (D-C): the
* NativeSQLStrategy rejects any join alias not in this set.
*/
allowedRelationships: Set<string>;
/** Derived measures, computed post-aggregation by the executor (Q1). */
Expand All @@ -46,15 +47,30 @@ export interface CompiledDataset {
}

/**
* Resolves a relationship name on a base object to the related object/table
* name, using the runtime's object graph. Optional: when omitted the compiler
* trusts the declared `include` names (the NativeSQLStrategy convention assumes
* the relationship name equals the related table name).
* The related object reached by traversing a relationship: its logical object
* name (used to resolve the NEXT hop in a multi-hop chain — ADR-0071) and its
* physical table name (the join target).
*/
export interface RelationshipTarget {
object: string;
table: string;
}

/**
* Resolves a relationship name on a base object to the related object/table,
* using the runtime's object graph. Optional: when omitted the compiler trusts
* the declared `include` names (the NativeSQLStrategy convention assumes the
* relationship name equals the related table name).
*
* May return a bare table-name `string` (legacy single-hop: object name is
* assumed equal to the table) or a {@link RelationshipTarget} (required to
* traverse further along a multi-hop path, where object differs from table for
* namespaced objects).
*/
export type RelationshipResolver = (
baseObject: string,
relationshipName: string,
) => string | undefined;
) => string | RelationshipTarget | undefined;

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

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

/** Max relationship hops in one `include` path — base → 3 hops = 4 objects
* (ADR-0071; Salesforce-report-type parity). To-one chains never fan out, so
* this is a performance/complexity guard, not a correctness limit. */
const MAX_JOIN_HOPS = 3;

/** SQL-safe join alias for a relationship PATH. The dotted path is the author-
* facing form; the alias replaces dots with `__` (Cube.js convention) so each
* prefix is one valid identifier — quoted dotted identifiers are rejected by
* the read-scope SQL guard (fail-closed). Single-segment paths are unchanged,
* so single-hop joins stay byte-for-byte identical. */
const joinAlias = (path: string): string => path.replace(/\./g, '__');

export function compileDataset(
dataset: Dataset,
resolver?: RelationshipResolver,
): CompiledDataset {
const include = dataset.include ?? [];
const allowedRelationships = new Set(include);

// Resolve each declared relationship to its TARGET TABLE and emit a Cube join.
// The relationship name (a lookup/master_detail field on the base object) is
// used as the join ALIAS, but the joined TABLE is the related object — these
// differ when objects are namespaced (e.g. lookup field `account` →
// table `crm_account`). Without resolving the table, the strategy would join a
// non-existent `"account"` table. When no resolver is supplied the relationship
// name is assumed to equal the table name (legacy convention / unit tests).

// Resolve each declared relationship PATH into its ordered join chain, emitting
// one Cube join per PATH PREFIX (ADR-0071 multi-hop, to-one only). The join
// ALIAS is the full dotted path (`account.owner`), which self-describes the
// chain: the parent alias is the path minus its last segment, the FK column is
// that last segment. So declaring `account.owner` auto-adds the intermediate
// `account` join, and the strategy can rebuild every `ON` from the alias alone.
// Without a resolver, each segment's relationship name is assumed to equal both
// the related object and its table (legacy convention / unit tests).
const resolveHop = (fromObject: string, rel: string): RelationshipTarget => {
if (!resolver) return { object: rel, table: rel };
const resolved = resolver(fromObject, rel);
if (!resolved) {
throw new Error(
`[dataset-compiler] dataset "${dataset.name}" includes relationship "${rel}" ` +
`which does not exist on object "${fromObject}".`,
);
}
return typeof resolved === 'string' ? { object: resolved, table: resolved } : resolved;
};
const joins: Record<string, CubeJoin> = {};
for (const rel of include) {
let targetTable: string = rel;
if (resolver) {
const resolved = resolver(dataset.object, rel);
if (!resolved) {
throw new Error(
`[dataset-compiler] dataset "${dataset.name}" includes relationship "${rel}" ` +
`which does not exist on object "${dataset.object}".`,
);
for (const path of include) {
const segments = path.split('.');
if (segments.length > MAX_JOIN_HOPS) {
throw new Error(
`[dataset-compiler] dataset "${dataset.name}" include path "${path}" exceeds the ` +
`${MAX_JOIN_HOPS}-hop limit (${segments.length} hops). Deeper traversal is not supported.`,
);
}
let fromObject = dataset.object;
let parentAlias = dataset.object;
let prefix = '';
for (const seg of segments) {
prefix = prefix ? `${prefix}.${seg}` : seg;
const target = resolveHop(fromObject, seg);
const alias = joinAlias(prefix);
if (!joins[alias]) {
// KEY is the SQL-safe alias; `name` carries the join TABLE; the strategy
// rebuilds the ON clause from the alias convention (`<parent>.<seg> = <alias>.id`).
joins[alias] = {
name: target.table,
relationship: 'many_to_one',
sql: `${parentAlias}.${seg} = ${prefix}.id`,
};
}
targetTable = resolved;
fromObject = target.object;
parentAlias = prefix;
}
// `name` carries the join TABLE; the strategy derives the ON clause from the
// relationship-name convention (`<base>.<rel> = <rel>.id`).
joins[rel] = {
name: targetTable,
relationship: 'many_to_one',
sql: `${dataset.object}.${rel} = ${rel}.id`,
};
}

// Assert any dotted field only traverses a DECLARED relationship (D-C).
// The join allowlist (D-C) is every registered alias — each declared path AND
// its intermediate prefixes — so a multi-hop field's intermediate joins pass.
const allowedRelationships = new Set(Object.keys(joins));

// Assert any dotted field only traverses a DECLARED relationship PATH (D-C).
const assertDeclared = (field: string, ownerKind: string, ownerName: string) => {
const prefix = relationshipPrefix(field);
if (prefix && !allowedRelationships.has(prefix)) {
const relPath = fieldRelationshipPath(field);
if (relPath && !joins[joinAlias(relPath)]) {
throw new Error(
`[dataset-compiler] ${ownerKind} "${ownerName}" references relationship "${prefix}" ` +
`via "${field}", but "${prefix}" is not declared in the dataset's \`include\`. ` +
`v1 only joins along declared relationships.`,
`[dataset-compiler] ${ownerKind} "${ownerName}" references relationship path "${relPath}" ` +
`via "${field}", but "${relPath}" is not declared in the dataset's \`include\`. ` +
`Only fields along a declared relationship path are joinable.`,
);
}
};
Expand Down
2 changes: 1 addition & 1 deletion packages/services/service-analytics/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export { CubeRegistry } from './cube-registry.js';

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

export { resolveDimensionLabels, pickDisplayField } from './dimension-labels.js';
export type { DimensionLabelDeps, FieldMetaLite } from './dimension-labels.js';
Expand Down
Loading