runic-migrate 0.1.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 ADDED
@@ -0,0 +1,33 @@
1
+ from runic import context
2
+ from runic.context import IrreversibleMigrationError
3
+ from runic.exceptions import MultipleBasesError, MultipleHeadsError
4
+ from runic.manifest import (
5
+ FulltextIndex,
6
+ MandatoryConstraint,
7
+ RangeIndex,
8
+ SchemaManifest,
9
+ UniqueConstraint,
10
+ VectorIndex,
11
+ )
12
+ from runic.operations import ConstraintFailedError, ConstraintTimeoutError, op
13
+ from runic.script import AmbiguousRevision, RevisionNotFound
14
+ from runic.service import RunicService
15
+
16
+ __all__ = [
17
+ "AmbiguousRevision",
18
+ "ConstraintFailedError",
19
+ "ConstraintTimeoutError",
20
+ "FulltextIndex",
21
+ "IrreversibleMigrationError",
22
+ "MandatoryConstraint",
23
+ "MultipleBasesError",
24
+ "MultipleHeadsError",
25
+ "RangeIndex",
26
+ "RevisionNotFound",
27
+ "RunicService",
28
+ "SchemaManifest",
29
+ "UniqueConstraint",
30
+ "VectorIndex",
31
+ "context",
32
+ "op",
33
+ ]
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Protocol, runtime_checkable
4
+
5
+ from runic.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
+
16
+ @property
17
+ def name(self) -> str: ...
18
+
19
+ # Low-level query execution
20
+ def run_query(self, query: str, params: dict | None = None) -> Any: ...
21
+ def run_ro_query(self, query: str) -> Any: ...
22
+
23
+ # Version tracking
24
+ def get_version(self) -> list[str]: ...
25
+ def set_version(self, revisions: list[str]) -> None: ...
26
+
27
+ # Schema introspection
28
+ def read_live_schema(self) -> LiveSchema: ...
29
+
30
+ # Schema DDL
31
+ def create_range_index(
32
+ self, label: str, prop: str, *, rel: bool = False
33
+ ) -> None: ...
34
+ def drop_range_index(self, label: str, prop: str, *, rel: bool = False) -> None: ...
35
+ def create_fulltext_index(
36
+ self,
37
+ label: str,
38
+ *props: str,
39
+ language: str | None = None,
40
+ stopwords: list[str] | None = None,
41
+ ) -> None: ...
42
+ def drop_fulltext_index(self, label: str, *props: str) -> None: ...
43
+ def create_vector_index(
44
+ self,
45
+ label: str,
46
+ prop: str,
47
+ dimension: int,
48
+ similarity: str,
49
+ *,
50
+ m: int = 16,
51
+ ef_construction: int = 200,
52
+ ef_runtime: int = 10,
53
+ ) -> None: ...
54
+ def drop_vector_index(self, label: str, prop: str) -> None: ...
55
+ def create_constraint(
56
+ self, kind: str, entity: str, label: str, props: list[str]
57
+ ) -> None: ...
58
+ def drop_constraint(
59
+ self, kind: str, entity: str, label: str, props: list[str]
60
+ ) -> None: ...
61
+
62
+ # Snapshots
63
+ def snapshot(self, snap_name: str) -> None: ...
64
+ def restore_snapshot(self, snap_name: str) -> None: ...
65
+ def snapshot_exists(self, snap_name: str) -> bool: ...
66
+
67
+
68
+ __all__ = ["GraphAdapter"]
@@ -0,0 +1,259 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import time
5
+ from typing import Any
6
+
7
+ from runic import introspect
8
+ from runic.adapters import GraphAdapter
9
+ from runic.introspect import LiveSchema
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+ _POLL_RETRIES = 30
14
+ _POLL_INTERVAL = 0.5
15
+
16
+ _VERSION_LABEL = "_FalkorMigrateVersion"
17
+ _GET_VERSION_QUERY = f"MATCH (v:{_VERSION_LABEL}) RETURN v.revisions, v.revision"
18
+ _SET_VERSION_QUERY = (
19
+ f"MERGE (v:{_VERSION_LABEL} {{singleton: true}})"
20
+ " SET v.revisions = $revisions, v.applied_at = timestamp()"
21
+ )
22
+
23
+
24
+ class ConstraintFailedError(Exception):
25
+ pass
26
+
27
+
28
+ class ConstraintTimeoutError(Exception):
29
+ pass
30
+
31
+
32
+ class FalkorDBAdapter(GraphAdapter):
33
+ """GraphAdapter implementation for FalkorDB (standalone or embedded via falkordblite)."""
34
+
35
+ def __init__(self, db: Any, graph: Any) -> None:
36
+ self._db = db
37
+ self._graph = graph
38
+
39
+ @classmethod
40
+ def from_url(cls, url: str, graph_name: str) -> FalkorDBAdapter:
41
+ from falkordb import FalkorDB
42
+
43
+ db = FalkorDB.from_url(url)
44
+ return cls(db, db.select_graph(graph_name))
45
+
46
+ def fork(self, graph_name: str) -> FalkorDBAdapter:
47
+ """Return a sibling adapter on the same connection for a different graph name."""
48
+ return FalkorDBAdapter(self._db, self._db.select_graph(graph_name))
49
+
50
+ # ------------------------------------------------------------------
51
+ # GraphAdapter Protocol
52
+ # ------------------------------------------------------------------
53
+
54
+ @property
55
+ def name(self) -> str:
56
+ return self._graph.name # type: ignore[no-any-return]
57
+
58
+ def run_query(self, query: str, params: dict | None = None) -> Any:
59
+ return self._graph.query(query, params) if params else self._graph.query(query)
60
+
61
+ def run_ro_query(self, query: str) -> Any:
62
+ return self._graph.ro_query(query)
63
+
64
+ # ------------------------------------------------------------------
65
+ # Version tracking
66
+ # ------------------------------------------------------------------
67
+
68
+ def get_version(self) -> list[str]:
69
+ try:
70
+ result = self._graph.ro_query(_GET_VERSION_QUERY)
71
+ except Exception as exc:
72
+ if "empty key" in str(exc).lower():
73
+ return []
74
+ raise
75
+ rows = result.result_set
76
+ if not rows:
77
+ return []
78
+ row = rows[0]
79
+ col0 = row[0]
80
+ col1 = row[1] if len(row) > 1 else None
81
+
82
+ if isinstance(col0, list):
83
+ return [r for r in col0 if r is not None]
84
+ if isinstance(col0, str):
85
+ return [col0]
86
+ if isinstance(col1, str):
87
+ return [col1]
88
+ return []
89
+
90
+ def set_version(self, revisions: list[str]) -> None:
91
+ log.info("stamping versions: %s", revisions)
92
+ self._graph.query(_SET_VERSION_QUERY, {"revisions": revisions})
93
+
94
+ # ------------------------------------------------------------------
95
+ # Schema introspection
96
+ # ------------------------------------------------------------------
97
+
98
+ def read_live_schema(self) -> LiveSchema:
99
+ return introspect.read_live_schema(self._graph)
100
+
101
+ # ------------------------------------------------------------------
102
+ # Schema DDL — range indexes
103
+ # ------------------------------------------------------------------
104
+
105
+ def create_range_index(self, label: str, prop: str, *, rel: bool = False) -> None:
106
+ if rel:
107
+ query = f"CREATE INDEX FOR ()-[r:{label}]->() ON (r.{prop})"
108
+ else:
109
+ query = f"CREATE INDEX FOR (n:{label}) ON (n.{prop})"
110
+ log.info("creating range index on %s.%s", label, prop)
111
+ self._graph.query(query)
112
+
113
+ def drop_range_index(self, label: str, prop: str, *, rel: bool = False) -> None: # noqa: ARG002
114
+ query = f"DROP INDEX ON :{label}({prop})"
115
+ log.info("dropping range index on %s.%s", label, prop)
116
+ self._graph.query(query)
117
+
118
+ # ------------------------------------------------------------------
119
+ # Schema DDL — fulltext indexes
120
+ # ------------------------------------------------------------------
121
+
122
+ def create_fulltext_index(
123
+ self,
124
+ label: str,
125
+ *props: str,
126
+ language: str | None = None,
127
+ stopwords: list[str] | None = None,
128
+ ) -> None:
129
+ if language or stopwords:
130
+ map_parts = [f"label: '{label}'"]
131
+ if language:
132
+ map_parts.append(f"language: '{language}'")
133
+ if stopwords:
134
+ sw = "[" + ", ".join(f"'{w}'" for w in stopwords) + "]"
135
+ map_parts.append(f"stopwords: {sw}")
136
+ map_literal = "{" + ", ".join(map_parts) + "}"
137
+ props_str = ", ".join(f"'{p}'" for p in props)
138
+ query = f"CALL db.idx.fulltext.createNodeIndex({map_literal}, {props_str})"
139
+ else:
140
+ props_str = ", ".join(f"'{p}'" for p in props)
141
+ query = f"CALL db.idx.fulltext.createNodeIndex('{label}', {props_str})"
142
+ log.info("creating fulltext index on %s %s", label, list(props))
143
+ self._graph.query(query)
144
+
145
+ def drop_fulltext_index(self, label: str, *props: str) -> None:
146
+ log.info("dropping fulltext index on %s %s", label, list(props))
147
+ for prop in props:
148
+ query = f"DROP FULLTEXT INDEX FOR (n:{label}) ON (n.{prop})"
149
+ self._graph.query(query)
150
+
151
+ # ------------------------------------------------------------------
152
+ # Schema DDL — vector indexes
153
+ # ------------------------------------------------------------------
154
+
155
+ def create_vector_index(
156
+ self,
157
+ label: str,
158
+ prop: str,
159
+ dimension: int,
160
+ similarity: str,
161
+ *,
162
+ m: int = 16,
163
+ ef_construction: int = 200,
164
+ ef_runtime: int = 10,
165
+ ) -> None:
166
+ options = (
167
+ f"{{dimension: {dimension}, similarityFunction: '{similarity}', "
168
+ f"M: {m}, efConstruction: {ef_construction}, efRuntime: {ef_runtime}}}"
169
+ )
170
+ query = f"CREATE VECTOR INDEX FOR (n:{label}) ON (n.{prop}) OPTIONS {options}"
171
+ log.info("creating vector index on %s.%s", label, prop)
172
+ self._graph.query(query)
173
+
174
+ def drop_vector_index(self, label: str, prop: str) -> None:
175
+ query = f"DROP VECTOR INDEX FOR (n:{label}) (n.{prop})"
176
+ log.info("dropping vector index on %s.%s", label, prop)
177
+ self._graph.query(query)
178
+
179
+ # ------------------------------------------------------------------
180
+ # Schema DDL — constraints
181
+ # ------------------------------------------------------------------
182
+
183
+ def create_constraint(
184
+ self, kind: str, entity: str, label: str, props: list[str]
185
+ ) -> None:
186
+ if kind == "UNIQUE":
187
+ for prop in props:
188
+ self.create_range_index(label, prop)
189
+ prop_count = str(len(props))
190
+ log.info("creating %s constraint on %s %s %s", kind, entity, label, props)
191
+ self._db.execute_command(
192
+ "GRAPH.CONSTRAINT",
193
+ "CREATE",
194
+ label,
195
+ kind,
196
+ entity,
197
+ label,
198
+ "PROPERTIES",
199
+ prop_count,
200
+ *props,
201
+ )
202
+ self._poll_constraint(label, props)
203
+
204
+ def _poll_constraint(self, label: str, props: list[str]) -> None:
205
+ for _ in range(_POLL_RETRIES):
206
+ result = self._graph.ro_query("CALL db.constraints()")
207
+ for row in result.result_set:
208
+ entry = row[0]
209
+ status = entry[4] if isinstance(entry, (list, tuple)) else str(entry)
210
+ if status == "FAILED":
211
+ raise ConstraintFailedError(f"constraint on {label}.{props} failed")
212
+ if status == "OPERATIONAL":
213
+ return
214
+ time.sleep(_POLL_INTERVAL)
215
+ raise ConstraintTimeoutError(
216
+ f"constraint on {label}.{props} did not become OPERATIONAL"
217
+ )
218
+
219
+ def drop_constraint(
220
+ self, kind: str, entity: str, label: str, props: list[str]
221
+ ) -> None:
222
+ prop_count = str(len(props))
223
+ log.info("dropping %s constraint on %s %s %s", kind, entity, label, props)
224
+ self._db.execute_command(
225
+ "GRAPH.CONSTRAINT",
226
+ "DROP",
227
+ label,
228
+ kind,
229
+ entity,
230
+ label,
231
+ "PROPERTIES",
232
+ prop_count,
233
+ *props,
234
+ )
235
+
236
+ # ------------------------------------------------------------------
237
+ # Snapshots
238
+ # ------------------------------------------------------------------
239
+
240
+ def snapshot(self, snap_name: str) -> None:
241
+ # GRAPH.COPY fails on an empty key; initialize graph if it doesn't exist yet
242
+ if self._graph.name not in self._db.list_graphs():
243
+ self._graph.query("RETURN 1")
244
+ self._graph.copy(snap_name)
245
+ log.debug("snapshot taken: %s → %s", self._graph.name, snap_name)
246
+
247
+ def restore_snapshot(self, snap_name: str) -> None:
248
+ snap_graph = self._db.select_graph(snap_name)
249
+ self._graph.delete()
250
+ snap_graph.copy(self._graph.name)
251
+ snap_graph.delete()
252
+ log.debug("snapshot restored: %s → %s", snap_name, self._graph.name)
253
+
254
+ def snapshot_exists(self, snap_name: str) -> bool:
255
+ return snap_name in self._db.list_graphs()
256
+
257
+ def delete_graph(self) -> None:
258
+ """Delete the underlying graph (used for ephemeral test cleanup)."""
259
+ self._graph.delete()
runic/autogen.py ADDED
@@ -0,0 +1,242 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from dataclasses import dataclass
5
+ from typing import Any, Literal
6
+
7
+ from runic.introspect import LiveSchema
8
+ from runic.manifest import (
9
+ FulltextIndex,
10
+ MandatoryConstraint,
11
+ RangeIndex,
12
+ SchemaManifest,
13
+ UniqueConstraint,
14
+ VectorIndex,
15
+ )
16
+
17
+ log = logging.getLogger(__name__)
18
+
19
+ _AUTOGEN_COMMENT = " # AUTOGENERATED — review before applying; cannot detect renames"
20
+
21
+
22
+ @dataclass
23
+ class DiffOp:
24
+ action: Literal["create", "drop"]
25
+ op_call: str
26
+ inverse_call: str
27
+
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Canonical key helpers
31
+ # ---------------------------------------------------------------------------
32
+
33
+
34
+ def _range_key(idx: RangeIndex) -> tuple:
35
+ entity = "RELATIONSHIP" if idx.rel else "NODE"
36
+ return ("range", entity, idx.label, idx.prop)
37
+
38
+
39
+ def _fulltext_key(idx: FulltextIndex) -> tuple:
40
+ return ("fulltext", "NODE", idx.label, idx.props)
41
+
42
+
43
+ def _vector_key(idx: VectorIndex) -> tuple:
44
+ return ("vector", "NODE", idx.label, idx.prop)
45
+
46
+
47
+ def _constraint_key(c: UniqueConstraint | MandatoryConstraint) -> tuple:
48
+ kind = "UNIQUE" if isinstance(c, UniqueConstraint) else "MANDATORY"
49
+ return (kind, c.entity, c.label, c.props)
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # op call renderers
54
+ # ---------------------------------------------------------------------------
55
+
56
+
57
+ def _range_create(idx: RangeIndex) -> str:
58
+ rel_arg = ", rel=True" if idx.rel else ""
59
+ return f'op.create_range_index("{idx.label}", "{idx.prop}"{rel_arg})'
60
+
61
+
62
+ def _range_drop(idx: RangeIndex) -> str:
63
+ rel_arg = ", rel=True" if idx.rel else ""
64
+ return f'op.drop_range_index("{idx.label}", "{idx.prop}"{rel_arg})'
65
+
66
+
67
+ def _fulltext_create(idx: FulltextIndex) -> str:
68
+ props_args = ", ".join(f'"{p}"' for p in idx.props)
69
+ extra: list[str] = []
70
+ if idx.language:
71
+ extra.append(f'language="{idx.language}"')
72
+ if idx.stopwords:
73
+ sw = "[" + ", ".join(f'"{w}"' for w in idx.stopwords) + "]"
74
+ extra.append(f"stopwords={sw}")
75
+ extra_str = (", " + ", ".join(extra)) if extra else ""
76
+ return f'op.create_fulltext_index("{idx.label}", {props_args}{extra_str})'
77
+
78
+
79
+ def _fulltext_drop(idx: FulltextIndex) -> str:
80
+ props_args = ", ".join(f'"{p}"' for p in idx.props)
81
+ return f'op.drop_fulltext_index("{idx.label}", {props_args})'
82
+
83
+
84
+ def _vector_create(idx: VectorIndex) -> str:
85
+ return (
86
+ f'op.create_vector_index("{idx.label}", "{idx.prop}", '
87
+ f'{idx.dimension}, "{idx.similarity}", '
88
+ f"m={idx.m}, ef_construction={idx.ef_construction}, ef_runtime={idx.ef_runtime})"
89
+ )
90
+
91
+
92
+ def _vector_drop(idx: VectorIndex) -> str:
93
+ return f'op.drop_vector_index("{idx.label}", "{idx.prop}")'
94
+
95
+
96
+ def _constraint_create(c: UniqueConstraint | MandatoryConstraint) -> str:
97
+ kind = "UNIQUE" if isinstance(c, UniqueConstraint) else "MANDATORY"
98
+ props_repr = "[" + ", ".join(f'"{p}"' for p in c.props) + "]"
99
+ return f'op.create_constraint("{kind}", "{c.entity}", "{c.label}", {props_repr})'
100
+
101
+
102
+ def _constraint_drop(c: UniqueConstraint | MandatoryConstraint) -> str:
103
+ kind = "UNIQUE" if isinstance(c, UniqueConstraint) else "MANDATORY"
104
+ props_repr = "[" + ", ".join(f'"{p}"' for p in c.props) + "]"
105
+ return f'op.drop_constraint("{kind}", "{c.entity}", "{c.label}", {props_repr})'
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # Diff engine
110
+ # ---------------------------------------------------------------------------
111
+
112
+
113
+ def diff_schema(manifest: SchemaManifest, live: LiveSchema) -> list[DiffOp]:
114
+ ops: list[DiffOp] = []
115
+
116
+ # Build manifest lookup by canonical key
117
+ manifest_range: dict[tuple, RangeIndex] = {
118
+ _range_key(i): i for i in manifest.range_indexes
119
+ }
120
+ manifest_fulltext: dict[tuple, FulltextIndex] = {
121
+ _fulltext_key(i): i for i in manifest.fulltext_indexes
122
+ }
123
+ manifest_vector: dict[tuple, VectorIndex] = {
124
+ _vector_key(i): i for i in manifest.vector_indexes
125
+ }
126
+ manifest_constraints: dict[tuple, Any] = {
127
+ _constraint_key(c): c for c in manifest.constraints
128
+ }
129
+
130
+ live_range: dict[tuple, RangeIndex] = {_range_key(i): i for i in live.range_indexes}
131
+ live_fulltext: dict[tuple, FulltextIndex] = {
132
+ _fulltext_key(i): i for i in live.fulltext_indexes
133
+ }
134
+ live_vector: dict[tuple, VectorIndex] = {
135
+ _vector_key(i): i for i in live.vector_indexes
136
+ }
137
+ live_constraints: dict[tuple, Any] = {
138
+ _constraint_key(c): c for c in live.constraints
139
+ }
140
+
141
+ # --- DROP ops (live - manifest) ---
142
+ # Drop order: constraints first (before their backing indexes), then indexes
143
+
144
+ # Drop constraints not in manifest
145
+ for key, c in live_constraints.items():
146
+ if key not in manifest_constraints:
147
+ ops.append(
148
+ DiffOp(
149
+ action="drop",
150
+ op_call=_constraint_drop(c),
151
+ inverse_call=_constraint_create(c),
152
+ )
153
+ )
154
+
155
+ # Drop indexes not in manifest
156
+ for key, idx in live_range.items():
157
+ if key not in manifest_range:
158
+ ops.append(
159
+ DiffOp(
160
+ action="drop",
161
+ op_call=_range_drop(idx),
162
+ inverse_call=_range_create(idx),
163
+ )
164
+ )
165
+ for key, idx in live_fulltext.items():
166
+ if key not in manifest_fulltext:
167
+ ops.append(
168
+ DiffOp(
169
+ action="drop",
170
+ op_call=_fulltext_drop(idx),
171
+ inverse_call=_fulltext_create(idx),
172
+ )
173
+ )
174
+ for key, idx in live_vector.items():
175
+ if key not in manifest_vector:
176
+ ops.append(
177
+ DiffOp(
178
+ action="drop",
179
+ op_call=_vector_drop(idx),
180
+ inverse_call=_vector_create(idx),
181
+ )
182
+ )
183
+
184
+ # --- CREATE ops (manifest - live) ---
185
+ # Create order: indexes first (UNIQUE constraint requires prior range index), then constraints
186
+
187
+ for key, idx in manifest_range.items():
188
+ if key not in live_range:
189
+ ops.append(
190
+ DiffOp(
191
+ action="create",
192
+ op_call=_range_create(idx),
193
+ inverse_call=_range_drop(idx),
194
+ )
195
+ )
196
+ for key, idx in manifest_fulltext.items():
197
+ if key not in live_fulltext:
198
+ ops.append(
199
+ DiffOp(
200
+ action="create",
201
+ op_call=_fulltext_create(idx),
202
+ inverse_call=_fulltext_drop(idx),
203
+ )
204
+ )
205
+ for key, idx in manifest_vector.items():
206
+ if key not in live_vector:
207
+ ops.append(
208
+ DiffOp(
209
+ action="create",
210
+ op_call=_vector_create(idx),
211
+ inverse_call=_vector_drop(idx),
212
+ )
213
+ )
214
+ for key, c in manifest_constraints.items():
215
+ if key not in live_constraints:
216
+ ops.append(
217
+ DiffOp(
218
+ action="create",
219
+ op_call=_constraint_create(c),
220
+ inverse_call=_constraint_drop(c),
221
+ )
222
+ )
223
+
224
+ log.debug("autogen diff: %d ops", len(ops))
225
+ return ops
226
+
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # Body renderers
230
+ # ---------------------------------------------------------------------------
231
+
232
+
233
+ def render_upgrade_body(ops: list[DiffOp]) -> str:
234
+ lines = [_AUTOGEN_COMMENT]
235
+ lines.extend(f" {op.op_call}" for op in ops)
236
+ return "\n".join(lines)
237
+
238
+
239
+ def render_downgrade_body(ops: list[DiffOp]) -> str:
240
+ lines = [_AUTOGEN_COMMENT]
241
+ lines.extend(f" {op.inverse_call}" for op in reversed(ops))
242
+ return "\n".join(lines)