diff --git a/schema.neo4j.json b/schema.neo4j.json index 3c8411b..3fbf885 100644 --- a/schema.neo4j.json +++ b/schema.neo4j.json @@ -313,7 +313,8 @@ "BodyNode" ], "properties": { - "kind": "string" + "kind": "string", + "_k": "string" } }, { @@ -336,7 +337,8 @@ ], "properties": { "var": "string", - "prov": "string[]" + "prov": "string[]", + "_k": "string" } }, { diff --git a/src/build/neo4j/bolt.ts b/src/build/neo4j/bolt.ts index c60102a..de1ee9a 100644 --- a/src/build/neo4j/bolt.ts +++ b/src/build/neo4j/bolt.ts @@ -150,18 +150,21 @@ async function upsertNodes(session: () => any, neo4j: any, nodes: NodeRow[]): Pr async function upsertEdges(session: () => any, neo4j: any, edges: EdgeRow[]): Promise { const groups = new Map(); for (const e of edges) { - bucket(groups, `${e.type}|${e.from.label}.${e.from.keyProp}|${e.to.label}.${e.to.keyProp}`).push(e); + bucket(groups, `${e.type}|${e.from.label}.${e.from.keyProp}|${e.to.label}.${e.to.keyProp}|${e.key !== undefined}`).push(e); } for (const group of groups.values()) { const { type, from, to } = group[0]; + // Discriminated relationships MERGE on `{_k}` so several distinct edges of one type coexist + // between the same endpoint pair (per-var DDG, the true/false CFG_NEXT pair). See EdgeRow.key. + const relKey = group[0].key !== undefined ? " {_k: row.k}" : ""; const cypher = `UNWIND $rows AS row ` + `MATCH (a:${from.label} {${from.keyProp}: row.f}) ` + `MATCH (b:${to.label} {${to.keyProp}: row.t}) ` + - `MERGE (a)-[r:${type}]->(b) SET r += row.p`; + `MERGE (a)-[r:${type}${relKey}]->(b) SET r += row.p`; for (const batch of chunk(group, BATCH)) { - const payload = batch.map((e) => ({ f: e.from.value, t: e.to.value, p: toParams(e.props, neo4j) })); + const payload = batch.map((e) => ({ f: e.from.value, t: e.to.value, k: e.key ?? null, p: toParams(e.props, neo4j) })); await withSession(session, (s) => s.run(cypher, { rows: payload })); } } diff --git a/src/build/neo4j/cypher.ts b/src/build/neo4j/cypher.ts index a724125..d66e5df 100644 --- a/src/build/neo4j/cypher.ts +++ b/src/build/neo4j/cypher.ts @@ -81,22 +81,24 @@ function nodeStatements(nodes: NodeRow[]): string[] { function edgeStatements(edges: EdgeRow[]): string[] { const groups = new Map(); for (const e of edges) { - const k = `${e.type}|${e.from.label}.${e.from.keyProp}|${e.to.label}.${e.to.keyProp}`; + const k = `${e.type}|${e.from.label}.${e.from.keyProp}|${e.to.label}.${e.to.keyProp}|${e.key !== undefined}`; (groups.get(k) ?? groups.set(k, []).get(k)!).push(e); } const blocks: string[] = []; for (const group of groups.values()) { const { type, from, to } = group[0]; + // Discriminated relationships MERGE on `{_k}` — see EdgeRow.key (issue #70). + const keyed = group[0].key !== undefined; for (const batch of chunk(group, BATCH)) { const list = batch - .map((e) => ` {f: ${cypherValue(e.from.value)}, t: ${cypherValue(e.to.value)}, p: ${cypherMap(e.props)}}`) + .map((e) => ` {f: ${cypherValue(e.from.value)}, t: ${cypherValue(e.to.value)}, ${keyed ? `k: ${cypherValue(e.key!)}, ` : ""}p: ${cypherMap(e.props)}}`) .join(",\n"); blocks.push( `UNWIND [\n${list}\n] AS row\n` + `MATCH (a:${from.label} {${from.keyProp}: row.f})\n` + `MATCH (b:${to.label} {${to.keyProp}: row.t})\n` + - `MERGE (a)-[r:${type}]->(b)\n` + + `MERGE (a)-[r:${type}${keyed ? " {_k: row.k}" : ""}]->(b)\n` + `SET r += row.p;`, ); } diff --git a/src/build/neo4j/project.ts b/src/build/neo4j/project.ts index fe46857..8469f88 100644 --- a/src/build/neo4j/project.ts +++ b/src/build/neo4j/project.ts @@ -126,9 +126,15 @@ function projectCallable(b: RowBuilder, c: V2Callable, owner: NodeRef, ownerRel: b.edge("HAS_BODY_NODE", node, bref); if (typeof bn.callee === "string") b.edge("RESOLVES_TO", bref, ref(bn.callee)); } - for (const e of edges(c.cfg)) b.edge("CFG_NEXT", ref(fq(c.id, e.src)), ref(fq(c.id, e.dst)), prune({ kind: e.kind ?? null })); + // kind-discriminated: a conditional's true/false pair between one endpoint pair must stay + // two relationships, not one MERGE (issue #70). + for (const e of edges(c.cfg)) b.edge("CFG_NEXT", ref(fq(c.id, e.src)), ref(fq(c.id, e.dst)), prune({ kind: e.kind ?? null }), e.kind ?? ""); for (const e of edges(c.cdg)) b.edge("CDG", ref(fq(c.id, e.src)), ref(fq(c.id, e.dst))); - for (const e of edges(c.ddg)) b.edge("DDG", ref(fq(c.id, e.src)), ref(fq(c.id, e.dst)), prune({ var: e.var ?? null, prov: e.prov ?? null })); + // (var, prov)-discriminated: the DDG legitimately carries several edges between one statement + // pair (one per variable, and the prov split) — a plain endpoint-pair MERGE collapses them and + // silently drops dependences (issue #70). + for (const e of edges(c.ddg)) + b.edge("DDG", ref(fq(c.id, e.src)), ref(fq(c.id, e.dst)), prune({ var: e.var ?? null, prov: e.prov ?? null }), `${e.var ?? ""}|${(e.prov ?? []).join(",")}`); for (const e of edges(c.summary)) b.edge("SUMMARY", ref(fq(c.id, e.src)), ref(fq(c.id, e.dst)), prune({ var: e.var ?? null })); // Nested callables (closures) + local classes. diff --git a/src/build/neo4j/rows.ts b/src/build/neo4j/rows.ts index 02200d0..fe4c5e2 100644 --- a/src/build/neo4j/rows.ts +++ b/src/build/neo4j/rows.ts @@ -33,6 +33,13 @@ export interface EdgeRow { from: NodeRef; to: NodeRef; props: Props; + /** + * Optional relationship discriminant: when set, the MERGE is on `{_k: key}` so several + * legitimately-distinct edges of one type may coexist between the same endpoint pair (per-var + * DDG edges, a conditional's true/false CFG_NEXT pair). Undefined keeps the plain + * endpoint-pair MERGE. (issue #70) + */ + key?: string; } export interface GraphRows { @@ -86,9 +93,10 @@ export class RowBuilder { return { label: labels[0], keyProp, value }; } - /** An edge whose endpoints are known to exist (both ends emitted as nodes this run). */ - edge(type: string, from: NodeRef, to: NodeRef, props: Props = {}): void { - this.edges.push({ type, from, to, props }); + /** An edge whose endpoints are known to exist (both ends emitted as nodes this run). + * `key` sets the relationship discriminant (see EdgeRow.key). */ + edge(type: string, from: NodeRef, to: NodeRef, props: Props = {}, key?: string): void { + this.edges.push({ type, from, to, props, key }); } /** diff --git a/src/build/neo4j/schema.ts b/src/build/neo4j/schema.ts index 3196eb5..a53adf4 100644 --- a/src/build/neo4j/schema.ts +++ b/src/build/neo4j/schema.ts @@ -179,9 +179,13 @@ export const REL_TYPES: RelType[] = [ to: ["Callable", "External", "AnonymousCallable"], properties: { weight: "integer", prov: "string[]" }, }, - { type: "CFG_NEXT", from: ["BodyNode"], to: ["BodyNode"], properties: { kind: "string" } }, + // `_k` is the relationship-identity discriminant (internal): CFG_NEXT merges per `kind` + // (a conditional's true/false pair), DDG per `(var, prov)` (one dependence per variable, plus + // the prov split) — a plain endpoint-pair MERGE would collapse legitimately-distinct edges + // (issue #70). + { type: "CFG_NEXT", from: ["BodyNode"], to: ["BodyNode"], properties: { kind: "string", _k: "string" } }, { type: "CDG", from: ["BodyNode"], to: ["BodyNode"], properties: {} }, - { type: "DDG", from: ["BodyNode"], to: ["BodyNode"], properties: { var: "string", prov: "string[]" } }, + { type: "DDG", from: ["BodyNode"], to: ["BodyNode"], properties: { var: "string", prov: "string[]", _k: "string" } }, { type: "SUMMARY", from: ["BodyNode"], to: ["BodyNode"], properties: { var: "string" } }, { type: "PARAM_IN", from: ["BodyNode"], to: ["BodyNode"], properties: { var: "string" } }, { type: "PARAM_OUT", from: ["BodyNode"], to: ["BodyNode"], properties: { var: "string" } }, diff --git a/test/neo4j-edge-identity.test.ts b/test/neo4j-edge-identity.test.ts new file mode 100644 index 0000000..6525081 --- /dev/null +++ b/test/neo4j-edge-identity.test.ts @@ -0,0 +1,45 @@ +/** + * Relationship-identity discriminant (issue #70): a plain endpoint-pair MERGE collapses + * legitimately-distinct edges of one type between the same two nodes — the DDG carries one edge + * per variable (plus the prov split), and a conditional's true/false CFG_NEXT pair shares its + * endpoints. Discriminated families MERGE on `{_k: row.k}` so each survives; undiscriminated + * families keep the plain endpoint-pair MERGE. + */ +import { describe, expect, test } from "bun:test"; +import { renderCypher } from "../src/build/neo4j"; +import { RowBuilder } from "../src/build/neo4j/rows"; + +function twoVarDdgRows() { + const b = new RowBuilder(); + const a = b.node(["CanNode", "BodyNode"], "id", "can://ts/app/f.ts/f()@1:0", {}); + const c = b.node(["CanNode", "BodyNode"], "id", "can://ts/app/f.ts/f()@2:0", {}); + b.edge("DDG", a, c, { var: "x", prov: ["reaching-defs"] }, "x|reaching-defs"); + b.edge("DDG", a, c, { var: "y", prov: ["reaching-defs"] }, "y|reaching-defs"); + b.edge("CDG", a, c); // undiscriminated control family stays a plain MERGE + return b.finish(); +} + +describe("edge identity discriminant (_k)", () => { + test("per-var DDG edges between one endpoint pair both survive rendering", () => { + const cypher = renderCypher(twoVarDdgRows(), "app"); + expect(cypher).toContain("MERGE (a)-[r:DDG {_k: row.k}]->(b)"); + expect(cypher).toContain("k: 'x|reaching-defs'"); + expect(cypher).toContain("k: 'y|reaching-defs'"); + }); + + test("undiscriminated families keep the plain endpoint-pair MERGE", () => { + const cypher = renderCypher(twoVarDdgRows(), "app"); + expect(cypher).toContain("MERGE (a)-[r:CDG]->(b)"); + expect(cypher).not.toContain("MERGE (a)-[r:CDG {_k"); + }); + + test("row model carries the discriminant only when set", () => { + const rows = twoVarDdgRows(); + const ddg = rows.edges.filter((e) => e.type === "DDG"); + const cdg = rows.edges.filter((e) => e.type === "CDG"); + expect(ddg.map((e) => e.key).sort()).toEqual(["x|reaching-defs", "y|reaching-defs"]); + expect(cdg[0].key).toBeUndefined(); + // distinct identity per (type, endpoints, key): nothing deduplicates the pair + expect(ddg.length).toBe(2); + }); +});