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
5 changes: 5 additions & 0 deletions .changeset/coalesce-fallback-inference.md
Original file line number Diff line number Diff line change
@@ -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.
45 changes: 44 additions & 1 deletion packages/cypher-codegen/src/frontend/InferType.node.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down Expand Up @@ -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(<nullable Double>, 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", () => {
Expand Down
33 changes: 28 additions & 5 deletions packages/cypher-codegen/src/frontend/InferType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
Loading