diff --git a/.changeset/dataset-multihop-ui.md b/.changeset/dataset-multihop-ui.md new file mode 100644 index 000000000..5bdce78ff --- /dev/null +++ b/.changeset/dataset-multihop-ui.md @@ -0,0 +1,19 @@ +--- +'@object-ui/app-shell': minor +--- + +feat(studio): multi-hop relationship fields in the dataset designer (ADR-0071) + +The dataset designer's field catalog and Included-relationships picker now +support multi-hop relationship paths (`account.owner.region`), matching the +framework's multi-hop join support (ADR-0071 P2): + +- `useDatasetFieldCatalog` walks each included path hop-by-hop, fetching every + object along the chain, so `path.field` options surface for fields two–three + to-one hops deep (grouped under a chained `Account → Owner → User` heading). +- The Included-relationships combo offers one level deeper along each + already-included path (drill `account` → `account.owner`), capped at 3 hops. +- The author-time "relationship not in Included" warning generalizes to the full + relationship path (`account.owner`), with one-click "Add it". + +Single-hop datasets are unchanged. diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/DatasetDefaultInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/DatasetDefaultInspector.tsx index c3d27b9be..81c3b5398 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/DatasetDefaultInspector.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/DatasetDefaultInspector.tsx @@ -196,10 +196,12 @@ function MeasureFormatField({ measure, onPatch, disabled }: { measure: Measure; ); } -/** The relationship prefix of a `relationship.field` path that isn't yet in `include`, else null. */ +/** The relationship PATH of a `relationship[.relationship].field` reference (all + * segments but the final column) that isn't yet in `include`, else null. ADR-0071 + * multi-hop: `account.owner.region` → `account.owner`. */ function missingRelationship(field: string | undefined, include: string[]): string | null { if (!field || !field.includes('.')) return null; - const rel = field.split('.')[0]; + const rel = field.slice(0, field.lastIndexOf('.')); return rel && !include.includes(rel) ? rel : null; } diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/useDatasetFields.test.ts b/packages/app-shell/src/views/metadata-admin/inspectors/useDatasetFields.test.ts index 26457762d..93447cb17 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/useDatasetFields.test.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/useDatasetFields.test.ts @@ -7,6 +7,7 @@ import { fieldTypeToDimensionType, normalizeObject, buildFieldOptions, + resolvePath, referencesDataset, type NormalizedObject, } from './useDatasetFields'; @@ -130,3 +131,56 @@ describe('referencesDataset', () => { expect(referencesDataset({ widgets: [{ object: 'sales' }] }, 'sales')).toBe(false); }); }); + +describe('resolvePath + multi-hop buildFieldOptions (ADR-0071)', () => { + const base: NormalizedObject = { + label: 'Opportunity', + fields: [{ name: 'amount', label: 'Amount', type: 'currency', def: {} }], + relationships: [{ name: 'account', label: 'Account', referenceTo: 'account' }], + }; + const accountObj: NormalizedObject = { + label: 'Account', + fields: [{ name: 'region', label: 'Region', type: 'text', def: {} }], + relationships: [{ name: 'owner', label: 'Owner', referenceTo: 'user' }], + }; + const userObj: NormalizedObject = { + label: 'User', + fields: [ + { name: 'region', label: 'User Region', type: 'text', def: {} }, + { name: 'email', label: 'Email', type: 'text', def: {} }, + ], + relationships: [], + }; + const objectsByName = { account: accountObj, user: userObj }; + + it('walks a single hop', () => { + const r = resolvePath(base, 'account', objectsByName); + expect(r?.target.label).toBe('Account'); + expect(r?.labels).toEqual(['Account']); + }); + + it('walks a two-hop chain, collecting each hop label', () => { + const r = resolvePath(base, 'account.owner', objectsByName); + expect(r?.target.label).toBe('User'); + expect(r?.labels).toEqual(['Account', 'Owner']); + }); + + it('returns undefined when a hop object is not loaded', () => { + expect(resolvePath(base, 'account.owner', { account: accountObj })).toBeUndefined(); + }); + + it('returns undefined for an unknown relationship segment', () => { + expect(resolvePath(base, 'nope', objectsByName)).toBeUndefined(); + }); + + it('emits multi-hop `path.field` options with a chained heading', () => { + const opts = buildFieldOptions(base, ['account.owner'], objectsByName); + expect(opts).toContainEqual({ value: 'account.owner.region', label: 'User Region', type: 'text', group: 'Account → Owner → User' }); + expect(opts).toContainEqual({ value: 'account.owner.email', label: 'Email', type: 'text', group: 'Account → Owner → User' }); + }); + + it('skips a multi-hop path whose chain is not fully loaded', () => { + const opts = buildFieldOptions(base, ['account.owner'], { account: accountObj }); + expect(opts.some((o) => o.value.startsWith('account.owner.'))).toBe(false); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/useDatasetFields.ts b/packages/app-shell/src/views/metadata-admin/inspectors/useDatasetFields.ts index 3499fca1e..37a27efa4 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/useDatasetFields.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/useDatasetFields.ts @@ -140,13 +140,38 @@ export function normalizeObject(doc: Record | null | undefined, } /** - * Build the flat `field` / `relationship.field` option list from the base - * object and the included relationships' (already-fetched) target objects. + * Walk a dotted relationship PATH from the base object, returning the object at + * its end (whose fields a `path.field` references) plus each hop's relationship + * label, or undefined if any hop can't be resolved (ADR-0071 multi-hop). + * `objectsByName` holds the already-fetched objects along the chain. + */ +export function resolvePath( + base: NormalizedObject, + path: string, + objectsByName: Record, +): { target: NormalizedObject; labels: string[] } | undefined { + let current: NormalizedObject = base; + const labels: string[] = []; + for (const seg of path.split('.')) { + const rel = current.relationships.find((r) => r.name === seg); + if (!rel?.referenceTo) return undefined; + const next = objectsByName[rel.referenceTo]; + if (!next) return undefined; + labels.push(rel.label); + current = next; + } + return { target: current, labels }; +} + +/** + * Build the flat `field` / `relationship[.relationship].field` option list from + * the base object and the (already-fetched) objects along each included PATH. + * Single-hop paths behave exactly as before. */ export function buildFieldOptions( base: NormalizedObject, include: string[], - targets: Record, + objectsByName: Record, ): DatasetFieldOption[] { const options: DatasetFieldOption[] = base.fields.map((f) => ({ value: f.name, @@ -154,13 +179,12 @@ export function buildFieldOptions( type: f.type, group: base.label, })); - for (const rel of include) { - const relationship = base.relationships.find((r) => r.name === rel); - const target = relationship?.referenceTo ? targets[relationship.referenceTo] : undefined; - if (!target) continue; - const heading = `${relationship!.label} → ${target.label}`; - for (const f of target.fields) { - options.push({ value: `${rel}.${f.name}`, label: f.label, type: f.type, group: heading }); + for (const path of include) { + const resolved = resolvePath(base, path, objectsByName); + if (!resolved) continue; + const heading = [...resolved.labels, resolved.target.label].join(' → '); + for (const f of resolved.target.fields) { + options.push({ value: `${path}.${f.name}`, label: f.label, type: f.type, group: heading }); } } return options; @@ -238,29 +262,48 @@ export function useDatasetFieldCatalog( const baseDoc = await client.get>('object', object); if (cancelled) return; const base = normalizeObject(baseDoc, object); - // Resolve which target objects the included relationships point at, - // then fetch each unique target's fields for `rel.field` options. - const wantedTargets = new Set(); - for (const rel of includeKey ? includeKey.split('') : []) { - const r = base.relationships.find((x) => x.name === rel); - if (r?.referenceTo) wantedTargets.add(r.referenceTo); + const includeList = includeKey ? includeKey.split('') : []; + // Walk each included PATH hop-by-hop, fetching every object along the + // chain (memoized by name) so multi-hop `a.b.field` paths resolve + // (ADR-0021 single-hop, generalized by ADR-0071). Hops can't be fetched + // in parallel across a chain — hop N's target is only known once hop + // N-1 is fetched. + const objectsByName: Record = { [object]: base }; + const fetchObject = async (name: string): Promise => { + if (objectsByName[name]) return objectsByName[name]; + let norm: NormalizedObject; + try { + norm = normalizeObject(await client.get>('object', name), name); + } catch { + norm = normalizeObject(null, name); + } + objectsByName[name] = norm; + return norm; + }; + for (const path of includeList) { + let current = base; + for (const seg of path.split('.')) { + const rel = current.relationships.find((r) => r.name === seg); + if (!rel?.referenceTo) break; + current = await fetchObject(rel.referenceTo); + } } - const targetEntries = await Promise.all( - [...wantedTargets].map(async (t) => { - try { - const doc = await client.get>('object', t); - return [t, normalizeObject(doc, t)] as const; - } catch { - return [t, normalizeObject(null, t)] as const; - } - }), - ); if (cancelled) return; - const targets: Record = Object.fromEntries(targetEntries); - const includeList = includeKey ? includeKey.split('') : []; + // The include combo offers base relationships AND one level deeper along + // each already-included path (so the author drills `account` -> + // `account.owner`), capped at the 3-hop ADR-0071 limit. + const relationshipPaths: DatasetRelationship[] = [...base.relationships]; + for (const path of includeList) { + if (path.split('.').length >= 3) continue; + const resolved = resolvePath(base, path, objectsByName); + if (!resolved) continue; + for (const r of resolved.target.relationships) { + relationshipPaths.push({ name: `${path}.${r.name}`, label: `${path}.${r.name}`, referenceTo: r.referenceTo }); + } + } setState({ - relationships: base.relationships, - fieldOptions: buildFieldOptions(base, includeList, targets), + relationships: relationshipPaths, + fieldOptions: buildFieldOptions(base, includeList, objectsByName), loading: false, }); } catch {