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
17 changes: 17 additions & 0 deletions .changeset/cypher-math-arithmetic-inference.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 24 additions & 0 deletions packages/cypher-codegen/src/frontend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>` |
| `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

Expand Down Expand Up @@ -94,6 +97,27 @@ List<NeverType> + List<T> = List<NeverType V T> = List<T>

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)

```
Expand Down
86 changes: 86 additions & 0 deletions packages/cypher-codegen/src/frontend/InferType.node.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } })
Expand Down
157 changes: 122 additions & 35 deletions packages/cypher-codegen/src/frontend/InferType.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -154,7 +158,31 @@ const AGGREGATE_RETURN_TYPES: Record<string, CypherType> = {
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 ──
Expand Down Expand Up @@ -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>): 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<A> + List<B> = List<A V B>
// 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<A> + List<B> = List<A V B>. 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(
Expand Down Expand Up @@ -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,
Expand All @@ -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({})
Expand Down Expand Up @@ -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
Expand Down
Loading