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. diff --git a/packages/cypher-codegen/src/frontend/CLAUDE.md b/packages/cypher-codegen/src/frontend/CLAUDE.md index 05950ae..8d177ce 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 @@ -94,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.node.unit.test.ts b/packages/cypher-codegen/src/frontend/InferType.node.unit.test.ts index 22433e3..b203152 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,92 @@ 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 — 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 } }) diff --git a/packages/cypher-codegen/src/frontend/InferType.ts b/packages/cypher-codegen/src/frontend/InferType.ts index 0600a2e..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, @@ -154,7 +158,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 ── @@ -192,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] +} - return inferAtomicType(atomic, env, schema) +/** 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] +} + +/** 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( @@ -371,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, @@ -383,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({}) @@ -651,6 +730,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