Skip to content

Commit 7888b7d

Browse files
authored
Merge pull request #137 from code0-tech/feat/recursion-data-types
Fix recursion data types
2 parents ccd726e + 610bde6 commit 7888b7d

2 files changed

Lines changed: 215 additions & 3 deletions

File tree

src/util/references.util.ts

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,50 @@ const decodeParameterName = (name: string): { nodeFunctionId: string; paramIndex
229229
return { nodeFunctionId, paramIndexFromName };
230230
};
231231

232+
/**
233+
* Maximum property depth for reference path extraction through recursive data
234+
* types. Only applies to types that participate in a reference cycle: the
235+
* visited set keeps each individual branch finite, but a cluster of mutually
236+
* recursive types still allows combinatorially many simple paths, so those are
237+
* additionally depth-capped. Non-recursive nesting is traversed exhaustively.
238+
*/
239+
const MAX_REFERENCE_DEPTH = 7;
240+
241+
/**
242+
* Determines whether an object type participates in a reference cycle, i.e. can
243+
* reach itself again through its (non-nullable) property types. Results are
244+
* memoized in the given cache since the same named types repeat throughout a
245+
* traversal. The DFS itself is guarded by its own seen set, so it terminates on
246+
* cycles that do not lead back to the start type.
247+
*/
248+
const isRecursiveType = (
249+
type: ts.Type,
250+
checker: ts.TypeChecker,
251+
cache: Map<ts.Type, boolean>
252+
): boolean => {
253+
const cached = cache.get(type);
254+
if (cached !== undefined) return cached;
255+
256+
const seen = new Set<ts.Type>();
257+
const reachesStart = (current: ts.Type): boolean => {
258+
if (seen.has(current) || !isRealObjectType(current)) return false;
259+
seen.add(current);
260+
261+
for (const property of current.getProperties()) {
262+
if (!property.valueDeclaration) continue;
263+
const propType = checker.getNonNullableType(
264+
checker.getTypeOfSymbolAtLocation(property, property.valueDeclaration)
265+
);
266+
if (propType === type || reachesStart(propType)) return true;
267+
}
268+
return false;
269+
};
270+
271+
const result = reachesStart(type);
272+
cache.set(type, result);
273+
return result;
274+
};
275+
232276
/**
233277
* Recursively extracts object properties from a type that match an expected type.
234278
*
@@ -243,6 +287,8 @@ const decodeParameterName = (name: string): { nodeFunctionId: string; paramIndex
243287
* @param checker - TypeScript TypeChecker for type comparison operations
244288
* @param expectedType - The target type to match when extracting properties
245289
* @param currentPath - The current property path being built during recursion (default: empty array)
290+
* @param visited - Object types already on the current traversal branch, used to break cycles in recursive data types
291+
* @param recursionCache - Memoization cache for isRecursiveType, shared across the whole traversal
246292
* @returns An array of objects containing the property path and the type at that path
247293
*
248294
* @example
@@ -253,7 +299,9 @@ const extractObjectProperties = (
253299
type: ts.Type,
254300
checker: ts.TypeChecker,
255301
expectedType: ts.Type,
256-
currentPath: ReferencePath[] = []
302+
currentPath: ReferencePath[] = [],
303+
visited: Set<ts.Type> = new Set(),
304+
recursionCache: Map<ts.Type, boolean> = new Map()
257305
): Array<{ path: ReferencePath[]; type: ts.Type }> => {
258306
const results: Array<{ path: ReferencePath[]; type: ts.Type }> = [];
259307

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

285345
// Recurse into nested properties
286-
results.push(...extractObjectProperties(propType, checker, expectedType, newPath));
346+
results.push(...extractObjectProperties(propType, checker, expectedType, newPath, visited, recursionCache));
287347
});
348+
visited.delete(nonNullableType);
288349
}
289350
}
290351

test/schema/schema.test.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -899,4 +899,155 @@ describe("Schema", () => {
899899
).toBe(true);
900900
});
901901

902+
it('offers all paths of a deeply nested non-recursive object as ReferenceValue suggestions', () => {
903+
// Node 1: custom::test::deeply_nested(): a 10-level nested object ending in TEXT.
904+
// Node 2: std::text::split(value: TEXT, delimiter: TEXT): LIST<TEXT>
905+
//
906+
// Reference path extraction caps traversal depth only for recursive data
907+
// types. A deeply nested but acyclic object must be traversed exhaustively,
908+
// so even the leaf 10 levels down is offered as a suggestion.
909+
const DEEPLY_NESTED_FN: FunctionDefinition = {
910+
id: "gid://sagittarius/FunctionDefinition/9004",
911+
identifier: "custom::test::deeply_nested",
912+
signature: "(): { l1: { l2: { l3: { l4: { l5: { l6: { l7: { l8: { l9: { l10: TEXT } } } } } } } } } }",
913+
};
914+
915+
const flow: Flow = {
916+
id: "gid://sagittarius/Flow/1",
917+
startingNodeId: "gid://sagittarius/NodeFunction/1",
918+
signature: "(): void",
919+
nodes: {
920+
nodes: [
921+
{
922+
id: "gid://sagittarius/NodeFunction/1",
923+
functionDefinition: {identifier: "custom::test::deeply_nested"},
924+
nextNodeId: "gid://sagittarius/NodeFunction/2",
925+
parameters: {
926+
nodes: [],
927+
},
928+
},
929+
{
930+
id: "gid://sagittarius/NodeFunction/2",
931+
functionDefinition: {identifier: "std::text::split"},
932+
parameters: {
933+
nodes: [
934+
{value: null},
935+
{value: {__typename: "LiteralValue", value: ","}},
936+
],
937+
},
938+
},
939+
],
940+
},
941+
};
942+
943+
const [valueSchema] = getSignatureSchema(
944+
flow,
945+
DATA_TYPES,
946+
[...FUNCTION_SIGNATURES, DEEPLY_NESTED_FN],
947+
"gid://sagittarius/NodeFunction/2",
948+
);
949+
950+
// value: TEXT → free-form text input.
951+
expect(valueSchema.schema.input).toBe("text");
952+
953+
const paths = ((valueSchema.schema.suggestions ?? []) as any[])
954+
.filter(
955+
(s) =>
956+
s.__typename === "ReferenceValue" &&
957+
s.nodeFunctionId === "gid://sagittarius/NodeFunction/1",
958+
)
959+
.map((s) => (s.referencePath ?? []).map((p: any) => p.path).join("."));
960+
961+
expect(paths).toContain("l1.l2.l3.l4.l5.l6.l7.l8.l9.l10");
962+
});
963+
964+
it('cuts cycles and depth-caps traversal of recursive data types', () => {
965+
// Two families of recursive data types, both returned by node 1:
966+
//
967+
// 1. A mutually recursive pair (RECURSIVE_ORDER ↔ RECURSIVE_CUSTOMER), the
968+
// same shape external actions ship for entity graphs (e.g. Shopware's
969+
// order → delivery → order). A branch must stop as soon as a type
970+
// already on it reappears, instead of overflowing the stack.
971+
//
972+
// 2. A cycle of 8 distinct types (CHAIN_1 → … → CHAIN_8 → CHAIN_1). The
973+
// per-branch cycle cut alone would allow simple paths through all 8
974+
// types, so recursive types are additionally depth-capped at 7 levels.
975+
const RECURSIVE_DATA_TYPES = [
976+
{identifier: "RECURSIVE_ORDER", genericKeys: [], type: "{ id: TEXT; customer?: RECURSIVE_CUSTOMER | null }"},
977+
{identifier: "RECURSIVE_CUSTOMER", genericKeys: [], type: "{ name: TEXT; lastOrder?: RECURSIVE_ORDER | null }"},
978+
{identifier: "CHAIN_1", genericKeys: [], type: "{ value: TEXT; next?: CHAIN_2 | null }"},
979+
{identifier: "CHAIN_2", genericKeys: [], type: "{ value: TEXT; next?: CHAIN_3 | null }"},
980+
{identifier: "CHAIN_3", genericKeys: [], type: "{ value: TEXT; next?: CHAIN_4 | null }"},
981+
{identifier: "CHAIN_4", genericKeys: [], type: "{ value: TEXT; next?: CHAIN_5 | null }"},
982+
{identifier: "CHAIN_5", genericKeys: [], type: "{ value: TEXT; next?: CHAIN_6 | null }"},
983+
{identifier: "CHAIN_6", genericKeys: [], type: "{ value: TEXT; next?: CHAIN_7 | null }"},
984+
{identifier: "CHAIN_7", genericKeys: [], type: "{ value: TEXT; next?: CHAIN_8 | null }"},
985+
{identifier: "CHAIN_8", genericKeys: [], type: "{ value: TEXT; next?: CHAIN_1 | null }"},
986+
];
987+
988+
const RECURSIVE_FN: FunctionDefinition = {
989+
id: "gid://sagittarius/FunctionDefinition/9005",
990+
identifier: "custom::test::recursive",
991+
signature: "(): { pair: RECURSIVE_ORDER; chain: CHAIN_1 }",
992+
};
993+
994+
const flow: Flow = {
995+
id: "gid://sagittarius/Flow/1",
996+
startingNodeId: "gid://sagittarius/NodeFunction/1",
997+
signature: "(): void",
998+
nodes: {
999+
nodes: [
1000+
{
1001+
id: "gid://sagittarius/NodeFunction/1",
1002+
functionDefinition: {identifier: "custom::test::recursive"},
1003+
nextNodeId: "gid://sagittarius/NodeFunction/2",
1004+
parameters: {
1005+
nodes: [],
1006+
},
1007+
},
1008+
{
1009+
id: "gid://sagittarius/NodeFunction/2",
1010+
functionDefinition: {identifier: "std::text::split"},
1011+
parameters: {
1012+
nodes: [
1013+
{value: null},
1014+
{value: {__typename: "LiteralValue", value: ","}},
1015+
],
1016+
},
1017+
},
1018+
],
1019+
},
1020+
};
1021+
1022+
const [valueSchema] = getSignatureSchema(
1023+
flow,
1024+
[...DATA_TYPES, ...RECURSIVE_DATA_TYPES],
1025+
[...FUNCTION_SIGNATURES, RECURSIVE_FN],
1026+
"gid://sagittarius/NodeFunction/2",
1027+
);
1028+
1029+
// value: TEXT → free-form text input.
1030+
expect(valueSchema.schema.input).toBe("text");
1031+
1032+
const paths = ((valueSchema.schema.suggestions ?? []) as any[])
1033+
.filter(
1034+
(s) =>
1035+
s.__typename === "ReferenceValue" &&
1036+
s.nodeFunctionId === "gid://sagittarius/NodeFunction/1",
1037+
)
1038+
.map((s) => (s.referencePath ?? []).map((p: any) => p.path).join("."));
1039+
1040+
// Properties inside the cycle are still reachable …
1041+
expect(paths).toContain("pair.id");
1042+
expect(paths).toContain("pair.customer.name");
1043+
// … but the branch stops where RECURSIVE_ORDER would reappear on it.
1044+
expect(paths).not.toContain("pair.customer.lastOrder.id");
1045+
1046+
// The chain is walked through distinct types up to the depth cap of 7
1047+
// (chain + 5×next + value) …
1048+
expect(paths).toContain("chain.next.next.next.next.next.value");
1049+
// … and no further, even though the next type would still be new to the branch.
1050+
expect(paths).not.toContain("chain.next.next.next.next.next.next.value");
1051+
});
1052+
9021053
})

0 commit comments

Comments
 (0)