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
12 changes: 10 additions & 2 deletions src/util/nodes.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,16 @@ const createNodeFunctionIfCompatible = (
// Simplify the return type by resolving type parameters
const simplifiedReturnType = resolveReturnType(checker, returnType);

// Only proceed if the return type is assignable to the target parameter type
if (!checker.isTypeAssignableTo(simplifiedReturnType, paramType)) {
// Only proceed if the return type is assignable to the target parameter type.
// The nullish part of the return type is ignored: a function returning
// `string | null` is still a valid suggestion for a plain `string` parameter.
// A purely nullish return type strips down to `never` (assignable to anything),
// so it must be excluded explicitly.
const nonNullableReturnType = checker.getNonNullableType(simplifiedReturnType);
if (
(nonNullableReturnType.flags & ts.TypeFlags.Never) !== 0 ||
!checker.isTypeAssignableTo(nonNullableReturnType, paramType)
) {
return null;
}

Expand Down
13 changes: 11 additions & 2 deletions src/util/references.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,8 +257,17 @@ const extractObjectProperties = (
): Array<{ path: ReferencePath[]; type: ts.Type }> => {
const results: Array<{ path: ReferencePath[]; type: ts.Type }> = [];

// Check if the current type matches the expected type
if (checker.isTypeAssignableTo(type, expectedType)) {
// Check if the current type matches the expected type. The nullish part of a
// candidate is ignored: a reference typed `string | null` (or an optional
// property `text?: string | null`) is still a valid suggestion for a plain
// `string` parameter — strict assignability would reject it under strictNullChecks.
// A purely nullish candidate strips down to `never` (assignable to anything),
// so it must be excluded explicitly.
const nonNullableType = checker.getNonNullableType(type);
if (
(nonNullableType.flags & ts.TypeFlags.Never) === 0 &&
checker.isTypeAssignableTo(nonNullableType, expectedType)
) {
results.push({ path: currentPath, type });
}

Expand Down
6 changes: 5 additions & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,11 @@ export function generateFlowSourceCode(
ref.referencePath?.forEach(pathObj => {
refCode += `?.${pathObj.path}`;
});
return `/* @pos ${id} ${index} */ ${refCode}`;
// Non-null assertion: a reference typed `string | null` (or an optional
// chain like `node_X?.text`) must still satisfy a plain `string` parameter
// under strictNullChecks — only the nullish part is waived, base type
// mismatches still fail validation.
return `/* @pos ${id} ${index} */ (${refCode})!`;
}
if (val.__typename === "LiteralValue") {
const jsonString = val?.value !== null && val?.value !== undefined ? stringify(val?.value) : undefined
Expand Down
116 changes: 115 additions & 1 deletion test/flowValidation.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {describe, expect, it} from 'vitest';
import {getFlowValidation} from '../src/validation/getFlowValidation';
import {Flow} from "@code0-tech/sagittarius-graphql-types"; // Pfad ggf. anpassen
import {Flow, FunctionDefinition} from "@code0-tech/sagittarius-graphql-types"; // Pfad ggf. anpassen
// @ts-ignore
import {DATA_TYPES, FUNCTION_SIGNATURES} from "./data";

Expand Down Expand Up @@ -1326,4 +1326,118 @@ describe('getFlowValidation - Integrationstest', () => {
]);
});

describe('nullable references into non-nullable parameters', () => {

// custom::text::nullable(): TEXT | null — no parameters, returns a nullable string.
const NULLABLE_TEXT_FN: FunctionDefinition = {
id: "gid://sagittarius/FunctionDefinition/9001",
identifier: "custom::text::nullable",
signature: "(): TEXT | null",
};

// custom::text::nullable_object(): {text?: TEXT | null} — returns an object whose
// `text` property is optional and nullable.
const NULLABLE_OBJECT_FN: FunctionDefinition = {
id: "gid://sagittarius/FunctionDefinition/9002",
identifier: "custom::text::nullable_object",
signature: "(): {text?: TEXT | null}",
};

// custom::number::nullable(): NUMBER | null — nullable, but the base type is
// a number, so it must stay incompatible with a TEXT parameter.
const NULLABLE_NUMBER_FN: FunctionDefinition = {
id: "gid://sagittarius/FunctionDefinition/9003",
identifier: "custom::number::nullable",
signature: "(): NUMBER | null",
};

const CUSTOM_FUNCTIONS = [
...FUNCTION_SIGNATURES,
NULLABLE_TEXT_FN,
NULLABLE_OBJECT_FN,
NULLABLE_NUMBER_FN,
];

// Builds a two-node flow: node 1 runs `firstIdentifier` (no parameters),
// node 2 runs std::text::split(value: TEXT, delimiter: TEXT) with `valueRef`
// as its `value` argument.
const buildFlow = (firstIdentifier: string, valueRef: any): Flow => ({
id: "gid://sagittarius/Flow/1",
startingNodeId: "gid://sagittarius/NodeFunction/1",
signature: "(): void",
nodes: {
nodes: [
{
id: "gid://sagittarius/NodeFunction/1",
functionDefinition: {identifier: firstIdentifier},
nextNodeId: "gid://sagittarius/NodeFunction/2",
parameters: {
nodes: [],
},
},
{
id: "gid://sagittarius/NodeFunction/2",
functionDefinition: {identifier: "std::text::split"},
parameters: {
nodes: [
{value: valueRef},
{value: {__typename: "LiteralValue", value: ","}},
],
},
},
],
},
});

it('accepts a TEXT | null reference for a plain TEXT parameter', () => {
// Node 1 returns TEXT | null; node 2's `value` parameter wants TEXT.
// The nullish part of the reference must not fail the validation.
const flow = buildFlow("custom::text::nullable", {
__typename: "ReferenceValue",
nodeFunctionId: "gid://sagittarius/NodeFunction/1",
});

const result = getFlowValidation(flow, CUSTOM_FUNCTIONS, DATA_TYPES);

expect(result.diagnostics).toHaveLength(0);
expect(result.isValid).toBe(true);
});

it('accepts a nullable object property reference for a plain TEXT parameter', () => {
// Node 1 returns {text?: TEXT | null}; the reference path drills into `text`
// (TEXT | null | undefined). The nullish part must not fail the validation.
const flow = buildFlow("custom::text::nullable_object", {
__typename: "ReferenceValue",
nodeFunctionId: "gid://sagittarius/NodeFunction/1",
referencePath: [{path: "text"}],
});

const result = getFlowValidation(flow, CUSTOM_FUNCTIONS, DATA_TYPES);

expect(result.diagnostics).toHaveLength(0);
expect(result.isValid).toBe(true);
});

it('still rejects a NUMBER | null reference for a plain TEXT parameter', () => {
// Ignoring the nullish part must not make the base types interchangeable:
// NUMBER | null stripped to NUMBER is still not a TEXT.
const flow = buildFlow("custom::number::nullable", {
__typename: "ReferenceValue",
nodeFunctionId: "gid://sagittarius/NodeFunction/1",
});

const result = getFlowValidation(flow, CUSTOM_FUNCTIONS, DATA_TYPES);

expect(result.isValid).toBe(false);
expect(result.diagnostics).toEqual(expect.arrayContaining([
expect.objectContaining({
nodeId: "gid://sagittarius/NodeFunction/2",
parameterIndex: 0,
severity: "error",
}),
]));
});

});

});
139 changes: 138 additions & 1 deletion test/schema/schema.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {describe, expect, it} from "vitest";
import {Flow} from "@code0-tech/sagittarius-graphql-types";
import {Flow, FunctionDefinition} from "@code0-tech/sagittarius-graphql-types";
import {getSignatureSchema, getTypeSchema} from "../../src";
import {DATA_TYPES, FUNCTION_SIGNATURES} from "../data";

Expand Down Expand Up @@ -688,4 +688,141 @@ describe("Schema", () => {
).toBe(true);
});

it('offers a nullable TEXT return as a ReferenceValue suggestion for a plain TEXT parameter', () => {
// Node 1: custom::text::nullable(): TEXT | null — no parameters, returns a
// nullable string.
// Node 2: std::text::split(value: TEXT, delimiter: TEXT): LIST<TEXT>
//
// The `value` parameter is declared as plain TEXT (string). Node 1's return
// type is string | null. Strict assignability would reject string | null → string,
// but the editor must still offer node 1 as a ReferenceValue suggestion here:
// the nullish part of a reference's type should be ignored for suggestion scoping.
const NULLABLE_TEXT_FN: FunctionDefinition = {
id: "gid://sagittarius/FunctionDefinition/9001",
identifier: "custom::text::nullable",
signature: "(): TEXT | null",
};

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::text::nullable"},
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, NULLABLE_TEXT_FN],
"gid://sagittarius/NodeFunction/2",
);

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

// Node 1 returns TEXT | null; stripping the nullish part leaves TEXT, which is
// assignable to the TEXT parameter. Its return value is a direct reference
// (no property path).
const suggestions = (valueSchema.schema.suggestions ?? []) as any[];
expect(
suggestions.some(
(s) =>
s.__typename === "ReferenceValue" &&
s.nodeFunctionId === "gid://sagittarius/NodeFunction/1" &&
!s.referencePath,
),
).toBe(true);
});

it('offers a nullable object property as a ReferenceValue path suggestion for a plain TEXT parameter', () => {
// Node 1: custom::text::nullable_object(): {text?: TEXT | null} — no parameters,
// returns an object whose `text` property is optional and nullable.
// Node 2: std::text::split(value: TEXT, delimiter: TEXT): LIST<TEXT>
//
// The `value` parameter is declared as plain TEXT (string). Node 1's `text`
// property has type string | null | undefined. Strict assignability would
// reject it, but the editor must still offer node 1's `text` property as a
// ReferenceValue suggestion with referencePath [{path: "text"}]: the nullish
// part of a reference's type should be ignored for suggestion scoping.
const NULLABLE_OBJECT_FN: FunctionDefinition = {
id: "gid://sagittarius/FunctionDefinition/9002",
identifier: "custom::text::nullable_object",
signature: "(): {text?: TEXT | null}",
};

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::text::nullable_object"},
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, NULLABLE_OBJECT_FN],
"gid://sagittarius/NodeFunction/2",
);

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

// Node 1's `text` property is TEXT | null | undefined; stripping the nullish
// part leaves TEXT, which is assignable to the TEXT parameter → node 1 must
// be offered with referencePath [{path: "text"}].
const suggestions = (valueSchema.schema.suggestions ?? []) as any[];
expect(
suggestions.some(
(s) =>
s.__typename === "ReferenceValue" &&
s.nodeFunctionId === "gid://sagittarius/NodeFunction/1" &&
Array.isArray(s.referencePath) &&
s.referencePath.length === 1 &&
s.referencePath[0].path === "text",
),
).toBe(true);
});

})