Skip to content

Commit 616157a

Browse files
os-zhuangclaude
andauthored
feat(studio): multi-hop relationship fields in the dataset designer (ADR-0071 P2) (#1979)
Generalizes the dataset designer's field catalog + Included-relationships picker from single-hop to multi-hop relationship paths, matching the framework's ADR-0071 join support (framework #2296). - useDatasetFields: add `resolvePath` (walk a dotted path through fetched objects, collecting hop labels); `buildFieldOptions` resolves multi-hop `path.field` options under a chained heading; `useDatasetFieldCatalog` walks each included path hop-by-hop, fetching every object in the chain, and offers deeper include paths (drill `account` → `account.owner`, 3-hop cap). - DatasetDefaultInspector: `missingRelationship` flags the full relationship path (`account.owner`), not just the first segment. - Single-hop unchanged; 6 new catalog tests; inspectors sweep 170 green; app-shell type-check clean. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0a0bc88 commit 616157a

4 files changed

Lines changed: 150 additions & 32 deletions

File tree

.changeset/dataset-multihop-ui.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@object-ui/app-shell': minor
3+
---
4+
5+
feat(studio): multi-hop relationship fields in the dataset designer (ADR-0071)
6+
7+
The dataset designer's field catalog and Included-relationships picker now
8+
support multi-hop relationship paths (`account.owner.region`), matching the
9+
framework's multi-hop join support (ADR-0071 P2):
10+
11+
- `useDatasetFieldCatalog` walks each included path hop-by-hop, fetching every
12+
object along the chain, so `path.field` options surface for fields two–three
13+
to-one hops deep (grouped under a chained `Account → Owner → User` heading).
14+
- The Included-relationships combo offers one level deeper along each
15+
already-included path (drill `account``account.owner`), capped at 3 hops.
16+
- The author-time "relationship not in Included" warning generalizes to the full
17+
relationship path (`account.owner`), with one-click "Add it".
18+
19+
Single-hop datasets are unchanged.

packages/app-shell/src/views/metadata-admin/inspectors/DatasetDefaultInspector.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,10 +196,12 @@ function MeasureFormatField({ measure, onPatch, disabled }: { measure: Measure;
196196
);
197197
}
198198

199-
/** The relationship prefix of a `relationship.field` path that isn't yet in `include`, else null. */
199+
/** The relationship PATH of a `relationship[.relationship].field` reference (all
200+
* segments but the final column) that isn't yet in `include`, else null. ADR-0071
201+
* multi-hop: `account.owner.region` → `account.owner`. */
200202
function missingRelationship(field: string | undefined, include: string[]): string | null {
201203
if (!field || !field.includes('.')) return null;
202-
const rel = field.split('.')[0];
204+
const rel = field.slice(0, field.lastIndexOf('.'));
203205
return rel && !include.includes(rel) ? rel : null;
204206
}
205207

packages/app-shell/src/views/metadata-admin/inspectors/useDatasetFields.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
fieldTypeToDimensionType,
88
normalizeObject,
99
buildFieldOptions,
10+
resolvePath,
1011
referencesDataset,
1112
type NormalizedObject,
1213
} from './useDatasetFields';
@@ -130,3 +131,56 @@ describe('referencesDataset', () => {
130131
expect(referencesDataset({ widgets: [{ object: 'sales' }] }, 'sales')).toBe(false);
131132
});
132133
});
134+
135+
describe('resolvePath + multi-hop buildFieldOptions (ADR-0071)', () => {
136+
const base: NormalizedObject = {
137+
label: 'Opportunity',
138+
fields: [{ name: 'amount', label: 'Amount', type: 'currency', def: {} }],
139+
relationships: [{ name: 'account', label: 'Account', referenceTo: 'account' }],
140+
};
141+
const accountObj: NormalizedObject = {
142+
label: 'Account',
143+
fields: [{ name: 'region', label: 'Region', type: 'text', def: {} }],
144+
relationships: [{ name: 'owner', label: 'Owner', referenceTo: 'user' }],
145+
};
146+
const userObj: NormalizedObject = {
147+
label: 'User',
148+
fields: [
149+
{ name: 'region', label: 'User Region', type: 'text', def: {} },
150+
{ name: 'email', label: 'Email', type: 'text', def: {} },
151+
],
152+
relationships: [],
153+
};
154+
const objectsByName = { account: accountObj, user: userObj };
155+
156+
it('walks a single hop', () => {
157+
const r = resolvePath(base, 'account', objectsByName);
158+
expect(r?.target.label).toBe('Account');
159+
expect(r?.labels).toEqual(['Account']);
160+
});
161+
162+
it('walks a two-hop chain, collecting each hop label', () => {
163+
const r = resolvePath(base, 'account.owner', objectsByName);
164+
expect(r?.target.label).toBe('User');
165+
expect(r?.labels).toEqual(['Account', 'Owner']);
166+
});
167+
168+
it('returns undefined when a hop object is not loaded', () => {
169+
expect(resolvePath(base, 'account.owner', { account: accountObj })).toBeUndefined();
170+
});
171+
172+
it('returns undefined for an unknown relationship segment', () => {
173+
expect(resolvePath(base, 'nope', objectsByName)).toBeUndefined();
174+
});
175+
176+
it('emits multi-hop `path.field` options with a chained heading', () => {
177+
const opts = buildFieldOptions(base, ['account.owner'], objectsByName);
178+
expect(opts).toContainEqual({ value: 'account.owner.region', label: 'User Region', type: 'text', group: 'Account → Owner → User' });
179+
expect(opts).toContainEqual({ value: 'account.owner.email', label: 'Email', type: 'text', group: 'Account → Owner → User' });
180+
});
181+
182+
it('skips a multi-hop path whose chain is not fully loaded', () => {
183+
const opts = buildFieldOptions(base, ['account.owner'], { account: accountObj });
184+
expect(opts.some((o) => o.value.startsWith('account.owner.'))).toBe(false);
185+
});
186+
});

packages/app-shell/src/views/metadata-admin/inspectors/useDatasetFields.ts

Lines changed: 73 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -140,27 +140,51 @@ export function normalizeObject(doc: Record<string, unknown> | null | undefined,
140140
}
141141

142142
/**
143-
* Build the flat `field` / `relationship.field` option list from the base
144-
* object and the included relationships' (already-fetched) target objects.
143+
* Walk a dotted relationship PATH from the base object, returning the object at
144+
* its end (whose fields a `path.field` references) plus each hop's relationship
145+
* label, or undefined if any hop can't be resolved (ADR-0071 multi-hop).
146+
* `objectsByName` holds the already-fetched objects along the chain.
147+
*/
148+
export function resolvePath(
149+
base: NormalizedObject,
150+
path: string,
151+
objectsByName: Record<string, NormalizedObject>,
152+
): { target: NormalizedObject; labels: string[] } | undefined {
153+
let current: NormalizedObject = base;
154+
const labels: string[] = [];
155+
for (const seg of path.split('.')) {
156+
const rel = current.relationships.find((r) => r.name === seg);
157+
if (!rel?.referenceTo) return undefined;
158+
const next = objectsByName[rel.referenceTo];
159+
if (!next) return undefined;
160+
labels.push(rel.label);
161+
current = next;
162+
}
163+
return { target: current, labels };
164+
}
165+
166+
/**
167+
* Build the flat `field` / `relationship[.relationship].field` option list from
168+
* the base object and the (already-fetched) objects along each included PATH.
169+
* Single-hop paths behave exactly as before.
145170
*/
146171
export function buildFieldOptions(
147172
base: NormalizedObject,
148173
include: string[],
149-
targets: Record<string, NormalizedObject>,
174+
objectsByName: Record<string, NormalizedObject>,
150175
): DatasetFieldOption[] {
151176
const options: DatasetFieldOption[] = base.fields.map((f) => ({
152177
value: f.name,
153178
label: f.label,
154179
type: f.type,
155180
group: base.label,
156181
}));
157-
for (const rel of include) {
158-
const relationship = base.relationships.find((r) => r.name === rel);
159-
const target = relationship?.referenceTo ? targets[relationship.referenceTo] : undefined;
160-
if (!target) continue;
161-
const heading = `${relationship!.label}${target.label}`;
162-
for (const f of target.fields) {
163-
options.push({ value: `${rel}.${f.name}`, label: f.label, type: f.type, group: heading });
182+
for (const path of include) {
183+
const resolved = resolvePath(base, path, objectsByName);
184+
if (!resolved) continue;
185+
const heading = [...resolved.labels, resolved.target.label].join(' → ');
186+
for (const f of resolved.target.fields) {
187+
options.push({ value: `${path}.${f.name}`, label: f.label, type: f.type, group: heading });
164188
}
165189
}
166190
return options;
@@ -238,29 +262,48 @@ export function useDatasetFieldCatalog(
238262
const baseDoc = await client.get<Record<string, unknown>>('object', object);
239263
if (cancelled) return;
240264
const base = normalizeObject(baseDoc, object);
241-
// Resolve which target objects the included relationships point at,
242-
// then fetch each unique target's fields for `rel.field` options.
243-
const wantedTargets = new Set<string>();
244-
for (const rel of includeKey ? includeKey.split('') : []) {
245-
const r = base.relationships.find((x) => x.name === rel);
246-
if (r?.referenceTo) wantedTargets.add(r.referenceTo);
265+
const includeList = includeKey ? includeKey.split('') : [];
266+
// Walk each included PATH hop-by-hop, fetching every object along the
267+
// chain (memoized by name) so multi-hop `a.b.field` paths resolve
268+
// (ADR-0021 single-hop, generalized by ADR-0071). Hops can't be fetched
269+
// in parallel across a chain — hop N's target is only known once hop
270+
// N-1 is fetched.
271+
const objectsByName: Record<string, NormalizedObject> = { [object]: base };
272+
const fetchObject = async (name: string): Promise<NormalizedObject> => {
273+
if (objectsByName[name]) return objectsByName[name];
274+
let norm: NormalizedObject;
275+
try {
276+
norm = normalizeObject(await client.get<Record<string, unknown>>('object', name), name);
277+
} catch {
278+
norm = normalizeObject(null, name);
279+
}
280+
objectsByName[name] = norm;
281+
return norm;
282+
};
283+
for (const path of includeList) {
284+
let current = base;
285+
for (const seg of path.split('.')) {
286+
const rel = current.relationships.find((r) => r.name === seg);
287+
if (!rel?.referenceTo) break;
288+
current = await fetchObject(rel.referenceTo);
289+
}
247290
}
248-
const targetEntries = await Promise.all(
249-
[...wantedTargets].map(async (t) => {
250-
try {
251-
const doc = await client.get<Record<string, unknown>>('object', t);
252-
return [t, normalizeObject(doc, t)] as const;
253-
} catch {
254-
return [t, normalizeObject(null, t)] as const;
255-
}
256-
}),
257-
);
258291
if (cancelled) return;
259-
const targets: Record<string, NormalizedObject> = Object.fromEntries(targetEntries);
260-
const includeList = includeKey ? includeKey.split('') : [];
292+
// The include combo offers base relationships AND one level deeper along
293+
// each already-included path (so the author drills `account` ->
294+
// `account.owner`), capped at the 3-hop ADR-0071 limit.
295+
const relationshipPaths: DatasetRelationship[] = [...base.relationships];
296+
for (const path of includeList) {
297+
if (path.split('.').length >= 3) continue;
298+
const resolved = resolvePath(base, path, objectsByName);
299+
if (!resolved) continue;
300+
for (const r of resolved.target.relationships) {
301+
relationshipPaths.push({ name: `${path}.${r.name}`, label: `${path}.${r.name}`, referenceTo: r.referenceTo });
302+
}
303+
}
261304
setState({
262-
relationships: base.relationships,
263-
fieldOptions: buildFieldOptions(base, includeList, targets),
305+
relationships: relationshipPaths,
306+
fieldOptions: buildFieldOptions(base, includeList, objectsByName),
264307
loading: false,
265308
});
266309
} catch {

0 commit comments

Comments
 (0)