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 +33 -0
- runic/adapters/__init__.py +68 -0
- runic/adapters/falkordb.py +259 -0
- runic/autogen.py +242 -0
- runic/cli.py +531 -0
- runic/config.py +8 -0
- runic/context.py +258 -0
- runic/exceptions.py +6 -0
- runic/introspect.py +174 -0
- runic/manifest.py +81 -0
- runic/operations.py +298 -0
- runic/script.py +394 -0
- runic/service.py +121 -0
- runic/templates/env.py.mako +11 -0
- runic/templates/script.py.mako +25 -0
- runic/testing.py +47 -0
- runic/version.py +69 -0
- runic_migrate-0.1.0.dist-info/METADATA +120 -0
- runic_migrate-0.1.0.dist-info/RECORD +22 -0
- runic_migrate-0.1.0.dist-info/WHEEL +4 -0
- runic_migrate-0.1.0.dist-info/entry_points.txt +2 -0
- runic_migrate-0.1.0.dist-info/licenses/LICENSE.md +21 -0
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)
|