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
15 changes: 15 additions & 0 deletions .changeset/cypher-codegen-typecheck-generated-output.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@evryg/effect-cypher-codegen": patch
---

Make generated query code type-check under Effect v4.

- Row decoding no longer emits an untyped `Neo4jRecordToObject` schema transform.
`record.toObject()` is called in the Effect pipeline and each row is validated
directly with `Schema.Array(Row)`, so the row struct fully type-checks the
decoded shape.
- The generated `TemporalString` transform now carries explicit
`SchemaTransformation.transform<string, unknown>` type parameters, matching its
`Schema.Unknown` source so it satisfies `decodeTo`'s getter signature.
- Single-file modules now annotate query parameters with their types instead of
relying on an implicit `any`, the same way the barrel output already did.
3 changes: 2 additions & 1 deletion packages/cypher-codegen/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"@evryg/effect-vitest-neo4j": "workspace:^",
"antlr-ng": "^1.0.10",
"effect": "4.0.0-beta.78",
"neo4j-driver": "^6.0.1"
"neo4j-driver": "^6.0.1",
"typescript": "5.9.3"
}
}
4 changes: 2 additions & 2 deletions packages/cypher-codegen/src/Register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,11 @@ registerHooks({
load(url, context, nextLoad) {
if (context.format === "cypher" || url.endsWith(".cypher")) {
const source = readFileSync(fileURLToPath(url), "utf-8").trim()
const columns = schema ? analyzeQuery(source, schema).columns : undefined
const analysis = schema ? analyzeQuery(source, schema) : undefined
return {
format: "module",
shortCircuit: true,
source: generateModule(source, columns)
source: generateModule(source, analysis?.columns, analysis?.params)
}
}
return nextLoad(url, context)
Expand Down
4 changes: 2 additions & 2 deletions packages/cypher-codegen/src/VitePlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ export const cypherPlugin = (opts?: { schema?: GraphSchema }) => ({
transform(_code: string, id: string) {
if (id.endsWith(".cypher")) {
const content = readFileSync(id, "utf-8").trim()
const columns = opts?.schema ? analyzeQuery(content, opts.schema).columns : undefined
return { code: generateModule(content, columns), map: null }
const analysis = opts?.schema ? analyzeQuery(content, opts.schema) : undefined
return { code: generateModule(content, analysis?.columns, analysis?.params), map: null }
}
}
})
54 changes: 34 additions & 20 deletions packages/cypher-codegen/src/backend/CypherCodegen.node.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,26 +81,28 @@ describe("generateModule with columns (typed codegen)", () => {
expect(source).toContain("Schema.String")
})

it("emits Neo4jRecordToObject transform with toObject()", () => {
it("maps records through .toObject() in the query pipeline (no record-level schema transform)", () => {
const source = generateModule("MATCH (c:Class) RETURN c.fqcn AS fqcn", [
col("fqcn", S("String"), false)
])
expect(source).toContain("Neo4jRecordToObject")
expect(source).toContain(".toObject()")
expect(source).not.toContain("Neo4jRecordToObject")
expect(source).toContain("records.map((r) => r.toObject())")
})

it("composes Neo4jRecordToObject with Row via decodeTo", () => {
it("decodes rows directly against the struct via Schema.Array(Row)", () => {
const source = generateModule("MATCH (c:Class) RETURN c.fqcn AS fqcn", [
col("fqcn", S("String"), false)
])
expect(source).toContain("Schema.Array(Neo4jRecordToObject.pipe(Schema.decodeTo(Row)))")
expect(source).toContain("Schema.decodeUnknownSync(Schema.Array(Row))")
})

it("passes decoder directly to Effect.map (no lambda)", () => {
it("maps records through .toObject() before decoding", () => {
const source = generateModule("MATCH (c:Class) RETURN c.fqcn AS fqcn", [
col("fqcn", S("String"), false)
])
expect(source).toContain("Effect.map(neo4j.query(cypher), decodeRows)")
expect(source).toContain(
"Effect.map(neo4j.query(cypher), (records) => decodeRows(records.map((r) => r.toObject())))"
)
})

it("imports Neo4jInt from effect-neo4j for Long columns", () => {
Expand Down Expand Up @@ -134,12 +136,23 @@ describe("generateModule with columns (typed codegen)", () => {
})

it("handles params AND columns together", () => {
const source = generateModule("MATCH (c:Class {fqcn: $fqcn}) RETURN c.name AS name", [
col("name", S("String"), false)
])
const source = generateModule(
"MATCH (c:Class {fqcn: $fqcn}) RETURN c.name AS name",
[col("name", S("String"), false)],
[{ name: "fqcn", type: "String", nullable: false }]
)
expect(source).toContain("{ fqcn }")
expect(source).toContain("Schema.Struct")
expect(source).toContain("Neo4jRecordToObject.pipe(Schema.decodeTo(Row")
expect(source).toContain("Schema.decodeUnknownSync(Schema.Array(Row))")
})

it("annotates module params with their types (no implicit any)", () => {
const source = generateModule(
"MATCH (c:Class {fqcn: $fqcn}) RETURN c.name AS name",
[col("name", S("String"), false)],
[{ name: "fqcn", type: "String", nullable: false }]
)
expect(source).toContain("export const query = ({ fqcn }: { fqcn: string }) =>")
})

it("emits Neo4jValue for UnknownType columns (escape hatch)", () => {
Expand Down Expand Up @@ -247,39 +260,40 @@ describe("generateBarrel — typed params", () => {
expect(source).toContain("{ note }: { note: string | null }")
})

it("emits shared Neo4jRecordToObject transform once", () => {
it("does not emit a record-level schema transform (toObject() happens in the pipeline)", () => {
const entry: BarrelEntry = {
filename: "Foo.cypher",
cypher: "MATCH (c:Class) RETURN c.fqcn AS fqcn",
columns: [col("fqcn", S("String"), false)],
params: []
}
const source = generateBarrel([entry])
const matches = source.match(/Neo4jRecordToObject = Schema\.Unknown\.pipe\(Schema\.decodeTo/g)
expect(matches).toHaveLength(1)
expect(source).toContain(".toObject()")
expect(source).not.toContain("Neo4jRecordToObject")
expect(source).toContain("records.map((r) => r.toObject())")
})

it("composes Neo4jRecordToObject with row schema via decodeTo", () => {
it("decodes rows directly against the row struct via Schema.Array(Row)", () => {
const entry: BarrelEntry = {
filename: "Foo.cypher",
cypher: "MATCH (c:Class) RETURN c.fqcn AS fqcn",
columns: [col("fqcn", S("String"), false)],
params: []
}
const source = generateBarrel([entry])
expect(source).toContain("Schema.Array(Neo4jRecordToObject.pipe(Schema.decodeTo(fooQueryRow)))")
expect(source).toContain("Schema.decodeUnknownSync(Schema.Array(fooQueryRow))")
})

it("passes decoder directly to Effect.map (no lambda)", () => {
it("maps records through .toObject() before decoding", () => {
const entry: BarrelEntry = {
filename: "Foo.cypher",
cypher: "MATCH (c:Class) RETURN c.fqcn AS fqcn",
columns: [col("fqcn", S("String"), false)],
params: []
}
const source = generateBarrel([entry])
expect(source).toContain("Effect.map(neo4j.query(fooQueryCypher), decodeFooQuery)")
expect(source).toContain(
"Effect.map(neo4j.query(fooQueryCypher), (records) => decodeFooQuery(records.map((r) => r.toObject())))"
)
})

it("emits Neo4jValue for UnknownType columns in barrel", () => {
Expand Down Expand Up @@ -319,7 +333,7 @@ describe("generateBarrel — no any in generated code", () => {
})

describe("generateModule — no any in generated code", () => {
it("Neo4jRecordToObject decode param is not typed as any", () => {
it("typed row decoder does not emit an any-typed param", () => {
const source = generateModule("MATCH (c:Class) RETURN c.fqcn AS fqcn", [
col("fqcn", S("String"), false)
])
Expand Down
Loading
Loading