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.
- mygraph_client-0.1.0/LICENSE +21 -0
- mygraph_client-0.1.0/PKG-INFO +167 -0
- mygraph_client-0.1.0/README.md +147 -0
- mygraph_client-0.1.0/mygraph/__init__.py +46 -0
- mygraph_client-0.1.0/mygraph/__main__.py +4 -0
- mygraph_client-0.1.0/mygraph/_ops.py +195 -0
- mygraph_client-0.1.0/mygraph/_protocol.py +222 -0
- mygraph_client-0.1.0/mygraph/cli.py +78 -0
- mygraph_client-0.1.0/mygraph/client.py +179 -0
- mygraph_client-0.1.0/mygraph/errors.py +62 -0
- mygraph_client-0.1.0/mygraph/py.typed +0 -0
- mygraph_client-0.1.0/mygraph/transaction.py +67 -0
- mygraph_client-0.1.0/mygraph/types.py +103 -0
- mygraph_client-0.1.0/mygraph_client.egg-info/PKG-INFO +167 -0
- mygraph_client-0.1.0/mygraph_client.egg-info/SOURCES.txt +20 -0
- mygraph_client-0.1.0/mygraph_client.egg-info/dependency_links.txt +1 -0
- mygraph_client-0.1.0/mygraph_client.egg-info/entry_points.txt +2 -0
- mygraph_client-0.1.0/mygraph_client.egg-info/top_level.txt +1 -0
- mygraph_client-0.1.0/pyproject.toml +34 -0
- mygraph_client-0.1.0/setup.cfg +4 -0
- mygraph_client-0.1.0/tests/test_integration.py +208 -0
- mygraph_client-0.1.0/tests/test_protocol.py +64 -0
|
@@ -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,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
|