nx-ladybug 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.
- nx_ladybug-0.1.0/LICENSE +65 -0
- nx_ladybug-0.1.0/PKG-INFO +94 -0
- nx_ladybug-0.1.0/README.md +69 -0
- nx_ladybug-0.1.0/pyproject.toml +39 -0
- nx_ladybug-0.1.0/setup.cfg +4 -0
- nx_ladybug-0.1.0/src/nx_ladybug.egg-info/PKG-INFO +94 -0
- nx_ladybug-0.1.0/src/nx_ladybug.egg-info/SOURCES.txt +11 -0
- nx_ladybug-0.1.0/src/nx_ladybug.egg-info/dependency_links.txt +1 -0
- nx_ladybug-0.1.0/src/nx_ladybug.egg-info/requires.txt +5 -0
- nx_ladybug-0.1.0/src/nx_ladybug.egg-info/top_level.txt +1 -0
- nx_ladybug-0.1.0/src/nxladybug/__init__.py +33 -0
- nx_ladybug-0.1.0/src/nxladybug/ladybug_store.py +419 -0
- nx_ladybug-0.1.0/tests/test_persist.py +253 -0
nx_ladybug-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
nx-arrow is distributed under the terms of either the MIT license or the
|
|
2
|
+
3-clause BSD license, at your option (SPDX: BSD-3-Clause OR MIT).
|
|
3
|
+
|
|
4
|
+
MIT License
|
|
5
|
+
|
|
6
|
+
Copyright (c) 2025-2026 Ladybug Memory Inc.
|
|
7
|
+
|
|
8
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
9
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
10
|
+
in the Software without restriction, including without limitation the rights
|
|
11
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
12
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
13
|
+
furnished to do so, subject to the following conditions:
|
|
14
|
+
|
|
15
|
+
The above copyright notice and this permission notice shall be included in all
|
|
16
|
+
copies or substantial portions of the Software.
|
|
17
|
+
|
|
18
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
19
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
20
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
21
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
22
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
23
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
24
|
+
SOFTWARE.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
Portions of this software are derived from NetworkX
|
|
29
|
+
(https://networkx.org/) and remain under its 3-clause BSD license:
|
|
30
|
+
|
|
31
|
+
::
|
|
32
|
+
|
|
33
|
+
Copyright (c) 2004-2026, NetworkX Developers
|
|
34
|
+
Aric Hagberg <hagberg@lanl.gov>
|
|
35
|
+
Dan Schult <dschult@colgate.edu>
|
|
36
|
+
Pieter Swart <swart@lanl.gov>
|
|
37
|
+
All rights reserved.
|
|
38
|
+
|
|
39
|
+
Redistribution and use in source and binary forms, with or without
|
|
40
|
+
modification, are permitted provided that the following conditions are
|
|
41
|
+
met:
|
|
42
|
+
|
|
43
|
+
* Redistributions of source code must retain the above copyright
|
|
44
|
+
notice, this list of conditions and the following disclaimer.
|
|
45
|
+
|
|
46
|
+
* Redistributions in binary form must reproduce the above
|
|
47
|
+
copyright notice, this list of conditions and the following
|
|
48
|
+
disclaimer in the documentation and/or other materials provided
|
|
49
|
+
with the distribution.
|
|
50
|
+
|
|
51
|
+
* Neither the name of the NetworkX Developers nor the names of its
|
|
52
|
+
contributors may be used to endorse or promote products derived
|
|
53
|
+
from this software without specific prior written permission.
|
|
54
|
+
|
|
55
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
|
56
|
+
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
|
57
|
+
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
|
58
|
+
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
|
59
|
+
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
|
60
|
+
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
|
61
|
+
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
|
62
|
+
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
|
63
|
+
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
|
64
|
+
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
65
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nx-ladybug
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: LadybugDB storage provider for nx-arrow columnar graphs
|
|
5
|
+
License-Expression: BSD-3-Clause OR MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/Ladybug-Memory/nx-ladybug
|
|
7
|
+
Project-URL: Source Code, https://github.com/Ladybug-Memory/nx-ladybug
|
|
8
|
+
Keywords: networks,graph theory,apache arrow,networkx,columnar,ladybugdb
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
17
|
+
Requires-Python: >=3.12
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: nx-arrow>=0.1.0
|
|
21
|
+
Requires-Dist: ladybug>=0.20
|
|
22
|
+
Provides-Extra: test
|
|
23
|
+
Requires-Dist: pytest; extra == "test"
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# nx-ladybug
|
|
27
|
+
|
|
28
|
+
LadybugDB storage provider for
|
|
29
|
+
[nx-arrow](https://github.com/Ladybug-Memory/nx-arrow) columnar graphs.
|
|
30
|
+
`nx-arrow` owns the Arrow-backed graph and the `arrow` NetworkX backend;
|
|
31
|
+
this package only adds persistence into
|
|
32
|
+
[LadybugDB](https://ladybugdb.com/). Build and analyze graphs with
|
|
33
|
+
`nx-arrow` everywhere — the only `nxladybug` import is the persist step.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install nx-arrow nx-ladybug
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
For development:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install -e ".[test]"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Use
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
import networkx as nx
|
|
51
|
+
from nxladybug import LadybugStorageProvider, persist
|
|
52
|
+
|
|
53
|
+
g = nx.DiGraph([(0, 1), (1, 2)], backend="arrow")
|
|
54
|
+
|
|
55
|
+
nx.out_degree_centrality(g, backend="arrow")
|
|
56
|
+
list(nx.topological_sort(g, backend="arrow"))
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Persist
|
|
60
|
+
|
|
61
|
+
`persist()` bulk-loads per-type frames through a storage provider — pick
|
|
62
|
+
LadybugDB or Parquet:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from nxarrow.parquet import ParquetStorageProvider
|
|
66
|
+
from nxladybug import LadybugStorageProvider, persist
|
|
67
|
+
|
|
68
|
+
# LadybugDB: one unified node table (NxNode) + one relationship table
|
|
69
|
+
# (NxEdge) + a meta table (NxMeta). Each frame is registered as an Arrow
|
|
70
|
+
# staging table and bulk-copied natively — COPY <table> FROM (MATCH ...) —
|
|
71
|
+
# so endpoints resolve through the node primary key inside the copy.
|
|
72
|
+
provider = LadybugStorageProvider("graph.lbdb") # or ":memory:"
|
|
73
|
+
persist(g, provider, graph_attrs=g.graph)
|
|
74
|
+
restored = provider.load_graph() # full round-trip
|
|
75
|
+
|
|
76
|
+
# ... or Parquet: one file per type plus a manifest.json
|
|
77
|
+
parquet = ParquetStorageProvider("graph_store")
|
|
78
|
+
persist(g, parquet, graph_attrs=g.graph)
|
|
79
|
+
restored = parquet.load_graph()
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The Ladybug provider drops and recreates its tables on every persist, so
|
|
83
|
+
re-persisting a smaller graph leaves no stale rows. `:memory:` databases
|
|
84
|
+
live as long as the provider; file paths reopen with
|
|
85
|
+
`LadybugStorageProvider.load(path)`.
|
|
86
|
+
|
|
87
|
+
Custom stores implement `store_nodes(frames)` / `store_edges(frames)` and
|
|
88
|
+
register with `nxarrow.register_storage_provider`.
|
|
89
|
+
|
|
90
|
+
## Develop
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
pytest
|
|
94
|
+
```
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# nx-ladybug
|
|
2
|
+
|
|
3
|
+
LadybugDB storage provider for
|
|
4
|
+
[nx-arrow](https://github.com/Ladybug-Memory/nx-arrow) columnar graphs.
|
|
5
|
+
`nx-arrow` owns the Arrow-backed graph and the `arrow` NetworkX backend;
|
|
6
|
+
this package only adds persistence into
|
|
7
|
+
[LadybugDB](https://ladybugdb.com/). Build and analyze graphs with
|
|
8
|
+
`nx-arrow` everywhere — the only `nxladybug` import is the persist step.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install nx-arrow nx-ladybug
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
For development:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install -e ".[test]"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Use
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
import networkx as nx
|
|
26
|
+
from nxladybug import LadybugStorageProvider, persist
|
|
27
|
+
|
|
28
|
+
g = nx.DiGraph([(0, 1), (1, 2)], backend="arrow")
|
|
29
|
+
|
|
30
|
+
nx.out_degree_centrality(g, backend="arrow")
|
|
31
|
+
list(nx.topological_sort(g, backend="arrow"))
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Persist
|
|
35
|
+
|
|
36
|
+
`persist()` bulk-loads per-type frames through a storage provider — pick
|
|
37
|
+
LadybugDB or Parquet:
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from nxarrow.parquet import ParquetStorageProvider
|
|
41
|
+
from nxladybug import LadybugStorageProvider, persist
|
|
42
|
+
|
|
43
|
+
# LadybugDB: one unified node table (NxNode) + one relationship table
|
|
44
|
+
# (NxEdge) + a meta table (NxMeta). Each frame is registered as an Arrow
|
|
45
|
+
# staging table and bulk-copied natively — COPY <table> FROM (MATCH ...) —
|
|
46
|
+
# so endpoints resolve through the node primary key inside the copy.
|
|
47
|
+
provider = LadybugStorageProvider("graph.lbdb") # or ":memory:"
|
|
48
|
+
persist(g, provider, graph_attrs=g.graph)
|
|
49
|
+
restored = provider.load_graph() # full round-trip
|
|
50
|
+
|
|
51
|
+
# ... or Parquet: one file per type plus a manifest.json
|
|
52
|
+
parquet = ParquetStorageProvider("graph_store")
|
|
53
|
+
persist(g, parquet, graph_attrs=g.graph)
|
|
54
|
+
restored = parquet.load_graph()
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The Ladybug provider drops and recreates its tables on every persist, so
|
|
58
|
+
re-persisting a smaller graph leaves no stale rows. `:memory:` databases
|
|
59
|
+
live as long as the provider; file paths reopen with
|
|
60
|
+
`LadybugStorageProvider.load(path)`.
|
|
61
|
+
|
|
62
|
+
Custom stores implement `store_nodes(frames)` / `store_edges(frames)` and
|
|
63
|
+
register with `nxarrow.register_storage_provider`.
|
|
64
|
+
|
|
65
|
+
## Develop
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
pytest
|
|
69
|
+
```
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
build-backend = "setuptools.build_meta"
|
|
3
|
+
requires = ["setuptools>=77"]
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nx-ladybug"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "LadybugDB storage provider for nx-arrow columnar graphs"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = "BSD-3-Clause OR MIT"
|
|
12
|
+
keywords = ["networks", "graph theory", "apache arrow", "networkx", "columnar", "ladybugdb"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Intended Audience :: Science/Research",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Topic :: Scientific/Engineering :: Information Analysis",
|
|
21
|
+
"Topic :: Scientific/Engineering :: Mathematics",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"nx-arrow>=0.1.0",
|
|
25
|
+
"ladybug>=0.20",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://github.com/Ladybug-Memory/nx-ladybug"
|
|
30
|
+
"Source Code" = "https://github.com/Ladybug-Memory/nx-ladybug"
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
test = ["pytest"]
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
where = ["src"]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nx-ladybug
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: LadybugDB storage provider for nx-arrow columnar graphs
|
|
5
|
+
License-Expression: BSD-3-Clause OR MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/Ladybug-Memory/nx-ladybug
|
|
7
|
+
Project-URL: Source Code, https://github.com/Ladybug-Memory/nx-ladybug
|
|
8
|
+
Keywords: networks,graph theory,apache arrow,networkx,columnar,ladybugdb
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
17
|
+
Requires-Python: >=3.12
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: nx-arrow>=0.1.0
|
|
21
|
+
Requires-Dist: ladybug>=0.20
|
|
22
|
+
Provides-Extra: test
|
|
23
|
+
Requires-Dist: pytest; extra == "test"
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# nx-ladybug
|
|
27
|
+
|
|
28
|
+
LadybugDB storage provider for
|
|
29
|
+
[nx-arrow](https://github.com/Ladybug-Memory/nx-arrow) columnar graphs.
|
|
30
|
+
`nx-arrow` owns the Arrow-backed graph and the `arrow` NetworkX backend;
|
|
31
|
+
this package only adds persistence into
|
|
32
|
+
[LadybugDB](https://ladybugdb.com/). Build and analyze graphs with
|
|
33
|
+
`nx-arrow` everywhere — the only `nxladybug` import is the persist step.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install nx-arrow nx-ladybug
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
For development:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install -e ".[test]"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Use
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
import networkx as nx
|
|
51
|
+
from nxladybug import LadybugStorageProvider, persist
|
|
52
|
+
|
|
53
|
+
g = nx.DiGraph([(0, 1), (1, 2)], backend="arrow")
|
|
54
|
+
|
|
55
|
+
nx.out_degree_centrality(g, backend="arrow")
|
|
56
|
+
list(nx.topological_sort(g, backend="arrow"))
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Persist
|
|
60
|
+
|
|
61
|
+
`persist()` bulk-loads per-type frames through a storage provider — pick
|
|
62
|
+
LadybugDB or Parquet:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from nxarrow.parquet import ParquetStorageProvider
|
|
66
|
+
from nxladybug import LadybugStorageProvider, persist
|
|
67
|
+
|
|
68
|
+
# LadybugDB: one unified node table (NxNode) + one relationship table
|
|
69
|
+
# (NxEdge) + a meta table (NxMeta). Each frame is registered as an Arrow
|
|
70
|
+
# staging table and bulk-copied natively — COPY <table> FROM (MATCH ...) —
|
|
71
|
+
# so endpoints resolve through the node primary key inside the copy.
|
|
72
|
+
provider = LadybugStorageProvider("graph.lbdb") # or ":memory:"
|
|
73
|
+
persist(g, provider, graph_attrs=g.graph)
|
|
74
|
+
restored = provider.load_graph() # full round-trip
|
|
75
|
+
|
|
76
|
+
# ... or Parquet: one file per type plus a manifest.json
|
|
77
|
+
parquet = ParquetStorageProvider("graph_store")
|
|
78
|
+
persist(g, parquet, graph_attrs=g.graph)
|
|
79
|
+
restored = parquet.load_graph()
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The Ladybug provider drops and recreates its tables on every persist, so
|
|
83
|
+
re-persisting a smaller graph leaves no stale rows. `:memory:` databases
|
|
84
|
+
live as long as the provider; file paths reopen with
|
|
85
|
+
`LadybugStorageProvider.load(path)`.
|
|
86
|
+
|
|
87
|
+
Custom stores implement `store_nodes(frames)` / `store_edges(frames)` and
|
|
88
|
+
register with `nxarrow.register_storage_provider`.
|
|
89
|
+
|
|
90
|
+
## Develop
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
pytest
|
|
94
|
+
```
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/nx_ladybug.egg-info/PKG-INFO
|
|
5
|
+
src/nx_ladybug.egg-info/SOURCES.txt
|
|
6
|
+
src/nx_ladybug.egg-info/dependency_links.txt
|
|
7
|
+
src/nx_ladybug.egg-info/requires.txt
|
|
8
|
+
src/nx_ladybug.egg-info/top_level.txt
|
|
9
|
+
src/nxladybug/__init__.py
|
|
10
|
+
src/nxladybug/ladybug_store.py
|
|
11
|
+
tests/test_persist.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nxladybug
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""LadybugDB storage provider for :mod:`nxarrow` columnar graphs.
|
|
2
|
+
|
|
3
|
+
``nx-arrow`` owns the Arrow-backed graph and the ``arrow`` NetworkX
|
|
4
|
+
backend; this package only adds persistence into LadybugDB. Build graphs
|
|
5
|
+
the usual way and choose a store at :func:`persist` time::
|
|
6
|
+
|
|
7
|
+
import networkx as nx
|
|
8
|
+
from nxarrow.parquet import ParquetStorageProvider
|
|
9
|
+
from nxladybug import LadybugStorageProvider, persist
|
|
10
|
+
|
|
11
|
+
g = nx.DiGraph([(0, 1), (1, 2)], backend="arrow")
|
|
12
|
+
persist(g, LadybugStorageProvider("graph.lbdb"), graph_attrs=g.graph)
|
|
13
|
+
# ... or persist(g, ParquetStorageProvider("store"), graph_attrs=g.graph)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from nxarrow.backend import (
|
|
17
|
+
get_storage_provider,
|
|
18
|
+
list_storage_providers,
|
|
19
|
+
persist,
|
|
20
|
+
register_storage_provider,
|
|
21
|
+
unregister_storage_provider,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from .ladybug_store import LadybugStorageProvider
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"LadybugStorageProvider",
|
|
28
|
+
"persist",
|
|
29
|
+
"register_storage_provider",
|
|
30
|
+
"unregister_storage_provider",
|
|
31
|
+
"get_storage_provider",
|
|
32
|
+
"list_storage_providers",
|
|
33
|
+
]
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"""LadybugDB storage provider for arrow-backed graphs.
|
|
2
|
+
|
|
3
|
+
Persists the per-type ``pyarrow.Table`` frames handed over by
|
|
4
|
+
:func:`nxarrow.backend.persist` into a LadybugDB database with one
|
|
5
|
+
unified node table and one unified relationship table::
|
|
6
|
+
|
|
7
|
+
NxNode(node, <attrs...>, node_type, _nx_order)
|
|
8
|
+
NxEdge(FROM NxNode TO NxNode, <attrs...>, edge_type, _nx_order)
|
|
9
|
+
NxMeta(key, value) # JSON-encoded graph-level attributes
|
|
10
|
+
|
|
11
|
+
Table names are configurable at construction. Every persist drops and
|
|
12
|
+
recreates the tables, so re-persisting a smaller graph leaves no stale
|
|
13
|
+
rows behind.
|
|
14
|
+
|
|
15
|
+
Each frame is bulk-loaded in two steps — Arrow memory registration into
|
|
16
|
+
a staging table, then a native bulk copy:
|
|
17
|
+
|
|
18
|
+
.. code-block:: python
|
|
19
|
+
|
|
20
|
+
conn.create_arrow_table(stage, frame) # register Arrow memory
|
|
21
|
+
conn.execute(f"COPY NxNode FROM " # bulk copy out of it
|
|
22
|
+
f"(MATCH (s:`{stage}`) RETURN s.`node`, ...)")
|
|
23
|
+
|
|
24
|
+
Column mapping is positional (staging ``RETURN`` order matches the
|
|
25
|
+
stored column order), mirroring the ``COPY <table> FROM $df`` convention
|
|
26
|
+
used by the LDBC bulk loader. Endpoints resolve through the node primary
|
|
27
|
+
key inside ``COPY``. Empty frames are skipped; a graph with no nodes or
|
|
28
|
+
no edges simply leaves that table out (loads treat a missing table as
|
|
29
|
+
empty).
|
|
30
|
+
|
|
31
|
+
Round-trip::
|
|
32
|
+
|
|
33
|
+
provider = LadybugStorageProvider(":memory:")
|
|
34
|
+
persist(graph, provider, graph_attrs=graph.graph)
|
|
35
|
+
restored = provider.load_graph()
|
|
36
|
+
|
|
37
|
+
``load_graph`` reads the tables back with ``MATCH ... RETURN`` (ordered
|
|
38
|
+
by the internal sequence column, which is then dropped) and rebuilds an
|
|
39
|
+
``ArrowDiGraph`` via :meth:`ArrowDiGraph.from_arrow`, so the restored
|
|
40
|
+
graph compares equal to the persisted one.
|
|
41
|
+
|
|
42
|
+
Fidelity notes. First, Arrow types map to Ladybug types: all integers to
|
|
43
|
+
``INT64``, floats to ``DOUBLE``, booleans to ``BOOL``, strings to
|
|
44
|
+
``STRING``, dates to ``DATE`` and timestamps to ``TIMESTAMP``; anything
|
|
45
|
+
else (times, durations, binaries, nested types, ...) is cast to
|
|
46
|
+
``STRING`` before the copy, which stringifies time keys in particular.
|
|
47
|
+
Second, the node key column (``node``) becomes the Ladybug primary key,
|
|
48
|
+
so key values must be unique, as they always are in a graph. Third,
|
|
49
|
+
``graph_attrs`` must be JSON-serializable; it is forwarded through
|
|
50
|
+
:func:`persist` keyword arguments and stored as one JSON document in the
|
|
51
|
+
meta table. Fourth, ``_nx_order`` is a reserved column name used for the
|
|
52
|
+
internal insertion sequence; graphs using it as an attribute are refused
|
|
53
|
+
with a clear error.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
import json
|
|
57
|
+
import uuid
|
|
58
|
+
|
|
59
|
+
import pyarrow as pa
|
|
60
|
+
|
|
61
|
+
from nxarrow.graph import ArrowDiGraph
|
|
62
|
+
|
|
63
|
+
__all__ = ["LadybugStorageProvider"]
|
|
64
|
+
|
|
65
|
+
DEFAULT_NODE_TABLE = "NxNode"
|
|
66
|
+
DEFAULT_EDGE_TABLE = "NxEdge"
|
|
67
|
+
DEFAULT_META_TABLE = "NxMeta"
|
|
68
|
+
|
|
69
|
+
#: Reserved column holding the global insertion sequence. Lets loads
|
|
70
|
+
#: restore exact node/edge order via ``ORDER BY`` regardless of storage
|
|
71
|
+
#: order; it is stripped from the adopted tables so it never surfaces as
|
|
72
|
+
#: an attribute.
|
|
73
|
+
ORDER_COL = "_nx_order"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _quote(identifier):
|
|
77
|
+
"""Quote an identifier with backticks (Ladybug escape: double them)."""
|
|
78
|
+
return "`" + str(identifier).replace("`", "``") + "`"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _literal(value):
|
|
82
|
+
"""Quote a string literal with single quotes (escape: double them)."""
|
|
83
|
+
return "'" + str(value).replace("'", "''") + "'"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _ladybug_type(dtype):
|
|
87
|
+
"""Map an Arrow type to a Ladybug column type (None: cast to STRING)."""
|
|
88
|
+
if pa.types.is_dictionary(dtype):
|
|
89
|
+
return _ladybug_type(dtype.value_type)
|
|
90
|
+
if pa.types.is_integer(dtype) or pa.types.is_unsigned_integer(dtype):
|
|
91
|
+
return "INT64"
|
|
92
|
+
if pa.types.is_floating(dtype):
|
|
93
|
+
return "DOUBLE"
|
|
94
|
+
if pa.types.is_boolean(dtype):
|
|
95
|
+
return "BOOL"
|
|
96
|
+
if pa.types.is_string(dtype) or pa.types.is_large_string(dtype):
|
|
97
|
+
return "STRING"
|
|
98
|
+
if pa.types.is_date(dtype):
|
|
99
|
+
return "DATE"
|
|
100
|
+
if pa.types.is_timestamp(dtype):
|
|
101
|
+
return "TIMESTAMP"
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _stage_frame(table):
|
|
106
|
+
"""Cast unmapped columns to STRING so every column has a Ladybug type."""
|
|
107
|
+
names = table.column_names
|
|
108
|
+
if ORDER_COL in names:
|
|
109
|
+
raise ValueError(
|
|
110
|
+
f"attribute name {ORDER_COL!r} is reserved for Ladybug persistence; "
|
|
111
|
+
"rename the attribute before persisting"
|
|
112
|
+
)
|
|
113
|
+
out = table
|
|
114
|
+
for field in table.schema:
|
|
115
|
+
if _ladybug_type(field.type) is None:
|
|
116
|
+
idx = out.schema.get_field_index(field.name)
|
|
117
|
+
out = out.set_column(
|
|
118
|
+
idx, field.name, out.column(field.name).cast(pa.string())
|
|
119
|
+
)
|
|
120
|
+
return out
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _unify(tables):
|
|
124
|
+
"""Concatenate tables with heterogeneous columns, filling gaps with nulls."""
|
|
125
|
+
if len(tables) == 1:
|
|
126
|
+
return tables[0]
|
|
127
|
+
schema = pa.unify_schemas([t.schema for t in tables])
|
|
128
|
+
return pa.concat_tables([t.cast(schema) for t in tables])
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class LadybugStorageProvider:
|
|
132
|
+
"""Store per-type Arrow frames in LadybugDB node/relationship tables.
|
|
133
|
+
|
|
134
|
+
Parameters
|
|
135
|
+
----------
|
|
136
|
+
db_path:
|
|
137
|
+
LadybugDB database path, or ``":memory:"`` (default) for a
|
|
138
|
+
transient database that lives as long as this provider. File
|
|
139
|
+
paths persist across processes; reopen with
|
|
140
|
+
:meth:`LadybugStorageProvider.load`.
|
|
141
|
+
node_table, edge_table, meta_table:
|
|
142
|
+
Ladybug table names for nodes, edges and the graph-attrs sidecar.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
def __init__(
|
|
146
|
+
self,
|
|
147
|
+
db_path=":memory:",
|
|
148
|
+
node_table=DEFAULT_NODE_TABLE,
|
|
149
|
+
edge_table=DEFAULT_EDGE_TABLE,
|
|
150
|
+
meta_table=DEFAULT_META_TABLE,
|
|
151
|
+
):
|
|
152
|
+
self.db_path = str(db_path)
|
|
153
|
+
self.node_table = node_table
|
|
154
|
+
self.edge_table = edge_table
|
|
155
|
+
self.meta_table = meta_table
|
|
156
|
+
self._db = None
|
|
157
|
+
self._conn = None
|
|
158
|
+
|
|
159
|
+
# -- connection lifecycle --
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def conn(self):
|
|
163
|
+
"""The live Ladybug connection, opened on first use."""
|
|
164
|
+
if self._conn is None:
|
|
165
|
+
import ladybug as lb
|
|
166
|
+
|
|
167
|
+
self._db = lb.Database(self.db_path)
|
|
168
|
+
self._conn = lb.Connection(self._db)
|
|
169
|
+
return self._conn
|
|
170
|
+
|
|
171
|
+
def close(self):
|
|
172
|
+
"""Close the connection (and database). The provider is unusable after."""
|
|
173
|
+
if self._conn is not None:
|
|
174
|
+
try:
|
|
175
|
+
self._conn.close()
|
|
176
|
+
finally:
|
|
177
|
+
self._conn = None
|
|
178
|
+
if self._db is not None:
|
|
179
|
+
try:
|
|
180
|
+
self._db.close()
|
|
181
|
+
finally:
|
|
182
|
+
self._db = None
|
|
183
|
+
|
|
184
|
+
def __enter__(self):
|
|
185
|
+
return self
|
|
186
|
+
|
|
187
|
+
def __exit__(self, *exc):
|
|
188
|
+
self.close()
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
# -- storage provider protocol (see nxarrow.backend.persist) --
|
|
192
|
+
|
|
193
|
+
def store_nodes(self, frames, graph_attrs=None, **kwargs):
|
|
194
|
+
"""Bulk-load node frames into the node table. Returns ``{table: rows}``."""
|
|
195
|
+
rows = [t.num_rows for t in frames.values()]
|
|
196
|
+
total = sum(rows)
|
|
197
|
+
conn = self.conn
|
|
198
|
+
# Drop the edge table first: it references the node table, and both
|
|
199
|
+
# are rebuilt on every persist so no stale rows survive re-persist.
|
|
200
|
+
conn.execute(f"DROP TABLE IF EXISTS {_quote(self.edge_table)}")
|
|
201
|
+
conn.execute(f"DROP TABLE IF EXISTS {_quote(self.node_table)}")
|
|
202
|
+
if total:
|
|
203
|
+
unified = _unify([_stage_frame(t) for t in frames.values()])
|
|
204
|
+
self._create_node_table(unified)
|
|
205
|
+
offset = 0
|
|
206
|
+
for table in frames.values():
|
|
207
|
+
offset = self._copy_node_frame(table, unified.schema, offset)
|
|
208
|
+
if graph_attrs is not None:
|
|
209
|
+
self._store_meta(dict(graph_attrs))
|
|
210
|
+
return {self.node_table: total} if total else {}
|
|
211
|
+
|
|
212
|
+
def store_edges(self, frames, graph_attrs=None, **kwargs):
|
|
213
|
+
"""Bulk-load edge frames into the relationship table. Returns ``{table: rows}``."""
|
|
214
|
+
rows = [t.num_rows for t in frames.values()]
|
|
215
|
+
total = sum(rows)
|
|
216
|
+
conn = self.conn
|
|
217
|
+
conn.execute(f"DROP TABLE IF EXISTS {_quote(self.edge_table)}")
|
|
218
|
+
if total:
|
|
219
|
+
self._ensure_node_table_for_edges(frames)
|
|
220
|
+
unified = _unify([_stage_frame(t) for t in frames.values()])
|
|
221
|
+
self._create_edge_table(unified)
|
|
222
|
+
offset = 0
|
|
223
|
+
for table in frames.values():
|
|
224
|
+
offset = self._copy_edge_frame(table, unified.schema, offset)
|
|
225
|
+
if graph_attrs is not None:
|
|
226
|
+
self._store_meta(dict(graph_attrs))
|
|
227
|
+
return {self.edge_table: total} if total else {}
|
|
228
|
+
|
|
229
|
+
# -- loading --
|
|
230
|
+
|
|
231
|
+
def load_graph(self):
|
|
232
|
+
"""Rebuild the persisted graph (tables, types, attrs, graph attrs)."""
|
|
233
|
+
nodes = self._load_table(self.node_table, endpoint=False)
|
|
234
|
+
edges = self._load_table(self.edge_table, endpoint=True)
|
|
235
|
+
graph = ArrowDiGraph.from_arrow(nodes, edges)
|
|
236
|
+
graph.graph.update(self._load_meta())
|
|
237
|
+
return graph
|
|
238
|
+
|
|
239
|
+
@classmethod
|
|
240
|
+
def load(cls, db_path, **kwargs):
|
|
241
|
+
"""Open the database at ``db_path`` and rebuild the persisted graph."""
|
|
242
|
+
return cls(db_path, **kwargs).load_graph()
|
|
243
|
+
|
|
244
|
+
# -- DDL --
|
|
245
|
+
|
|
246
|
+
def _create_node_table(self, unified):
|
|
247
|
+
cols = [
|
|
248
|
+
f"{_quote(f.name)} {_ladybug_type(f.type)}" for f in unified.schema
|
|
249
|
+
]
|
|
250
|
+
cols.append(f"{_quote(ORDER_COL)} INT64")
|
|
251
|
+
cols.append(f"PRIMARY KEY ({_quote('node')})")
|
|
252
|
+
self.conn.execute(
|
|
253
|
+
f"CREATE NODE TABLE {_quote(self.node_table)} ({', '.join(cols)})"
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
def _create_edge_table(self, unified):
|
|
257
|
+
props = [
|
|
258
|
+
f"{_quote(f.name)} {_ladybug_type(f.type)}"
|
|
259
|
+
for f in unified.schema
|
|
260
|
+
if f.name not in ("source", "target")
|
|
261
|
+
]
|
|
262
|
+
props.append(f"{_quote(ORDER_COL)} INT64")
|
|
263
|
+
self.conn.execute(
|
|
264
|
+
f"CREATE REL TABLE {_quote(self.edge_table)} "
|
|
265
|
+
f"(FROM {_quote(self.node_table)} TO {_quote(self.node_table)}"
|
|
266
|
+
+ (f", {', '.join(props)}" if props else "")
|
|
267
|
+
+ ")"
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def _ensure_node_table_for_edges(self, frames):
|
|
271
|
+
"""Create a bare node table so a standalone edge store has a TO/FROM target.
|
|
272
|
+
|
|
273
|
+
:func:`persist` always stores nodes first, so this only triggers on
|
|
274
|
+
direct ``store_edges`` calls. The key type is inferred from the
|
|
275
|
+
``source`` column of the unified edge frames.
|
|
276
|
+
"""
|
|
277
|
+
names = {row[0] for row in self._table_names()}
|
|
278
|
+
if self.node_table in names:
|
|
279
|
+
return
|
|
280
|
+
unified = _unify([_stage_frame(t) for t in frames.values()])
|
|
281
|
+
key_type = _ladybug_type(unified.schema.field("source").type) or "STRING"
|
|
282
|
+
self.conn.execute(
|
|
283
|
+
f"CREATE NODE TABLE {_quote(self.node_table)} "
|
|
284
|
+
f"({_quote('node')} {key_type}, PRIMARY KEY ({_quote('node')}))"
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
# -- COPY --
|
|
288
|
+
|
|
289
|
+
def _copy_frame(self, dest_table, dest_cols, frame):
|
|
290
|
+
"""Register ``frame`` as a staging table and bulk-copy it into ``dest_table``.
|
|
291
|
+
|
|
292
|
+
Column mapping is positional: the ``RETURN`` list follows
|
|
293
|
+
``dest_cols`` order, matching the stored column order.
|
|
294
|
+
"""
|
|
295
|
+
stage = f"_nx_stage_{uuid.uuid4().hex}"
|
|
296
|
+
conn = self.conn
|
|
297
|
+
conn.create_arrow_table(stage, frame)
|
|
298
|
+
try:
|
|
299
|
+
aliased = "s"
|
|
300
|
+
returns = ", ".join(
|
|
301
|
+
f"{_quote(aliased)}.{_quote(c)}" for c in dest_cols
|
|
302
|
+
)
|
|
303
|
+
conn.execute(
|
|
304
|
+
f"COPY {_quote(dest_table)} FROM "
|
|
305
|
+
f"(MATCH ({_quote(aliased)}:{_quote(stage)}) RETURN {returns})"
|
|
306
|
+
)
|
|
307
|
+
finally:
|
|
308
|
+
try:
|
|
309
|
+
conn.drop_arrow_table(stage)
|
|
310
|
+
except Exception:
|
|
311
|
+
pass
|
|
312
|
+
|
|
313
|
+
def _copy_node_frame(self, table, schema, offset):
|
|
314
|
+
if table.num_rows == 0:
|
|
315
|
+
return offset
|
|
316
|
+
staged = _stage_frame(table.cast(schema))
|
|
317
|
+
order = pa.array(
|
|
318
|
+
range(offset, offset + table.num_rows), type=pa.int64()
|
|
319
|
+
)
|
|
320
|
+
staged = staged.append_column(ORDER_COL, order)
|
|
321
|
+
self._copy_frame(self.node_table, [*schema.names, ORDER_COL], staged)
|
|
322
|
+
return offset + table.num_rows
|
|
323
|
+
|
|
324
|
+
def _copy_edge_frame(self, table, schema, offset):
|
|
325
|
+
if table.num_rows == 0:
|
|
326
|
+
return offset
|
|
327
|
+
staged = _stage_frame(table.cast(schema))
|
|
328
|
+
order = pa.array(
|
|
329
|
+
range(offset, offset + table.num_rows), type=pa.int64()
|
|
330
|
+
)
|
|
331
|
+
staged = staged.append_column(ORDER_COL, order)
|
|
332
|
+
self._copy_frame(self.edge_table, [*schema.names, ORDER_COL], staged)
|
|
333
|
+
return offset + table.num_rows
|
|
334
|
+
|
|
335
|
+
# -- graph attrs sidecar --
|
|
336
|
+
|
|
337
|
+
def _store_meta(self, graph_attrs):
|
|
338
|
+
import pyarrow as pa
|
|
339
|
+
|
|
340
|
+
conn = self.conn
|
|
341
|
+
conn.execute(f"DROP TABLE IF EXISTS {_quote(self.meta_table)}")
|
|
342
|
+
conn.execute(
|
|
343
|
+
f"CREATE NODE TABLE {_quote(self.meta_table)} "
|
|
344
|
+
f"({_quote('key')} STRING, {_quote('value')} STRING, "
|
|
345
|
+
f"PRIMARY KEY ({_quote('key')}))"
|
|
346
|
+
)
|
|
347
|
+
frame = pa.table(
|
|
348
|
+
{
|
|
349
|
+
"key": pa.array(["graph"], type=pa.string()),
|
|
350
|
+
"value": pa.array(
|
|
351
|
+
[json.dumps(graph_attrs)], type=pa.string()
|
|
352
|
+
),
|
|
353
|
+
}
|
|
354
|
+
)
|
|
355
|
+
self._copy_frame(self.meta_table, ["key", "value"], frame)
|
|
356
|
+
|
|
357
|
+
def _load_meta(self):
|
|
358
|
+
if self.meta_table not in {row[0] for row in self._table_names()}:
|
|
359
|
+
return {}
|
|
360
|
+
result = self.conn.execute(
|
|
361
|
+
f"MATCH (m:{_quote(self.meta_table)}) "
|
|
362
|
+
f"WHERE m.{_quote('key')} = 'graph' "
|
|
363
|
+
f"RETURN m.{_quote('value')} AS {_quote('value')}"
|
|
364
|
+
).get_as_arrow()
|
|
365
|
+
if result.num_rows == 0:
|
|
366
|
+
return {}
|
|
367
|
+
return json.loads(result.column("value")[0].as_py())
|
|
368
|
+
|
|
369
|
+
# -- reads --
|
|
370
|
+
|
|
371
|
+
def _table_names(self):
|
|
372
|
+
result = self.conn.execute("CALL show_tables() RETURN *").get_as_arrow()
|
|
373
|
+
return list(
|
|
374
|
+
zip(result.column("name").to_pylist(), result.column("type").to_pylist())
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
def _table_columns(self, table):
|
|
378
|
+
"""Stored column names of ``table`` in DDL order.
|
|
379
|
+
|
|
380
|
+
Relationship endpoints (``FROM``/``TO``) are not listed by
|
|
381
|
+
``table_info`` — only real properties are — so no filtering for
|
|
382
|
+
them is needed.
|
|
383
|
+
"""
|
|
384
|
+
result = self.conn.execute(
|
|
385
|
+
f"CALL table_info({_literal(table)}) RETURN *"
|
|
386
|
+
).get_as_arrow()
|
|
387
|
+
return result.column("name").to_pylist()
|
|
388
|
+
|
|
389
|
+
def _load_table(self, table, endpoint):
|
|
390
|
+
if table not in {name for name, _ in self._table_names()}:
|
|
391
|
+
return None
|
|
392
|
+
columns = self._table_columns(table)
|
|
393
|
+
ordered = ORDER_COL in columns
|
|
394
|
+
if endpoint:
|
|
395
|
+
node, edge = _quote(self.node_table), _quote(table)
|
|
396
|
+
src, tgt = _quote("source"), _quote("target")
|
|
397
|
+
returns = [
|
|
398
|
+
f"a.{_quote('node')} AS {src}",
|
|
399
|
+
f"b.{_quote('node')} AS {tgt}",
|
|
400
|
+
]
|
|
401
|
+
returns += [
|
|
402
|
+
f"e.{_quote(c)} AS {_quote(c)}"
|
|
403
|
+
for c in columns
|
|
404
|
+
if c != ORDER_COL
|
|
405
|
+
]
|
|
406
|
+
query = (
|
|
407
|
+
f"MATCH (a:{node})-[e:{edge}]->(b:{node}) "
|
|
408
|
+
f"RETURN {', '.join(returns)}"
|
|
409
|
+
)
|
|
410
|
+
if ordered:
|
|
411
|
+
query += f" ORDER BY e.{_quote(ORDER_COL)}"
|
|
412
|
+
else:
|
|
413
|
+
returns = [f"n.{_quote(c)} AS {_quote(c)}" for c in columns if c != ORDER_COL]
|
|
414
|
+
query = f"MATCH (n:{_quote(table)}) RETURN {', '.join(returns)}"
|
|
415
|
+
if ordered:
|
|
416
|
+
query += f" ORDER BY n.{_quote(ORDER_COL)}"
|
|
417
|
+
result = self.conn.execute(query).get_as_arrow()
|
|
418
|
+
# get_as_arrow may chunk columns; combine for clean tables.
|
|
419
|
+
return result.combine_chunks() if result.num_columns else None
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Persistence tests: same ``arrow``-backend graphs, parquet or Ladybug store."""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
pa = pytest.importorskip("pyarrow")
|
|
8
|
+
pytest.importorskip("ladybug")
|
|
9
|
+
|
|
10
|
+
import networkx as nx
|
|
11
|
+
from nxarrow.backend import persist
|
|
12
|
+
from nxarrow.graph import ArrowDiGraph
|
|
13
|
+
from nxarrow.parquet import ParquetStorageProvider
|
|
14
|
+
|
|
15
|
+
from nxladybug import LadybugStorageProvider
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture
|
|
19
|
+
def typed_graph():
|
|
20
|
+
g = nx.DiGraph(backend="arrow")
|
|
21
|
+
g.add_node("alice", node_type="user", age=30)
|
|
22
|
+
g.add_node("bob", node_type="user", age=25)
|
|
23
|
+
g.add_node("post1", node_type="post", text="hello")
|
|
24
|
+
g.add_edge("alice", "post1", edge_type="authored")
|
|
25
|
+
g.add_edge("bob", "post1", edge_type="liked", weight=0.9)
|
|
26
|
+
g.add_edge("alice", "bob", edge_type="follows")
|
|
27
|
+
return g
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _assert_graphs_equal(original, restored):
|
|
31
|
+
assert restored.node_types == original.node_types
|
|
32
|
+
assert restored.edge_types == original.edge_types
|
|
33
|
+
assert restored.nodes_table().num_rows == original.nodes_table().num_rows
|
|
34
|
+
assert restored.edges_table().num_rows == original.edges_table().num_rows
|
|
35
|
+
for node in original.nodes:
|
|
36
|
+
assert restored.has_node(node)
|
|
37
|
+
assert restored.node_attrs(node) == original.node_attrs(node)
|
|
38
|
+
for u, v in original.edges:
|
|
39
|
+
assert restored.has_edge(u, v)
|
|
40
|
+
assert restored.edge_attrs(u, v) == original.edge_attrs(u, v)
|
|
41
|
+
assert restored.graph == original.graph
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _row_count(provider, query):
|
|
45
|
+
return provider.conn.execute(query).get_as_arrow().column(0)[0].as_py()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# -- Ladybug store --
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_ladybug_provider_roundtrip(typed_graph):
|
|
52
|
+
typed_graph.graph["name"] = "roundtrip"
|
|
53
|
+
provider = LadybugStorageProvider()
|
|
54
|
+
try:
|
|
55
|
+
counts = persist(typed_graph, provider, graph_attrs=typed_graph.graph)
|
|
56
|
+
assert counts == {provider.node_table: 3, provider.edge_table: 3}
|
|
57
|
+
names = {name for name, _ in provider._table_names()}
|
|
58
|
+
assert {provider.node_table, provider.edge_table, provider.meta_table} <= names
|
|
59
|
+
|
|
60
|
+
restored = provider.load_graph()
|
|
61
|
+
_assert_graphs_equal(typed_graph, restored)
|
|
62
|
+
# insertion order survives the round-trip via the sequence column
|
|
63
|
+
assert restored.nodes == typed_graph.nodes
|
|
64
|
+
assert restored.edges == typed_graph.edges
|
|
65
|
+
finally:
|
|
66
|
+
provider.close()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_ladybug_provider_roundtrip_untyped_and_empty():
|
|
70
|
+
provider = LadybugStorageProvider()
|
|
71
|
+
try:
|
|
72
|
+
g = nx.DiGraph(backend="arrow")
|
|
73
|
+
g.add_edge("a", "b", weight=1.5)
|
|
74
|
+
persist(g, provider)
|
|
75
|
+
restored = provider.load_graph()
|
|
76
|
+
_assert_graphs_equal(g, restored)
|
|
77
|
+
assert restored.nodes_table().column_names == ["node"]
|
|
78
|
+
|
|
79
|
+
persist(nx.DiGraph(backend="arrow"), provider)
|
|
80
|
+
empty = provider.load_graph()
|
|
81
|
+
assert empty.number_of_nodes() == 0
|
|
82
|
+
assert empty.number_of_edges() == 0
|
|
83
|
+
finally:
|
|
84
|
+
provider.close()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_ladybug_provider_repersist_drops_stale_rows(typed_graph):
|
|
88
|
+
provider = LadybugStorageProvider()
|
|
89
|
+
try:
|
|
90
|
+
persist(typed_graph, provider)
|
|
91
|
+
assert _row_count(provider, "MATCH (n:NxNode) RETURN count(*)") == 3
|
|
92
|
+
assert _row_count(provider, "MATCH ()-[e:NxEdge]->() RETURN count(*)") == 3
|
|
93
|
+
|
|
94
|
+
typed_graph.remove_edge("alice", "bob") # drops the only "follows" edge
|
|
95
|
+
typed_graph.remove_edge("bob", "post1") # drops the only "liked" edge
|
|
96
|
+
persist(typed_graph, provider)
|
|
97
|
+
assert _row_count(provider, "MATCH ()-[e:NxEdge]->() RETURN count(*)") == 1
|
|
98
|
+
_assert_graphs_equal(typed_graph, provider.load_graph())
|
|
99
|
+
finally:
|
|
100
|
+
provider.close()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_ladybug_provider_file_backed_reopen(tmp_path, typed_graph):
|
|
104
|
+
typed_graph.graph["name"] = "file-roundtrip"
|
|
105
|
+
db_path = tmp_path / "graph.lbdb"
|
|
106
|
+
provider = LadybugStorageProvider(db_path)
|
|
107
|
+
persist(typed_graph, provider, graph_attrs=typed_graph.graph)
|
|
108
|
+
provider.close()
|
|
109
|
+
|
|
110
|
+
restored = LadybugStorageProvider.load(db_path)
|
|
111
|
+
_assert_graphs_equal(typed_graph, restored)
|
|
112
|
+
assert restored.nodes == typed_graph.nodes
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_ladybug_roundtrip_preserves_int_keys():
|
|
116
|
+
g = nx.DiGraph(backend="arrow")
|
|
117
|
+
g.add_edge(0, 1, weight=2.5)
|
|
118
|
+
g.add_edge(1, 2)
|
|
119
|
+
provider = LadybugStorageProvider()
|
|
120
|
+
try:
|
|
121
|
+
persist(g, provider)
|
|
122
|
+
restored = provider.load_graph()
|
|
123
|
+
assert restored.nodes == [0, 1, 2]
|
|
124
|
+
assert restored.edges == [(0, 1), (1, 2)]
|
|
125
|
+
assert restored.edge_attrs(0, 1) == {"weight": 2.5}
|
|
126
|
+
assert restored.nodes_table().schema.field("node").type == pa.int64()
|
|
127
|
+
finally:
|
|
128
|
+
provider.close()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_ladybug_roundtrip_native_key_types():
|
|
132
|
+
cases = [
|
|
133
|
+
([0, 1, 2], pa.int64()),
|
|
134
|
+
([0.5, 1.5, 2.5], pa.float64()),
|
|
135
|
+
(["a", "b", "c"], pa.string()),
|
|
136
|
+
(
|
|
137
|
+
[
|
|
138
|
+
datetime.date(2024, 1, 1),
|
|
139
|
+
datetime.date(2024, 1, 2),
|
|
140
|
+
datetime.date(2024, 1, 3),
|
|
141
|
+
],
|
|
142
|
+
pa.date32(),
|
|
143
|
+
),
|
|
144
|
+
(
|
|
145
|
+
[
|
|
146
|
+
datetime.datetime(2024, 1, 1, 1, 2),
|
|
147
|
+
datetime.datetime(2024, 1, 2),
|
|
148
|
+
datetime.datetime(2024, 1, 3, 4, 5),
|
|
149
|
+
],
|
|
150
|
+
pa.timestamp("us"),
|
|
151
|
+
),
|
|
152
|
+
]
|
|
153
|
+
for keys, expected_type in cases:
|
|
154
|
+
g = nx.DiGraph(backend="arrow")
|
|
155
|
+
g.add_edge(keys[0], keys[1], weight=1.0)
|
|
156
|
+
g.add_edge(keys[1], keys[2])
|
|
157
|
+
provider = LadybugStorageProvider()
|
|
158
|
+
try:
|
|
159
|
+
persist(g, provider)
|
|
160
|
+
restored = provider.load_graph()
|
|
161
|
+
assert restored.nodes == keys
|
|
162
|
+
assert restored.edges == [(keys[0], keys[1]), (keys[1], keys[2])]
|
|
163
|
+
assert restored.edge_attrs(keys[0], keys[1]) == {"weight": 1.0}
|
|
164
|
+
assert restored.nodes_table().schema.field("node").type == expected_type
|
|
165
|
+
finally:
|
|
166
|
+
provider.close()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_ladybug_lazy_persist_roundtrip(typed_graph):
|
|
170
|
+
# index-free adopted tables persist without materializing the index
|
|
171
|
+
nodes, edges = typed_graph.nodes_table(), typed_graph.edges_table()
|
|
172
|
+
lazy = ArrowDiGraph.from_arrow(nodes, edges, index=False)
|
|
173
|
+
provider = LadybugStorageProvider()
|
|
174
|
+
try:
|
|
175
|
+
counts = persist(lazy, provider)
|
|
176
|
+
assert lazy._index_built is False
|
|
177
|
+
assert sum(counts.values()) == 6
|
|
178
|
+
_assert_graphs_equal(typed_graph, provider.load_graph())
|
|
179
|
+
finally:
|
|
180
|
+
provider.close()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# -- Parquet store (same graphs, alternative choice at persist() time) --
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_parquet_provider_roundtrip(tmp_path, typed_graph):
|
|
187
|
+
typed_graph.graph["name"] = "roundtrip"
|
|
188
|
+
provider = ParquetStorageProvider(tmp_path / "store")
|
|
189
|
+
counts = persist(typed_graph, provider, graph_attrs=typed_graph.graph)
|
|
190
|
+
assert sum(counts.values()) == 6
|
|
191
|
+
assert (tmp_path / "store" / "manifest.json").exists()
|
|
192
|
+
|
|
193
|
+
restored = provider.load_graph()
|
|
194
|
+
_assert_graphs_equal(typed_graph, restored)
|
|
195
|
+
# per-type files: 2 node types + 3 edge types
|
|
196
|
+
assert len(list((tmp_path / "store" / "nodes").glob("*.parquet"))) == 2
|
|
197
|
+
assert len(list((tmp_path / "store" / "edges").glob("*.parquet"))) == 3
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_parquet_provider_roundtrip_untyped_and_empty(tmp_path):
|
|
201
|
+
provider = ParquetStorageProvider(tmp_path / "store")
|
|
202
|
+
g = nx.DiGraph(backend="arrow")
|
|
203
|
+
g.add_edge("a", "b", weight=1.5)
|
|
204
|
+
persist(g, provider)
|
|
205
|
+
restored = provider.load_graph()
|
|
206
|
+
_assert_graphs_equal(g, restored)
|
|
207
|
+
assert restored.nodes_table().column_names == ["node"]
|
|
208
|
+
|
|
209
|
+
persist(nx.DiGraph(backend="arrow"), provider)
|
|
210
|
+
empty = provider.load_graph()
|
|
211
|
+
assert empty.number_of_nodes() == 0
|
|
212
|
+
assert empty.number_of_edges() == 0
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_parquet_provider_repersist_drops_stale_files(tmp_path, typed_graph):
|
|
216
|
+
provider = ParquetStorageProvider(tmp_path / "store")
|
|
217
|
+
persist(typed_graph, provider)
|
|
218
|
+
assert len(list((tmp_path / "store" / "edges").glob("*.parquet"))) == 3
|
|
219
|
+
|
|
220
|
+
typed_graph.remove_edge("alice", "bob") # drops the only "follows" edge
|
|
221
|
+
typed_graph.remove_edge("bob", "post1") # drops the only "liked" edge
|
|
222
|
+
persist(typed_graph, provider)
|
|
223
|
+
assert len(list((tmp_path / "store" / "edges").glob("*.parquet"))) == 1
|
|
224
|
+
_assert_graphs_equal(typed_graph, provider.load_graph())
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def test_parquet_roundtrip_preserves_int_keys(tmp_path):
|
|
228
|
+
g = nx.DiGraph(backend="arrow")
|
|
229
|
+
g.add_edge(0, 1, weight=2.5)
|
|
230
|
+
g.add_edge(1, 2)
|
|
231
|
+
provider = ParquetStorageProvider(tmp_path / "store")
|
|
232
|
+
persist(g, provider)
|
|
233
|
+
restored = provider.load_graph()
|
|
234
|
+
assert restored.nodes == [0, 1, 2]
|
|
235
|
+
assert restored.edges == [(0, 1), (1, 2)]
|
|
236
|
+
assert restored.edge_attrs(0, 1) == {"weight": 2.5}
|
|
237
|
+
assert restored.nodes_table().schema.field("node").type == pa.int64()
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# -- the choice itself: one graph, either store --
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def test_same_graph_persists_to_both_stores(tmp_path, typed_graph):
|
|
244
|
+
typed_graph.graph["name"] = "either-store"
|
|
245
|
+
ladybug = LadybugStorageProvider()
|
|
246
|
+
parquet = ParquetStorageProvider(tmp_path / "store")
|
|
247
|
+
try:
|
|
248
|
+
persist(typed_graph, ladybug, graph_attrs=typed_graph.graph)
|
|
249
|
+
persist(typed_graph, parquet, graph_attrs=typed_graph.graph)
|
|
250
|
+
_assert_graphs_equal(typed_graph, ladybug.load_graph())
|
|
251
|
+
_assert_graphs_equal(typed_graph, parquet.load_graph())
|
|
252
|
+
finally:
|
|
253
|
+
ladybug.close()
|