Skip to content

Commit c7d209c

Browse files
authored
Merge pull request #127 from code0-tech/feat/#126
Fixing schema and suggestions by combination
2 parents ebf1f50 + 3b2c069 commit c7d209c

3 files changed

Lines changed: 519 additions & 28 deletions

File tree

src/schema/getSignatureSchema.ts

Lines changed: 71 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
11
import {DataType, Flow, FunctionDefinition, NodeFunction} from "@code0-tech/sagittarius-graphql-types"
22
import {createCompilerHost, generateFlowSourceCode, sanitizeId} from "../utils"
33
import ts, {Type} from "typescript"
4-
import {getSchema, Schema} from "../util/schema.util"
4+
import {getSchema, mergeSchemas, Schema} from "../util/schema.util"
55

66
/**
77
* Represents the schema information for a node parameter.
88
* Includes the parameter's schema definition and any parameter dependencies that block it.
99
*/
1010
export interface NodeSchema {
1111
nodeId: NodeFunction["id"]
12-
/** The schema definition for this node parameter */
12+
/**
13+
* The schema definition for this node parameter. Produced by merging the
14+
* function-declared parameter schema with the node's concrete value schema:
15+
* the function schema drives the structural shape, the node schema contributes
16+
* additional suggestions, and a generic function parameter falls back to the
17+
* node's concrete shape (never as a select).
18+
*/
1319
schema: Schema
14-
/** The schema definition for the function parameter */
15-
functionSchema: Schema
1620
/** Array of parameter indices that must be resolved before this parameter */
1721
blockedBy?: number[]
1822
}
@@ -95,6 +99,14 @@ export const getSignatureSchema = (
9599
// Identify parameter dependencies based on type parameters
96100
const funktionDependencies = getParameterDependencies(funktion!, nodeParameterTypes)
97101

102+
// Track which parameter slots actually carry a user-supplied value. The merge
103+
// uses this as a last-resort signal: if the function- and node-side schemas
104+
// both came out generic but the user did set something, the lift falls back
105+
// to `data` so the UI has an open object to render against.
106+
const valueProvidedByIndex = (targetNode?.parameters?.nodes ?? []).map(
107+
(p) => p?.value != null
108+
)
109+
98110
// Generate schema for each parameter
99111
return generateNodeSchemas(
100112
nodeId,
@@ -105,6 +117,7 @@ export const getSignatureSchema = (
105117
funktionDependencies,
106118
nodeId ? declaredFunctionsMap : new Map(),
107119
nodeId ? functions : [],
120+
valueProvidedByIndex,
108121
)
109122
}
110123

@@ -292,9 +305,11 @@ const getParameterDependencies = (
292305
* @param checker - The TypeScript type checker
293306
* @param node - The node's variable declaration
294307
* @param nodeParameterTypes - Merged parameter types to use for schema generation
308+
* @param functionParameterTypes
295309
* @param funktionDependencies - Parameter dependencies to link with each parameter
296310
* @param declaredFunctionsMap - Map of available functions for schema context
297311
* @param functions - Array of function definitions
312+
* @param valueProvidedByIndex
298313
* @returns Array of NodeSchema objects
299314
*/
300315
const generateNodeSchemas = (
@@ -306,31 +321,67 @@ const generateNodeSchemas = (
306321
funktionDependencies: ParameterDependency[],
307322
declaredFunctionsMap: Map<string, ts.FunctionDeclaration>,
308323
functions: FunctionDefinition[],
324+
valueProvidedByIndex: boolean[],
309325
): NodeSchema[] => {
310326
if (!nodeParameterTypes) {
311327
return []
312328
}
313329

314-
return nodeParameterTypes.map((parameterType, index) => ({
315-
nodeId: nodeId,
316-
schema: getSchema(
330+
return nodeParameterTypes.map((parameterType, index) => {
331+
const functionParameterType = functionParameterTypes?.[index]
332+
// Suggestions are scoped by what the *function* parameter accepts (e.g.
333+
// `T` widens to `any`, so anything in scope is a valid candidate), even
334+
// when the node value has narrowed the actual parameter type — otherwise
335+
// setting a boolean literal in a generic slot would silently hide all
336+
// other suggestions.
337+
const suggestionType = functionParameterType
338+
? widenForSuggestions(checker, functionParameterType)
339+
: undefined
340+
341+
const nodeSchema = getSchema(
317342
checker,
318343
node,
319344
parameterType,
320345
Array.from(declaredFunctionsMap.values()),
321-
functions
322-
),
323-
functionSchema: getSchema(
324-
checker,
325-
node,
326-
functionParameterTypes?.[index]!,
327-
Array.from(declaredFunctionsMap.values()),
328346
functions,
329-
false
330-
),
331-
blockedBy: funktionDependencies
332-
.filter((dep) => dep.parameterIndex === index)
333-
.map((dep) => dep.dependsOnIndex),
334-
}))
347+
true,
348+
suggestionType,
349+
)
350+
const functionSchema = functionParameterType
351+
? getSchema(
352+
checker,
353+
node,
354+
functionParameterType,
355+
Array.from(declaredFunctionsMap.values()),
356+
functions,
357+
false
358+
)
359+
: undefined
360+
361+
return {
362+
nodeId: nodeId,
363+
schema: mergeSchemas(
364+
functionSchema,
365+
nodeSchema,
366+
valueProvidedByIndex[index] ?? false,
367+
),
368+
blockedBy: funktionDependencies
369+
.filter((dep) => dep.parameterIndex === index)
370+
.map((dep) => dep.dependsOnIndex),
371+
}
372+
})
373+
}
374+
375+
// Widen a function parameter type so that suggestion collection asks "what could
376+
// the function accept here", not "what does the current value narrow this to".
377+
// An unconstrained type parameter accepts anything → `any`. A constrained type
378+
// parameter is replaced by its constraint. Everything else is used as-is.
379+
const widenForSuggestions = (checker: ts.TypeChecker, type: ts.Type): ts.Type => {
380+
if ((type.flags & ts.TypeFlags.TypeParameter) === 0) return type
381+
const decl = type.symbol?.declarations?.[0]
382+
if (decl && ts.isTypeParameterDeclaration(decl) && decl.constraint) {
383+
return checker.getTypeFromTypeNode(decl.constraint)
384+
}
385+
return checker.getAnyType()
335386
}
336387

src/util/schema.util.ts

Lines changed: 166 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -124,39 +124,46 @@ export const getSchema = (
124124
parameterType: ts.Type,
125125
functionDeclarations: FunctionDeclaration[],
126126
functions: FunctionDefinition[],
127-
suggestions: boolean = true
127+
suggestions: boolean = true,
128+
suggestionType?: ts.Type,
128129
): Schema => {
129130

130131
if ((parameterType.flags & ts.TypeFlags.TypeParameter) !== 0) {
131132
const decl = parameterType.symbol?.declarations?.[0]
132133
if (decl && ts.isTypeParameterDeclaration(decl) && decl.constraint) {
133134
// getTypeFromTypeNode statt getBaseConstraintOfType → aliasSymbol bleibt erhalten
134135
const constraintType = checker.getTypeFromTypeNode(decl.constraint)
135-
return getSchema(checker, node, constraintType, functionDeclarations, functions, suggestions)
136+
return getSchema(checker, node, constraintType, functionDeclarations, functions, suggestions, suggestionType)
136137
}
137138
}
138139

140+
// Suggestions are filtered by what the surrounding function accepts, not by
141+
// the narrower type a current value happens to narrow the node-side to.
142+
// Example: `<T>(value: T)` with a current boolean literal must still surface
143+
// every reference in scope, because the function takes anything.
144+
const typeForSuggestions = suggestionType ?? parameterType;
145+
139146
// Collect all available suggestions for this parameter
140147
const combinedSuggestions = suggestions ? {
141148
suggestions: [
142-
...getValues(parameterType, checker),
149+
...getValues(typeForSuggestions, checker),
143150
...(node ? getReferences(
144151
checker,
145152
node,
146-
parameterType,
153+
typeForSuggestions,
147154
checker.getSymbolsInScope(node, ts.SymbolFlags.Variable)
148155
) : []),
149156
...getNodes(
150157
checker,
151158
functionDeclarations,
152159
functions,
153-
parameterType
160+
typeForSuggestions
154161
),
155162
...getSubFlows(
156163
checker,
157164
functionDeclarations,
158165
functions,
159-
parameterType
166+
typeForSuggestions
160167
),
161168
],
162169
} : {};
@@ -274,9 +281,161 @@ export const getSchema = (
274281
};
275282
}
276283

277-
// Fallback for unknown or generic types
284+
// Fallback for unknown or generic types — still surface any collected
285+
// suggestions (e.g. references in scope) so the UI is never silently empty.
278286
return {
279287
input: "generic",
288+
...combinedSuggestions,
289+
};
290+
};
291+
292+
/**
293+
* Merges a function-declared parameter schema with the schema derived from the
294+
* concrete node value. The function schema is treated as the source of truth for
295+
* the structural shape (input kind, properties, items); the node schema only
296+
* contributes additional suggestions and, when the function schema is generic,
297+
* a fallback shape.
298+
*
299+
* Rules:
300+
* - If the function schema is generic, follow the node schema — but never as a
301+
* select. A single literal value (e.g. "Test") narrowing a generic T must not
302+
* collapse the input into a select with one option; it should remain free-form
303+
* text/number/boolean matching the literal kind.
304+
* - Otherwise use the function schema's input kind and merge suggestions from both.
305+
* Recurse into `properties` (for data) and `items` (for list) so nested generics
306+
* inside concrete containers are handled the same way.
307+
*
308+
* Suggestions are concatenated and de-duplicated by structural equality.
309+
*
310+
* @param functionSchema - The schema derived from the declared function parameter type
311+
* @param nodeSchema - The schema derived from the node's concrete (narrowed) parameter type
312+
* @returns A single merged schema
313+
*/
314+
export const mergeSchemas = (
315+
functionSchema: Schema | undefined,
316+
nodeSchema: Schema,
317+
valueProvided: boolean = false,
318+
): Schema => {
319+
if (!functionSchema) {
320+
return liftGenericIfValued(demoteSelect(nodeSchema), valueProvided);
321+
}
322+
323+
if (functionSchema.input === "generic") {
324+
return liftGenericIfValued(demoteSelect(nodeSchema), valueProvided);
325+
}
326+
327+
const suggestions = mergeSuggestions(
328+
functionSchema.suggestions,
329+
nodeSchema.suggestions,
330+
);
331+
332+
if (functionSchema.input === "data") {
333+
const fProps = functionSchema.properties ?? {};
334+
const nProps =
335+
nodeSchema.input === "data" ? (nodeSchema.properties ?? {}) : {};
336+
const properties: Record<string, Schema | Schema[]> = {};
337+
const keys = new Set([...Object.keys(fProps), ...Object.keys(nProps)]);
338+
for (const key of keys) {
339+
const f = fProps[key];
340+
const n = nProps[key];
341+
properties[key] = mergeProperty(f, n);
342+
}
343+
return {
344+
...functionSchema,
345+
properties,
346+
...(suggestions ? {suggestions} : {}),
347+
};
348+
}
349+
350+
if (functionSchema.input === "list") {
351+
const fItems = functionSchema.items ?? [];
352+
const nItems =
353+
nodeSchema.input === "list" ? (nodeSchema.items ?? []) : [];
354+
const items =
355+
fItems.length === nItems.length && fItems.length > 0
356+
? fItems.map((f, i) => mergeSchemas(f, nItems[i]))
357+
: fItems;
358+
return {
359+
...functionSchema,
360+
items,
361+
...(suggestions ? {suggestions} : {}),
362+
};
363+
}
364+
365+
return {
366+
...functionSchema,
367+
...(suggestions ? {suggestions} : {}),
368+
};
369+
};
370+
371+
const mergeProperty = (
372+
f: Schema | Schema[] | undefined,
373+
n: Schema | Schema[] | undefined,
374+
): Schema | Schema[] => {
375+
if (f && !Array.isArray(f) && n && !Array.isArray(n)) {
376+
return mergeSchemas(f, n);
377+
}
378+
return (f ?? n)!;
379+
};
380+
381+
const mergeSuggestions = (
382+
a: Input["suggestions"],
383+
b: Input["suggestions"],
384+
): Input["suggestions"] | undefined => {
385+
const all = [...(a ?? []), ...(b ?? [])];
386+
if (all.length === 0) return undefined;
387+
const seen = new Set<string>();
388+
const result: NonNullable<Input["suggestions"]> = [];
389+
for (const item of all) {
390+
const key = JSON.stringify(item);
391+
if (seen.has(key)) continue;
392+
seen.add(key);
393+
result.push(item);
394+
}
395+
return result;
396+
};
397+
398+
// Generic means "we extracted nothing structural from either side". That is the
399+
// right answer for an empty parameter slot, but if the user has provided a value
400+
// then the merge already had the node-side schema to draw from — if both sides
401+
// still came out generic (e.g. the value resolved to `any`/`unknown`), the most
402+
// useful open shape is `data`. Primitive values never reach this branch: their
403+
// node schema is text / number / boolean / select-then-demoted, so the merge
404+
// produces a concrete kind before this lift runs.
405+
const liftGenericIfValued = (schema: Schema, valueProvided: boolean): Schema => {
406+
if (!valueProvided || schema.input !== "generic") return schema;
407+
return {
408+
input: "data",
409+
properties: {},
410+
required: [],
411+
...(schema.suggestions ? {suggestions: schema.suggestions} : {}),
412+
};
413+
};
414+
415+
const demoteSelect = (schema: Schema): Schema => {
416+
if (schema.input !== "select") return schema;
417+
const suggestions = schema.suggestions ?? [];
418+
const literalKinds = new Set<string>();
419+
for (const s of suggestions) {
420+
const value = (s as LiteralValue).value;
421+
const kind = typeof value;
422+
if (kind === "string" || kind === "number" || kind === "boolean") {
423+
literalKinds.add(kind);
424+
}
425+
}
426+
const target: PrimitiveInput["input"] =
427+
literalKinds.size === 1
428+
? (
429+
{
430+
string: "text",
431+
number: "number",
432+
boolean: "boolean",
433+
} as const
434+
)[[...literalKinds][0] as "string" | "number" | "boolean"]
435+
: "text";
436+
return {
437+
input: target,
438+
...(suggestions.length > 0 ? {suggestions} : {}),
280439
};
281440
};
282441

0 commit comments

Comments
 (0)