runic-py 0.2.0__py3-none-any.whl
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.
- runic/__init__.py +13 -0
- runic/migrate/__init__.py +27 -0
- runic/migrate/adapters/__init__.py +129 -0
- runic/migrate/adapters/falkordb.py +353 -0
- runic/migrate/autogen.py +242 -0
- runic/migrate/checksum.py +14 -0
- runic/migrate/cli.py +880 -0
- runic/migrate/config.py +8 -0
- runic/migrate/context.py +526 -0
- runic/migrate/exceptions.py +18 -0
- runic/migrate/introspect.py +556 -0
- runic/migrate/manifest.py +81 -0
- runic/migrate/operations.py +173 -0
- runic/migrate/script.py +452 -0
- runic/migrate/service.py +27 -0
- runic/migrate/templates/env.py.mako +86 -0
- runic/migrate/templates/script.py.mako +27 -0
- runic/migrate/testing.py +48 -0
- runic/migrate/version.py +38 -0
- runic/orm/__init__.py +82 -0
- runic/orm/core/__init__.py +0 -0
- runic/orm/core/descriptors.py +305 -0
- runic/orm/core/metadata.py +216 -0
- runic/orm/core/models.py +312 -0
- runic/orm/core/types.py +158 -0
- runic/orm/exceptions.py +30 -0
- runic/orm/mapper/__init__.py +0 -0
- runic/orm/mapper/mapper.py +361 -0
- runic/orm/mapper/relationship_loader.py +251 -0
- runic/orm/repository/__init__.py +12 -0
- runic/orm/repository/async_repository.py +180 -0
- runic/orm/repository/cypher.py +67 -0
- runic/orm/repository/pagination.py +105 -0
- runic/orm/repository/protocol.py +91 -0
- runic/orm/repository/repository.py +190 -0
- runic/orm/schema/__init__.py +11 -0
- runic/orm/schema/index_manager.py +195 -0
- runic/orm/schema/schema_manager.py +173 -0
- runic/orm/session/__init__.py +0 -0
- runic/orm/session/async_session.py +340 -0
- runic/orm/session/connection_pool.py +56 -0
- runic/orm/session/session.py +385 -0
- runic_py-0.2.0.dist-info/METADATA +192 -0
- runic_py-0.2.0.dist-info/RECORD +47 -0
- runic_py-0.2.0.dist-info/WHEEL +4 -0
- runic_py-0.2.0.dist-info/entry_points.txt +2 -0
- runic_py-0.2.0.dist-info/licenses/LICENSE.md +21 -0
runic/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from runic.migrate.exceptions import (
|
|
2
|
+
MultipleBasesError,
|
|
3
|
+
MultipleHeadsError,
|
|
4
|
+
ConstraintFailedError,
|
|
5
|
+
ConstraintTimeoutError,
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"ConstraintFailedError",
|
|
10
|
+
"ConstraintTimeoutError",
|
|
11
|
+
"MultipleBasesError",
|
|
12
|
+
"MultipleHeadsError",
|
|
13
|
+
]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from runic.migrate import context
|
|
2
|
+
from runic.migrate.context import IrreversibleMigrationError, Runic
|
|
3
|
+
from runic.migrate.manifest import (
|
|
4
|
+
FulltextIndex,
|
|
5
|
+
MandatoryConstraint,
|
|
6
|
+
RangeIndex,
|
|
7
|
+
SchemaManifest,
|
|
8
|
+
UniqueConstraint,
|
|
9
|
+
VectorIndex,
|
|
10
|
+
)
|
|
11
|
+
from runic.migrate.script import AmbiguousRevision, RevisionNotFound
|
|
12
|
+
from runic.migrate.service import init
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"AmbiguousRevision",
|
|
16
|
+
"FulltextIndex",
|
|
17
|
+
"IrreversibleMigrationError",
|
|
18
|
+
"MandatoryConstraint",
|
|
19
|
+
"RangeIndex",
|
|
20
|
+
"RevisionNotFound",
|
|
21
|
+
"Runic",
|
|
22
|
+
"SchemaManifest",
|
|
23
|
+
"UniqueConstraint",
|
|
24
|
+
"VectorIndex",
|
|
25
|
+
"context",
|
|
26
|
+
"init",
|
|
27
|
+
]
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Protocol, runtime_checkable
|
|
4
|
+
|
|
5
|
+
from runic.migrate.introspect import LiveSchema
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@runtime_checkable
|
|
9
|
+
class GraphAdapter(Protocol):
|
|
10
|
+
"""Protocol all graph-database adapters must satisfy.
|
|
11
|
+
|
|
12
|
+
The runic core depends only on this interface — no FalkorDB or any other
|
|
13
|
+
concrete database client leaks into shared code.
|
|
14
|
+
|
|
15
|
+
Note: ``LiveSchema`` (returned by ``read_live_schema``) is currently parsed
|
|
16
|
+
from FalkorDB's ``CALL db.indexes()`` / ``CALL db.constraints()`` output in
|
|
17
|
+
``runic.migrate.introspect``. A future adapter must override ``read_live_schema``
|
|
18
|
+
and may supply its own introspection logic.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def name(self) -> str: ...
|
|
23
|
+
|
|
24
|
+
# Low-level query execution
|
|
25
|
+
def run_query(self, query: str, params: dict | None = None) -> Any: ...
|
|
26
|
+
def run_ro_query(self, query: str) -> Any: ...
|
|
27
|
+
|
|
28
|
+
# Sibling adapter for a different graph/database on the same connection
|
|
29
|
+
def fork(self, graph_name: str) -> GraphAdapter: ...
|
|
30
|
+
|
|
31
|
+
# Version tracking
|
|
32
|
+
def get_version(self) -> list[str]: ...
|
|
33
|
+
def set_version(self, revisions: list[str]) -> None: ...
|
|
34
|
+
|
|
35
|
+
# Schema introspection
|
|
36
|
+
def read_live_schema(self) -> LiveSchema: ...
|
|
37
|
+
|
|
38
|
+
# Schema DDL
|
|
39
|
+
def create_range_index(
|
|
40
|
+
self, label: str, prop: str, *, rel: bool = False
|
|
41
|
+
) -> None: ...
|
|
42
|
+
def drop_range_index(self, label: str, prop: str, *, rel: bool = False) -> None: ...
|
|
43
|
+
def create_fulltext_index(
|
|
44
|
+
self,
|
|
45
|
+
label: str,
|
|
46
|
+
*props: str,
|
|
47
|
+
language: str | None = None,
|
|
48
|
+
stopwords: list[str] | None = None,
|
|
49
|
+
) -> None: ...
|
|
50
|
+
def drop_fulltext_index(self, label: str, *props: str) -> None: ...
|
|
51
|
+
def create_vector_index(
|
|
52
|
+
self,
|
|
53
|
+
label: str,
|
|
54
|
+
prop: str,
|
|
55
|
+
dimension: int,
|
|
56
|
+
similarity: str,
|
|
57
|
+
*,
|
|
58
|
+
m: int = 16,
|
|
59
|
+
ef_construction: int = 200,
|
|
60
|
+
ef_runtime: int = 10,
|
|
61
|
+
) -> None: ...
|
|
62
|
+
def drop_vector_index(self, label: str, prop: str) -> None: ...
|
|
63
|
+
def create_constraint(
|
|
64
|
+
self, kind: str, entity: str, label: str, props: list[str]
|
|
65
|
+
) -> None: ...
|
|
66
|
+
def drop_constraint(
|
|
67
|
+
self, kind: str, entity: str, label: str, props: list[str]
|
|
68
|
+
) -> None: ...
|
|
69
|
+
|
|
70
|
+
# Lifecycle
|
|
71
|
+
def delete_graph(self) -> None: ...
|
|
72
|
+
|
|
73
|
+
# Snapshots
|
|
74
|
+
def snapshot(self, snap_name: str) -> None: ...
|
|
75
|
+
def restore_snapshot(self, snap_name: str) -> None: ...
|
|
76
|
+
def snapshot_exists(self, snap_name: str) -> bool: ...
|
|
77
|
+
|
|
78
|
+
# Checksum & attribution tracking
|
|
79
|
+
def get_checksums(self) -> dict[str, str]: ...
|
|
80
|
+
def set_checksum(
|
|
81
|
+
self, rev_id: str, checksum: str, installed_by: str | None = None
|
|
82
|
+
) -> None: ...
|
|
83
|
+
def get_installed_by(self) -> dict[str, str]: ...
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def create_adapter(backend: str, **kwargs: Any) -> GraphAdapter:
|
|
87
|
+
"""Instantiate a named adapter from keyword arguments.
|
|
88
|
+
|
|
89
|
+
Two connection variants are supported for ``"falkordb"``:
|
|
90
|
+
|
|
91
|
+
**URL variant** — credentials embedded in the connection string::
|
|
92
|
+
|
|
93
|
+
create_adapter(
|
|
94
|
+
"falkordb", url="falkor://:mypassword@localhost:6379", graph_name="my_graph"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
**Params variant** — explicit host/port/auth kwargs::
|
|
98
|
+
|
|
99
|
+
create_adapter(
|
|
100
|
+
"falkordb",
|
|
101
|
+
host="localhost",
|
|
102
|
+
port=6379,
|
|
103
|
+
username="myuser",
|
|
104
|
+
password="mypassword",
|
|
105
|
+
graph_name="my_graph",
|
|
106
|
+
)
|
|
107
|
+
"""
|
|
108
|
+
if backend == "falkordb":
|
|
109
|
+
from runic.migrate.adapters.falkordb import FalkorDBAdapter
|
|
110
|
+
|
|
111
|
+
graph_name = kwargs["graph_name"]
|
|
112
|
+
if "url" in kwargs:
|
|
113
|
+
return FalkorDBAdapter.from_url(
|
|
114
|
+
kwargs["url"],
|
|
115
|
+
graph_name,
|
|
116
|
+
username=kwargs.get("username"),
|
|
117
|
+
password=kwargs.get("password"),
|
|
118
|
+
)
|
|
119
|
+
return FalkorDBAdapter.from_params(
|
|
120
|
+
graph_name,
|
|
121
|
+
host=kwargs.get("host", "localhost"),
|
|
122
|
+
port=int(kwargs.get("port", 6379)),
|
|
123
|
+
username=kwargs.get("username"),
|
|
124
|
+
password=kwargs.get("password"),
|
|
125
|
+
)
|
|
126
|
+
raise KeyError(f"Unknown adapter backend {backend!r}. Supported: 'falkordb'")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
__all__ = ["GraphAdapter", "create_adapter"]
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from runic.migrate import introspect
|
|
8
|
+
from runic.migrate.adapters import GraphAdapter
|
|
9
|
+
from runic.migrate.exceptions import ConstraintFailedError, ConstraintTimeoutError
|
|
10
|
+
from runic.migrate.introspect import LiveSchema
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
_POLL_RETRIES = 30
|
|
15
|
+
_POLL_INTERVAL = 0.5
|
|
16
|
+
|
|
17
|
+
_VERSION_LABEL = "_FalkorMigrateVersion"
|
|
18
|
+
_GET_VERSION_QUERY = f"MATCH (v:{_VERSION_LABEL}) RETURN v.revisions, v.revision"
|
|
19
|
+
_SET_VERSION_QUERY = (
|
|
20
|
+
f"MERGE (v:{_VERSION_LABEL} {{singleton: true}})"
|
|
21
|
+
" SET v.revisions = $revisions, v.applied_at = timestamp()"
|
|
22
|
+
)
|
|
23
|
+
_GET_TRACKING_QUERY = f"MATCH (v:{_VERSION_LABEL}) RETURN v.checksums, v.installed_by"
|
|
24
|
+
_SET_TRACKING_QUERY = (
|
|
25
|
+
f"MERGE (v:{_VERSION_LABEL} {{singleton: true}})"
|
|
26
|
+
" SET v.checksums = $checksums, v.installed_by = $installed_by"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _parse_kv_list(items: list | None) -> dict[str, str]:
|
|
31
|
+
if not items:
|
|
32
|
+
return {}
|
|
33
|
+
result: dict[str, str] = {}
|
|
34
|
+
for item in items:
|
|
35
|
+
if item:
|
|
36
|
+
k, _, v = str(item).partition(":")
|
|
37
|
+
result[k] = v
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _encode_kv_list(d: dict[str, str]) -> list[str]:
|
|
42
|
+
return [f"{k}:{v}" for k, v in d.items()]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class FalkorDBAdapter(GraphAdapter):
|
|
46
|
+
"""GraphAdapter implementation for FalkorDB (standalone or embedded via falkordblite)."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, db: Any, graph: Any) -> None:
|
|
49
|
+
self._db = db
|
|
50
|
+
self._graph = graph
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def from_url(
|
|
54
|
+
cls,
|
|
55
|
+
url: str,
|
|
56
|
+
graph_name: str,
|
|
57
|
+
*,
|
|
58
|
+
username: str | None = None,
|
|
59
|
+
password: str | None = None,
|
|
60
|
+
) -> FalkorDBAdapter:
|
|
61
|
+
from falkordb import FalkorDB
|
|
62
|
+
|
|
63
|
+
kwargs: dict = {"protocol": 2}
|
|
64
|
+
if username is not None:
|
|
65
|
+
kwargs["username"] = username
|
|
66
|
+
if password is not None:
|
|
67
|
+
kwargs["password"] = password
|
|
68
|
+
db = FalkorDB.from_url(url, **kwargs)
|
|
69
|
+
return cls(db, db.select_graph(graph_name))
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def from_params(
|
|
73
|
+
cls,
|
|
74
|
+
graph_name: str,
|
|
75
|
+
*,
|
|
76
|
+
host: str = "localhost",
|
|
77
|
+
port: int = 6379,
|
|
78
|
+
username: str | None = None,
|
|
79
|
+
password: str | None = None,
|
|
80
|
+
) -> FalkorDBAdapter:
|
|
81
|
+
from falkordb import FalkorDB
|
|
82
|
+
|
|
83
|
+
kwargs: dict = {"host": host, "port": port}
|
|
84
|
+
if username is not None:
|
|
85
|
+
kwargs["username"] = username
|
|
86
|
+
if password is not None:
|
|
87
|
+
kwargs["password"] = password
|
|
88
|
+
db = FalkorDB(**kwargs)
|
|
89
|
+
return cls(db, db.select_graph(graph_name))
|
|
90
|
+
|
|
91
|
+
def fork(self, graph_name: str) -> FalkorDBAdapter:
|
|
92
|
+
"""Return a sibling adapter on the same connection for a different graph name."""
|
|
93
|
+
return FalkorDBAdapter(self._db, self._db.select_graph(graph_name))
|
|
94
|
+
|
|
95
|
+
# ------------------------------------------------------------------
|
|
96
|
+
# GraphAdapter Protocol
|
|
97
|
+
# ------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def name(self) -> str:
|
|
101
|
+
return self._graph.name # type: ignore[no-any-return]
|
|
102
|
+
|
|
103
|
+
def run_query(self, query: str, params: dict | None = None) -> Any:
|
|
104
|
+
return self._graph.query(query, params) if params else self._graph.query(query)
|
|
105
|
+
|
|
106
|
+
def run_ro_query(self, query: str) -> Any:
|
|
107
|
+
return self._graph.ro_query(query)
|
|
108
|
+
|
|
109
|
+
def run_command(self, *args: Any) -> Any:
|
|
110
|
+
return self._db.execute_command(*args)
|
|
111
|
+
|
|
112
|
+
# ------------------------------------------------------------------
|
|
113
|
+
# Version tracking
|
|
114
|
+
# ------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def get_version(self) -> list[str]:
|
|
117
|
+
try:
|
|
118
|
+
result = self._graph.ro_query(_GET_VERSION_QUERY)
|
|
119
|
+
except Exception as exc:
|
|
120
|
+
if "empty key" in str(exc).lower():
|
|
121
|
+
return []
|
|
122
|
+
raise
|
|
123
|
+
rows = result.result_set
|
|
124
|
+
if not rows:
|
|
125
|
+
return []
|
|
126
|
+
row = rows[0]
|
|
127
|
+
col0 = row[0]
|
|
128
|
+
col1 = row[1] if len(row) > 1 else None
|
|
129
|
+
|
|
130
|
+
if isinstance(col0, list):
|
|
131
|
+
return [r for r in col0 if r is not None]
|
|
132
|
+
if isinstance(col0, str):
|
|
133
|
+
return [col0]
|
|
134
|
+
if isinstance(col1, str):
|
|
135
|
+
return [col1]
|
|
136
|
+
return []
|
|
137
|
+
|
|
138
|
+
def set_version(self, revisions: list[str]) -> None:
|
|
139
|
+
log.info("stamping versions: %s", revisions)
|
|
140
|
+
self._graph.query(_SET_VERSION_QUERY, {"revisions": revisions})
|
|
141
|
+
|
|
142
|
+
# ------------------------------------------------------------------
|
|
143
|
+
# Schema introspection
|
|
144
|
+
# ------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def read_live_schema(self) -> LiveSchema:
|
|
147
|
+
return introspect.read_live_schema(self._graph)
|
|
148
|
+
|
|
149
|
+
# ------------------------------------------------------------------
|
|
150
|
+
# Schema DDL — range indexes
|
|
151
|
+
# ------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
def create_range_index(self, label: str, prop: str, *, rel: bool = False) -> None:
|
|
154
|
+
if rel:
|
|
155
|
+
query = f"CREATE INDEX FOR ()-[r:{label}]->() ON (r.{prop})"
|
|
156
|
+
else:
|
|
157
|
+
query = f"CREATE INDEX FOR (n:{label}) ON (n.{prop})"
|
|
158
|
+
log.info("creating range index on %s.%s", label, prop)
|
|
159
|
+
self._graph.query(query)
|
|
160
|
+
|
|
161
|
+
def drop_range_index(self, label: str, prop: str, *, rel: bool = False) -> None: # noqa: ARG002
|
|
162
|
+
query = f"DROP INDEX ON :{label}({prop})"
|
|
163
|
+
log.info("dropping range index on %s.%s", label, prop)
|
|
164
|
+
self._graph.query(query)
|
|
165
|
+
|
|
166
|
+
# ------------------------------------------------------------------
|
|
167
|
+
# Schema DDL — fulltext indexes
|
|
168
|
+
# ------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
def create_fulltext_index(
|
|
171
|
+
self,
|
|
172
|
+
label: str,
|
|
173
|
+
*props: str,
|
|
174
|
+
language: str | None = None,
|
|
175
|
+
stopwords: list[str] | None = None,
|
|
176
|
+
) -> None:
|
|
177
|
+
if language or stopwords:
|
|
178
|
+
map_parts = [f"label: '{label}'"]
|
|
179
|
+
if language:
|
|
180
|
+
map_parts.append(f"language: '{language}'")
|
|
181
|
+
if stopwords:
|
|
182
|
+
sw = "[" + ", ".join(f"'{w}'" for w in stopwords) + "]"
|
|
183
|
+
map_parts.append(f"stopwords: {sw}")
|
|
184
|
+
map_literal = "{" + ", ".join(map_parts) + "}"
|
|
185
|
+
props_str = ", ".join(f"'{p}'" for p in props)
|
|
186
|
+
query = f"CALL db.idx.fulltext.createNodeIndex({map_literal}, {props_str})"
|
|
187
|
+
else:
|
|
188
|
+
props_str = ", ".join(f"'{p}'" for p in props)
|
|
189
|
+
query = f"CALL db.idx.fulltext.createNodeIndex('{label}', {props_str})"
|
|
190
|
+
log.info("creating fulltext index on %s %s", label, list(props))
|
|
191
|
+
self._graph.query(query)
|
|
192
|
+
|
|
193
|
+
def drop_fulltext_index(self, label: str, *props: str) -> None:
|
|
194
|
+
log.info("dropping fulltext index on %s %s", label, list(props))
|
|
195
|
+
for prop in props:
|
|
196
|
+
query = f"DROP FULLTEXT INDEX FOR (n:{label}) ON (n.{prop})"
|
|
197
|
+
self._graph.query(query)
|
|
198
|
+
|
|
199
|
+
# ------------------------------------------------------------------
|
|
200
|
+
# Schema DDL — vector indexes
|
|
201
|
+
# ------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
def create_vector_index(
|
|
204
|
+
self,
|
|
205
|
+
label: str,
|
|
206
|
+
prop: str,
|
|
207
|
+
dimension: int,
|
|
208
|
+
similarity: str,
|
|
209
|
+
*,
|
|
210
|
+
m: int = 16,
|
|
211
|
+
ef_construction: int = 200,
|
|
212
|
+
ef_runtime: int = 10,
|
|
213
|
+
) -> None:
|
|
214
|
+
options = (
|
|
215
|
+
f"{{dimension: {dimension}, similarityFunction: '{similarity}', "
|
|
216
|
+
f"M: {m}, efConstruction: {ef_construction}, efRuntime: {ef_runtime}}}"
|
|
217
|
+
)
|
|
218
|
+
query = f"CREATE VECTOR INDEX FOR (n:{label}) ON (n.{prop}) OPTIONS {options}"
|
|
219
|
+
log.info("creating vector index on %s.%s", label, prop)
|
|
220
|
+
self._graph.query(query)
|
|
221
|
+
|
|
222
|
+
def drop_vector_index(self, label: str, prop: str) -> None:
|
|
223
|
+
query = f"DROP VECTOR INDEX FOR (n:{label}) (n.{prop})"
|
|
224
|
+
log.info("dropping vector index on %s.%s", label, prop)
|
|
225
|
+
self._graph.query(query)
|
|
226
|
+
|
|
227
|
+
# ------------------------------------------------------------------
|
|
228
|
+
# Schema DDL — constraints
|
|
229
|
+
# ------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
def create_constraint(
|
|
232
|
+
self, kind: str, entity: str, label: str, props: list[str]
|
|
233
|
+
) -> None:
|
|
234
|
+
if kind == "UNIQUE":
|
|
235
|
+
for prop in props:
|
|
236
|
+
self.create_range_index(label, prop)
|
|
237
|
+
prop_count = str(len(props))
|
|
238
|
+
log.info("creating %s constraint on %s %s %s", kind, entity, label, props)
|
|
239
|
+
self._db.execute_command(
|
|
240
|
+
"GRAPH.CONSTRAINT",
|
|
241
|
+
"CREATE",
|
|
242
|
+
label,
|
|
243
|
+
kind,
|
|
244
|
+
entity,
|
|
245
|
+
label,
|
|
246
|
+
"PROPERTIES",
|
|
247
|
+
prop_count,
|
|
248
|
+
*props,
|
|
249
|
+
)
|
|
250
|
+
self._poll_constraint(label, props)
|
|
251
|
+
|
|
252
|
+
def _poll_constraint(self, label: str, props: list[str]) -> None:
|
|
253
|
+
for _ in range(_POLL_RETRIES):
|
|
254
|
+
result = self._graph.ro_query("CALL db.constraints()")
|
|
255
|
+
for row in result.result_set:
|
|
256
|
+
entry = row[0]
|
|
257
|
+
status = entry[4] if isinstance(entry, (list, tuple)) else str(entry)
|
|
258
|
+
if status == "FAILED":
|
|
259
|
+
raise ConstraintFailedError(f"constraint on {label}.{props} failed")
|
|
260
|
+
if status == "OPERATIONAL":
|
|
261
|
+
return
|
|
262
|
+
time.sleep(_POLL_INTERVAL)
|
|
263
|
+
raise ConstraintTimeoutError(
|
|
264
|
+
f"constraint on {label}.{props} did not become OPERATIONAL"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def drop_constraint(
|
|
268
|
+
self, kind: str, entity: str, label: str, props: list[str]
|
|
269
|
+
) -> None:
|
|
270
|
+
prop_count = str(len(props))
|
|
271
|
+
log.info("dropping %s constraint on %s %s %s", kind, entity, label, props)
|
|
272
|
+
self._db.execute_command(
|
|
273
|
+
"GRAPH.CONSTRAINT",
|
|
274
|
+
"DROP",
|
|
275
|
+
label,
|
|
276
|
+
kind,
|
|
277
|
+
entity,
|
|
278
|
+
label,
|
|
279
|
+
"PROPERTIES",
|
|
280
|
+
prop_count,
|
|
281
|
+
*props,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
# ------------------------------------------------------------------
|
|
285
|
+
# Snapshots
|
|
286
|
+
# ------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
def snapshot(self, snap_name: str) -> None:
|
|
289
|
+
# GRAPH.COPY fails on an empty key; initialize graph if it doesn't exist yet
|
|
290
|
+
if self._graph.name not in self._db.list_graphs():
|
|
291
|
+
self._graph.query("RETURN 1")
|
|
292
|
+
self._graph.copy(snap_name)
|
|
293
|
+
log.debug("snapshot taken: %s → %s", self._graph.name, snap_name)
|
|
294
|
+
|
|
295
|
+
def restore_snapshot(self, snap_name: str) -> None:
|
|
296
|
+
snap_graph = self._db.select_graph(snap_name)
|
|
297
|
+
self._graph.delete()
|
|
298
|
+
snap_graph.copy(self._graph.name)
|
|
299
|
+
snap_graph.delete()
|
|
300
|
+
log.debug("snapshot restored: %s → %s", snap_name, self._graph.name)
|
|
301
|
+
|
|
302
|
+
def snapshot_exists(self, snap_name: str) -> bool:
|
|
303
|
+
return snap_name in self._db.list_graphs()
|
|
304
|
+
|
|
305
|
+
# ------------------------------------------------------------------
|
|
306
|
+
# Checksum & attribution tracking
|
|
307
|
+
# ------------------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
def _get_tracking(self) -> tuple[dict[str, str], dict[str, str]]:
|
|
310
|
+
"""Return (checksums, installed_by) dicts from the version node."""
|
|
311
|
+
try:
|
|
312
|
+
result = self._graph.ro_query(_GET_TRACKING_QUERY)
|
|
313
|
+
except Exception as exc:
|
|
314
|
+
if "empty key" in str(exc).lower():
|
|
315
|
+
return {}, {}
|
|
316
|
+
raise
|
|
317
|
+
rows = result.result_set
|
|
318
|
+
if not rows:
|
|
319
|
+
return {}, {}
|
|
320
|
+
row = rows[0]
|
|
321
|
+
checksums = _parse_kv_list(row[0] if row[0] is not None else None)
|
|
322
|
+
installed = _parse_kv_list(
|
|
323
|
+
row[1] if len(row) > 1 and row[1] is not None else None
|
|
324
|
+
)
|
|
325
|
+
return checksums, installed
|
|
326
|
+
|
|
327
|
+
def get_checksums(self) -> dict[str, str]:
|
|
328
|
+
checksums, _ = self._get_tracking()
|
|
329
|
+
return checksums
|
|
330
|
+
|
|
331
|
+
def get_installed_by(self) -> dict[str, str]:
|
|
332
|
+
_, installed = self._get_tracking()
|
|
333
|
+
return installed
|
|
334
|
+
|
|
335
|
+
def set_checksum(
|
|
336
|
+
self, rev_id: str, checksum: str, installed_by: str | None = None
|
|
337
|
+
) -> None:
|
|
338
|
+
checksums, installed = self._get_tracking()
|
|
339
|
+
checksums[rev_id] = checksum
|
|
340
|
+
if installed_by is not None:
|
|
341
|
+
installed[rev_id] = installed_by
|
|
342
|
+
self._graph.query(
|
|
343
|
+
_SET_TRACKING_QUERY,
|
|
344
|
+
{
|
|
345
|
+
"checksums": _encode_kv_list(checksums),
|
|
346
|
+
"installed_by": _encode_kv_list(installed),
|
|
347
|
+
},
|
|
348
|
+
)
|
|
349
|
+
log.debug("recorded checksum for revision %s", rev_id)
|
|
350
|
+
|
|
351
|
+
def delete_graph(self) -> None:
|
|
352
|
+
"""Delete the underlying graph (used for ephemeral test cleanup)."""
|
|
353
|
+
self._graph.delete()
|