diff --git a/.changeset/dataset-multihop-joins.md b/.changeset/dataset-multihop-joins.md new file mode 100644 index 0000000000..69e360ffcf --- /dev/null +++ b/.changeset/dataset-multihop-joins.md @@ -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. diff --git a/packages/services/service-analytics/src/__tests__/dataset-compiler.test.ts b/packages/services/service-analytics/src/__tests__/dataset-compiler.test.ts index 6296fbdee2..c67538280b 100644 --- a/packages/services/service-analytics/src/__tests__/dataset-compiler.test.ts +++ b/packages/services/service-analytics/src/__tests__/dataset-compiler.test.ts @@ -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> = { + 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[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'); + }); +}); diff --git a/packages/services/service-analytics/src/__tests__/native-sql-rls.test.ts b/packages/services/service-analytics/src/__tests__/native-sql-rls.test.ts index 34d46bb15c..e967830268 100644 --- a/packages/services/service-analytics/src/__tests__/native-sql-rls.test.ts +++ b/packages/services/service-analytics/src/__tests__/native-sql-rls.test.ts @@ -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 => ({ + 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/, + ); + }); +}); diff --git a/packages/services/service-analytics/src/dataset-compiler.ts b/packages/services/service-analytics/src/dataset-compiler.ts index da273e5cb7..db4303cc96 100644 --- a/packages/services/service-analytics/src/dataset-compiler.ts +++ b/packages/services/service-analytics/src/dataset-compiler.ts @@ -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; /** Derived measures, computed post-aggregation by the executor (Q1). */ @@ -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'] { @@ -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 = {}; - 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 (`. = .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 (`. = .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.`, ); } }; diff --git a/packages/services/service-analytics/src/index.ts b/packages/services/service-analytics/src/index.ts index 7a147b5061..aa4617d1dc 100644 --- a/packages/services/service-analytics/src/index.ts +++ b/packages/services/service-analytics/src/index.ts @@ -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'; diff --git a/packages/services/service-analytics/src/strategies/native-sql-strategy.ts b/packages/services/service-analytics/src/strategies/native-sql-strategy.ts index d25aff5a52..13f982f333 100644 --- a/packages/services/service-analytics/src/strategies/native-sql-strategy.ts +++ b/packages/services/service-analytics/src/strategies/native-sql-strategy.ts @@ -210,14 +210,22 @@ export class NativeSQLStrategy implements AnalyticsStrategy { whereClauses.push(`(${rendered})`); } + /** SQL-safe join alias for a relationship path (dots → `__`); single-segment + * paths are unchanged. Mirrors the dataset compiler's `cube.joins` keying so + * alias, allowlist, and per-hop RLS all agree on one valid identifier. */ + private joinAlias(path: string): string { + return path.replace(/\./g, '__'); + } + /** * Resolve a dimension/measure/filter SQL expression that may reference a * related table via dot notation (e.g. `account.industry`). * - * When the resolved `sql` contains a dot, treat the prefix as a lookup - * field on the cube's table and synthesise a `LEFT JOIN` against the - * related table. The convention (matching the auto-cube generator and - * ObjectStack object schemas) is: + * A dotted `sql` is a relationship PATH (ADR-0071 multi-hop): every segment + * but the last is a to-one relationship hop, the last is the column. Each hop + * synthesises a `LEFT JOIN` aliased by its full path prefix, chained + * parent→child. The convention (matching the auto-cube generator and + * ObjectStack object schemas) for a single hop is: * * . = .id * @@ -247,26 +255,36 @@ export class NativeSQLStrategy implements AnalyticsStrategy { } return rawSql; } - // Only the first dotted hop is supported (single-level relation). - const [alias, ...rest] = rawSql.split('.'); - if (!alias || rest.length === 0) return rawSql; - const column = rest.join('.'); - if (!joins.has(alias)) { - // The relationship name is the join ALIAS; the joined TABLE is the - // related object. For datasets these differ when objects are namespaced - // (lookup `account` → table `crm_account`), so resolve the table from the - // Cube's `joins` map (emitted by the dataset compiler). Fall back to the - // alias as the table for legacy/same-name cubes. - const joinTable = cube?.joins?.[alias]?.name ?? alias; - // Only emit an explicit alias when the table differs from it; when they - // match, `LEFT JOIN "account" ON …` is cleaner (and back-compat). - const tableRef = joinTable === alias ? `"${alias}"` : `"${joinTable}" "${alias}"`; - joins.set( - alias, - `LEFT JOIN ${tableRef} ON "${parentTable}"."${alias}" = "${alias}"."id"`, - ); + // Multi-hop (ADR-0071): the dotted path IS the join chain. Every segment but + // the last is a relationship hop; the last is the column. The join ALIAS at + // each hop is the full path PREFIX (`account`, then `account.owner`), which + // encodes its own parent (the prefix minus its last segment) and FK column + // (that segment). Register one LEFT JOIN per prefix, chaining parent→child. + const segments = rawSql.split('.'); + const column = segments[segments.length - 1]; + const hops = segments.slice(0, -1); + if (hops.length === 0 || !column) return rawSql; + let parentAlias = parentTable; + let prefix = ''; + for (const seg of hops) { + prefix = prefix ? `${prefix}.${seg}` : seg; + const alias = this.joinAlias(prefix); + if (!joins.has(alias)) { + // The joined TABLE is resolved from the Cube's `joins` map (emitted by + // the dataset compiler, keyed by the same alias); fall back to the alias + // as the table for legacy/same-name cubes. + const joinTable = cube?.joins?.[alias]?.name ?? alias; + // Only emit an explicit alias when the table differs from it; when they + // match, `LEFT JOIN "account" ON …` is cleaner (and back-compat). + const tableRef = joinTable === alias ? `"${alias}"` : `"${joinTable}" "${alias}"`; + joins.set( + alias, + `LEFT JOIN ${tableRef} ON "${parentAlias}"."${seg}" = "${alias}"."id"`, + ); + } + parentAlias = alias; } - return `"${alias}"."${column}"`; + return `"${parentAlias}"."${column}"`; } /** @@ -384,9 +402,13 @@ export class NativeSQLStrategy implements AnalyticsStrategy { const rawSql = dim?.sql ?? measure?.sql ?? (member.includes('.') ? member.split('.').slice(1).join('.') : member); if (rawSql.includes('.')) { - const [alias, ...rest] = rawSql.split('.'); - const object = cube.joins?.[alias]?.name ?? alias; - return { object, field: rest.join('.') }; + // Multi-hop (ADR-0071): the column's owning object is the join at the + // relationship PATH (all segments but the last); the column is the last. + const segments = rawSql.split('.'); + const field = segments[segments.length - 1]; + const relPath = segments.slice(0, -1).join('.'); + const object = cube.joins?.[this.joinAlias(relPath)]?.name ?? relPath; + return { object, field }; } return { object: baseTable, field: rawSql }; } diff --git a/packages/spec/src/ui/dataset.zod.ts b/packages/spec/src/ui/dataset.zod.ts index 0f8f3570b9..cac0155a6c 100644 --- a/packages/spec/src/ui/dataset.zod.ts +++ b/packages/spec/src/ui/dataset.zod.ts @@ -40,11 +40,12 @@ export const DatasetDimensionSchema = lazySchema(() => z.object({ name: SnakeCaseIdentifierSchema.describe('Dimension name — referenced by presentations'), label: I18nLabelSchema.optional(), /** - * A field on the base object, OR a `relationship.field` path (e.g. - * `account.region`). The join is DERIVED from the declared relationship in - * `Dataset.include` — the author never writes a predicate. + * A field on the base object, OR a relationship path (one or more to-one hops) + * ending in a field — e.g. `account.region` or `account.owner.region` + * (ADR-0071 multi-hop). The join chain is DERIVED from the relationship(s) + * declared in `Dataset.include`; the author never writes a predicate. */ - field: z.string().describe('Base field or `relationship.field` path'), + field: z.string().describe('Base field, or `relationship[.relationship].field` path'), type: z.enum(['string', 'number', 'date', 'boolean', 'lookup']).optional(), /** Default bucketing for date dimensions (day/week/month/quarter/year). */ dateGranularity: DateGranularity.optional(), @@ -66,7 +67,7 @@ export const DatasetMeasureSchema = lazySchema(() => z.object({ label: I18nLabelSchema.optional(), /** Aggregation function — reuses the canonical query.zod enum. */ aggregate: AggregationFunction.optional().describe('Aggregation (sum/avg/count/...); omit when `derived` is set'), - /** Base or `relationship.field`. Optional for `count` (count(*)). */ + /** Base field, or `relationship[.relationship].field` path. Optional for `count` (count(*)). */ field: z.string().optional().describe('Aggregated field; optional for count(*)'), /** Measure-scoped filter (e.g. only won deals for "won_amount"). */ filter: FilterConditionSchema.optional(), @@ -107,12 +108,23 @@ export const DatasetSchema = lazySchema(() => z.object({ object: z.string().describe('Base object name'), /** - * Relationships to include, BY NAME (lookup / master_detail field names on - * the object graph). Joins are COMPILED from these — the author writes no ON - * clause. v1 (D-C): only declared relationships are joinable; no arbitrary - * predicates. + * Relationships to include, by NAME or by PATH — lookup / master_detail field + * names on the object graph, optionally chained through to-one relationships + * up to 3 hops (`account`, `account.owner`; ADR-0071 multi-hop). Joins are + * COMPILED from these — the author writes no ON clause. Declaring `a.b` + * implicitly includes the intermediate `a`. D-C: only declared paths are + * joinable; no arbitrary predicates, and to-many traversal is out of scope. */ - include: z.array(z.string()).optional().describe('Relationship names to join (derived from object graph)'), + include: z + .array( + z + .string() + .refine((p) => p.split('.').length <= 3, { + message: 'include path exceeds the 3-hop limit (ADR-0071)', + }), + ) + .optional() + .describe('Relationship names/paths to join (derived from object graph; max 3 hops)'), /** Definition-level filter (the dataset's intrinsic scope, e.g. non-deleted). */ filter: FilterConditionSchema.optional().describe('Intrinsic dataset scope filter'),