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
43 changes: 32 additions & 11 deletions bindings/python/src/graphqlite/graph/edges.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,40 +60,61 @@ def upsert_edge(
source_id: str,
target_id: str,
edge_data: dict[str, Any],
rel_type: str = "RELATED"
rel_type: str = "RELATED",
edge_id: Optional[str] = None,
) -> None:
"""
Create or update an edge between two nodes.

If an edge of the same type already exists, its properties are updated
(merge semantics -- existing properties not in edge_data are preserved).
If no edge of that type exists, a new one is created.
Without edge_id: if an edge of the same type already exists, its
properties are updated (merge semantics -- existing properties not in
edge_data are preserved). If no edge of that type exists, a new one is
created.

With edge_id: the edge is matched/merged on that caller-assigned id
(stored as an ``id`` property on the relationship) instead of on the
(source, target, rel_type) triple. Repeated calls with the same
edge_id update that edge in place; different edge_ids create distinct
parallel edges between the same two nodes with the same type.

Both source and target nodes must exist.

Args:
source_id: Source node id
target_id: Target node id
edge_data: Dictionary of edge properties
rel_type: Relationship type label
edge_id: Optional caller-assigned edge identifier
"""
safe_rel_type = sanitize_rel_type(rel_type)

self._conn.cypher(
f"MATCH (a {{id: $src}}), (b {{id: $tgt}}) "
f"MERGE (a)-[r:{safe_rel_type}]->(b)",
params={"src": source_id, "tgt": target_id},
)
if edge_id is None:
self._conn.cypher(
f"MATCH (a {{id: $src}}), (b {{id: $tgt}}) "
f"MERGE (a)-[r:{safe_rel_type}]->(b)",
params={"src": source_id, "tgt": target_id},
)
rel_match = f"[r:{safe_rel_type}]"
base_params = {"src": source_id, "tgt": target_id}
else:
self._conn.cypher(
f"MATCH (a {{id: $src}}), (b {{id: $tgt}}) "
f"MERGE (a)-[r:{safe_rel_type} {{id: $eid}}]->(b)",
params={"src": source_id, "tgt": target_id, "eid": edge_id},
)
rel_match = f"[r:{safe_rel_type} {{id: $eid}}]"
base_params = {"src": source_id, "tgt": target_id, "eid": edge_id}

if edge_data:
params = {"src": source_id, "tgt": target_id}
params = dict(base_params)
set_parts = []
for i, (k, v) in enumerate(edge_data.items()):
param_name = f"v{i}"
set_parts.append(f"r.{k} = ${param_name}")
params[param_name] = v
set_str = ", ".join(set_parts)
self._conn.cypher(
f"MATCH (a {{id: $src}})-[r:{safe_rel_type}]->"
f"MATCH (a {{id: $src}})-{rel_match}->"
f"(b {{id: $tgt}}) SET {set_str}",
params=params,
)
Expand Down
51 changes: 51 additions & 0 deletions bindings/python/tests/test_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,57 @@ def test_upsert_edge_update_empty_props(g):
assert edge["properties"]["weight"] == 1


def test_upsert_edge_with_edge_id_parallel_edges(g):
"""GitHub #97: a caller-assigned edge_id addresses parallel edges on the
same (source, target, type) triple."""
g.upsert_node("a", {"name": "A"})
g.upsert_node("b", {"name": "B"})

# Two distinct edge_ids on the same triple -> two parallel edges
g.upsert_edge("a", "b", {"seq": 1}, rel_type="KNOWS", edge_id="k1")
g.upsert_edge("a", "b", {"seq": 2}, rel_type="KNOWS", edge_id="k2")

rows = g.query(
"MATCH (a {id: 'a'})-[r:KNOWS]->(b {id: 'b'}) "
"RETURN r.id AS eid, r.seq AS seq"
)
assert len(rows) == 2
by_id = {row["eid"]: row["seq"] for row in rows}
assert by_id == {"k1": 1, "k2": 2}


def test_upsert_edge_with_edge_id_upserts_in_place(g):
"""GitHub #97: repeating an edge_id updates that edge, not a new one."""
g.upsert_node("a", {"name": "A"})
g.upsert_node("b", {"name": "B"})

g.upsert_edge("a", "b", {"seq": 1}, rel_type="KNOWS", edge_id="k1")
g.upsert_edge("a", "b", {"seq": 10, "note": "updated"}, rel_type="KNOWS", edge_id="k1")

rows = g.query(
"MATCH (a {id: 'a'})-[r:KNOWS]->(b {id: 'b'}) "
"RETURN r.seq AS seq, r.note AS note"
)
assert len(rows) == 1
assert rows[0]["seq"] == 10
assert rows[0]["note"] == "updated"


def test_upsert_edge_without_edge_id_keeps_triple_semantics(g):
"""Without edge_id, repeated upserts still merge on the triple."""
g.upsert_node("a", {"name": "A"})
g.upsert_node("b", {"name": "B"})

g.upsert_edge("a", "b", {"w": 1}, rel_type="KNOWS")
g.upsert_edge("a", "b", {"w": 2}, rel_type="KNOWS")

rows = g.query(
"MATCH (a {id: 'a'})-[r:KNOWS]->(b {id: 'b'}) RETURN r.w AS w"
)
assert len(rows) == 1
assert rows[0]["w"] == 2


def test_get_edge_by_type(g):
"""get_edge should be able to retrieve a specific edge type."""
g.upsert_node("a", {"name": "A"})
Expand Down
62 changes: 62 additions & 0 deletions bindings/rust/src/graph/edges.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,68 @@ impl Graph {
Ok(())
}

/// Create or update an edge identified by a caller-assigned edge id.
///
/// The edge is matched/merged on `edge_id` (stored as an `id` property on
/// the relationship) instead of on the (source, target, rel_type) triple,
/// so multiple parallel edges can exist between the same two nodes with
/// the same relationship type. Repeated calls with the same `edge_id`
/// update that edge's properties in place; different `edge_id`s create
/// distinct edges.
///
/// Both source and target nodes must exist.
pub fn upsert_edge_with_id<I, K, V>(
&self,
source_id: &str,
target_id: &str,
props: I,
rel_type: &str,
edge_id: &str,
) -> Result<()>
where
I: IntoIterator<Item = (K, V)>,
K: AsRef<str>,
V: Into<PropertyValue>,
{
let safe_rel_type = sanitize_rel_type(rel_type);

let props: Vec<(String, PropertyValue)> = props
.into_iter()
.map(|(k, v)| (k.as_ref().to_string(), v.into()))
.collect();

let merge_query = format!(
"MATCH (a {{id: $src}}), (b {{id: $tgt}}) MERGE (a)-[r:{} {{id: $eid}}]->(b)",
safe_rel_type
);
self.connection()
.cypher_builder(&merge_query)
.param("src", source_id)
.param("tgt", target_id)
.param("eid", edge_id)
.run()?;

if !props.is_empty() {
let set_parts: Vec<String> = props
.iter()
.map(|(k, v)| format!("r.{} = {}", k, v.to_cypher()))
.collect();
let set_str = set_parts.join(", ");
let set_query = format!(
"MATCH (a {{id: $src}})-[r:{} {{id: $eid}}]->(b {{id: $tgt}}) SET {}",
safe_rel_type, set_str
);
self.connection()
.cypher_builder(&set_query)
.param("src", source_id)
.param("tgt", target_id)
.param("eid", edge_id)
.run()?;
}

Ok(())
}

/// Delete the directed edge between two nodes.
pub fn delete_edge(
&self,
Expand Down
38 changes: 38 additions & 0 deletions bindings/rust/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,44 @@ fn test_graph_upsert_edge() {
assert!(!g.has_edge("b", "a", None).unwrap()); // Directed edge
}

#[test]
fn test_graph_upsert_edge_with_id() {
// GitHub #97: caller-assigned edge ids address parallel edges on the
// same (source, target, type) triple.
let g = test_graph();

g.upsert_node("a", [("name", "A")], "Node").unwrap();
g.upsert_node("b", [("name", "B")], "Node").unwrap();

// Two distinct edge_ids on the same triple -> two parallel edges
g.upsert_edge_with_id("a", "b", [("seq", "1")], "KNOWS", "k1")
.unwrap();
g.upsert_edge_with_id("a", "b", [("seq", "2")], "KNOWS", "k2")
.unwrap();

let result = g
.connection()
.cypher("MATCH (a {id: 'a'})-[r:KNOWS]->(b {id: 'b'}) RETURN r.id AS eid")
.unwrap();
assert_eq!(result.len(), 2);

// Repeating an edge_id updates that edge in place
g.upsert_edge_with_id("a", "b", [("seq", "10")], "KNOWS", "k1")
.unwrap();
let result = g
.connection()
.cypher("MATCH (a {id: 'a'})-[r:KNOWS]->(b {id: 'b'}) RETURN r.id AS eid")
.unwrap();
assert_eq!(result.len(), 2);
let result = g
.connection()
.cypher("MATCH ()-[r:KNOWS {id: 'k1'}]->() RETURN r.seq AS seq")
.unwrap();
assert_eq!(result.len(), 1);
let seq: i64 = result[0].get("seq").unwrap_or(0);
assert_eq!(seq, 10);
}

#[test]
fn test_graph_stats() {
let g = test_graph();
Expand Down
1 change: 1 addition & 0 deletions docs/src/explanation/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Building a purpose-built graph engine would require implementing disk layout, bu
The transpiler approach means:

- **Durability and atomicity come for free.** Every write goes through SQLite's WAL and journalling machinery.
- **Concurrent access inherits SQLite's guarantees.** Multiple processes (not just threads) can safely read and write the same database file at the same time; SQLite's locking serialises the writes with no corruption or lost updates. The default rollback-journal mode allows one writer at a time with readers blocked during writes; enabling [WAL mode](https://www.sqlite.org/wal.html) (`PRAGMA journal_mode=WAL`) lets readers proceed concurrently with a writer and typically improves write throughput. GraphQLite adds no locking of its own — whatever concurrency SQLite supports in your configuration is what you get.
- **Standard tooling works.** The underlying tables are plain SQLite tables. You can inspect them with the SQLite CLI, use SQLite backup APIs, and attach the database to other tools.
- **Query execution is handled by a proven optimiser.** The generated SQL benefits from SQLite's query planner, covering indexes, and prepared statement caching.

Expand Down
36 changes: 35 additions & 1 deletion docs/testing/semantic-coverage-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ out in sections 2 and 3.
| `MERGE (a)-[r:R {k:v}]->(b)` | rel | `39:T-0187` ✓ | `39:T-0187` ✓ (T-0186/7) | GAP | n/a |
| `MERGE (n) SET n.k = v` | node | `39:T-0195a` ✓ | GAP | GAP | n/a |
| `MERGE (n) SET n += {..}` | node | `39:T-0195b` ✓ | GAP | GAP | `39:T-0195b` ✓ |
| `MATCH (a) CREATE (a)-[:R]->(b)` | new rel | `10:…` | GAP | GAP | n/a |
| `MATCH (a) CREATE (a)-[:R]->(b)` | new rel | `10:…`, `39:GH-95` ✓ (incl. `RETURN r`/`r.k` read-back) | GAP | GAP | n/a |
| `MATCH (a) CREATE (a)-[:R]->(b) SET b.k = v` | new node | `39:T-0198c` ✓ | GAP | GAP | n/a |
| `MATCH (a) MATCH (b) CREATE (a)-[:R]->(b)` | rel | `39:T-0197a` ✓ | `39:T-0197c` ✓ | GAP | n/a |
| `MATCH (a) MATCH (b) MERGE (a)-[r]->(b) SET r.k = v` | rel | `39:T-0196` ✓ | `39:T-0196` ✓ | GAP | n/a |
Expand Down Expand Up @@ -108,6 +108,9 @@ shipped — remaining gaps below.
| Trailing SET after MERGE, rel | ✓ | GAP | file follow-up |
| Trailing SET after MATCH+MERGE, rel | ✓ | GAP | file follow-up |
| `SET r +=` on rel var | `39:T-0202c` ✓ | `39:T-0202d` ✓ | |
| MATCH rel inline prop filter (read) | `39:GH-96 1.2` ✓ | `39:GH-96 1.1/1.4` ✓ | fixed in GH-96 |
| MERGE node inline prop (match phase) | ✓ | `39:GH-97 3.1` ✓ | fixed with GH-97 |
| MERGE rel inline prop (match phase) | ✓ | `39:GH-97 3.2` ✓ | fixed with GH-97 |

---

Expand Down Expand Up @@ -699,3 +702,34 @@ WithOrderBy4 [12] ("Sort by an aliased aggregate projection") and Pattern2 [8]
whole table. The catch-all branch now appends the (non-aggregate) transformed
expression to GROUP BY, mirroring the identifier/property branches. Aggregate
projections (`find_aggregating_call` non-null) are still excluded from GROUP BY.

## Coverage update (2026-08-25) — GitHub issues #95/#96/#97

Regression tests live in `tests/functional/39_issue_regression_tests.sql`
(hard assertions: a CHECK-constrained temp table aborts the run under
`sqlite3 -bail` on any mismatch). Verified alongside unit 947/947, Python
357, Rust 244, and a full TCK pass-set diff (zero regressions).

- **GH-96 — `MATCH ()-[r:T {k: $param}]->()`** — relationship inline
property filters previously *skipped* parameter values entirely,
matching every edge of the type (node patterns and WHERE clauses were
unaffected). `transform_match.c` now emits the same OR-of-EXISTS
parameter condition the node path uses. Cells: read-filter literal vs
`$param` symmetry for rel patterns (section 3), including a
`SET`-scoped-by-filter safety check.
- **GH-95 — `MATCH … CREATE (a)-[r]->(b) RETURN r`** — RETURN of a
variable introduced by CREATE (not bound by any MATCH) raised
"Unknown variable" *after* committing the write. The
MATCH+CREATE+RETURN handler now projects such variables from per-row
variable maps (bare var, `var.k`, aggregates, SKIP/LIMIT; one result
row per MATCH row). Cell: `MATCH (a) CREATE (a)-[:R]->(b)` read-back
on the created rel var (section 1).
- **GH-97 — MERGE `$param` inline properties** — the MERGE match phase
ignored parameter-valued inline properties for both nodes and edges
(matching any node of the label / any edge on the triple), and node
creation dropped them. `executor_merge.c` now resolves parameters in
`find_node_by_pattern`, `find_edge_by_pattern`, and the node-create
property phase. This makes caller-assigned edge ids workable:
`upsert_edge(..., edge_id=...)` (Python) / `upsert_edge_with_id`
(Rust) merge on an `id` relationship property so parallel edges on
the same (source, target, type) triple are individually addressable.
Loading
Loading