Ladybug version
v0.19.1
What operating system are you using?
ubuntu, windows
What happened?
Bug: UNWIND + two sequential MATCH ... WHERE clauses bind the same row to every CREATEd edge
Summary
When a Cypher query combines UNWIND with two separate MATCH (...) WHERE ...
clauses (each filtering by a property taken from the unwound row) followed by a
CREATE of a relationship between the two matched nodes, every created edge ends
up pointing at the first unwound row's nodes — regardless of what the other
rows' values are. The row count is correct; the content is not.
A single combined MATCH (a), (b) WHERE a.x = ... AND b.x = ... (one clause,
two patterns) does not show the bug. Splitting the exact same filter into two
separate MATCH ... WHERE clauses does.
This was found indirectly: a downstream read query that joins across the
relationship appeared to return "the same row repeated N times." Deeper
investigation showed the read was accurate — the write had genuinely created N
duplicate edges, all pointing at the first item's target, because of this bug.
Environment
@ladybugdb/core (npm) — reproduced on 0.19.1
- Also reproduced with the 0.17.1 native binary (
@ladybugdb/core-linux-x64@0.17.1)
swapped in for testing — query_result.js is byte-identical between the two
npm versions, so this is not a JS-wrapper regression between them; the defect
lives in the native query engine.
- Did not fail in ghcr.io/ladybugdb/explorer:0:18.0
- Platform: Linux x64, Node.js 26
- In-memory database (
:memory:) or with file
- can be reproduced with cypher queries in ldb explorer.
Minimal reproduction -JS
import lbug from '@ladybugdb/core';
const db = new lbug.Database(':memory:');
const conn = new lbug.Connection(db);
async function run(query) {
const raw = await conn.query(query);
const result = Array.isArray(raw) ? raw[0] : raw;
return result.getAll();
}
// ── Schema: one node table, one self-referential relationship table ──────────
await run(`CREATE NODE TABLE Item(id SERIAL, kind STRING, PRIMARY KEY(id))`);
await run(`CREATE REL TABLE Contains(FROM Item TO Item, label STRING)`);
// ── Data: one "container" node (id 0) and N "leaf" nodes (id 1..N) ───────────
await run(`CREATE (:Item {kind: 'container'})`);
for (let i = 1; i <= 12; i++) {
await run(`CREATE (:Item {kind: 'leaf'})`);
}
// ── The failing pattern: UNWIND + two SEPARATE MATCH...WHERE clauses ─────────
// This mirrors a common batch-insert idiom: resolve both endpoints of an edge
// by an id carried on the unwound row, then create the edge between them.
const items = [];
for (let i = 1; i <= 12; i++) items.push(`{src: 0, tgt: ${i}}`);
await run(
`UNWIND [${items.join(', ')}] AS item
MATCH (s:Item)
WHERE s.id = item.src
MATCH (t:Item)
WHERE t.id = item.tgt
CREATE (s)-[:Contains {label: 'contains'}]->(t)`
);
// ── Inspect what was actually created ─────────────────────────────────────────
console.log(await run('MATCH (a:Item)-[r:Contains]->(b:Item) RETURN a.id AS srcId, b.id AS targetId'));
Actual output
[
{ srcId: 0, targetId: 1 },
{ srcId: 0, targetId: 1 },
{ srcId: 0, targetId: 1 },
{ srcId: 0, targetId: 1 },
{ srcId: 0, targetId: 1 },
{ srcId: 0, targetId: 1 },
{ srcId: 0, targetId: 1 },
{ srcId: 0, targetId: 1 },
{ srcId: 0, targetId: 1 },
{ srcId: 0, targetId: 1 },
{ srcId: 0, targetId: 1 },
{ srcId: 0, targetId: 1 },
]
12 edges exist (the count from UNWIND is respected), but all 12 are
(0)-[:Contains]->(1) — the first row's {src: 0, tgt: 1} — even though the
other 11 rows specify tgt: 2 through tgt: 12.
Expected output
12 distinct edges, one per unwound row:
[
{ srcId: 0, targetId: 1 },
{ srcId: 0, targetId: 2 },
{ srcId: 0, targetId: 3 },
{ srcId: 0, targetId: 4 },
{ srcId: 0, targetId: 5 },
{ srcId: 0, targetId: 6 },
{ srcId: 0, targetId: 7 },
{ srcId: 0, targetId: 8 },
{ srcId: 0, targetId: 9 },
{ srcId: 0, targetId: 10 },
{ srcId: 0, targetId: 11 },
{ srcId: 0, targetId: 12 },
]
(Order is not asserted — order-independent set equality is what's expected.)
Minimal Example - pure cypher
CREATE NODE TABLE Item(id SERIAL, kind STRING, PRIMARY KEY(id));
CREATE REL TABLE Contains(FROM Item TO Item, label STRING);
CREATE (:Item {kind: 'container'});
CREATE (:Item {kind: 'leaf'});
CREATE (:Item {kind: 'leaf'});
CREATE (:Item {kind: 'leaf'});
CREATE (:Item {kind: 'leaf'});
CREATE (:Item {kind: 'leaf'});
CREATE (:Item {kind: 'leaf'});
CREATE (:Item {kind: 'leaf'});
CREATE (:Item {kind: 'leaf'});
CREATE (:Item {kind: 'leaf'});
CREATE (:Item {kind: 'leaf'});
CREATE (:Item {kind: 'leaf'});
CREATE (:Item {kind: 'leaf'});
UNWIND [{src: 0, tgt: 1},{src: 0, tgt: 2},{src: 0, tgt: 3},{src: 0, tgt: 4},{src: 0, tgt: 5},{src: 0, tgt: 6},{src: 0, tgt: 7},{src: 0, tgt: 8},{src: 0, tgt: 9},{src: 0, tgt: 10},{src: 0, tgt: 11}] AS item
MATCH (s:Item)
WHERE s.id = item.src
MATCH (t:Item)
WHERE t.id = item.tgt
CREATE (s)-[:Contains {label: 'contains'}]->(t)
;
// the following alternative expression creates the expected result
// UNWIND [{src: 0, tgt: 1},{src: 0, tgt: 2},{src: 0, tgt: 3},{src: 0, tgt: 4},{src: 0, tgt: 5},{src: 0, tgt: 6},{src: 0, tgt: 7},{src: 0, tgt: 8},{src: 0, tgt: 9},{src: 0, tgt: 10},{src: 0, tgt: 11}] AS item
// MATCH (s:Item), (t:Item)
// WHERE s.id = item.src AND t.id = item.tgt
// CREATE (s)-[:Contains {label: 'contains'}]->(t);
MATCH (a:Item)-[r:Contains]->(b:Item) RETURN a.id AS srcId, b.id AS targetId;
Boundary condition
Re-running with N unwound rows ({src: 0, tgt: 1} .. {src: 0, tgt: N}):
| N |
distinct targets created |
expected |
result |
| 1 |
1 |
1 |
OK |
| 2 |
1 |
2 |
BUG |
| 3 |
1 |
3 |
BUG |
| 4–9 |
1 |
4–9 |
BUG (same pattern each time) |
A single unwound row is fine — the "freeze on row 0" only becomes visible with
two or more rows, and is consistent (not flaky/order-dependent in these runs)
from N=2 upward.
What does not trigger the bug
Replacing the two separate MATCH ... WHERE clauses with one MATCH that
introduces both patterns in a single clause produces correct, distinct edges:
await run(
`UNWIND [${items.join(', ')}] AS item
MATCH (s:Item), (t:Item)
WHERE s.id = item.src AND t.id = item.tgt
CREATE (s)-[:Contains {label: 'contains'}]->(t)`
);
// -> correct: 12 distinct edges, one per row
Plain multi-row reads (no relationship traversal) are also unaffected:
// Correct, distinct rows:
await run('MATCH (n:Item) WHERE n.kind = \'leaf\' RETURN n.id AS id ORDER BY n.id');
// Correct, distinct rows:
await run('UNWIND [1,2,3,4,5,6,7,8,9,10,11,12] AS i RETURN i AS id');
So the defect is specific to: UNWIND feeding two (or more) sequential
MATCH (...) WHERE <prop> = <unwound-row-field> clauses that are then used
together in a CREATE. The second (and presumably any subsequent) MATCH's
filter appears to be evaluated once against the first unwound row and reused
for every row, rather than being re-evaluated per row.
Practical impact
Any application using the "resolve both edge endpoints by id looked up from a
batch, using two independent MATCH ... WHERE clauses" idiom for bulk edge
creation will silently create the wrong graph: the edge count matches what
was requested, so naive verification (assert numEdgesCreated === batch.length)
passes, but the edges collapse onto whichever row happened to be evaluated
first. Downstream, this corrupts any traversal that follows those edges (e.g.
containment/parent-child hierarchies), while COUNT-only checks and
non-relationship reads keep passing — making the bug easy to miss.
Are there known steps to reproduce?
No response
Ladybug version
v0.19.1
What operating system are you using?
ubuntu, windows
What happened?
Bug:
UNWIND+ two sequentialMATCH ... WHEREclauses bind the same row to everyCREATEd edgeSummary
When a Cypher query combines
UNWINDwith two separateMATCH (...) WHERE ...clauses (each filtering by a property taken from the unwound row) followed by a
CREATEof a relationship between the two matched nodes, every created edge endsup pointing at the first unwound row's nodes — regardless of what the other
rows' values are. The row count is correct; the content is not.
A single combined
MATCH (a), (b) WHERE a.x = ... AND b.x = ...(one clause,two patterns) does not show the bug. Splitting the exact same filter into two
separate
MATCH ... WHEREclauses does.This was found indirectly: a downstream read query that joins across the
relationship appeared to return "the same row repeated N times." Deeper
investigation showed the read was accurate — the write had genuinely created N
duplicate edges, all pointing at the first item's target, because of this bug.
Environment
@ladybugdb/core(npm) — reproduced on 0.19.1@ladybugdb/core-linux-x64@0.17.1)swapped in for testing —
query_result.jsis byte-identical between the twonpm versions, so this is not a JS-wrapper regression between them; the defect
lives in the native query engine.
:memory:) or with fileMinimal reproduction -JS
Actual output
12 edges exist (the count from
UNWINDis respected), but all 12 are(0)-[:Contains]->(1)— the first row's{src: 0, tgt: 1}— even though theother 11 rows specify
tgt: 2throughtgt: 12.Expected output
12 distinct edges, one per unwound row:
(Order is not asserted — order-independent set equality is what's expected.)
Minimal Example - pure cypher
Boundary condition
Re-running with
Nunwound rows ({src: 0, tgt: 1}..{src: 0, tgt: N}):A single unwound row is fine — the "freeze on row 0" only becomes visible with
two or more rows, and is consistent (not flaky/order-dependent in these runs)
from N=2 upward.
What does not trigger the bug
Replacing the two separate
MATCH ... WHEREclauses with oneMATCHthatintroduces both patterns in a single clause produces correct, distinct edges:
Plain multi-row reads (no relationship traversal) are also unaffected:
So the defect is specific to:
UNWINDfeeding two (or more) sequentialMATCH (...) WHERE <prop> = <unwound-row-field>clauses that are then usedtogether in a
CREATE. The second (and presumably any subsequent)MATCH'sfilter appears to be evaluated once against the first unwound row and reused
for every row, rather than being re-evaluated per row.
Practical impact
Any application using the "resolve both edge endpoints by id looked up from a
batch, using two independent
MATCH ... WHEREclauses" idiom for bulk edgecreation will silently create the wrong graph: the edge count matches what
was requested, so naive verification (
assert numEdgesCreated === batch.length)passes, but the edges collapse onto whichever row happened to be evaluated
first. Downstream, this corrupts any traversal that follows those edges (e.g.
containment/parent-child hierarchies), while
COUNT-only checks andnon-relationship reads keep passing — making the bug easy to miss.
Are there known steps to reproduce?
No response