Skip to content

Bug: Unwind with two MATCH.. WHERE clauses binds the same value to all elements of the following clauses #822

Description

@TheAnyKey

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions