gestaltdb 0.3.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.
- gestaltdb-0.3.0/PKG-INFO +244 -0
- gestaltdb-0.3.0/README.md +182 -0
- gestaltdb-0.3.0/pyproject.toml +44 -0
- gestaltdb-0.3.0/setup.cfg +4 -0
- gestaltdb-0.3.0/src/gestaltdb/__init__.py +7 -0
- gestaltdb-0.3.0/src/gestaltdb/cypher.py +104 -0
- gestaltdb-0.3.0/src/gestaltdb/cypher_ast.py +180 -0
- gestaltdb-0.3.0/src/gestaltdb/cypher_parser.py +584 -0
- gestaltdb-0.3.0/src/gestaltdb/cypher_plan.py +216 -0
- gestaltdb-0.3.0/src/gestaltdb/cypher_runtime.py +590 -0
- gestaltdb-0.3.0/src/gestaltdb/graphdb.py +2240 -0
- gestaltdb-0.3.0/src/gestaltdb/ingestion.py +233 -0
- gestaltdb-0.3.0/src/gestaltdb/kvstores.py +1414 -0
- gestaltdb-0.3.0/src/gestaltdb/sampling.py +198 -0
- gestaltdb-0.3.0/src/gestaltdb/serializers.py +230 -0
- gestaltdb-0.3.0/src/gestaltdb.egg-info/PKG-INFO +244 -0
- gestaltdb-0.3.0/src/gestaltdb.egg-info/SOURCES.txt +26 -0
- gestaltdb-0.3.0/src/gestaltdb.egg-info/dependency_links.txt +1 -0
- gestaltdb-0.3.0/src/gestaltdb.egg-info/requires.txt +68 -0
- gestaltdb-0.3.0/src/gestaltdb.egg-info/top_level.txt +1 -0
- gestaltdb-0.3.0/tests/test_cypher.py +1195 -0
- gestaltdb-0.3.0/tests/test_graphdb_interfaces.py +320 -0
- gestaltdb-0.3.0/tests/test_ingestion.py +426 -0
- gestaltdb-0.3.0/tests/test_kvstores.py +92 -0
- gestaltdb-0.3.0/tests/test_optional_dependencies.py +43 -0
- gestaltdb-0.3.0/tests/test_sampling.py +155 -0
- gestaltdb-0.3.0/tests/test_serializers.py +87 -0
- gestaltdb-0.3.0/tests/test_typed_traversal.py +60 -0
gestaltdb-0.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gestaltdb
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: A pure Python GraphDB for attributed graphs.
|
|
5
|
+
Requires-Python: <3.14,>=3.9
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: google>=3.0.0
|
|
8
|
+
Requires-Dist: msgpack>=1.1.2
|
|
9
|
+
Requires-Dist: plyvel>=1.5.1
|
|
10
|
+
Requires-Dist: protobuf>=6.33.6
|
|
11
|
+
Provides-Extra: lmdb
|
|
12
|
+
Requires-Dist: lmdb; extra == "lmdb"
|
|
13
|
+
Provides-Extra: leveldb
|
|
14
|
+
Requires-Dist: plyvel; extra == "leveldb"
|
|
15
|
+
Provides-Extra: msgpack
|
|
16
|
+
Requires-Dist: msgpack; extra == "msgpack"
|
|
17
|
+
Provides-Extra: protobuf
|
|
18
|
+
Requires-Dist: protobuf; extra == "protobuf"
|
|
19
|
+
Provides-Extra: bloom
|
|
20
|
+
Requires-Dist: pybloom-live; extra == "bloom"
|
|
21
|
+
Provides-Extra: coverage
|
|
22
|
+
Requires-Dist: coverage; extra == "coverage"
|
|
23
|
+
Requires-Dist: pytest; extra == "coverage"
|
|
24
|
+
Provides-Extra: docs
|
|
25
|
+
Requires-Dist: furo; extra == "docs"
|
|
26
|
+
Requires-Dist: myst-parser; extra == "docs"
|
|
27
|
+
Requires-Dist: sphinx; extra == "docs"
|
|
28
|
+
Provides-Extra: rocksdb
|
|
29
|
+
Requires-Dist: pyrex-rocksdb>=0.3.0a0; extra == "rocksdb"
|
|
30
|
+
Provides-Extra: arrow
|
|
31
|
+
Requires-Dist: pyarrow; extra == "arrow"
|
|
32
|
+
Provides-Extra: polars
|
|
33
|
+
Requires-Dist: polars; extra == "polars"
|
|
34
|
+
Requires-Dist: pyarrow; extra == "polars"
|
|
35
|
+
Provides-Extra: fast-ingest
|
|
36
|
+
Requires-Dist: pyarrow; extra == "fast-ingest"
|
|
37
|
+
Requires-Dist: polars; extra == "fast-ingest"
|
|
38
|
+
Requires-Dist: pyrex-rocksdb>=0.3.0a0; extra == "fast-ingest"
|
|
39
|
+
Provides-Extra: all
|
|
40
|
+
Requires-Dist: lmdb; extra == "all"
|
|
41
|
+
Requires-Dist: msgpack; extra == "all"
|
|
42
|
+
Requires-Dist: plyvel; extra == "all"
|
|
43
|
+
Requires-Dist: protobuf; extra == "all"
|
|
44
|
+
Requires-Dist: pybloom-live; extra == "all"
|
|
45
|
+
Requires-Dist: pyrex-rocksdb>=0.3.0a0; extra == "all"
|
|
46
|
+
Requires-Dist: pyarrow; extra == "all"
|
|
47
|
+
Requires-Dist: polars; extra == "all"
|
|
48
|
+
Provides-Extra: dev
|
|
49
|
+
Requires-Dist: coverage; extra == "dev"
|
|
50
|
+
Requires-Dist: furo; extra == "dev"
|
|
51
|
+
Requires-Dist: lmdb; extra == "dev"
|
|
52
|
+
Requires-Dist: msgpack; extra == "dev"
|
|
53
|
+
Requires-Dist: myst-parser; extra == "dev"
|
|
54
|
+
Requires-Dist: plyvel; extra == "dev"
|
|
55
|
+
Requires-Dist: protobuf; extra == "dev"
|
|
56
|
+
Requires-Dist: pybloom-live; extra == "dev"
|
|
57
|
+
Requires-Dist: pyrex-rocksdb>=0.3.0a0; extra == "dev"
|
|
58
|
+
Requires-Dist: pyarrow; extra == "dev"
|
|
59
|
+
Requires-Dist: polars; extra == "dev"
|
|
60
|
+
Requires-Dist: pytest; extra == "dev"
|
|
61
|
+
Requires-Dist: sphinx; extra == "dev"
|
|
62
|
+
|
|
63
|
+
# GestaltDB
|
|
64
|
+
|
|
65
|
+

|
|
66
|
+
[](https://mylonasc.github.io/gestaltdb/)
|
|
67
|
+
|
|
68
|
+
GestaltDB is a pure Python graph database toolkit for attributed graphs. It stores nodes, edges, labels, typed adjacency records, and property indexes on embedded key-value backends.
|
|
69
|
+
|
|
70
|
+
Documentation: https://mylonasc.github.io/gestaltdb/
|
|
71
|
+
|
|
72
|
+
## Install From PyPI
|
|
73
|
+
|
|
74
|
+
With pip:
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
python -m pip install gestaltdb
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
With uv:
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
uv add gestaltdb
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Install columnar ingestion dependencies:
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
python -m pip install "gestaltdb[arrow,polars]"
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Install all optional backends and serializers:
|
|
93
|
+
|
|
94
|
+
```sh
|
|
95
|
+
python -m pip install "gestaltdb[all]"
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Optional extras include `lmdb`, `leveldb`, `rocksdb`, `arrow`, `polars`, `fast-ingest`, `msgpack`, `protobuf`, `bloom`, `docs`, `dev`, and `all`.
|
|
99
|
+
|
|
100
|
+
## Basic Example
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from tempfile import TemporaryDirectory
|
|
104
|
+
|
|
105
|
+
from gestaltdb.graphdb import Edge, GraphDB, Node
|
|
106
|
+
from gestaltdb.kvstores import LevelDBStore
|
|
107
|
+
from gestaltdb.serializers import PickleSerializer
|
|
108
|
+
|
|
109
|
+
with TemporaryDirectory() as tmpdir:
|
|
110
|
+
graph = GraphDB(LevelDBStore(path=f"{tmpdir}/graph"), PickleSerializer())
|
|
111
|
+
|
|
112
|
+
graph.put_node(Node(node_id="alice", labels=["Person"], properties={"name": "Alice"}))
|
|
113
|
+
graph.put_node(Node(node_id="bob", labels=["Person"], properties={"name": "Bob"}))
|
|
114
|
+
graph.put_edge(Edge(
|
|
115
|
+
edge_id="alice-knows-bob",
|
|
116
|
+
source="alice",
|
|
117
|
+
target="bob",
|
|
118
|
+
properties={"type": "knows", "since": 2024},
|
|
119
|
+
))
|
|
120
|
+
|
|
121
|
+
result = graph.query('MATCH (a:Person {name: "Alice"}) MATCH (a)-[:knows]->(b) RETURN a.id, b.name')
|
|
122
|
+
print(result.records)
|
|
123
|
+
|
|
124
|
+
graph.close()
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Arrow Ingestion Example
|
|
128
|
+
|
|
129
|
+
This example ingests entity columns from PyArrow arrays. `JSONSerializer` lets GestaltDB build node and edge payloads from structured columns.
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
from tempfile import TemporaryDirectory
|
|
133
|
+
|
|
134
|
+
import pyarrow as pa
|
|
135
|
+
|
|
136
|
+
from gestaltdb.graphdb import GraphDB
|
|
137
|
+
from gestaltdb.kvstores import LevelDBStore
|
|
138
|
+
from gestaltdb.serializers import JSONSerializer
|
|
139
|
+
|
|
140
|
+
with TemporaryDirectory() as tmpdir:
|
|
141
|
+
graph = GraphDB(LevelDBStore(path=f"{tmpdir}/graph"), JSONSerializer())
|
|
142
|
+
|
|
143
|
+
graph.ingest_nodes_arrow_entities(
|
|
144
|
+
pa.array(["alice", "bob", "carol"]),
|
|
145
|
+
labels=pa.array([["Person"], ["Person"], ["Person"]]),
|
|
146
|
+
properties={
|
|
147
|
+
"name": pa.array(["Alice", "Bob", "Carol"]),
|
|
148
|
+
"age": pa.array([34, 36, 29]),
|
|
149
|
+
},
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
graph.ingest_edges_arrow_entities(
|
|
153
|
+
pa.array(["alice-knows-bob", "bob-knows-carol"]),
|
|
154
|
+
pa.array(["alice", "bob"]),
|
|
155
|
+
pa.array(["bob", "carol"]),
|
|
156
|
+
pa.array(["knows", "knows"]),
|
|
157
|
+
properties={"since": pa.array([2024, 2025])},
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
result = graph.query('MATCH (a:Person {name: "Alice"}) MATCH (a)-[:knows]->(b) RETURN a.id, b.name')
|
|
161
|
+
print(result.records)
|
|
162
|
+
|
|
163
|
+
graph.close()
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Polars Ingestion Example
|
|
167
|
+
|
|
168
|
+
This example ingests the same graph from Polars DataFrames. Property columns are converted into node and edge payloads during ingestion.
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
from tempfile import TemporaryDirectory
|
|
172
|
+
|
|
173
|
+
import polars as pl
|
|
174
|
+
|
|
175
|
+
from gestaltdb.graphdb import GraphDB
|
|
176
|
+
from gestaltdb.kvstores import LevelDBStore
|
|
177
|
+
from gestaltdb.serializers import JSONSerializer
|
|
178
|
+
|
|
179
|
+
nodes = pl.DataFrame({
|
|
180
|
+
"node_id": ["alice", "bob", "carol"],
|
|
181
|
+
"labels": [["Person"], ["Person"], ["Person"]],
|
|
182
|
+
"name": ["Alice", "Bob", "Carol"],
|
|
183
|
+
"age": [34, 36, 29],
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
edges = pl.DataFrame({
|
|
187
|
+
"edge_id": ["alice-knows-bob", "bob-knows-carol"],
|
|
188
|
+
"source": ["alice", "bob"],
|
|
189
|
+
"target": ["bob", "carol"],
|
|
190
|
+
"edge_type": ["knows", "knows"],
|
|
191
|
+
"since": [2024, 2025],
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
with TemporaryDirectory() as tmpdir:
|
|
195
|
+
graph = GraphDB(LevelDBStore(path=f"{tmpdir}/graph"), JSONSerializer())
|
|
196
|
+
|
|
197
|
+
graph.ingest_nodes_polars_entities(nodes)
|
|
198
|
+
graph.ingest_edges_polars_entities(edges)
|
|
199
|
+
|
|
200
|
+
result = graph.query('MATCH (a:Person) MATCH (a)-[:knows]->(b) RETURN a.name, b.name ORDER BY a.name')
|
|
201
|
+
print(result.records)
|
|
202
|
+
|
|
203
|
+
graph.close()
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## Install From A Checkout
|
|
207
|
+
|
|
208
|
+
From a local checkout:
|
|
209
|
+
|
|
210
|
+
```sh
|
|
211
|
+
uv sync
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Install into another project:
|
|
215
|
+
|
|
216
|
+
```sh
|
|
217
|
+
uv add /path/to/gestaltdb
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
With pip:
|
|
221
|
+
|
|
222
|
+
```sh
|
|
223
|
+
python -m pip install /path/to/gestaltdb
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## Features
|
|
227
|
+
|
|
228
|
+
- Attributed `Node` and `Edge` objects with stable IDs.
|
|
229
|
+
- Native node labels and typed edge traversal through `edge.properties["type"]`.
|
|
230
|
+
- LMDB, LevelDB, and RocksDB/PyRex storage backends.
|
|
231
|
+
- Pickle, JSON, MessagePack, and Protobuf serializers.
|
|
232
|
+
- Label, relationship type, property, composite, and range indexes.
|
|
233
|
+
- Read-only Cypher subset for indexed scans, typed traversal, filtering, ordering, limits, and chained `MATCH` clauses.
|
|
234
|
+
- Bulk and columnar ingestion helpers for Arrow and Polars.
|
|
235
|
+
- Typed path and subgraph sampling.
|
|
236
|
+
|
|
237
|
+
See the full documentation for backend selection, indexing, Cypher syntax, ingestion, sampling, and benchmarks.
|
|
238
|
+
|
|
239
|
+
<details>
|
|
240
|
+
<summary>Name origin</summary>
|
|
241
|
+
|
|
242
|
+
The name GestaltDB is inspired by Gestalt psychology and the idea that the whole is something more than its parts.
|
|
243
|
+
|
|
244
|
+
</details>
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# GestaltDB
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
[](https://mylonasc.github.io/gestaltdb/)
|
|
5
|
+
|
|
6
|
+
GestaltDB is a pure Python graph database toolkit for attributed graphs. It stores nodes, edges, labels, typed adjacency records, and property indexes on embedded key-value backends.
|
|
7
|
+
|
|
8
|
+
Documentation: https://mylonasc.github.io/gestaltdb/
|
|
9
|
+
|
|
10
|
+
## Install From PyPI
|
|
11
|
+
|
|
12
|
+
With pip:
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
python -m pip install gestaltdb
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
With uv:
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
uv add gestaltdb
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Install columnar ingestion dependencies:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
python -m pip install "gestaltdb[arrow,polars]"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Install all optional backends and serializers:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
python -m pip install "gestaltdb[all]"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Optional extras include `lmdb`, `leveldb`, `rocksdb`, `arrow`, `polars`, `fast-ingest`, `msgpack`, `protobuf`, `bloom`, `docs`, `dev`, and `all`.
|
|
37
|
+
|
|
38
|
+
## Basic Example
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from tempfile import TemporaryDirectory
|
|
42
|
+
|
|
43
|
+
from gestaltdb.graphdb import Edge, GraphDB, Node
|
|
44
|
+
from gestaltdb.kvstores import LevelDBStore
|
|
45
|
+
from gestaltdb.serializers import PickleSerializer
|
|
46
|
+
|
|
47
|
+
with TemporaryDirectory() as tmpdir:
|
|
48
|
+
graph = GraphDB(LevelDBStore(path=f"{tmpdir}/graph"), PickleSerializer())
|
|
49
|
+
|
|
50
|
+
graph.put_node(Node(node_id="alice", labels=["Person"], properties={"name": "Alice"}))
|
|
51
|
+
graph.put_node(Node(node_id="bob", labels=["Person"], properties={"name": "Bob"}))
|
|
52
|
+
graph.put_edge(Edge(
|
|
53
|
+
edge_id="alice-knows-bob",
|
|
54
|
+
source="alice",
|
|
55
|
+
target="bob",
|
|
56
|
+
properties={"type": "knows", "since": 2024},
|
|
57
|
+
))
|
|
58
|
+
|
|
59
|
+
result = graph.query('MATCH (a:Person {name: "Alice"}) MATCH (a)-[:knows]->(b) RETURN a.id, b.name')
|
|
60
|
+
print(result.records)
|
|
61
|
+
|
|
62
|
+
graph.close()
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Arrow Ingestion Example
|
|
66
|
+
|
|
67
|
+
This example ingests entity columns from PyArrow arrays. `JSONSerializer` lets GestaltDB build node and edge payloads from structured columns.
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from tempfile import TemporaryDirectory
|
|
71
|
+
|
|
72
|
+
import pyarrow as pa
|
|
73
|
+
|
|
74
|
+
from gestaltdb.graphdb import GraphDB
|
|
75
|
+
from gestaltdb.kvstores import LevelDBStore
|
|
76
|
+
from gestaltdb.serializers import JSONSerializer
|
|
77
|
+
|
|
78
|
+
with TemporaryDirectory() as tmpdir:
|
|
79
|
+
graph = GraphDB(LevelDBStore(path=f"{tmpdir}/graph"), JSONSerializer())
|
|
80
|
+
|
|
81
|
+
graph.ingest_nodes_arrow_entities(
|
|
82
|
+
pa.array(["alice", "bob", "carol"]),
|
|
83
|
+
labels=pa.array([["Person"], ["Person"], ["Person"]]),
|
|
84
|
+
properties={
|
|
85
|
+
"name": pa.array(["Alice", "Bob", "Carol"]),
|
|
86
|
+
"age": pa.array([34, 36, 29]),
|
|
87
|
+
},
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
graph.ingest_edges_arrow_entities(
|
|
91
|
+
pa.array(["alice-knows-bob", "bob-knows-carol"]),
|
|
92
|
+
pa.array(["alice", "bob"]),
|
|
93
|
+
pa.array(["bob", "carol"]),
|
|
94
|
+
pa.array(["knows", "knows"]),
|
|
95
|
+
properties={"since": pa.array([2024, 2025])},
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
result = graph.query('MATCH (a:Person {name: "Alice"}) MATCH (a)-[:knows]->(b) RETURN a.id, b.name')
|
|
99
|
+
print(result.records)
|
|
100
|
+
|
|
101
|
+
graph.close()
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Polars Ingestion Example
|
|
105
|
+
|
|
106
|
+
This example ingests the same graph from Polars DataFrames. Property columns are converted into node and edge payloads during ingestion.
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from tempfile import TemporaryDirectory
|
|
110
|
+
|
|
111
|
+
import polars as pl
|
|
112
|
+
|
|
113
|
+
from gestaltdb.graphdb import GraphDB
|
|
114
|
+
from gestaltdb.kvstores import LevelDBStore
|
|
115
|
+
from gestaltdb.serializers import JSONSerializer
|
|
116
|
+
|
|
117
|
+
nodes = pl.DataFrame({
|
|
118
|
+
"node_id": ["alice", "bob", "carol"],
|
|
119
|
+
"labels": [["Person"], ["Person"], ["Person"]],
|
|
120
|
+
"name": ["Alice", "Bob", "Carol"],
|
|
121
|
+
"age": [34, 36, 29],
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
edges = pl.DataFrame({
|
|
125
|
+
"edge_id": ["alice-knows-bob", "bob-knows-carol"],
|
|
126
|
+
"source": ["alice", "bob"],
|
|
127
|
+
"target": ["bob", "carol"],
|
|
128
|
+
"edge_type": ["knows", "knows"],
|
|
129
|
+
"since": [2024, 2025],
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
with TemporaryDirectory() as tmpdir:
|
|
133
|
+
graph = GraphDB(LevelDBStore(path=f"{tmpdir}/graph"), JSONSerializer())
|
|
134
|
+
|
|
135
|
+
graph.ingest_nodes_polars_entities(nodes)
|
|
136
|
+
graph.ingest_edges_polars_entities(edges)
|
|
137
|
+
|
|
138
|
+
result = graph.query('MATCH (a:Person) MATCH (a)-[:knows]->(b) RETURN a.name, b.name ORDER BY a.name')
|
|
139
|
+
print(result.records)
|
|
140
|
+
|
|
141
|
+
graph.close()
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Install From A Checkout
|
|
145
|
+
|
|
146
|
+
From a local checkout:
|
|
147
|
+
|
|
148
|
+
```sh
|
|
149
|
+
uv sync
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Install into another project:
|
|
153
|
+
|
|
154
|
+
```sh
|
|
155
|
+
uv add /path/to/gestaltdb
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
With pip:
|
|
159
|
+
|
|
160
|
+
```sh
|
|
161
|
+
python -m pip install /path/to/gestaltdb
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Features
|
|
165
|
+
|
|
166
|
+
- Attributed `Node` and `Edge` objects with stable IDs.
|
|
167
|
+
- Native node labels and typed edge traversal through `edge.properties["type"]`.
|
|
168
|
+
- LMDB, LevelDB, and RocksDB/PyRex storage backends.
|
|
169
|
+
- Pickle, JSON, MessagePack, and Protobuf serializers.
|
|
170
|
+
- Label, relationship type, property, composite, and range indexes.
|
|
171
|
+
- Read-only Cypher subset for indexed scans, typed traversal, filtering, ordering, limits, and chained `MATCH` clauses.
|
|
172
|
+
- Bulk and columnar ingestion helpers for Arrow and Polars.
|
|
173
|
+
- Typed path and subgraph sampling.
|
|
174
|
+
|
|
175
|
+
See the full documentation for backend selection, indexing, Cypher syntax, ingestion, sampling, and benchmarks.
|
|
176
|
+
|
|
177
|
+
<details>
|
|
178
|
+
<summary>Name origin</summary>
|
|
179
|
+
|
|
180
|
+
The name GestaltDB is inspired by Gestalt psychology and the idea that the whole is something more than its parts.
|
|
181
|
+
|
|
182
|
+
</details>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "gestaltdb"
|
|
7
|
+
version = "0.3.0"
|
|
8
|
+
description = "A pure Python GraphDB for attributed graphs."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9,<3.14"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"google>=3.0.0",
|
|
13
|
+
"msgpack>=1.1.2",
|
|
14
|
+
"plyvel>=1.5.1",
|
|
15
|
+
"protobuf>=6.33.6",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
lmdb = ["lmdb"]
|
|
20
|
+
leveldb = ["plyvel"]
|
|
21
|
+
msgpack = ["msgpack"]
|
|
22
|
+
protobuf = ["protobuf"]
|
|
23
|
+
bloom = ["pybloom-live"]
|
|
24
|
+
coverage = ["coverage", "pytest"]
|
|
25
|
+
docs = ["furo", "myst-parser", "sphinx"]
|
|
26
|
+
rocksdb = ["pyrex-rocksdb>=0.3.0a0"]
|
|
27
|
+
arrow = ["pyarrow"]
|
|
28
|
+
polars = ["polars", "pyarrow"]
|
|
29
|
+
fast-ingest = ["pyarrow", "polars", "pyrex-rocksdb>=0.3.0a0"]
|
|
30
|
+
all = ["lmdb", "msgpack", "plyvel", "protobuf", "pybloom-live", "pyrex-rocksdb>=0.3.0a0", "pyarrow", "polars"]
|
|
31
|
+
dev = ["coverage", "furo", "lmdb", "msgpack", "myst-parser", "plyvel", "protobuf", "pybloom-live", "pyrex-rocksdb>=0.3.0a0", "pyarrow", "polars", "pytest", "sphinx"]
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
where = ["src"]
|
|
35
|
+
|
|
36
|
+
[tool.pytest.ini_options]
|
|
37
|
+
pythonpath = ["src"]
|
|
38
|
+
testpaths = ["tests"]
|
|
39
|
+
|
|
40
|
+
[dependency-groups]
|
|
41
|
+
dev = [
|
|
42
|
+
"coverage>=7.10.7",
|
|
43
|
+
"pytest>=8.0",
|
|
44
|
+
]
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Minimal read-only Cypher support for GestaltDB.
|
|
2
|
+
|
|
3
|
+
The supported subset maps directly to existing typed adjacency and sampling APIs:
|
|
4
|
+
|
|
5
|
+
MATCH (a {id: "node-id"})-[:TYPE1]->(b)<-[:TYPE2]-(c) RETURN a.name, b LIMIT 10
|
|
6
|
+
CALL pg.sample_typed_paths(["node-id"], [{"edge_type": "TYPE", "sample_size": 2}]) YIELD path RETURN path
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from .cypher_ast import MatchQuery, MultiMatchQuery, NodeScanQuery, RelationshipScanQuery, SampleTypedPathsCall
|
|
14
|
+
from .cypher_plan import LogicalPlan, plan_query
|
|
15
|
+
from .cypher_parser import parse as _parse_query, split_top_level_args as _split_top_level_args
|
|
16
|
+
from .cypher_runtime import QueryContext, execute_match, execute_multi_match, execute_node_scan, execute_relationship_scan
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class QueryResult:
|
|
21
|
+
"""Tabular query result returned by ``GraphDB.query``.
|
|
22
|
+
|
|
23
|
+
``columns`` contains projected column names in return order. ``records`` is
|
|
24
|
+
a list of dictionaries keyed by column name.
|
|
25
|
+
|
|
26
|
+
Examples:
|
|
27
|
+
>>> result = QueryResult(columns=("n",), records=[{"n": "node"}])
|
|
28
|
+
>>> len(result)
|
|
29
|
+
1
|
|
30
|
+
>>> list(result)[0]["n"]
|
|
31
|
+
'node'
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
columns: tuple[str, ...]
|
|
35
|
+
records: list[dict[str, object]]
|
|
36
|
+
|
|
37
|
+
def __iter__(self):
|
|
38
|
+
"""Iterate over result records."""
|
|
39
|
+
return iter(self.records)
|
|
40
|
+
|
|
41
|
+
def __len__(self):
|
|
42
|
+
"""Return the number of result records."""
|
|
43
|
+
return len(self.records)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse(query: str) -> MatchQuery | SampleTypedPathsCall | NodeScanQuery | RelationshipScanQuery | MultiMatchQuery:
|
|
47
|
+
"""Parse the supported Cypher subset.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
query: Cypher query text.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Parsed query object.
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
ValueError: If the query is outside the supported subset.
|
|
57
|
+
|
|
58
|
+
Examples:
|
|
59
|
+
>>> parse('MATCH (n:Drug) RETURN n').label
|
|
60
|
+
'Drug'
|
|
61
|
+
"""
|
|
62
|
+
return _parse_query(query)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def plan(query: str) -> LogicalPlan:
|
|
66
|
+
"""Return the logical plan for a supported Cypher query."""
|
|
67
|
+
return plan_query(parse(query))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def execute(graph, query: str, parameters: dict[str, object] | None = None) -> QueryResult:
|
|
71
|
+
"""Execute a supported Cypher query against a ``GraphDB`` instance.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
graph: ``GraphDB`` instance used for indexed lookups and traversal.
|
|
75
|
+
query: Cypher query text.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
``QueryResult`` with projected records.
|
|
79
|
+
|
|
80
|
+
Examples:
|
|
81
|
+
>>> execute(graph_db, 'MATCH (n:Drug) RETURN n') # doctest: +SKIP
|
|
82
|
+
"""
|
|
83
|
+
parsed = parse(query)
|
|
84
|
+
plan_query(parsed)
|
|
85
|
+
parameters = parameters or {}
|
|
86
|
+
if isinstance(parsed, SampleTypedPathsCall):
|
|
87
|
+
paths = graph.sample_typed_paths(parsed.seed_ids, parsed.pattern)
|
|
88
|
+
if parsed.limit is not None:
|
|
89
|
+
paths = paths[:parsed.limit]
|
|
90
|
+
return QueryResult(
|
|
91
|
+
columns=parsed.returns,
|
|
92
|
+
records=[{"path": path} for path in paths],
|
|
93
|
+
)
|
|
94
|
+
if isinstance(parsed, NodeScanQuery):
|
|
95
|
+
records = execute_node_scan(parsed, QueryContext(graph=graph, parameters=parameters))
|
|
96
|
+
return QueryResult(columns=parsed.returns, records=records)
|
|
97
|
+
if isinstance(parsed, RelationshipScanQuery):
|
|
98
|
+
records = execute_relationship_scan(parsed, QueryContext(graph=graph, parameters=parameters))
|
|
99
|
+
return QueryResult(columns=parsed.returns, records=records)
|
|
100
|
+
if isinstance(parsed, MultiMatchQuery):
|
|
101
|
+
records = execute_multi_match(parsed, QueryContext(graph=graph, parameters=parameters))
|
|
102
|
+
return QueryResult(columns=parsed.returns, records=records)
|
|
103
|
+
records = execute_match(parsed, QueryContext(graph=graph, parameters=parameters))
|
|
104
|
+
return QueryResult(columns=parsed.returns, records=records)
|