diff --git a/.changeset/coalesce-fallback-inference.md b/.changeset/coalesce-fallback-inference.md new file mode 100644 index 0000000..d771d5f --- /dev/null +++ b/.changeset/coalesce-fallback-inference.md @@ -0,0 +1,5 @@ +--- +"@evryg/effect-cypher-codegen": patch +--- + +coalesce now accounts for fallback argument types when the leading argument is nullable. Previously the result type was the leading argument's type stripped of nullable, ignoring the fallbacks. When a nullable Double property is coalesced with an integer literal (`coalesce(n.score, 0)`), the runtime yields that integer literal as a database Integer when the property is null — which the Double decoder rejects. The result now widens to Long (the integer-tolerant numeric superset, which also passes floats through), so the emitted column decodes correctly. Same-type and non-nullable leading arguments are unaffected. diff --git a/packages/cypher-codegen/src/frontend/InferType.node.unit.test.ts b/packages/cypher-codegen/src/frontend/InferType.node.unit.test.ts index dd022c6..22433e3 100644 --- a/packages/cypher-codegen/src/frontend/InferType.node.unit.test.ts +++ b/packages/cypher-codegen/src/frontend/InferType.node.unit.test.ts @@ -41,7 +41,23 @@ const schema = new GraphSchema({ mandatory: false }), new VertexProperty({ labels: ["Method"], propertyName: "returnType", propertyTypes: ["String"], mandatory: false }), - new VertexProperty({ labels: ["Method"], propertyName: "ccn", propertyTypes: ["Long"], mandatory: false }) + new VertexProperty({ labels: ["Method"], propertyName: "ccn", propertyTypes: ["Long"], mandatory: false }), + // Sample is a synthetic fixture for type-inference tests: its properties are named by their type + // and nullability role, not by any domain concept. + new VertexProperty({ + labels: ["Sample"], + propertyName: "nullableDouble", + propertyTypes: ["Double"], + mandatory: false + }), + new VertexProperty({ labels: ["Sample"], propertyName: "nullableLong", propertyTypes: ["Long"], mandatory: false }), + new VertexProperty({ labels: ["Sample"], propertyName: "requiredLong", propertyTypes: ["Long"], mandatory: true }), + new VertexProperty({ + labels: ["Sample"], + propertyName: "requiredString", + propertyTypes: ["String"], + mandatory: true + }) ], edgeProperties: [ new EdgeProperty({ edgeType: "CALLS", propertyName: "confidence", propertyTypes: ["String"], mandatory: true }), @@ -180,6 +196,33 @@ describe("inferExpressionType — coalesce", () => { const result = inferExpressionType(parseExpression("coalesce(f.lineCount, 0)"), env, schema) expect(result).toEqual(new ScalarType({ scalarType: "Long" })) }) + + it("nullable Double coalesced with an integer literal infers Long", () => { + // coalesce(, 0): when the property is null the runtime returns the integer + // literal as a database Integer object, which only the integer-tolerant decoder accepts. The + // fallback's integer type must widen the result to Long, not stay Double. + const env = envWith({ s: { type: new VertexType({ label: "Sample" }), nullable: false } }) + const result = inferExpressionType(parseExpression("coalesce(s.nullableDouble, 0)"), env, schema) + expect(result).toEqual(new ScalarType({ scalarType: "Long" })) + }) + + it("nullable Long coalesced with an integer literal stays Long", () => { + const env = envWith({ s: { type: new VertexType({ label: "Sample" }), nullable: false } }) + const result = inferExpressionType(parseExpression("coalesce(s.nullableLong, 0)"), env, schema) + expect(result).toEqual(new ScalarType({ scalarType: "Long" })) + }) + + it("non-null Long coalesced with an integer literal stays Long", () => { + const env = envWith({ s: { type: new VertexType({ label: "Sample" }), nullable: false } }) + const result = inferExpressionType(parseExpression("coalesce(s.requiredLong, 0)"), env, schema) + expect(result).toEqual(new ScalarType({ scalarType: "Long" })) + }) + + it("non-null String coalesced with a string literal stays String", () => { + const env = envWith({ s: { type: new VertexType({ label: "Sample" }), nullable: false } }) + const result = inferExpressionType(parseExpression("coalesce(s.requiredString, \"x\")"), env, schema) + expect(result).toEqual(new ScalarType({ scalarType: "String" })) + }) }) describe("inferExpressionType — size", () => { diff --git a/packages/cypher-codegen/src/frontend/InferType.ts b/packages/cypher-codegen/src/frontend/InferType.ts index c5f7cda..0600a2e 100644 --- a/packages/cypher-codegen/src/frontend/InferType.ts +++ b/packages/cypher-codegen/src/frontend/InferType.ts @@ -589,6 +589,21 @@ function extractIsNotNullVar(expr: ExpressionContext): string | undefined { return symbol.getText() } +/** + * Unify two coalesce candidate types (already stripped of nullable). Equal scalars collapse to that + * scalar; two numeric scalars (Long/Double in any mix) collapse to Long — the integer-tolerant + * decoder is the numeric superset, accepting both database integers and floats. Anything else keeps + * the leading argument's type (no regression for heterogeneous or non-scalar candidates). + */ +function unifyCoalesce(a: CypherType, b: CypherType): CypherType { + if (a._tag === "ScalarType" && b._tag === "ScalarType") { + if (a.scalarType === b.scalarType) return a + const isNumeric = (s: string) => s === "Long" || s === "Double" + if (isNumeric(a.scalarType) && isNumeric(b.scalarType)) return new ScalarType({ scalarType: "Long" }) + } + return a +} + function inferFunctionType( func: FunctionInvocationContext, env: TypeEnv, @@ -608,13 +623,21 @@ function inferFunctionType( throw new CypherTypeError("collect() requires an argument") } - // coalesce(x, ...) → type of first arg, stripped of nullable (coalesce provides fallback) + // coalesce(x, ...) → coalesce returns the first non-null argument, so the result type unifies the + // candidates left→right, each stripped of nullable. Walk arguments until the first non-nullable one + // (later arguments are unreachable). An integer-literal fallback for a nullable Double widens the + // result to Long: when the property is null the runtime yields that integer literal, and only the + // integer-tolerant decoder accepts it (it also passes floats through unchanged). if (funcName === "coalesce") { - if (args.length > 0) { - const argType = inferExpressionType(args[0], env, schema) - return argType._tag === "NullableType" ? argType.inner : argType + if (args.length === 0) throw new CypherTypeError("coalesce() requires arguments") + let result: CypherType | undefined + for (const arg of args) { + const argType = inferExpressionType(arg, env, schema) + const stripped = argType._tag === "NullableType" ? argType.inner : argType + result = result === undefined ? stripped : unifyCoalesce(result, stripped) + if (argType._tag !== "NullableType") break } - throw new CypherTypeError("coalesce() requires arguments") + return result! } // properties(x) → Map with unknown fields