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
19 changes: 19 additions & 0 deletions .changeset/dataset-multihop-ui.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
fieldTypeToDimensionType,
normalizeObject,
buildFieldOptions,
resolvePath,
referencesDataset,
type NormalizedObject,
} from './useDatasetFields';
Expand Down Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -140,27 +140,51 @@ export function normalizeObject(doc: Record<string, unknown> | 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<string, NormalizedObject>,
): { 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<string, NormalizedObject>,
objectsByName: Record<string, NormalizedObject>,
): DatasetFieldOption[] {
const options: DatasetFieldOption[] = base.fields.map((f) => ({
value: f.name,
label: f.label,
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;
Expand Down Expand Up @@ -238,29 +262,48 @@ export function useDatasetFieldCatalog(
const baseDoc = await client.get<Record<string, unknown>>('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<string>();
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<string, NormalizedObject> = { [object]: base };
const fetchObject = async (name: string): Promise<NormalizedObject> => {
if (objectsByName[name]) return objectsByName[name];
let norm: NormalizedObject;
try {
norm = normalizeObject(await client.get<Record<string, unknown>>('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<Record<string, unknown>>('object', t);
return [t, normalizeObject(doc, t)] as const;
} catch {
return [t, normalizeObject(null, t)] as const;
}
}),
);
if (cancelled) return;
const targets: Record<string, NormalizedObject> = 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 {
Expand Down