Skip to content
Closed
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
6 changes: 4 additions & 2 deletions schema.neo4j.json
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,8 @@
"BodyNode"
],
"properties": {
"kind": "string"
"kind": "string",
"_k": "string"
}
},
{
Expand All @@ -336,7 +337,8 @@
],
"properties": {
"var": "string",
"prov": "string[]"
"prov": "string[]",
"_k": "string"
}
},
{
Expand Down
9 changes: 6 additions & 3 deletions src/build/neo4j/bolt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,18 +150,21 @@ async function upsertNodes(session: () => any, neo4j: any, nodes: NodeRow[]): Pr
async function upsertEdges(session: () => any, neo4j: any, edges: EdgeRow[]): Promise<void> {
const groups = new Map<string, EdgeRow[]>();
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 }));
}
}
Expand Down
8 changes: 5 additions & 3 deletions src/build/neo4j/cypher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,22 +81,24 @@ function nodeStatements(nodes: NodeRow[]): string[] {
function edgeStatements(edges: EdgeRow[]): string[] {
const groups = new Map<string, EdgeRow[]>();
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;`,
);
}
Expand Down
10 changes: 8 additions & 2 deletions src/build/neo4j/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 11 additions & 3 deletions src/build/neo4j/rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 });
}

/**
Expand Down
8 changes: 6 additions & 2 deletions src/build/neo4j/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" } },
Expand Down
45 changes: 45 additions & 0 deletions test/neo4j-edge-identity.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});