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
67 changes: 64 additions & 3 deletions src/util/references.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,50 @@ const decodeParameterName = (name: string): { nodeFunctionId: string; paramIndex
return { nodeFunctionId, paramIndexFromName };
};

/**
* Maximum property depth for reference path extraction through recursive data
* types. Only applies to types that participate in a reference cycle: the
* visited set keeps each individual branch finite, but a cluster of mutually
* recursive types still allows combinatorially many simple paths, so those are
* additionally depth-capped. Non-recursive nesting is traversed exhaustively.
*/
const MAX_REFERENCE_DEPTH = 7;

/**
* Determines whether an object type participates in a reference cycle, i.e. can
* reach itself again through its (non-nullable) property types. Results are
* memoized in the given cache since the same named types repeat throughout a
* traversal. The DFS itself is guarded by its own seen set, so it terminates on
* cycles that do not lead back to the start type.
*/
const isRecursiveType = (
type: ts.Type,
checker: ts.TypeChecker,
cache: Map<ts.Type, boolean>
): boolean => {
const cached = cache.get(type);
if (cached !== undefined) return cached;

const seen = new Set<ts.Type>();
const reachesStart = (current: ts.Type): boolean => {
if (seen.has(current) || !isRealObjectType(current)) return false;
seen.add(current);

for (const property of current.getProperties()) {
if (!property.valueDeclaration) continue;
const propType = checker.getNonNullableType(
checker.getTypeOfSymbolAtLocation(property, property.valueDeclaration)
);
if (propType === type || reachesStart(propType)) return true;
}
return false;
};

const result = reachesStart(type);
cache.set(type, result);
return result;
};

/**
* Recursively extracts object properties from a type that match an expected type.
*
Expand All @@ -243,6 +287,8 @@ const decodeParameterName = (name: string): { nodeFunctionId: string; paramIndex
* @param checker - TypeScript TypeChecker for type comparison operations
* @param expectedType - The target type to match when extracting properties
* @param currentPath - The current property path being built during recursion (default: empty array)
* @param visited - Object types already on the current traversal branch, used to break cycles in recursive data types
* @param recursionCache - Memoization cache for isRecursiveType, shared across the whole traversal
* @returns An array of objects containing the property path and the type at that path
*
* @example
Expand All @@ -253,7 +299,9 @@ const extractObjectProperties = (
type: ts.Type,
checker: ts.TypeChecker,
expectedType: ts.Type,
currentPath: ReferencePath[] = []
currentPath: ReferencePath[] = [],
visited: Set<ts.Type> = new Set(),
recursionCache: Map<ts.Type, boolean> = new Map()
): Array<{ path: ReferencePath[]; type: ts.Type }> => {
const results: Array<{ path: ReferencePath[]; type: ts.Type }> = [];

Expand All @@ -274,17 +322,30 @@ const extractObjectProperties = (
// Recursively traverse into object properties. Traversal also runs on the
// non-nullable type: a nullable object in the chain (e.g. `{bla?: TEXT} | null`)
// is a union whose getProperties() is empty, which would cut off all nested paths.
if (isRealObjectType(nonNullableType)) {
// Recursive data types (e.g. Order.delivery.order) are cut off via the visited
// set — the checker caches type identities, so a cycle revisits the same ts.Type
// object. The set only tracks the current branch (backtracked below) so the same
// type may still appear on sibling paths. The depth cap only applies to types
// that are part of a reference cycle (checked last, it's the expensive test);
// purely nested non-recursive objects are traversed to arbitrary depth.
if (
isRealObjectType(nonNullableType) &&
!visited.has(nonNullableType) &&
(currentPath.length < MAX_REFERENCE_DEPTH ||
!isRecursiveType(nonNullableType, checker, recursionCache))
) {
const properties = nonNullableType.getProperties();
if (properties && properties.length > 0) {
visited.add(nonNullableType);
properties.forEach((property) => {
const propType = checker.getTypeOfSymbolAtLocation(property, property.valueDeclaration!);
const propName = property.getName();
const newPath = [...currentPath, { path: propName }];

// Recurse into nested properties
results.push(...extractObjectProperties(propType, checker, expectedType, newPath));
results.push(...extractObjectProperties(propType, checker, expectedType, newPath, visited, recursionCache));
});
visited.delete(nonNullableType);
}
}

Expand Down
151 changes: 151 additions & 0 deletions test/schema/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -899,4 +899,155 @@ describe("Schema", () => {
).toBe(true);
});

it('offers all paths of a deeply nested non-recursive object as ReferenceValue suggestions', () => {
// Node 1: custom::test::deeply_nested(): a 10-level nested object ending in TEXT.
// Node 2: std::text::split(value: TEXT, delimiter: TEXT): LIST<TEXT>
//
// Reference path extraction caps traversal depth only for recursive data
// types. A deeply nested but acyclic object must be traversed exhaustively,
// so even the leaf 10 levels down is offered as a suggestion.
const DEEPLY_NESTED_FN: FunctionDefinition = {
id: "gid://sagittarius/FunctionDefinition/9004",
identifier: "custom::test::deeply_nested",
signature: "(): { l1: { l2: { l3: { l4: { l5: { l6: { l7: { l8: { l9: { l10: TEXT } } } } } } } } } }",
};

const flow: Flow = {
id: "gid://sagittarius/Flow/1",
startingNodeId: "gid://sagittarius/NodeFunction/1",
signature: "(): void",
nodes: {
nodes: [
{
id: "gid://sagittarius/NodeFunction/1",
functionDefinition: {identifier: "custom::test::deeply_nested"},
nextNodeId: "gid://sagittarius/NodeFunction/2",
parameters: {
nodes: [],
},
},
{
id: "gid://sagittarius/NodeFunction/2",
functionDefinition: {identifier: "std::text::split"},
parameters: {
nodes: [
{value: null},
{value: {__typename: "LiteralValue", value: ","}},
],
},
},
],
},
};

const [valueSchema] = getSignatureSchema(
flow,
DATA_TYPES,
[...FUNCTION_SIGNATURES, DEEPLY_NESTED_FN],
"gid://sagittarius/NodeFunction/2",
);

// value: TEXT → free-form text input.
expect(valueSchema.schema.input).toBe("text");

const paths = ((valueSchema.schema.suggestions ?? []) as any[])
.filter(
(s) =>
s.__typename === "ReferenceValue" &&
s.nodeFunctionId === "gid://sagittarius/NodeFunction/1",
)
.map((s) => (s.referencePath ?? []).map((p: any) => p.path).join("."));

expect(paths).toContain("l1.l2.l3.l4.l5.l6.l7.l8.l9.l10");
});

it('cuts cycles and depth-caps traversal of recursive data types', () => {
// Two families of recursive data types, both returned by node 1:
//
// 1. A mutually recursive pair (RECURSIVE_ORDER ↔ RECURSIVE_CUSTOMER), the
// same shape external actions ship for entity graphs (e.g. Shopware's
// order → delivery → order). A branch must stop as soon as a type
// already on it reappears, instead of overflowing the stack.
//
// 2. A cycle of 8 distinct types (CHAIN_1 → … → CHAIN_8 → CHAIN_1). The
// per-branch cycle cut alone would allow simple paths through all 8
// types, so recursive types are additionally depth-capped at 7 levels.
const RECURSIVE_DATA_TYPES = [
{identifier: "RECURSIVE_ORDER", genericKeys: [], type: "{ id: TEXT; customer?: RECURSIVE_CUSTOMER | null }"},
{identifier: "RECURSIVE_CUSTOMER", genericKeys: [], type: "{ name: TEXT; lastOrder?: RECURSIVE_ORDER | null }"},
{identifier: "CHAIN_1", genericKeys: [], type: "{ value: TEXT; next?: CHAIN_2 | null }"},
{identifier: "CHAIN_2", genericKeys: [], type: "{ value: TEXT; next?: CHAIN_3 | null }"},
{identifier: "CHAIN_3", genericKeys: [], type: "{ value: TEXT; next?: CHAIN_4 | null }"},
{identifier: "CHAIN_4", genericKeys: [], type: "{ value: TEXT; next?: CHAIN_5 | null }"},
{identifier: "CHAIN_5", genericKeys: [], type: "{ value: TEXT; next?: CHAIN_6 | null }"},
{identifier: "CHAIN_6", genericKeys: [], type: "{ value: TEXT; next?: CHAIN_7 | null }"},
{identifier: "CHAIN_7", genericKeys: [], type: "{ value: TEXT; next?: CHAIN_8 | null }"},
{identifier: "CHAIN_8", genericKeys: [], type: "{ value: TEXT; next?: CHAIN_1 | null }"},
];

const RECURSIVE_FN: FunctionDefinition = {
id: "gid://sagittarius/FunctionDefinition/9005",
identifier: "custom::test::recursive",
signature: "(): { pair: RECURSIVE_ORDER; chain: CHAIN_1 }",
};

const flow: Flow = {
id: "gid://sagittarius/Flow/1",
startingNodeId: "gid://sagittarius/NodeFunction/1",
signature: "(): void",
nodes: {
nodes: [
{
id: "gid://sagittarius/NodeFunction/1",
functionDefinition: {identifier: "custom::test::recursive"},
nextNodeId: "gid://sagittarius/NodeFunction/2",
parameters: {
nodes: [],
},
},
{
id: "gid://sagittarius/NodeFunction/2",
functionDefinition: {identifier: "std::text::split"},
parameters: {
nodes: [
{value: null},
{value: {__typename: "LiteralValue", value: ","}},
],
},
},
],
},
};

const [valueSchema] = getSignatureSchema(
flow,
[...DATA_TYPES, ...RECURSIVE_DATA_TYPES],
[...FUNCTION_SIGNATURES, RECURSIVE_FN],
"gid://sagittarius/NodeFunction/2",
);

// value: TEXT → free-form text input.
expect(valueSchema.schema.input).toBe("text");

const paths = ((valueSchema.schema.suggestions ?? []) as any[])
.filter(
(s) =>
s.__typename === "ReferenceValue" &&
s.nodeFunctionId === "gid://sagittarius/NodeFunction/1",
)
.map((s) => (s.referencePath ?? []).map((p: any) => p.path).join("."));

// Properties inside the cycle are still reachable …
expect(paths).toContain("pair.id");
expect(paths).toContain("pair.customer.name");
// … but the branch stops where RECURSIVE_ORDER would reappear on it.
expect(paths).not.toContain("pair.customer.lastOrder.id");

// The chain is walked through distinct types up to the depth cap of 7
// (chain + 5×next + value) …
expect(paths).toContain("chain.next.next.next.next.next.value");
// … and no further, even though the next type would still be new to the branch.
expect(paths).not.toContain("chain.next.next.next.next.next.next.value");
});

})