diff --git a/.changeset/cypher-set-param-nullability.md b/.changeset/cypher-set-param-nullability.md new file mode 100644 index 0000000..063b0d3 --- /dev/null +++ b/.changeset/cypher-set-param-nullability.md @@ -0,0 +1,7 @@ +--- +"@evryg/effect-cypher-codegen": minor +--- + +Infer parameter nullability for `SET` clauses + +`QueryAnalyzer` now types parameters bound via a `SET` clause (`MATCH (n:Label {...}) SET n.prop = $param`), in addition to node-pattern property maps and `IN` list expressions. A param assigned to an optional vertex property is typed `T | null` (so callers can pass `null` to clear it), while one assigned to a mandatory property stays non-nullable. Map-merge assignments (`SET n += $map`) are unaffected, and params whose left-hand side cannot be resolved to a schema property keep the previous non-nullable `String` fallback. diff --git a/packages/cypher-codegen/src/frontend/QueryAnalyzer.node.unit.test.ts b/packages/cypher-codegen/src/frontend/QueryAnalyzer.node.unit.test.ts index f2f94b8..d635bcd 100644 --- a/packages/cypher-codegen/src/frontend/QueryAnalyzer.node.unit.test.ts +++ b/packages/cypher-codegen/src/frontend/QueryAnalyzer.node.unit.test.ts @@ -200,6 +200,45 @@ describe("analyzeQuery — param nullability", () => { }) }) +describe("analyzeQuery — SET-clause param nullability", () => { + it("marks a SET param bound to an optional vertex property as nullable", () => { + // Method.visibility is optional in the schema fixture + const cypher = "MATCH (m:Method {id: $id}) SET m.visibility = $vis RETURN m.id AS id" + const result = analyzeQuery(cypher, schema) + expect(result.params).toEqual([ + { name: "id", type: "String", nullable: false }, + { name: "vis", type: "String", nullable: true } + ]) + }) + + it("marks a SET param bound to a mandatory vertex property as non-nullable", () => { + // Method.name is mandatory in the schema fixture + const cypher = "MATCH (m:Method {id: $id}) SET m.name = $nm RETURN m.id AS id" + const result = analyzeQuery(cypher, schema) + expect(result.params).toEqual([ + { name: "id", type: "String", nullable: false }, + { name: "nm", type: "String", nullable: false } + ]) + }) + + it("types each item of a multi-property SET independently", () => { + const cypher = "MATCH (m:Method {id: $id}) SET m.visibility = $vis, m.name = $nm RETURN m.id AS id" + const result = analyzeQuery(cypher, schema) + expect(result.params).toEqual([ + { name: "id", type: "String", nullable: false }, + { name: "vis", type: "String", nullable: true }, + { name: "nm", type: "String", nullable: false } + ]) + }) + + it("does not double-count a param constrained in both a MATCH pattern and a SET", () => { + // Method.visibility is optional → nullable, and appears once despite two usages + const cypher = "MATCH (m:Method {visibility: $v}) SET m.visibility = $v RETURN m.id AS id" + const result = analyzeQuery(cypher, schema) + expect(result.params).toEqual([{ name: "v", type: "String", nullable: true }]) + }) +}) + describe("analyzeQuery — WITH rebinding", () => { it("tracks variables through WITH clause", () => { const cypher = `MATCH (c:Class) diff --git a/packages/cypher-codegen/src/frontend/QueryAnalyzer.ts b/packages/cypher-codegen/src/frontend/QueryAnalyzer.ts index c7cc777..1f1a860 100644 --- a/packages/cypher-codegen/src/frontend/QueryAnalyzer.ts +++ b/packages/cypher-codegen/src/frontend/QueryAnalyzer.ts @@ -13,6 +13,7 @@ import { PatternElemContext, ReadingStatementContext, RelationDetailContext, + SetItemContext, UnwindStContext, UpdatingStatementContext, WithStContext @@ -633,6 +634,34 @@ function extractParams(tree: ReturnType, schema: GraphSchema): Arr paramUsages.push({ paramName, label, property, isInClause: true }) }) + // Pass 3: Walk SET items of the form `. = $param`, mirroring Pass 1. + // Only the `propertyExpression ASSIGN expression` alternative is handled; `SET r += $map` + // (map-merge) uses the `symbol ADD_ASSIGN expression` alternative, whose propertyExpression() + // is null, so it is naturally excluded — that map param is a separate feature. + walkTree(tree, SetItemContext, (item) => { + const propExpr = item.propertyExpression() + if (!propExpr) return + + // LHS must be a simple `var.prop` (single property segment). + const names = propExpr.name() + if (names.length !== 1) return + const varName = propExpr.atom().getText() + const property = names[0]!.getText() + + const expr = item.expression() + if (!expr) return + const paramCtx = findParameter(expr) + if (!paramCtx) return + const paramSym = paramCtx.symbol() + if (!paramSym) return + const paramName = paramSym.getText() + if (seenParams.has(paramName)) return + seenParams.add(paramName) + + const label = varLabels.get(varName) + paramUsages.push({ paramName, label, property }) + }) + return paramUsages.map((usage) => { if (usage.label && usage.property) { const resolved = lookupParamType(schema, usage.label, usage.property)