mygraph-client 0.1.0__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Julio Contreras
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,167 @@
1
+ Metadata-Version: 2.4
2
+ Name: mygraph-client
3
+ Version: 0.1.0
4
+ Summary: Python client for the MyGraph server (protocol v1)
5
+ Author: Julio Contreras
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/julioacontreras/my-graph
8
+ Project-URL: Repository, https://github.com/julioacontreras/my-graph
9
+ Project-URL: Issues, https://github.com/julioacontreras/my-graph/issues
10
+ Keywords: graph,database,client,mygraph
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Topic :: Database
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Dynamic: license-file
20
+
21
+ # mygraph-client (Python)
22
+
23
+ Python client for the MyGraph server. It talks to the server over TCP using
24
+ MyGraph protocol v1 ([docs/PROTOCOL.md](https://github.com/julioacontreras/my-graph/blob/main/docs/PROTOCOL.md))
25
+ and needs only the standard library (Python ≥ 3.10).
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ pip install mygraph-client
31
+ ```
32
+
33
+ The import name is `mygraph`:
34
+
35
+ ```python
36
+ import mygraph
37
+ ```
38
+
39
+ For development, from the repository root:
40
+
41
+ ```bash
42
+ pip install -e python/
43
+ ```
44
+
45
+ ## Start a server
46
+
47
+ ```bash
48
+ cargo run -p mygraph-server -- start # 127.0.0.1:6660, in memory
49
+ cargo run -p mygraph-server -- start --port 6660 --data ./data
50
+ ```
51
+
52
+ ## Command line client
53
+
54
+ `pip install` also installs the `mygraph-client` command. With a server on
55
+ port 6660 (for example `docker compose up -d`):
56
+
57
+ ```bash
58
+ mygraph-client "MATCH (n) RETURN n;"
59
+ ```
60
+
61
+ ```text
62
+ n
63
+ (0:User {"age": 25, "name": "Alice"})
64
+ (1:User {"name": "Bob"})
65
+ ```
66
+
67
+ Without a query argument the script is read from stdin, so multi-line
68
+ queries work too:
69
+
70
+ ```bash
71
+ mygraph-client <<'CYPHER'
72
+ MATCH (n)
73
+ RETURN n;
74
+ CYPHER
75
+ ```
76
+
77
+ | Option | Default | |
78
+ |---|---|---|
79
+ | `--host HOST` | `localhost` | Server host |
80
+ | `--port PORT` | `6660` | Server port |
81
+ | `--json` | off | One JSON object per record |
82
+
83
+ Without installing, `python3 -m mygraph` does the same. On error it prints
84
+ the message to stderr and exits with status 1.
85
+
86
+ ## Usage
87
+
88
+ ```python
89
+ import mygraph
90
+
91
+ with mygraph.connect("localhost", 6660) as db:
92
+ # Any Cypher, with parameters as a dict and/or keyword arguments
93
+ r = db.run("MATCH (u:User) WHERE u.age > $min RETURN u.name AS name", min=18)
94
+ for record in r: # records are dicts
95
+ print(record["name"])
96
+ r.value("name"); r.values("name"); r.single(); r.stats.nodes_created
97
+
98
+ # Create
99
+ alice = db.create_node("User", {"name": "Alice", "age": 25}) # -> Node
100
+ bob = db.create_node(["User", "Admin"], {"name": "Bob"})
101
+ knows = db.create_relationship(alice.id, bob.id, "KNOWS", {"since": 2020})
102
+
103
+ # Search
104
+ db.get_node(alice.id) # Node | None
105
+ db.find_nodes("User", {"name": "Alice"}, order_by="age", limit=10) # list[Node]
106
+ db.count_nodes("User")
107
+ db.find_relationships("KNOWS", start_id=alice.id) # list[Relationship]
108
+
109
+ # Update
110
+ db.update_node(alice.id, {"age": 26}) # merge: SET n += $props (None removes a key)
111
+ db.update_node(alice.id, {"name": "Alicia"}, replace=True) # SET n = $props
112
+ db.update_node(alice.id, add_labels="VIP", remove_labels="Admin")
113
+ db.update_relationship(knows.id, {"since": 2021})
114
+
115
+ # Delete
116
+ db.delete_relationship(knows.id)
117
+ db.delete_node(alice.id) # DETACH DELETE by default
118
+ db.delete_nodes("User", {"name": "Bob"}) # returns how many
119
+
120
+ # Transactions: commit on exit, rollback on exception
121
+ with db.transaction() as tx:
122
+ tx.create_node("User", {"name": "Carol"})
123
+ tx.run("MATCH (u:User {name: 'Carol'}) SET u.vip = true")
124
+
125
+ # Databases and schemas (the current one is per connection)
126
+ db.create_database("shop"); db.use_database("shop"); db.databases()
127
+ ```
128
+
129
+ `Node` has `id`, `labels`, `properties` (and `node["key"]`). `Relationship`
130
+ has `id`, `start_node`, `end_node`, `type`, `properties`.
131
+
132
+ Helpers write labels, types and property keys as backtick-quoted
133
+ identifiers, and always send values as parameters, so user input is never
134
+ spliced into the query text.
135
+
136
+ ## Errors
137
+
138
+ | Exception | When |
139
+ |---|---|
140
+ | `CypherSyntaxError` | Invalid Cypher (`.line`, `.column`) |
141
+ | `NotFoundError`, `AlreadyExistsError` | Unknown or duplicate database/schema |
142
+ | `TransactionError` | e.g. COMMIT with no open transaction |
143
+ | `QueryError` | Base class of the above; `.code` holds the engine error kind |
144
+ | `ProtocolError` | The server or the client broke protocol v1 |
145
+ | `ConnectionClosedError` | Cannot connect, or the connection was lost/closed |
146
+
147
+ An error in a statement does not close the connection.
148
+
149
+ ## Notes for MyGraph v0.1
150
+
151
+ - An open transaction blocks every other connection until it commits or rolls
152
+ back. Keep transactions short.
153
+ - While a transaction is open, everything sent on that client runs inside it.
154
+ - A client serializes its requests with a lock. Threads that share one
155
+ client also share its current database and transaction, so use one client
156
+ per thread for independent work.
157
+ - `AUTH` is accepted but not checked yet (no users or roles on the server).
158
+
159
+ ## Tests
160
+
161
+ ```bash
162
+ cd python
163
+ python3 -m unittest discover -s tests -t .
164
+ ```
165
+
166
+ The integration tests build `mygraph-server` with cargo and start it on a
167
+ free port. Set `MYGRAPH_SERVER_BIN` to use an existing binary instead.
@@ -0,0 +1,147 @@
1
+ # mygraph-client (Python)
2
+
3
+ Python client for the MyGraph server. It talks to the server over TCP using
4
+ MyGraph protocol v1 ([docs/PROTOCOL.md](https://github.com/julioacontreras/my-graph/blob/main/docs/PROTOCOL.md))
5
+ and needs only the standard library (Python ≥ 3.10).
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install mygraph-client
11
+ ```
12
+
13
+ The import name is `mygraph`:
14
+
15
+ ```python
16
+ import mygraph
17
+ ```
18
+
19
+ For development, from the repository root:
20
+
21
+ ```bash
22
+ pip install -e python/
23
+ ```
24
+
25
+ ## Start a server
26
+
27
+ ```bash
28
+ cargo run -p mygraph-server -- start # 127.0.0.1:6660, in memory
29
+ cargo run -p mygraph-server -- start --port 6660 --data ./data
30
+ ```
31
+
32
+ ## Command line client
33
+
34
+ `pip install` also installs the `mygraph-client` command. With a server on
35
+ port 6660 (for example `docker compose up -d`):
36
+
37
+ ```bash
38
+ mygraph-client "MATCH (n) RETURN n;"
39
+ ```
40
+
41
+ ```text
42
+ n
43
+ (0:User {"age": 25, "name": "Alice"})
44
+ (1:User {"name": "Bob"})
45
+ ```
46
+
47
+ Without a query argument the script is read from stdin, so multi-line
48
+ queries work too:
49
+
50
+ ```bash
51
+ mygraph-client <<'CYPHER'
52
+ MATCH (n)
53
+ RETURN n;
54
+ CYPHER
55
+ ```
56
+
57
+ | Option | Default | |
58
+ |---|---|---|
59
+ | `--host HOST` | `localhost` | Server host |
60
+ | `--port PORT` | `6660` | Server port |
61
+ | `--json` | off | One JSON object per record |
62
+
63
+ Without installing, `python3 -m mygraph` does the same. On error it prints
64
+ the message to stderr and exits with status 1.
65
+
66
+ ## Usage
67
+
68
+ ```python
69
+ import mygraph
70
+
71
+ with mygraph.connect("localhost", 6660) as db:
72
+ # Any Cypher, with parameters as a dict and/or keyword arguments
73
+ r = db.run("MATCH (u:User) WHERE u.age > $min RETURN u.name AS name", min=18)
74
+ for record in r: # records are dicts
75
+ print(record["name"])
76
+ r.value("name"); r.values("name"); r.single(); r.stats.nodes_created
77
+
78
+ # Create
79
+ alice = db.create_node("User", {"name": "Alice", "age": 25}) # -> Node
80
+ bob = db.create_node(["User", "Admin"], {"name": "Bob"})
81
+ knows = db.create_relationship(alice.id, bob.id, "KNOWS", {"since": 2020})
82
+
83
+ # Search
84
+ db.get_node(alice.id) # Node | None
85
+ db.find_nodes("User", {"name": "Alice"}, order_by="age", limit=10) # list[Node]
86
+ db.count_nodes("User")
87
+ db.find_relationships("KNOWS", start_id=alice.id) # list[Relationship]
88
+
89
+ # Update
90
+ db.update_node(alice.id, {"age": 26}) # merge: SET n += $props (None removes a key)
91
+ db.update_node(alice.id, {"name": "Alicia"}, replace=True) # SET n = $props
92
+ db.update_node(alice.id, add_labels="VIP", remove_labels="Admin")
93
+ db.update_relationship(knows.id, {"since": 2021})
94
+
95
+ # Delete
96
+ db.delete_relationship(knows.id)
97
+ db.delete_node(alice.id) # DETACH DELETE by default
98
+ db.delete_nodes("User", {"name": "Bob"}) # returns how many
99
+
100
+ # Transactions: commit on exit, rollback on exception
101
+ with db.transaction() as tx:
102
+ tx.create_node("User", {"name": "Carol"})
103
+ tx.run("MATCH (u:User {name: 'Carol'}) SET u.vip = true")
104
+
105
+ # Databases and schemas (the current one is per connection)
106
+ db.create_database("shop"); db.use_database("shop"); db.databases()
107
+ ```
108
+
109
+ `Node` has `id`, `labels`, `properties` (and `node["key"]`). `Relationship`
110
+ has `id`, `start_node`, `end_node`, `type`, `properties`.
111
+
112
+ Helpers write labels, types and property keys as backtick-quoted
113
+ identifiers, and always send values as parameters, so user input is never
114
+ spliced into the query text.
115
+
116
+ ## Errors
117
+
118
+ | Exception | When |
119
+ |---|---|
120
+ | `CypherSyntaxError` | Invalid Cypher (`.line`, `.column`) |
121
+ | `NotFoundError`, `AlreadyExistsError` | Unknown or duplicate database/schema |
122
+ | `TransactionError` | e.g. COMMIT with no open transaction |
123
+ | `QueryError` | Base class of the above; `.code` holds the engine error kind |
124
+ | `ProtocolError` | The server or the client broke protocol v1 |
125
+ | `ConnectionClosedError` | Cannot connect, or the connection was lost/closed |
126
+
127
+ An error in a statement does not close the connection.
128
+
129
+ ## Notes for MyGraph v0.1
130
+
131
+ - An open transaction blocks every other connection until it commits or rolls
132
+ back. Keep transactions short.
133
+ - While a transaction is open, everything sent on that client runs inside it.
134
+ - A client serializes its requests with a lock. Threads that share one
135
+ client also share its current database and transaction, so use one client
136
+ per thread for independent work.
137
+ - `AUTH` is accepted but not checked yet (no users or roles on the server).
138
+
139
+ ## Tests
140
+
141
+ ```bash
142
+ cd python
143
+ python3 -m unittest discover -s tests -t .
144
+ ```
145
+
146
+ The integration tests build `mygraph-server` with cargo and start it on a
147
+ free port. Set `MYGRAPH_SERVER_BIN` to use an existing binary instead.
@@ -0,0 +1,46 @@
1
+ """Python client for the MyGraph server.
2
+
3
+ from mygraph import connect
4
+
5
+ with connect("localhost", 6660) as db:
6
+ alice = db.create_node("User", {"name": "Alice"})
7
+ for user in db.find_nodes("User", {"name": "Alice"}):
8
+ print(user.id, user.properties)
9
+
10
+ The client speaks MyGraph protocol v1 (``docs/PROTOCOL.md``) over TCP and
11
+ needs nothing outside the Python standard library.
12
+ """
13
+
14
+ from .client import Client, connect
15
+ from .errors import (
16
+ AlreadyExistsError,
17
+ ConnectionClosedError,
18
+ CypherSyntaxError,
19
+ MyGraphError,
20
+ NotFoundError,
21
+ ProtocolError,
22
+ QueryError,
23
+ TransactionError,
24
+ )
25
+ from .transaction import Transaction
26
+ from .types import Node, Relationship, Result, Stats
27
+
28
+ __version__ = "0.1.0"
29
+
30
+ __all__ = [
31
+ "AlreadyExistsError",
32
+ "Client",
33
+ "ConnectionClosedError",
34
+ "CypherSyntaxError",
35
+ "MyGraphError",
36
+ "Node",
37
+ "NotFoundError",
38
+ "ProtocolError",
39
+ "QueryError",
40
+ "Relationship",
41
+ "Result",
42
+ "Stats",
43
+ "Transaction",
44
+ "TransactionError",
45
+ "connect",
46
+ ]
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+ import sys
3
+
4
+ sys.exit(main())
@@ -0,0 +1,195 @@
1
+ """Node and relationship helpers built on ``run``.
2
+
3
+ The helpers generate Cypher. Values always travel as parameters. Labels,
4
+ relationship types and property keys are written as backtick-quoted
5
+ identifiers, so any name is safe to pass.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Iterable, Mapping, Sequence
11
+
12
+ from .types import Node, Relationship, Result
13
+
14
+
15
+ def quote(name: str) -> str:
16
+ """Backtick-quotes an identifier (label, type or property key)."""
17
+ if not isinstance(name, str) or not name:
18
+ raise ValueError(f"invalid identifier: {name!r}")
19
+ return "`" + name.replace("`", "``") + "`"
20
+
21
+
22
+ def _labels(labels: str | Sequence[str] | None) -> str:
23
+ if labels is None:
24
+ return ""
25
+ if isinstance(labels, str):
26
+ labels = [labels]
27
+ return "".join(":" + quote(label) for label in labels)
28
+
29
+
30
+ def _where(var: str, properties: Mapping[str, Any] | None, params: dict, extra: Iterable[str] = ()) -> str:
31
+ conds = list(extra)
32
+ for i, (key, value) in enumerate((properties or {}).items()):
33
+ if value is None:
34
+ conds.append(f"{var}.{quote(key)} IS NULL")
35
+ else:
36
+ params[f"p{i}"] = value
37
+ conds.append(f"{var}.{quote(key)} = $p{i}")
38
+ return " WHERE " + " AND ".join(conds) if conds else ""
39
+
40
+
41
+ def _paging(skip: int | None, limit: int | None) -> str:
42
+ out = ""
43
+ for word, n in (("SKIP", skip), ("LIMIT", limit)):
44
+ if n is not None:
45
+ if isinstance(n, bool) or not isinstance(n, int) or n < 0:
46
+ raise ValueError(f"{word.lower()} must be a non-negative int")
47
+ out += f" {word} {n}"
48
+ return out
49
+
50
+
51
+ class CypherOps:
52
+ """CRUD helpers. Subclasses provide ``run``."""
53
+
54
+ def run(self, query: str, params: Mapping[str, Any] | None = None, **kwargs: Any) -> Result:
55
+ raise NotImplementedError
56
+
57
+ # ------------------------------------------------------------ nodes
58
+
59
+ def create_node(self, labels: str | Sequence[str] | None = None, properties: Mapping[str, Any] | None = None) -> Node:
60
+ """Creates a node and returns it."""
61
+ r = self.run(f"CREATE (n{_labels(labels)}) SET n = $props RETURN n", {"props": dict(properties or {})})
62
+ return r.value()
63
+
64
+ def get_node(self, node_id: int) -> Node | None:
65
+ """The node with that id, or ``None``."""
66
+ return self.run("MATCH (n) WHERE id(n) = $id RETURN n", {"id": node_id}).value()
67
+
68
+ def find_nodes(
69
+ self,
70
+ labels: str | Sequence[str] | None = None,
71
+ properties: Mapping[str, Any] | None = None,
72
+ *,
73
+ skip: int | None = None,
74
+ limit: int | None = None,
75
+ order_by: str | None = None,
76
+ descending: bool = False,
77
+ ) -> list[Node]:
78
+ """Nodes with all the given labels whose properties equal the given
79
+ values (``None`` matches a missing property)."""
80
+ params: dict[str, Any] = {}
81
+ q = f"MATCH (n{_labels(labels)}){_where('n', properties, params)} RETURN n"
82
+ if order_by is not None:
83
+ q += f" ORDER BY n.{quote(order_by)}" + (" DESC" if descending else "")
84
+ return self.run(q + _paging(skip, limit), params).values(0)
85
+
86
+ def count_nodes(self, labels: str | Sequence[str] | None = None, properties: Mapping[str, Any] | None = None) -> int:
87
+ params: dict[str, Any] = {}
88
+ q = f"MATCH (n{_labels(labels)}){_where('n', properties, params)} RETURN count(n)"
89
+ return self.run(q, params).value()
90
+
91
+ def update_node(
92
+ self,
93
+ node_id: int,
94
+ properties: Mapping[str, Any] | None = None,
95
+ *,
96
+ replace: bool = False,
97
+ add_labels: str | Sequence[str] | None = None,
98
+ remove_labels: str | Sequence[str] | None = None,
99
+ ) -> Node | None:
100
+ """Updates a node and returns it, or ``None`` if it does not exist.
101
+
102
+ By default ``properties`` are merged (``SET n += $props``; a ``None``
103
+ value removes that property). With ``replace=True`` they replace all
104
+ existing properties (``SET n = $props``).
105
+ """
106
+ clauses = []
107
+ params: dict[str, Any] = {"id": node_id}
108
+ if properties is not None or replace:
109
+ params["props"] = dict(properties or {})
110
+ clauses.append("SET n = $props" if replace else "SET n += $props")
111
+ if add_labels:
112
+ clauses.append(f"SET n{_labels(add_labels)}")
113
+ if remove_labels:
114
+ clauses.append(f"REMOVE n{_labels(remove_labels)}")
115
+ q = "MATCH (n) WHERE id(n) = $id " + " ".join(clauses) + " RETURN n"
116
+ return self.run(q, params).value()
117
+
118
+ def delete_node(self, node_id: int, *, detach: bool = True) -> bool:
119
+ """Deletes a node. With ``detach=True`` (default) its relationships
120
+ are deleted too; otherwise a node with relationships raises.
121
+ Returns whether a node was deleted."""
122
+ verb = "DETACH DELETE" if detach else "DELETE"
123
+ r = self.run(f"MATCH (n) WHERE id(n) = $id {verb} n", {"id": node_id})
124
+ return r.stats.nodes_deleted > 0
125
+
126
+ def delete_nodes(
127
+ self,
128
+ labels: str | Sequence[str] | None = None,
129
+ properties: Mapping[str, Any] | None = None,
130
+ *,
131
+ detach: bool = True,
132
+ ) -> int:
133
+ """Deletes every matching node. Returns how many were deleted."""
134
+ params: dict[str, Any] = {}
135
+ verb = "DETACH DELETE" if detach else "DELETE"
136
+ r = self.run(f"MATCH (n{_labels(labels)}){_where('n', properties, params)} {verb} n", params)
137
+ return r.stats.nodes_deleted
138
+
139
+ # ------------------------------------------------------------ relationships
140
+
141
+ def create_relationship(
142
+ self,
143
+ start_id: int,
144
+ end_id: int,
145
+ rel_type: str,
146
+ properties: Mapping[str, Any] | None = None,
147
+ ) -> Relationship | None:
148
+ """Creates ``(start)-[:rel_type]->(end)`` and returns it, or ``None``
149
+ if either node does not exist."""
150
+ q = (
151
+ "MATCH (a) WHERE id(a) = $start MATCH (b) WHERE id(b) = $end "
152
+ f"CREATE (a)-[r:{quote(rel_type)}]->(b) SET r = $props RETURN r"
153
+ )
154
+ params = {"start": start_id, "end": end_id, "props": dict(properties or {})}
155
+ return self.run(q, params).value()
156
+
157
+ def get_relationship(self, rel_id: int) -> Relationship | None:
158
+ return self.run("MATCH ()-[r]->() WHERE id(r) = $id RETURN r", {"id": rel_id}).value()
159
+
160
+ def find_relationships(
161
+ self,
162
+ rel_type: str | None = None,
163
+ properties: Mapping[str, Any] | None = None,
164
+ *,
165
+ start_id: int | None = None,
166
+ end_id: int | None = None,
167
+ skip: int | None = None,
168
+ limit: int | None = None,
169
+ ) -> list[Relationship]:
170
+ """Relationships of a type (any if ``None``) whose properties equal
171
+ the given values, optionally from/to specific nodes."""
172
+ params: dict[str, Any] = {}
173
+ extra = []
174
+ if start_id is not None:
175
+ params["start"] = start_id
176
+ extra.append("id(a) = $start")
177
+ if end_id is not None:
178
+ params["end"] = end_id
179
+ extra.append("id(b) = $end")
180
+ t = f":{quote(rel_type)}" if rel_type else ""
181
+ q = f"MATCH (a)-[r{t}]->(b){_where('r', properties, params, extra)} RETURN r"
182
+ return self.run(q + _paging(skip, limit), params).values(0)
183
+
184
+ def update_relationship(
185
+ self, rel_id: int, properties: Mapping[str, Any], *, replace: bool = False
186
+ ) -> Relationship | None:
187
+ """Merges (or with ``replace=True`` replaces) a relationship's
188
+ properties. Returns it, or ``None`` if it does not exist."""
189
+ op = "=" if replace else "+="
190
+ q = f"MATCH ()-[r]->() WHERE id(r) = $id SET r {op} $props RETURN r"
191
+ return self.run(q, {"id": rel_id, "props": dict(properties)}).value()
192
+
193
+ def delete_relationship(self, rel_id: int) -> bool:
194
+ r = self.run("MATCH ()-[r]->() WHERE id(r) = $id DELETE r", {"id": rel_id})
195
+ return r.stats.relationships_deleted > 0