From cf2b25d555410a8d22d7021a816bc0898496adcc Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Musso Date: Sat, 6 Jun 2026 14:14:24 +0200 Subject: [PATCH 1/2] Add failing tests for coalesce fallback-argument type inference coalesce(, 0) currently infers Double, ignoring the integer-literal fallback. When the property is null the runtime yields the integer literal as a database Integer object, which the Double decoder rejects. The leading-arg type must widen to Long to stay decodable. Adds a synthetic Sample vertex whose properties are named by their type and nullability role (no domain concepts), plus regression guards for the non-null and same-type cases that must stay unchanged. Generated with AI Co-Authored-By: AI --- .../src/frontend/InferType.node.unit.test.ts | 45 ++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) 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", () => { From 7cb519927c42beaaebd642785214785962a36cb8 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Musso Date: Sat, 6 Jun 2026 14:14:24 +0200 Subject: [PATCH 2/2] Account for fallback argument types in coalesce inference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit coalesce returns the first non-null argument, so its result type must unify the candidates, not just take the leading one. Walk arguments left→right (stopping at the first non-nullable one — later ones are unreachable), stripping nullable and unifying scalars: equal types collapse to that type; two numeric scalars (Long/Double in any mix) collapse to Long, the integer-tolerant superset that accepts database integers and passes floats through; anything else keeps the leading type. This makes coalesce(, 0) infer Long so the emitted column decodes the integer literal the runtime yields when the property is null, while leaving non-nullable and same-type cases unchanged. Generated with AI Co-Authored-By: AI --- .changeset/coalesce-fallback-inference.md | 5 +++ .../cypher-codegen/src/frontend/InferType.ts | 33 ++++++++++++++++--- 2 files changed, 33 insertions(+), 5 deletions(-) create mode 100644 .changeset/coalesce-fallback-inference.md 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.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