graphqlite 0.4.0__tar.gz → 0.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. {graphqlite-0.4.0/src/graphqlite.egg-info → graphqlite-0.4.2}/PKG-INFO +1 -1
  2. {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite/__init__.py +1 -1
  3. {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite/connection.py +19 -7
  4. {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite/graph/edges.py +21 -22
  5. {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite/graph/nodes.py +9 -13
  6. {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite/graph/queries.py +18 -12
  7. {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite/manager.py +23 -3
  8. {graphqlite-0.4.0 → graphqlite-0.4.2/src/graphqlite.egg-info}/PKG-INFO +1 -1
  9. {graphqlite-0.4.0 → graphqlite-0.4.2}/tests/test_graph.py +218 -0
  10. {graphqlite-0.4.0 → graphqlite-0.4.2}/FAQ.md +0 -0
  11. {graphqlite-0.4.0 → graphqlite-0.4.2}/README.md +0 -0
  12. {graphqlite-0.4.0 → graphqlite-0.4.2}/pyproject.toml +0 -0
  13. {graphqlite-0.4.0 → graphqlite-0.4.2}/setup.cfg +0 -0
  14. {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite/_platform.py +0 -0
  15. {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite/algorithms/__init__.py +0 -0
  16. {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite/algorithms/_parsing.py +0 -0
  17. {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite/algorithms/centrality.py +0 -0
  18. {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite/algorithms/community.py +0 -0
  19. {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite/algorithms/components.py +0 -0
  20. {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite/algorithms/export.py +0 -0
  21. {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite/algorithms/paths.py +0 -0
  22. {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite/algorithms/similarity.py +0 -0
  23. {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite/algorithms/traversal.py +0 -0
  24. {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite/graph/__init__.py +0 -0
  25. {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite/graph/_base.py +0 -0
  26. {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite/graph/batch.py +0 -0
  27. {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite/graph/bulk.py +0 -0
  28. {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite/utils.py +0 -0
  29. {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite.egg-info/SOURCES.txt +0 -0
  30. {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite.egg-info/dependency_links.txt +0 -0
  31. {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite.egg-info/requires.txt +0 -0
  32. {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite.egg-info/top_level.txt +0 -0
  33. {graphqlite-0.4.0 → graphqlite-0.4.2}/tests/test_connection.py +0 -0
  34. {graphqlite-0.4.0 → graphqlite-0.4.2}/tests/test_manager.py +0 -0
  35. {graphqlite-0.4.0 → graphqlite-0.4.2}/tests/test_new_functions.py +0 -0
  36. {graphqlite-0.4.0 → graphqlite-0.4.2}/uv.lock +0 -0
  37. {graphqlite-0.4.0 → graphqlite-0.4.2}/vendor/sqlite/sqlite3.h +0 -0
  38. {graphqlite-0.4.0 → graphqlite-0.4.2}/vendor/sqlite/sqlite3ext.h +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphqlite
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: SQLite extension for graph queries using Cypher
5
5
  Author: GraphQLite Contributors
6
6
  License: MIT
@@ -8,7 +8,7 @@ from .manager import GraphManager, graphs
8
8
  from .utils import escape_string, sanitize_rel_type, CYPHER_RESERVED
9
9
  from ._platform import get_loadable_path
10
10
 
11
- __version__ = "0.4.0"
11
+ __version__ = "0.4.2"
12
12
  __all__ = [
13
13
  "BulkInsertResult",
14
14
  "Connection", "connect", "wrap", "load", "loadable_path",
@@ -142,11 +142,23 @@ class Connection:
142
142
  Example:
143
143
  >>> db.cypher("MATCH (n) WHERE n.name = $name RETURN n", {"name": "Alice"})
144
144
  """
145
- if params:
146
- params_json = json.dumps(params)
147
- cursor = self._conn.execute("SELECT cypher(?, ?)", (query, params_json))
148
- else:
149
- cursor = self._conn.execute("SELECT cypher(?)", (query,))
145
+ try:
146
+ if params:
147
+ params_json = json.dumps(params)
148
+ cursor = self._conn.execute("SELECT cypher(?, ?)", (query, params_json))
149
+ else:
150
+ cursor = self._conn.execute("SELECT cypher(?)", (query,))
151
+ except sqlite3.Error as e:
152
+ # Parse structured JSON error from extension
153
+ err_str = str(e)
154
+ try:
155
+ err_data = json.loads(err_str)
156
+ if isinstance(err_data, dict) and "error" in err_data:
157
+ raise sqlite3.Error(err_data["error"]) from None
158
+ except (json.JSONDecodeError, TypeError):
159
+ pass
160
+ raise
161
+
150
162
  row = cursor.fetchone()
151
163
 
152
164
  if row is None or row[0] is None:
@@ -158,8 +170,8 @@ class Connection:
158
170
  try:
159
171
  data = json.loads(result_str)
160
172
  except json.JSONDecodeError:
161
- # Non-JSON result (error message or scalar)
162
- if result_str.startswith("Error"):
173
+ # Non-JSON result (scalar or legacy error)
174
+ if result_str.startswith("Error") or result_str.startswith("{\"error\""):
163
175
  raise sqlite3.Error(result_str)
164
176
  return CypherResult([{"result": result_str}], ["result"])
165
177
 
@@ -23,9 +23,10 @@ class EdgesMixin(BaseMixin):
23
23
  """
24
24
  rel_pattern = f":{sanitize_rel_type(rel_type)}" if rel_type else ""
25
25
  result = self._conn.cypher(
26
- f"MATCH (a {{id: '{self._escape(source_id)}'}})-[r{rel_pattern}]->"
27
- f"(b {{id: '{self._escape(target_id)}'}}) "
28
- f"RETURN count(r) AS cnt"
26
+ f"MATCH (a {{id: $src}})-[r{rel_pattern}]->"
27
+ f"(b {{id: $tgt}}) "
28
+ f"RETURN count(r) AS cnt",
29
+ params={"src": source_id, "tgt": target_id},
29
30
  )
30
31
  if len(result) == 0:
31
32
  return False
@@ -46,8 +47,9 @@ class EdgesMixin(BaseMixin):
46
47
  """
47
48
  rel_pattern = f":{sanitize_rel_type(rel_type)}" if rel_type else ""
48
49
  result = self._conn.cypher(
49
- f"MATCH (a {{id: '{self._escape(source_id)}'}})-[r{rel_pattern}]->"
50
- f"(b {{id: '{self._escape(target_id)}'}}) RETURN r"
50
+ f"MATCH (a {{id: $src}})-[r{rel_pattern}]->"
51
+ f"(b {{id: $tgt}}) RETURN r",
52
+ params={"src": source_id, "tgt": target_id},
51
53
  )
52
54
  if len(result) == 0:
53
55
  return None
@@ -75,29 +77,25 @@ class EdgesMixin(BaseMixin):
75
77
  rel_type: Relationship type label
76
78
  """
77
79
  safe_rel_type = sanitize_rel_type(rel_type)
78
- esc_source = self._escape(source_id)
79
- esc_target = self._escape(target_id)
80
80
 
81
81
  self._conn.cypher(
82
- f"MATCH (a {{id: '{esc_source}'}}), (b {{id: '{esc_target}'}}) "
83
- f"MERGE (a)-[r:{safe_rel_type}]->(b)"
82
+ f"MATCH (a {{id: $src}}), (b {{id: $tgt}}) "
83
+ f"MERGE (a)-[r:{safe_rel_type}]->(b)",
84
+ params={"src": source_id, "tgt": target_id},
84
85
  )
85
86
 
86
87
  if edge_data:
88
+ params = {"src": source_id, "tgt": target_id}
87
89
  set_parts = []
88
- for k, v in edge_data.items():
89
- if isinstance(v, str):
90
- set_parts.append(f"r.{k} = '{self._escape(v)}'")
91
- elif isinstance(v, bool):
92
- set_parts.append(f"r.{k} = {str(v).lower()}")
93
- elif v is None:
94
- set_parts.append(f"r.{k} = null")
95
- else:
96
- set_parts.append(f"r.{k} = {v}")
90
+ for i, (k, v) in enumerate(edge_data.items()):
91
+ param_name = f"v{i}"
92
+ set_parts.append(f"r.{k} = ${param_name}")
93
+ params[param_name] = v
97
94
  set_str = ", ".join(set_parts)
98
95
  self._conn.cypher(
99
- f"MATCH (a {{id: '{esc_source}'}})-[r:{safe_rel_type}]->"
100
- f"(b {{id: '{esc_target}'}}) SET {set_str}"
96
+ f"MATCH (a {{id: $src}})-[r:{safe_rel_type}]->"
97
+ f"(b {{id: $tgt}}) SET {set_str}",
98
+ params=params,
101
99
  )
102
100
 
103
101
  def delete_edge(self, source_id: str, target_id: str, rel_type: Optional[str] = None) -> None:
@@ -111,8 +109,9 @@ class EdgesMixin(BaseMixin):
111
109
  """
112
110
  rel_pattern = f":{sanitize_rel_type(rel_type)}" if rel_type else ""
113
111
  self._conn.cypher(
114
- f"MATCH (a {{id: '{self._escape(source_id)}'}})-[r{rel_pattern}]->"
115
- f"(b {{id: '{self._escape(target_id)}'}}) DELETE r"
112
+ f"MATCH (a {{id: $src}})-[r{rel_pattern}]->"
113
+ f"(b {{id: $tgt}}) DELETE r",
114
+ params={"src": source_id, "tgt": target_id},
116
115
  )
117
116
 
118
117
  def get_all_edges(self) -> list[dict]:
@@ -20,7 +20,8 @@ class NodesMixin(BaseMixin):
20
20
  True if node exists, False otherwise
21
21
  """
22
22
  result = self._conn.cypher(
23
- f"MATCH (n {{id: '{self._escape(node_id)}'}}) RETURN count(n) AS cnt"
23
+ "MATCH (n {id: $id}) RETURN count(n) AS cnt",
24
+ params={"id": node_id},
24
25
  )
25
26
  if len(result) == 0:
26
27
  return False
@@ -38,7 +39,8 @@ class NodesMixin(BaseMixin):
38
39
  Node dict with 'id', 'labels', 'properties' or None if not found
39
40
  """
40
41
  result = self._conn.cypher(
41
- f"MATCH (n {{id: '{self._escape(node_id)}'}}) RETURN n"
42
+ "MATCH (n {id: $id}) RETURN n",
43
+ params={"id": node_id},
42
44
  )
43
45
  if len(result) == 0:
44
46
  return None
@@ -66,17 +68,10 @@ class NodesMixin(BaseMixin):
66
68
  if self.has_node(node_id):
67
69
  # Update existing node
68
70
  for k, v in node_data.items():
69
- if isinstance(v, str):
70
- val = f"'{self._escape(v)}'"
71
- elif isinstance(v, bool):
72
- val = str(v).lower()
73
- elif v is None:
74
- val = "null"
75
- else:
76
- val = str(v)
77
71
  self._conn.cypher(
78
- f"MATCH (n {{id: '{self._escape(node_id)}'}}) "
79
- f"SET n.{k} = {val} RETURN n"
72
+ f"MATCH (n {{id: $id}}) "
73
+ f"SET n.{k} = $val RETURN n",
74
+ params={"id": node_id, "val": v},
80
75
  )
81
76
  else:
82
77
  # Create new node
@@ -91,7 +86,8 @@ class NodesMixin(BaseMixin):
91
86
  node_id: The node's id property value
92
87
  """
93
88
  self._conn.cypher(
94
- f"MATCH (n {{id: '{self._escape(node_id)}'}}) DETACH DELETE n"
89
+ "MATCH (n {id: $id}) DETACH DELETE n",
90
+ params={"id": node_id},
95
91
  )
96
92
 
97
93
  def get_all_nodes(self, label: Optional[str] = None) -> list[dict]:
@@ -21,8 +21,9 @@ class QueriesMixin(BaseMixin):
21
21
  Number of edges connected to the node
22
22
  """
23
23
  result = self._conn.cypher(
24
- f"MATCH (n {{id: '{self._escape(node_id)}'}})-[r]-() "
25
- f"RETURN count(r) AS degree"
24
+ "MATCH (n {id: $id})-[r]-() "
25
+ "RETURN count(r) AS degree",
26
+ params={"id": node_id},
26
27
  )
27
28
  if len(result) == 0:
28
29
  return 0
@@ -42,8 +43,9 @@ class QueriesMixin(BaseMixin):
42
43
  List of neighbor node dicts
43
44
  """
44
45
  result = self._conn.cypher(
45
- f"MATCH (n {{id: '{self._escape(node_id)}'}})-[]-(m) "
46
- f"RETURN DISTINCT m"
46
+ "MATCH (n {id: $id})-[]-(m) "
47
+ "RETURN DISTINCT m",
48
+ params={"id": node_id},
47
49
  )
48
50
  return [row.get("m") for row in result if row.get("m")]
49
51
 
@@ -58,8 +60,9 @@ class QueriesMixin(BaseMixin):
58
60
  List of (source_id, target_id, properties) tuples
59
61
  """
60
62
  result = self._conn.cypher(
61
- f"MATCH (n {{id: '{self._escape(node_id)}'}})-[r]-(m) "
62
- f"RETURN n.id AS source, m.id AS target, r"
63
+ "MATCH (n {id: $id})-[r]-(m) "
64
+ "RETURN n.id AS source, m.id AS target, r",
65
+ params={"id": node_id},
63
66
  )
64
67
  return [(row["source"], row["target"], row.get("r", {})) for row in result]
65
68
 
@@ -74,8 +77,9 @@ class QueriesMixin(BaseMixin):
74
77
  List of dicts with 'source', 'target', 'r' keys
75
78
  """
76
79
  result = self._conn.cypher(
77
- f"MATCH (a {{id: '{self._escape(node_id)}'}})-[r]->(b) "
78
- f"RETURN a.id AS source, b.id AS target, r"
80
+ "MATCH (a {id: $id})-[r]->(b) "
81
+ "RETURN a.id AS source, b.id AS target, r",
82
+ params={"id": node_id},
79
83
  )
80
84
  return result.to_list()
81
85
 
@@ -90,8 +94,9 @@ class QueriesMixin(BaseMixin):
90
94
  List of dicts with 'source', 'target', 'r' keys
91
95
  """
92
96
  result = self._conn.cypher(
93
- f"MATCH (a)-[r]->(b {{id: '{self._escape(node_id)}'}}) "
94
- f"RETURN a.id AS source, b.id AS target, r"
97
+ "MATCH (a)-[r]->(b {id: $id}) "
98
+ "RETURN a.id AS source, b.id AS target, r",
99
+ params={"id": node_id},
95
100
  )
96
101
  return result.to_list()
97
102
 
@@ -109,8 +114,9 @@ class QueriesMixin(BaseMixin):
109
114
  from ..utils import sanitize_rel_type
110
115
  safe_type = sanitize_rel_type(rel_type)
111
116
  result = self._conn.cypher(
112
- f"MATCH (a {{id: '{self._escape(node_id)}'}})-[r:{safe_type}]->(b) "
113
- f"RETURN a.id AS source, b.id AS target, r"
117
+ f"MATCH (a {{id: $id}})-[r:{safe_type}]->(b) "
118
+ f"RETURN a.id AS source, b.id AS target, r",
119
+ params={"id": node_id},
114
120
  )
115
121
  return result.to_list()
116
122
 
@@ -258,9 +258,29 @@ class GraphManager:
258
258
  import json
259
259
  if params:
260
260
  params_json = json.dumps(params)
261
- cursor = coord.execute("SELECT cypher(?, ?)", (cypher, params_json))
261
+ try:
262
+ cursor = coord.execute("SELECT cypher(?, ?)", (cypher, params_json))
263
+ except sqlite3.Error as e:
264
+ err_str = str(e)
265
+ try:
266
+ err_data = json.loads(err_str)
267
+ if isinstance(err_data, dict) and "error" in err_data:
268
+ raise sqlite3.Error(err_data["error"]) from None
269
+ except (json.JSONDecodeError, TypeError):
270
+ pass
271
+ raise
262
272
  else:
263
- cursor = coord.execute("SELECT cypher(?)", (cypher,))
273
+ try:
274
+ cursor = coord.execute("SELECT cypher(?)", (cypher,))
275
+ except sqlite3.Error as e:
276
+ err_str = str(e)
277
+ try:
278
+ err_data = json.loads(err_str)
279
+ if isinstance(err_data, dict) and "error" in err_data:
280
+ raise sqlite3.Error(err_data["error"]) from None
281
+ except (json.JSONDecodeError, TypeError):
282
+ pass
283
+ raise
264
284
 
265
285
  row = cursor.fetchone()
266
286
 
@@ -273,7 +293,7 @@ class GraphManager:
273
293
  try:
274
294
  data = json.loads(result_str)
275
295
  except json.JSONDecodeError:
276
- if result_str.startswith("Error"):
296
+ if result_str.startswith("Error") or result_str.startswith("{\"error\""):
277
297
  raise sqlite3.Error(result_str)
278
298
  return CypherResult([{"result": result_str}], ["result"])
279
299
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphqlite
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: SQLite extension for graph queries using Cypher
5
5
  Author: GraphQLite Contributors
6
6
  License: MIT
@@ -1492,6 +1492,70 @@ def test_merge_on_match_set_function(g):
1492
1492
  assert result[0]["n.name"] == "UPDATED"
1493
1493
 
1494
1494
 
1495
+ def test_merge_with_set_return(g):
1496
+ """Issue #48: MERGE + WITH + SET + RETURN returns column data."""
1497
+ result = g.query("""
1498
+ MERGE (n:MergeWithPy {id: 'mwp1'})
1499
+ WITH n
1500
+ SET n.updated = true
1501
+ RETURN n.id, n.updated
1502
+ """)
1503
+ assert len(result) == 1
1504
+ assert result[0]["n.id"] == "mwp1"
1505
+ assert result[0]["n.updated"] is True
1506
+
1507
+
1508
+ def test_merge_with_set_no_return(g):
1509
+ """Issue #48: MERGE + WITH + SET without RETURN succeeds."""
1510
+ g.query("""
1511
+ MERGE (n:MergeWithPy2 {id: 'mwp2'})
1512
+ WITH n
1513
+ SET n.updated = true
1514
+ """)
1515
+ result = g.query("MATCH (n:MergeWithPy2 {id: 'mwp2'}) RETURN n.updated")
1516
+ assert len(result) == 1
1517
+ assert result[0]["n.updated"] is True
1518
+
1519
+
1520
+ def test_merge_with_return_no_set(g):
1521
+ """Issue #48: MERGE + WITH + RETURN without SET returns column data."""
1522
+ result = g.query("""
1523
+ MERGE (n:MergeWithPy3 {id: 'mwp3'})
1524
+ WITH n
1525
+ RETURN n.id
1526
+ """)
1527
+ assert len(result) == 1
1528
+ assert result[0]["n.id"] == "mwp3"
1529
+
1530
+
1531
+ def test_merge_with_multiple_set(g):
1532
+ """Issue #54: MERGE + WITH + multiple SET clauses all execute."""
1533
+ result = g.query("""
1534
+ MERGE (n:MergeMultiSetPy {id: 'mms1'})
1535
+ WITH n
1536
+ SET n.a = 'one'
1537
+ SET n.b = 'two'
1538
+ RETURN n.a, n.b
1539
+ """)
1540
+ assert len(result) == 1
1541
+ assert result[0]["n.a"] == "one"
1542
+ assert result[0]["n.b"] == "two"
1543
+
1544
+
1545
+ def test_merge_with_edge_variable(g):
1546
+ """Issue #54: MERGE relationship + WITH carries edge variable."""
1547
+ g.query("""
1548
+ MERGE (a:EdgeVarPy {id: 'evp1'})-[:KNOWS]->(b:EdgeVarPy {id: 'evp2'})
1549
+ """)
1550
+ result = g.query("""
1551
+ MERGE (a:EdgeVarPy {id: 'evp1'})-[r:KNOWS]->(b:EdgeVarPy {id: 'evp2'})
1552
+ WITH a, r
1553
+ RETURN a.id
1554
+ """)
1555
+ assert len(result) == 1
1556
+ assert result[0]["a.id"] == "evp1"
1557
+
1558
+
1495
1559
  def test_remove_return(g):
1496
1560
  """Test REMOVE + RETURN in a single query."""
1497
1561
  g.query('CREATE (n:RemRetPy {name: "Dave", temp: "delete_me"})')
@@ -1499,3 +1563,157 @@ def test_remove_return(g):
1499
1563
  assert len(result) == 1
1500
1564
  assert result[0]["n.name"] == "Dave"
1501
1565
  assert result[0]["n.temp"] is None
1566
+
1567
+
1568
+ # =============================================================================
1569
+ # CALL {} Subquery
1570
+ # =============================================================================
1571
+
1572
+ def test_call_subquery_standalone(g):
1573
+ """Test standalone CALL { MATCH ... RETURN }."""
1574
+ g.connection.cypher('CREATE (:CallPy {name: "Alice"})')
1575
+ g.connection.cypher('CREATE (:CallPy {name: "Bob"})')
1576
+ result = g.query('CALL { MATCH (n:CallPy) RETURN n.name ORDER BY n.name }')
1577
+ assert len(result) == 2
1578
+ assert result[0]["n.name"] == "Alice"
1579
+ assert result[1]["n.name"] == "Bob"
1580
+
1581
+
1582
+ def test_call_subquery_with_import(g):
1583
+ """Test CALL with WITH variable import and SET."""
1584
+ g.connection.cypher('CREATE (:CallPyW {name: "Carol"})')
1585
+ g.connection.cypher(
1586
+ 'MATCH (n:CallPyW {name: "Carol"}) CALL { WITH n SET n.touched = true }'
1587
+ )
1588
+ result = g.query('MATCH (n:CallPyW) RETURN n.name, n.touched')
1589
+ assert len(result) == 1
1590
+ assert result[0]["n.name"] == "Carol"
1591
+ assert result[0]["n.touched"] is True
1592
+
1593
+
1594
+ def test_call_subquery_union(g):
1595
+ """Test CALL with UNION inside."""
1596
+ result = g.query('CALL { RETURN 1 AS n UNION RETURN 2 AS n }')
1597
+ assert len(result) == 2
1598
+ values = sorted([r["n"] for r in result])
1599
+ assert values == [1, 2]
1600
+
1601
+
1602
+ # =============================================================================
1603
+ # Issue Regression Tests (#34b, #49, #50, #51, #55)
1604
+ # =============================================================================
1605
+
1606
+ def test_issue_49_unwind_param_create_set(g):
1607
+ """Issue #49: UNWIND $param + CREATE + SET should work."""
1608
+ g.query(
1609
+ 'UNWIND $items AS item CREATE (n:Uw49Py) SET n.id = item.id, n.name = item.name',
1610
+ {"items": [{"id": "a", "name": "Alpha"}, {"id": "b", "name": "Beta"}]}
1611
+ )
1612
+ result = g.query('MATCH (n:Uw49Py) RETURN n.id, n.name ORDER BY n.id')
1613
+ assert len(result) == 2
1614
+ assert result[0]["n.id"] == "a"
1615
+ assert result[0]["n.name"] == "Alpha"
1616
+ assert result[1]["n.id"] == "b"
1617
+ assert result[1]["n.name"] == "Beta"
1618
+
1619
+
1620
+ def test_issue_49_unwind_param_merge(g):
1621
+ """Issue #49: UNWIND $param + MERGE should iterate per item."""
1622
+ g.query(
1623
+ 'UNWIND $items AS item MERGE (n:Uw49MgPy {id: item.id})',
1624
+ {"items": [{"id": "x"}, {"id": "y"}]}
1625
+ )
1626
+ result = g.query('MATCH (n:Uw49MgPy) RETURN n.id ORDER BY n.id')
1627
+ assert len(result) == 2
1628
+ assert result[0]["n.id"] == "x"
1629
+ assert result[1]["n.id"] == "y"
1630
+
1631
+
1632
+ def test_issue_49_unwind_literal_set(g):
1633
+ """Issue #49: UNWIND literal + SET should propagate item value."""
1634
+ g.query('UNWIND ["a", "b"] AS item CREATE (n:Uw49LPy) SET n.id = item')
1635
+ result = g.query('MATCH (n:Uw49LPy) RETURN n.id ORDER BY n.id')
1636
+ assert len(result) == 2
1637
+ assert result[0]["n.id"] == "a"
1638
+ assert result[1]["n.id"] == "b"
1639
+
1640
+
1641
+ def test_issue_50_startnode_endnode_same_return(g):
1642
+ """Issue #50: startNode(r).name and endNode(r).name in same RETURN should have distinct columns."""
1643
+ g.connection.cypher('CREATE (a:Sn50Py {name: "Alice"})-[:K50Py]->(b:Sn50Py {name: "Bob"})')
1644
+ result = g.query('MATCH ()-[r:K50Py]->() RETURN startNode(r).name, endNode(r).name')
1645
+ assert len(result) == 1
1646
+ row = result[0]
1647
+ # Columns must be distinct — both values present
1648
+ values = list(row.values())
1649
+ assert "Alice" in values
1650
+ assert "Bob" in values
1651
+ assert len(row) == 2 # Two distinct keys, not one
1652
+
1653
+
1654
+ def test_issue_51_call_merge_scoping(g):
1655
+ """Issue #51: CALL { WITH c MATCH (d) MERGE (c)-[:REL]->(d) } should link c to d, not self-loop."""
1656
+ g.connection.cypher('CREATE (c:Co51Py {id: "acme"})')
1657
+ g.connection.cypher('CREATE (d:Dep51Py {id: "eng"})')
1658
+ g.connection.cypher(
1659
+ 'MATCH (c:Co51Py {id: "acme"}) '
1660
+ 'CALL { With c MATCH (d:Dep51Py {id: "eng"}) MERGE (c)-[:HAS51Py]->(d) }'
1661
+ )
1662
+ result = g.query('MATCH (a)-[:HAS51Py]->(b) RETURN a.id, b.id')
1663
+ assert len(result) == 1
1664
+ assert result[0]["a.id"] == "acme"
1665
+ assert result[0]["b.id"] == "eng" # NOT "acme" (self-loop)
1666
+
1667
+
1668
+ def test_issue_34b_optional_match_where_null_row(g):
1669
+ """Issue #34b: OPTIONAL MATCH WHERE filter should preserve null rows."""
1670
+ g.connection.cypher('CREATE (a:P34bPy {id: "alice"})')
1671
+ g.connection.cypher('CREATE (r:Pet34bPy {id: "rex", name: "Rex"})')
1672
+ g.connection.cypher(
1673
+ 'MATCH (a:P34bPy {id: "alice"}), (r:Pet34bPy {id: "rex"}) CREATE (a)-[:OWNS34bPy]->(r)'
1674
+ )
1675
+ result = g.query(
1676
+ 'MATCH (a:P34bPy {id: "alice"}) '
1677
+ 'OPTIONAL MATCH (a)-[:OWNS34bPy]->(r:Pet34bPy) WHERE r.name = "nonexistent" '
1678
+ 'RETURN a.id, r.id'
1679
+ )
1680
+ assert len(result) == 1
1681
+ assert result[0]["a.id"] == "alice"
1682
+ assert result[0]["r.id"] is None
1683
+
1684
+
1685
+ def test_with_match_merge(g):
1686
+ """MATCH+WITH+MATCH+MERGE should work without errors."""
1687
+ g.connection.cypher('CREATE (a:WmmPy {id: "src"})')
1688
+ g.connection.cypher('CREATE (b:WmmPy {id: "tgt"})')
1689
+ g.connection.cypher(
1690
+ 'MATCH (a:WmmPy {id: "src"}) WITH a '
1691
+ 'MATCH (b:WmmPy {id: "tgt"}) MERGE (a)-[:LinkPy]->(b)'
1692
+ )
1693
+ result = g.query('MATCH (a)-[:LinkPy]->(b) RETURN a.id, b.id')
1694
+ assert len(result) == 1
1695
+ assert result[0]["a.id"] == "src"
1696
+ assert result[0]["b.id"] == "tgt"
1697
+
1698
+
1699
+ def test_functions_in_set(g):
1700
+ """SET n.x = func(n.prop) should resolve node properties in function args."""
1701
+ g.connection.cypher('CREATE (n:FnSetPy {name: " Hello World "})')
1702
+ g.connection.cypher('MATCH (n:FnSetPy) SET n.trimmed = trim(n.name)')
1703
+ result = g.query('MATCH (n:FnSetPy) RETURN n.trimmed')
1704
+ assert len(result) == 1
1705
+ assert result[0]["n.trimmed"] == "Hello World"
1706
+
1707
+
1708
+ def test_unwind_merge_on_create_set(g):
1709
+ """Batch MERGE with ON CREATE SET from params."""
1710
+ g.query(
1711
+ 'UNWIND $items AS item MERGE (n:BatchPy {id: item.id}) ON CREATE SET n.name = item.name',
1712
+ {"items": [{"id": "b1", "name": "First"}, {"id": "b2", "name": "Second"}]}
1713
+ )
1714
+ result = g.query('MATCH (n:BatchPy) RETURN n.id, n.name ORDER BY n.id')
1715
+ assert len(result) == 2
1716
+ assert result[0]["n.id"] == "b1"
1717
+ assert result[0]["n.name"] == "First"
1718
+ assert result[1]["n.id"] == "b2"
1719
+ assert result[1]["n.name"] == "Second"
File without changes
File without changes
File without changes
File without changes
File without changes