From 035174c4dea57dc6376d83709e6be7c79d713b26 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Musso Date: Sat, 13 Jun 2026 01:00:03 +0200 Subject: [PATCH 1/5] cypher-codegen: add failing tests for scalar math functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds unit coverage for log/log10/exp/sqrt/trig/ceil/floor/round/e/pi/rand (→ Double), sign (→ Long), and abs (input-preserving). These currently throw "Unrecognized function" — the type checker has no entries for scalar math. Generated with AI Co-Authored-By: AI --- .../src/frontend/InferType.node.unit.test.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) 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 22433e3..e8c658f 100644 --- a/packages/cypher-codegen/src/frontend/InferType.node.unit.test.ts +++ b/packages/cypher-codegen/src/frontend/InferType.node.unit.test.ts @@ -164,6 +164,45 @@ describe("inferExpressionType — aggregate functions", () => { }) }) +describe("inferExpressionType — scalar math functions", () => { + const env = envWith({ + l: { type: new ScalarType({ scalarType: "Long" }), nullable: false }, + d: { type: new ScalarType({ scalarType: "Double" }), nullable: false } + }) + + // All of these return Float in Neo4j regardless of argument type. + it.each([ + "log(l)", + "log10(l)", + "exp(l)", + "sqrt(l)", + "sin(l)", + "cos(l)", + "tan(l)", + "ceil(l)", + "floor(l)", + "round(d)", + "e()", + "pi()", + "rand()" + ])("%s infers Double", (expr) => { + const result = inferExpressionType(parseExpression(expr), env, schema) + expect(result).toEqual(new ScalarType({ scalarType: "Double" })) + }) + + it("sign(l) infers Long", () => { + const result = inferExpressionType(parseExpression("sign(l)"), env, schema) + expect(result).toEqual(new ScalarType({ scalarType: "Long" })) + }) + + it("abs preserves the argument's numeric type", () => { + expect(inferExpressionType(parseExpression("abs(l)"), env, schema)) + .toEqual(new ScalarType({ scalarType: "Long" })) + expect(inferExpressionType(parseExpression("abs(d)"), env, schema)) + .toEqual(new ScalarType({ scalarType: "Double" })) + }) +}) + describe("inferExpressionType — collect", () => { it("collect(scalar) returns ListType(scalar)", () => { const env = envWith({ c: { type: new VertexType({ label: "Class" }), nullable: false } }) From 6f67dd4416724bab9fe769faf1d03ac58acbaac9 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Musso Date: Sat, 13 Jun 2026 01:01:30 +0200 Subject: [PATCH 2/5] cypher-codegen: recognize scalar math functions in the type checker Adds Neo4j scalar math functions to the type checker. log/log10/exp/sqrt, trig, ceil/floor/round, e/pi/rand and friends return Double; sign returns Long; abs is special-cased to preserve its argument's numeric type. Previously any of these threw "Unrecognized function", failing codegen for queries that project bare math calls. Generated with AI Co-Authored-By: AI --- .../cypher-codegen/src/frontend/CLAUDE.md | 3 ++ .../cypher-codegen/src/frontend/InferType.ts | 34 ++++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/cypher-codegen/src/frontend/CLAUDE.md b/packages/cypher-codegen/src/frontend/CLAUDE.md index 05950ae..6ac0371 100644 --- a/packages/cypher-codegen/src/frontend/CLAUDE.md +++ b/packages/cypher-codegen/src/frontend/CLAUDE.md @@ -44,6 +44,9 @@ If `env(x).nullable = true`, the entire result is wrapped in `NullableType` rega | `type(r)` | `String` | | `keys(x)`, `labels(x)` | `List` | | `properties(x)` | `Map<[]>` (empty map — fields unknown statically) | +| `log`, `log10`, `exp`, `sqrt`, `ceil`, `floor`, `round`, `sin`, `cos`, `tan`, `cot`, `asin`, `acos`, `atan`, `atan2`, `degrees`, `radians`, `haversine`, `e()`, `pi()`, `rand()` | `Double` (Neo4j math functions return Float regardless of argument type) | +| `sign(x)` | `Long` | +| `abs(x)` | `strip_nullable(infer(x))` — input-preserving (`abs(Long)=Long`, `abs(Double)=Double`) | ### CASE expression diff --git a/packages/cypher-codegen/src/frontend/InferType.ts b/packages/cypher-codegen/src/frontend/InferType.ts index 0600a2e..5610806 100644 --- a/packages/cypher-codegen/src/frontend/InferType.ts +++ b/packages/cypher-codegen/src/frontend/InferType.ts @@ -154,7 +154,31 @@ const AGGREGATE_RETURN_TYPES: Record = { left: new ScalarType({ scalarType: "String" }), right: new ScalarType({ scalarType: "String" }), reverse: new ScalarType({ scalarType: "String" }), - split: ListType(new ScalarType({ scalarType: "String" })) + split: ListType(new ScalarType({ scalarType: "String" })), + // Scalar math functions. In Neo4j these return Float regardless of argument type, + // except sign (Integer) and abs (input-preserving — handled in inferFunctionType). + e: new ScalarType({ scalarType: "Double" }), + pi: new ScalarType({ scalarType: "Double" }), + rand: new ScalarType({ scalarType: "Double" }), + exp: new ScalarType({ scalarType: "Double" }), + log: new ScalarType({ scalarType: "Double" }), + log10: new ScalarType({ scalarType: "Double" }), + sqrt: new ScalarType({ scalarType: "Double" }), + ceil: new ScalarType({ scalarType: "Double" }), + floor: new ScalarType({ scalarType: "Double" }), + round: new ScalarType({ scalarType: "Double" }), + sin: new ScalarType({ scalarType: "Double" }), + cos: new ScalarType({ scalarType: "Double" }), + tan: new ScalarType({ scalarType: "Double" }), + cot: new ScalarType({ scalarType: "Double" }), + asin: new ScalarType({ scalarType: "Double" }), + acos: new ScalarType({ scalarType: "Double" }), + atan: new ScalarType({ scalarType: "Double" }), + atan2: new ScalarType({ scalarType: "Double" }), + degrees: new ScalarType({ scalarType: "Double" }), + radians: new ScalarType({ scalarType: "Double" }), + haversine: new ScalarType({ scalarType: "Double" }), + sign: new ScalarType({ scalarType: "Long" }) } // ── Recursive expression type inference ── @@ -651,6 +675,14 @@ function inferFunctionType( return ListType(new ScalarType({ scalarType: "String" })) } + // abs(x) preserves the numeric type of its argument (abs(Long) → Long, abs(Double) → Double), + // unlike the fixed-return math functions in AGGREGATE_RETURN_TYPES. + if (funcName === "abs") { + if (args.length === 0) throw new CypherTypeError("abs() requires an argument") + const argType = inferExpressionType(args[0], env, schema) + return argType._tag === "NullableType" ? argType.inner : argType + } + // Known aggregates / conversion functions const known = AGGREGATE_RETURN_TYPES[funcName] if (known) return known From b1d782e9f9dc7e86e59481617c2dadf1b98d1dee Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Musso Date: Sat, 13 Jun 2026 01:02:07 +0200 Subject: [PATCH 3/5] cypher-codegen: add failing tests for arithmetic operand typing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds unit coverage for numeric arithmetic unification (Long ⊔ Double = Double across operands and operator levels, power → Double) and float-literal typing (1.5 → Double). These currently infer Long because arithmetic types only the first operand and all numeric literals are typed Long. String and list concatenation cases included as regression guards. Generated with AI Co-Authored-By: AI --- .../src/frontend/InferType.node.unit.test.ts | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) 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 e8c658f..b203152 100644 --- a/packages/cypher-codegen/src/frontend/InferType.node.unit.test.ts +++ b/packages/cypher-codegen/src/frontend/InferType.node.unit.test.ts @@ -203,6 +203,53 @@ describe("inferExpressionType — scalar math functions", () => { }) }) +describe("inferExpressionType — arithmetic operand typing", () => { + const env = envWith({ + l: { type: new ScalarType({ scalarType: "Long" }), nullable: false }, + d: { type: new ScalarType({ scalarType: "Double" }), nullable: false }, + s: { type: new ScalarType({ scalarType: "String" }), nullable: false }, + xs: { type: ListType(new ScalarType({ scalarType: "String" })), nullable: false } + }) + + // Numeric arithmetic unifies operand types: Long ⊔ Double = Double, and a Double anywhere in + // the expression (any operand, any operator level) widens the result. Power (^) returns Float. + // Float literals (1.5) must type as Double, not Long. + it.each([ + "l + d", + "d + l", + "l * d", + "l / d", + "l ^ l", + "d * d", + "l * 1.5", + "1.5" + ])("%s infers Double", (expr) => { + const result = inferExpressionType(parseExpression(expr), env, schema) + expect(result).toEqual(new ScalarType({ scalarType: "Double" })) + }) + + it.each([ + "l + l", + "l * l", + "l % l", + "l + 2", + "2" + ])("%s infers Long", (expr) => { + const result = inferExpressionType(parseExpression(expr), env, schema) + expect(result).toEqual(new ScalarType({ scalarType: "Long" })) + }) + + it("string concatenation stays String", () => { + const result = inferExpressionType(parseExpression("s + s"), env, schema) + expect(result).toEqual(new ScalarType({ scalarType: "String" })) + }) + + it("list concatenation stays List", () => { + const result = inferExpressionType(parseExpression("xs + xs"), env, schema) + expect(result).toEqual(ListType(new ScalarType({ scalarType: "String" }))) + }) +}) + describe("inferExpressionType — collect", () => { it("collect(scalar) returns ListType(scalar)", () => { const env = envWith({ c: { type: new VertexType({ label: "Class" }), nullable: false } }) From 0ecdf01fa938e18bebe0d527fd9a3495b97a337f Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Musso Date: Sat, 13 Jun 2026 01:04:28 +0200 Subject: [PATCH 4/5] cypher-codegen: unify numeric operand types in arithmetic inference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arithmetic previously typed only the first operand, so Long * Double inferred Long (codegenning Neo4jInt and risking a runtime decode failure on floats). Refactors the addSub/multDiv/power/unary levels into recursive helpers that infer every operand and unify them by the numeric join (Long ⊔ Double = Double); ^ yields Double. Also types numeric literals by their text (1.5/1e3 → Double), so priceLong * 1.5 now infers Double. Non-numeric, String, and list operands are unchanged. Generated with AI Co-Authored-By: AI --- .../cypher-codegen/src/frontend/CLAUDE.md | 21 +++ .../cypher-codegen/src/frontend/InferType.ts | 123 +++++++++++++----- 2 files changed, 110 insertions(+), 34 deletions(-) diff --git a/packages/cypher-codegen/src/frontend/CLAUDE.md b/packages/cypher-codegen/src/frontend/CLAUDE.md index 6ac0371..8d177ce 100644 --- a/packages/cypher-codegen/src/frontend/CLAUDE.md +++ b/packages/cypher-codegen/src/frontend/CLAUDE.md @@ -97,6 +97,27 @@ List + List = List = List This is why `[] + someList` correctly infers the element type of `someList`. +### Numeric arithmetic operators (`+ - * / % ^`) + +Numeric operands unify by the join on the numeric scalars, where `Double` is the upper bound: + +``` +Long ⊔ Long = Long +Long ⊔ Double = Double +``` + +A `Double` operand anywhere in an expression — at any operator level — widens the whole result to +`Double` (`l * d`, `l / d`, `d + l` all infer `Double`). With only `Long` operands the result stays +`Long`, matching Cypher integer arithmetic (`Long / Long → Long`, `Long % Long → Long`). +Exponentiation `^` always yields `Double` for numeric operands (Cypher power returns Float). + +Non-numeric arithmetic (dates, durations) is left unchanged: when operands aren't all numeric +scalars the result keeps the leading operand's type. String operands make `+` a String +concatenation (see above), and list operands make `+` a list concatenation. + +Numeric **literals** carry their int/float distinction: `1.5`, `1e3`, and float-suffixed literals +infer `Double`; plain integers infer `Long`. So `priceLong * 1.5` correctly infers `Double`. + ### filterWith (ANY/ALL/NONE/SINGLE) ``` diff --git a/packages/cypher-codegen/src/frontend/InferType.ts b/packages/cypher-codegen/src/frontend/InferType.ts index 5610806..c3b29a5 100644 --- a/packages/cypher-codegen/src/frontend/InferType.ts +++ b/packages/cypher-codegen/src/frontend/InferType.ts @@ -1,13 +1,17 @@ /** @since 0.0.1 */ import type { GraphSchema } from "@evryg/effect-neo4j-schema" import type { + AddSubExpressionContext, AtomContext, AtomicExpressionContext, CaseExpressionContext, ExpressionContext, FunctionInvocationContext, MapPairContext, - PropertyExpressionContext + MultDivExpressionContext, + PowerExpressionContext, + PropertyExpressionContext, + UnaryAddSubExpressionContext } from "../internal/generated-parser/CypherParser.js" import { type CypherType, @@ -216,46 +220,85 @@ export function inferExpressionType( if (addSubs.length > 1) return new ScalarType({ scalarType: "Boolean" }) // addSubExpression: multDivExpression ((PLUS | SUB) multDivExpression)* - const addSub = addSubs[0] + return inferAddSubType(addSubs[0], env, schema) +} + +// ── Arithmetic operand typing ── + +const isNumericScalar = (t: CypherType): boolean => + t._tag === "ScalarType" && (t.scalarType === "Long" || t.scalarType === "Double") + +/** Numeric join over arithmetic operands: `Long ⊔ Double = Double`. Returns `undefined` when any + * operand is not a numeric scalar (e.g. dates/durations), so callers fall back to their leading + * operand and don't regress non-numeric arithmetic. */ +function numericJoin(types: ReadonlyArray): CypherType | undefined { + if (types.length === 0 || !types.every(isNumericScalar)) return undefined + const anyDouble = types.some((t) => t._tag === "ScalarType" && t.scalarType === "Double") + return new ScalarType({ scalarType: anyDouble ? "Double" : "Long" }) +} + +/** addSubExpression: multDivExpression ((PLUS | SUB) multDivExpression)* */ +function inferAddSubType( + addSub: AddSubExpressionContext, + env: TypeEnv, + schema: GraphSchema +): CypherType { const multDivs = addSub.multDivExpression() + if (multDivs.length === 1) return inferMultDivType(multDivs[0], env, schema) - // String concatenation or list concatenation: multiple addSub operands - if (multDivs.length > 1) { - const inferSingle = (md: typeof multDivs[0]): CypherType => { - const p = md.powerExpression()[0] - const u = p.unaryAddSubExpression()[0] - return inferAtomicType(u.atomicExpression(), env, schema) - } - const types = multDivs.map(inferSingle) - - // List concatenation: List + List = List - // NeverType is the identity for join (bottom element) - if (types.every(isListType)) { - const elements = types.map(extractListElementType) - const nonNever = elements.filter((e) => e._tag !== "NeverType") - const joined = nonNever.length > 0 ? nonNever[0] : elements[0] - return ListType(joined) - } + const types = multDivs.map((md) => inferMultDivType(md, env, schema)) - if (types.some((t) => t._tag === "ScalarType" && t.scalarType === "String")) { - return new ScalarType({ scalarType: "String" }) - } - // Numeric: return first operand type - return types[0] + // List concatenation: List + List = List. NeverType is the join identity. + if (types.every(isListType)) { + const elements = types.map(extractListElementType) + const nonNever = elements.filter((e) => e._tag !== "NeverType") + const joined = nonNever.length > 0 ? nonNever[0] : elements[0] + return ListType(joined) } - // multDivExpression: powerExpression ((MULT | DIV | MOD) powerExpression)* - const multDiv = multDivs[0] - const powers = multDiv.powerExpression() + // String concatenation: a String operand anywhere makes the whole expression a String. + if (types.some((t) => t._tag === "ScalarType" && t.scalarType === "String")) { + return new ScalarType({ scalarType: "String" }) + } - // powerExpression: unaryAddSubExpression (CARET unaryAddSubExpression)* - const power = powers[0] - const unary = power.unaryAddSubExpression()[0] + // Numeric: unify operands (Long + Double = Double). Non-numeric (dates/durations) keep the + // leading operand's type, preserving prior behavior. + return numericJoin(types) ?? types[0] +} - // unaryAddSubExpression: (PLUS | SUB)? atomicExpression - const atomic = unary.atomicExpression() +/** multDivExpression: powerExpression ((MULT | DIV | MOD) powerExpression)* */ +function inferMultDivType( + multDiv: MultDivExpressionContext, + env: TypeEnv, + schema: GraphSchema +): CypherType { + const powers = multDiv.powerExpression() + if (powers.length === 1) return inferPowerType(powers[0], env, schema) + const types = powers.map((p) => inferPowerType(p, env, schema)) + // Long * Long → Long and Long / Long → Long match Cypher integer arithmetic; any Double widens. + return numericJoin(types) ?? types[0] +} + +/** powerExpression: unaryAddSubExpression (CARET unaryAddSubExpression)* */ +function inferPowerType( + power: PowerExpressionContext, + env: TypeEnv, + schema: GraphSchema +): CypherType { + const unaries = power.unaryAddSubExpression() + if (unaries.length === 1) return inferUnaryType(unaries[0], env, schema) + const types = unaries.map((u) => inferUnaryType(u, env, schema)) + // Cypher exponentiation (^) returns Float for numeric operands. + return numericJoin(types) ? new ScalarType({ scalarType: "Double" }) : types[0] +} - return inferAtomicType(atomic, env, schema) +/** unaryAddSubExpression: (PLUS | SUB)? atomicExpression — sign does not change the type. */ +function inferUnaryType( + unary: UnaryAddSubExpressionContext, + env: TypeEnv, + schema: GraphSchema +): CypherType { + return inferAtomicType(unary.atomicExpression(), env, schema) } function inferAtomicType( @@ -395,6 +438,17 @@ function inferPropertyExpressionType( return current } +/** Classify a numeric literal by its text: a decimal point, an exponent, or a float/double suffix + * means Double; everything else (plain integers, hex) is Long. The lexer's `DIGIT` token covers + * both integers and floats, so the int/float distinction lives here rather than in the grammar. */ +function inferNumLitType(text: string): ScalarType { + const t = text.replace(/^[+-]/, "") + if (/[.]/.test(t) || /[eE]/.test(t) || /[fd]$/i.test(t)) { + return new ScalarType({ scalarType: "Double" }) + } + return new ScalarType({ scalarType: "Long" }) +} + function inferAtomType( atom: AtomContext, env: TypeEnv, @@ -407,7 +461,8 @@ function inferAtomType( // Literal const literal = atom.literal() if (literal) { - if (literal.numLit()) return new ScalarType({ scalarType: "Long" }) + const numLit = literal.numLit() + if (numLit) return inferNumLitType(numLit.getText()) if (literal.stringLit() || literal.charLit()) return new ScalarType({ scalarType: "String" }) if (literal.boolLit()) return new ScalarType({ scalarType: "Boolean" }) if (literal.NULL_W()) return new NeverType({}) From 331739c5a2793fbb410c0d6a5af1b25815597da8 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Musso Date: Sat, 13 Jun 2026 01:06:44 +0200 Subject: [PATCH 5/5] cypher-codegen: changeset for math + arithmetic inference fixes Generated with AI Co-Authored-By: AI --- .changeset/cypher-math-arithmetic-inference.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .changeset/cypher-math-arithmetic-inference.md diff --git a/.changeset/cypher-math-arithmetic-inference.md b/.changeset/cypher-math-arithmetic-inference.md new file mode 100644 index 0000000..36e6d6f --- /dev/null +++ b/.changeset/cypher-math-arithmetic-inference.md @@ -0,0 +1,17 @@ +--- +"@evryg/effect-cypher-codegen": patch +--- + +Improve numeric type inference in the Cypher type checker. + +- Recognize Neo4j scalar math functions. `log`, `log10`, `exp`, `sqrt`, the trigonometric + functions, `ceil`, `floor`, `round`, `e()`, `pi()`, `rand()` and friends now infer `Double`; + `sign` infers `Long`; and `abs` preserves its argument's numeric type. Previously any of these + threw `Unrecognized function`, failing codegen for queries that projected a bare math call such as + `RETURN log(x) AS weight`. +- Unify numeric operand types in arithmetic. Expressions previously took only the first operand's + type, so `Long * Double` (or `priceLong * 1.5`) inferred `Long` — emitting `Neo4jInt` and risking + a runtime decode failure on floats. Numeric arithmetic now unifies operands by the numeric join + (`Long ⊔ Double = Double`), a `Double` anywhere widens the result, and exponentiation (`^`) yields + `Double`. Numeric literals also carry their int/float distinction (`1.5`/`1e3` → `Double`). String + and list concatenation are unaffected.