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.
- {graphqlite-0.4.0/src/graphqlite.egg-info → graphqlite-0.4.2}/PKG-INFO +1 -1
- {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite/__init__.py +1 -1
- {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite/connection.py +19 -7
- {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite/graph/edges.py +21 -22
- {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite/graph/nodes.py +9 -13
- {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite/graph/queries.py +18 -12
- {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite/manager.py +23 -3
- {graphqlite-0.4.0 → graphqlite-0.4.2/src/graphqlite.egg-info}/PKG-INFO +1 -1
- {graphqlite-0.4.0 → graphqlite-0.4.2}/tests/test_graph.py +218 -0
- {graphqlite-0.4.0 → graphqlite-0.4.2}/FAQ.md +0 -0
- {graphqlite-0.4.0 → graphqlite-0.4.2}/README.md +0 -0
- {graphqlite-0.4.0 → graphqlite-0.4.2}/pyproject.toml +0 -0
- {graphqlite-0.4.0 → graphqlite-0.4.2}/setup.cfg +0 -0
- {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite/_platform.py +0 -0
- {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite/algorithms/__init__.py +0 -0
- {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite/algorithms/_parsing.py +0 -0
- {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite/algorithms/centrality.py +0 -0
- {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite/algorithms/community.py +0 -0
- {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite/algorithms/components.py +0 -0
- {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite/algorithms/export.py +0 -0
- {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite/algorithms/paths.py +0 -0
- {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite/algorithms/similarity.py +0 -0
- {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite/algorithms/traversal.py +0 -0
- {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite/graph/__init__.py +0 -0
- {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite/graph/_base.py +0 -0
- {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite/graph/batch.py +0 -0
- {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite/graph/bulk.py +0 -0
- {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite/utils.py +0 -0
- {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite.egg-info/SOURCES.txt +0 -0
- {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite.egg-info/dependency_links.txt +0 -0
- {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite.egg-info/requires.txt +0 -0
- {graphqlite-0.4.0 → graphqlite-0.4.2}/src/graphqlite.egg-info/top_level.txt +0 -0
- {graphqlite-0.4.0 → graphqlite-0.4.2}/tests/test_connection.py +0 -0
- {graphqlite-0.4.0 → graphqlite-0.4.2}/tests/test_manager.py +0 -0
- {graphqlite-0.4.0 → graphqlite-0.4.2}/tests/test_new_functions.py +0 -0
- {graphqlite-0.4.0 → graphqlite-0.4.2}/uv.lock +0 -0
- {graphqlite-0.4.0 → graphqlite-0.4.2}/vendor/sqlite/sqlite3.h +0 -0
- {graphqlite-0.4.0 → graphqlite-0.4.2}/vendor/sqlite/sqlite3ext.h +0 -0
|
@@ -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.
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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 (
|
|
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:
|
|
27
|
-
f"(b {{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:
|
|
50
|
-
f"(b {{id:
|
|
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:
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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:
|
|
100
|
-
f"(b {{id:
|
|
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:
|
|
115
|
-
f"(b {{id:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
79
|
-
f"SET n.{k} =
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|