inputlayer-client-dev 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.
- inputlayer/__init__.py +151 -0
- inputlayer/_ast.py +133 -0
- inputlayer/_naming.py +44 -0
- inputlayer/_protocol.py +225 -0
- inputlayer/_proxy.py +244 -0
- inputlayer/aggregations.py +126 -0
- inputlayer/auth.py +70 -0
- inputlayer/client.py +178 -0
- inputlayer/client_sync.py +231 -0
- inputlayer/compiler.py +672 -0
- inputlayer/connection.py +314 -0
- inputlayer/derived.py +141 -0
- inputlayer/exceptions.py +76 -0
- inputlayer/functions.py +285 -0
- inputlayer/index.py +40 -0
- inputlayer/knowledge_graph.py +589 -0
- inputlayer/migrations/__init__.py +51 -0
- inputlayer/migrations/autodetector.py +120 -0
- inputlayer/migrations/cli.py +254 -0
- inputlayer/migrations/executor.py +95 -0
- inputlayer/migrations/loader.py +91 -0
- inputlayer/migrations/operations.py +298 -0
- inputlayer/migrations/recorder.py +44 -0
- inputlayer/migrations/state.py +107 -0
- inputlayer/migrations/writer.py +183 -0
- inputlayer/notifications.py +83 -0
- inputlayer/py.typed +0 -0
- inputlayer/relation.py +102 -0
- inputlayer/result.py +89 -0
- inputlayer/session.py +75 -0
- inputlayer/types.py +192 -0
- inputlayer_client_dev-0.1.0.dist-info/METADATA +364 -0
- inputlayer_client_dev-0.1.0.dist-info/RECORD +35 -0
- inputlayer_client_dev-0.1.0.dist-info/WHEEL +4 -0
- inputlayer_client_dev-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Migration operations - atomic schema/rule changes with forward and backward commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class CreateRelation:
|
|
11
|
+
"""Create a new relation with a typed schema."""
|
|
12
|
+
|
|
13
|
+
name: str
|
|
14
|
+
columns: list[tuple[str, str]] # [(col_name, datalog_type), ...]
|
|
15
|
+
|
|
16
|
+
def forward_commands(self) -> list[str]:
|
|
17
|
+
parts = ", ".join(f"{col}: {tp}" for col, tp in self.columns)
|
|
18
|
+
return [f"+{self.name}({parts})"]
|
|
19
|
+
|
|
20
|
+
def backward_commands(self) -> list[str]:
|
|
21
|
+
return [f".rel drop {self.name}"]
|
|
22
|
+
|
|
23
|
+
def describe(self) -> str:
|
|
24
|
+
return f"Create relation {self.name}"
|
|
25
|
+
|
|
26
|
+
def to_dict(self) -> dict[str, Any]:
|
|
27
|
+
return {"type": "CreateRelation", "name": self.name, "columns": self.columns}
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def from_dict(cls, d: dict[str, Any]) -> CreateRelation:
|
|
31
|
+
return cls(name=d["name"], columns=[tuple(c) for c in d["columns"]])
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class DropRelation:
|
|
36
|
+
"""Drop an existing relation (stores columns for reversibility)."""
|
|
37
|
+
|
|
38
|
+
name: str
|
|
39
|
+
columns: list[tuple[str, str]]
|
|
40
|
+
|
|
41
|
+
def forward_commands(self) -> list[str]:
|
|
42
|
+
return [f".rel drop {self.name}"]
|
|
43
|
+
|
|
44
|
+
def backward_commands(self) -> list[str]:
|
|
45
|
+
parts = ", ".join(f"{col}: {tp}" for col, tp in self.columns)
|
|
46
|
+
return [f"+{self.name}({parts})"]
|
|
47
|
+
|
|
48
|
+
def describe(self) -> str:
|
|
49
|
+
return f"Drop relation {self.name}"
|
|
50
|
+
|
|
51
|
+
def to_dict(self) -> dict[str, Any]:
|
|
52
|
+
return {"type": "DropRelation", "name": self.name, "columns": self.columns}
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_dict(cls, d: dict[str, Any]) -> DropRelation:
|
|
56
|
+
return cls(name=d["name"], columns=[tuple(c) for c in d["columns"]])
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class CreateRule:
|
|
61
|
+
"""Create a new rule with one or more clauses."""
|
|
62
|
+
|
|
63
|
+
name: str
|
|
64
|
+
clauses: list[str] # Compiled Datalog strings
|
|
65
|
+
|
|
66
|
+
def forward_commands(self) -> list[str]:
|
|
67
|
+
return list(self.clauses)
|
|
68
|
+
|
|
69
|
+
def backward_commands(self) -> list[str]:
|
|
70
|
+
return [f".rule drop {self.name}"]
|
|
71
|
+
|
|
72
|
+
def describe(self) -> str:
|
|
73
|
+
n = len(self.clauses)
|
|
74
|
+
return f"Create rule {self.name} ({n} clause{'s' if n != 1 else ''})"
|
|
75
|
+
|
|
76
|
+
def to_dict(self) -> dict[str, Any]:
|
|
77
|
+
return {"type": "CreateRule", "name": self.name, "clauses": self.clauses}
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def from_dict(cls, d: dict[str, Any]) -> CreateRule:
|
|
81
|
+
return cls(name=d["name"], clauses=d["clauses"])
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class DropRule:
|
|
86
|
+
"""Drop an existing rule (stores clauses for reversibility)."""
|
|
87
|
+
|
|
88
|
+
name: str
|
|
89
|
+
clauses: list[str]
|
|
90
|
+
|
|
91
|
+
def forward_commands(self) -> list[str]:
|
|
92
|
+
return [f".rule drop {self.name}"]
|
|
93
|
+
|
|
94
|
+
def backward_commands(self) -> list[str]:
|
|
95
|
+
return list(self.clauses)
|
|
96
|
+
|
|
97
|
+
def describe(self) -> str:
|
|
98
|
+
return f"Drop rule {self.name}"
|
|
99
|
+
|
|
100
|
+
def to_dict(self) -> dict[str, Any]:
|
|
101
|
+
return {"type": "DropRule", "name": self.name, "clauses": self.clauses}
|
|
102
|
+
|
|
103
|
+
@classmethod
|
|
104
|
+
def from_dict(cls, d: dict[str, Any]) -> DropRule:
|
|
105
|
+
return cls(name=d["name"], clauses=d["clauses"])
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class ReplaceRule:
|
|
110
|
+
"""Replace a rule's clauses (drop + recreate)."""
|
|
111
|
+
|
|
112
|
+
name: str
|
|
113
|
+
old_clauses: list[str]
|
|
114
|
+
new_clauses: list[str]
|
|
115
|
+
|
|
116
|
+
def forward_commands(self) -> list[str]:
|
|
117
|
+
return [f".rule drop {self.name}"] + list(self.new_clauses)
|
|
118
|
+
|
|
119
|
+
def backward_commands(self) -> list[str]:
|
|
120
|
+
return [f".rule drop {self.name}"] + list(self.old_clauses)
|
|
121
|
+
|
|
122
|
+
def describe(self) -> str:
|
|
123
|
+
return f"Replace rule {self.name}"
|
|
124
|
+
|
|
125
|
+
def to_dict(self) -> dict[str, Any]:
|
|
126
|
+
return {
|
|
127
|
+
"type": "ReplaceRule",
|
|
128
|
+
"name": self.name,
|
|
129
|
+
"old_clauses": self.old_clauses,
|
|
130
|
+
"new_clauses": self.new_clauses,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
@classmethod
|
|
134
|
+
def from_dict(cls, d: dict[str, Any]) -> ReplaceRule:
|
|
135
|
+
return cls(
|
|
136
|
+
name=d["name"],
|
|
137
|
+
old_clauses=d["old_clauses"],
|
|
138
|
+
new_clauses=d["new_clauses"],
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass(frozen=True)
|
|
143
|
+
class CreateIndex:
|
|
144
|
+
"""Create an HNSW vector index."""
|
|
145
|
+
|
|
146
|
+
name: str
|
|
147
|
+
relation: str
|
|
148
|
+
column: str
|
|
149
|
+
metric: str = "cosine"
|
|
150
|
+
m: int = 16
|
|
151
|
+
ef_construction: int = 100
|
|
152
|
+
ef_search: int = 50
|
|
153
|
+
|
|
154
|
+
def forward_commands(self) -> list[str]:
|
|
155
|
+
return [
|
|
156
|
+
f".index create {self.name} on {self.relation}({self.column}) "
|
|
157
|
+
f"type hnsw metric {self.metric} "
|
|
158
|
+
f"m {self.m} ef_construction {self.ef_construction} "
|
|
159
|
+
f"ef_search {self.ef_search}"
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
def backward_commands(self) -> list[str]:
|
|
163
|
+
return [f".index drop {self.name}"]
|
|
164
|
+
|
|
165
|
+
def describe(self) -> str:
|
|
166
|
+
return f"Create index {self.name} on {self.relation}({self.column})"
|
|
167
|
+
|
|
168
|
+
def to_dict(self) -> dict[str, Any]:
|
|
169
|
+
return {
|
|
170
|
+
"type": "CreateIndex",
|
|
171
|
+
"name": self.name,
|
|
172
|
+
"relation": self.relation,
|
|
173
|
+
"column": self.column,
|
|
174
|
+
"metric": self.metric,
|
|
175
|
+
"m": self.m,
|
|
176
|
+
"ef_construction": self.ef_construction,
|
|
177
|
+
"ef_search": self.ef_search,
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
@classmethod
|
|
181
|
+
def from_dict(cls, d: dict[str, Any]) -> CreateIndex:
|
|
182
|
+
return cls(
|
|
183
|
+
name=d["name"],
|
|
184
|
+
relation=d["relation"],
|
|
185
|
+
column=d["column"],
|
|
186
|
+
metric=d.get("metric", "cosine"),
|
|
187
|
+
m=d.get("m", 16),
|
|
188
|
+
ef_construction=d.get("ef_construction", 100),
|
|
189
|
+
ef_search=d.get("ef_search", 50),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@dataclass(frozen=True)
|
|
194
|
+
class DropIndex:
|
|
195
|
+
"""Drop an HNSW vector index (stores params for reversibility)."""
|
|
196
|
+
|
|
197
|
+
name: str
|
|
198
|
+
relation: str
|
|
199
|
+
column: str
|
|
200
|
+
metric: str = "cosine"
|
|
201
|
+
m: int = 16
|
|
202
|
+
ef_construction: int = 100
|
|
203
|
+
ef_search: int = 50
|
|
204
|
+
|
|
205
|
+
def forward_commands(self) -> list[str]:
|
|
206
|
+
return [f".index drop {self.name}"]
|
|
207
|
+
|
|
208
|
+
def backward_commands(self) -> list[str]:
|
|
209
|
+
return [
|
|
210
|
+
f".index create {self.name} on {self.relation}({self.column}) "
|
|
211
|
+
f"type hnsw metric {self.metric} "
|
|
212
|
+
f"m {self.m} ef_construction {self.ef_construction} "
|
|
213
|
+
f"ef_search {self.ef_search}"
|
|
214
|
+
]
|
|
215
|
+
|
|
216
|
+
def describe(self) -> str:
|
|
217
|
+
return f"Drop index {self.name}"
|
|
218
|
+
|
|
219
|
+
def to_dict(self) -> dict[str, Any]:
|
|
220
|
+
return {
|
|
221
|
+
"type": "DropIndex",
|
|
222
|
+
"name": self.name,
|
|
223
|
+
"relation": self.relation,
|
|
224
|
+
"column": self.column,
|
|
225
|
+
"metric": self.metric,
|
|
226
|
+
"m": self.m,
|
|
227
|
+
"ef_construction": self.ef_construction,
|
|
228
|
+
"ef_search": self.ef_search,
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
@classmethod
|
|
232
|
+
def from_dict(cls, d: dict[str, Any]) -> DropIndex:
|
|
233
|
+
return cls(
|
|
234
|
+
name=d["name"],
|
|
235
|
+
relation=d["relation"],
|
|
236
|
+
column=d["column"],
|
|
237
|
+
metric=d.get("metric", "cosine"),
|
|
238
|
+
m=d.get("m", 16),
|
|
239
|
+
ef_construction=d.get("ef_construction", 100),
|
|
240
|
+
ef_search=d.get("ef_search", 50),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@dataclass(frozen=True)
|
|
245
|
+
class RunDatalog:
|
|
246
|
+
"""Execute arbitrary Datalog commands (escape hatch)."""
|
|
247
|
+
|
|
248
|
+
forward: list[str]
|
|
249
|
+
backward: list[str]
|
|
250
|
+
|
|
251
|
+
def forward_commands(self) -> list[str]:
|
|
252
|
+
return list(self.forward)
|
|
253
|
+
|
|
254
|
+
def backward_commands(self) -> list[str]:
|
|
255
|
+
return list(self.backward)
|
|
256
|
+
|
|
257
|
+
def describe(self) -> str:
|
|
258
|
+
n = len(self.forward)
|
|
259
|
+
return f"Run {n} custom Datalog command{'s' if n != 1 else ''}"
|
|
260
|
+
|
|
261
|
+
def to_dict(self) -> dict[str, Any]:
|
|
262
|
+
return {"type": "RunDatalog", "forward": self.forward, "backward": self.backward}
|
|
263
|
+
|
|
264
|
+
@classmethod
|
|
265
|
+
def from_dict(cls, d: dict[str, Any]) -> RunDatalog:
|
|
266
|
+
return cls(forward=d["forward"], backward=d["backward"])
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# Union type for all operations
|
|
270
|
+
Operation = (
|
|
271
|
+
CreateRelation
|
|
272
|
+
| DropRelation
|
|
273
|
+
| CreateRule
|
|
274
|
+
| DropRule
|
|
275
|
+
| ReplaceRule
|
|
276
|
+
| CreateIndex
|
|
277
|
+
| DropIndex
|
|
278
|
+
| RunDatalog
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
_OPERATION_REGISTRY: dict[str, type] = {
|
|
282
|
+
"CreateRelation": CreateRelation,
|
|
283
|
+
"DropRelation": DropRelation,
|
|
284
|
+
"CreateRule": CreateRule,
|
|
285
|
+
"DropRule": DropRule,
|
|
286
|
+
"ReplaceRule": ReplaceRule,
|
|
287
|
+
"CreateIndex": CreateIndex,
|
|
288
|
+
"DropIndex": DropIndex,
|
|
289
|
+
"RunDatalog": RunDatalog,
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def operation_from_dict(d: dict[str, Any]) -> Operation:
|
|
294
|
+
"""Deserialize an operation from a dict."""
|
|
295
|
+
cls = _OPERATION_REGISTRY.get(d["type"])
|
|
296
|
+
if cls is None:
|
|
297
|
+
raise ValueError(f"Unknown operation type: {d['type']}")
|
|
298
|
+
return cls.from_dict(d)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Migration recorder - track applied migrations in the DB."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import TYPE_CHECKING, Protocol
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class KGExecutor(Protocol):
|
|
10
|
+
"""Minimal interface for executing Datalog commands."""
|
|
11
|
+
|
|
12
|
+
def execute(self, datalog: str) -> object: ...
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
MIGRATION_RELATION = "__inputlayer_migrations__"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MigrationRecorder:
|
|
19
|
+
"""Track which migrations have been applied using an internal relation."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, kg: KGExecutor) -> None:
|
|
22
|
+
self._kg = kg
|
|
23
|
+
|
|
24
|
+
def ensure_schema(self) -> None:
|
|
25
|
+
"""Create the migration tracking relation if it doesn't exist."""
|
|
26
|
+
self._kg.execute(f"+{MIGRATION_RELATION}(name: string, applied_at: string)")
|
|
27
|
+
|
|
28
|
+
def get_applied(self) -> list[str]:
|
|
29
|
+
"""Return sorted list of applied migration names."""
|
|
30
|
+
result = self._kg.execute(f"?Name, At <- {MIGRATION_RELATION}(Name, At)")
|
|
31
|
+
rows = getattr(result, "rows", []) or []
|
|
32
|
+
return sorted(str(row[0]) for row in rows)
|
|
33
|
+
|
|
34
|
+
def record_applied(self, name: str) -> None:
|
|
35
|
+
"""Record that a migration has been applied."""
|
|
36
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
37
|
+
self._kg.execute(f'+{MIGRATION_RELATION}("{name}", "{now}")')
|
|
38
|
+
|
|
39
|
+
def record_reverted(self, name: str) -> None:
|
|
40
|
+
"""Remove the record for a reverted migration."""
|
|
41
|
+
self._kg.execute(
|
|
42
|
+
f'-{MIGRATION_RELATION}(Name, At) <- '
|
|
43
|
+
f'{MIGRATION_RELATION}(Name, At), Name = "{name}"'
|
|
44
|
+
)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""ModelState - snapshot of the full schema for diffing and embedding in migrations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from inputlayer.derived import Derived
|
|
10
|
+
from inputlayer.index import HnswIndex
|
|
11
|
+
from inputlayer.relation import Relation
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ModelState:
|
|
16
|
+
"""Snapshot of all relations, rules, and indexes at a point in time."""
|
|
17
|
+
|
|
18
|
+
relations: dict[str, list[tuple[str, str]]] = field(default_factory=dict)
|
|
19
|
+
rules: dict[str, list[str]] = field(default_factory=dict)
|
|
20
|
+
indexes: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def from_models(
|
|
24
|
+
cls,
|
|
25
|
+
relations: list[type[Relation]] | None = None,
|
|
26
|
+
derived: list[type[Derived]] | None = None,
|
|
27
|
+
indexes: list[HnswIndex] | None = None,
|
|
28
|
+
) -> ModelState:
|
|
29
|
+
"""Build state by introspecting Python model classes."""
|
|
30
|
+
from inputlayer.compiler import compile_rule
|
|
31
|
+
from inputlayer.relation import Relation as RelBase
|
|
32
|
+
from inputlayer.types import python_type_to_datalog
|
|
33
|
+
|
|
34
|
+
state = cls()
|
|
35
|
+
|
|
36
|
+
# Process plain relations
|
|
37
|
+
for rel_cls in relations or []:
|
|
38
|
+
name = RelBase._resolve_name(rel_cls)
|
|
39
|
+
cols = RelBase._get_columns(rel_cls)
|
|
40
|
+
col_types = RelBase._get_column_types(rel_cls)
|
|
41
|
+
state.relations[name] = [
|
|
42
|
+
(col, python_type_to_datalog(col_types[col])) for col in cols
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
# Process derived relations (schema + rules)
|
|
46
|
+
for der_cls in derived or []:
|
|
47
|
+
name = RelBase._resolve_name(der_cls)
|
|
48
|
+
cols = RelBase._get_columns(der_cls)
|
|
49
|
+
col_types = RelBase._get_column_types(der_cls)
|
|
50
|
+
state.relations[name] = [
|
|
51
|
+
(col, python_type_to_datalog(col_types[col])) for col in cols
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
# Compile each rule clause to Datalog
|
|
55
|
+
compiled_clauses = []
|
|
56
|
+
head_columns = cols
|
|
57
|
+
for clause in der_cls.rules:
|
|
58
|
+
datalog = compile_rule(
|
|
59
|
+
name,
|
|
60
|
+
head_columns,
|
|
61
|
+
clause.select_map,
|
|
62
|
+
clause.relations,
|
|
63
|
+
clause.condition,
|
|
64
|
+
persistent=True,
|
|
65
|
+
)
|
|
66
|
+
compiled_clauses.append(datalog)
|
|
67
|
+
state.rules[name] = compiled_clauses
|
|
68
|
+
|
|
69
|
+
# Process indexes
|
|
70
|
+
for idx in indexes or []:
|
|
71
|
+
rel_name = RelBase._resolve_name(idx.relation)
|
|
72
|
+
state.indexes[idx.name] = {
|
|
73
|
+
"relation": rel_name,
|
|
74
|
+
"column": idx.column,
|
|
75
|
+
"metric": idx.metric,
|
|
76
|
+
"m": idx.m,
|
|
77
|
+
"ef_construction": idx.ef_construction,
|
|
78
|
+
"ef_search": idx.ef_search,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return state
|
|
82
|
+
|
|
83
|
+
def to_dict(self) -> dict[str, Any]:
|
|
84
|
+
"""Serialize to a plain dict for embedding in migration files."""
|
|
85
|
+
return {
|
|
86
|
+
"relations": {
|
|
87
|
+
name: [list(c) for c in cols]
|
|
88
|
+
for name, cols in self.relations.items()
|
|
89
|
+
},
|
|
90
|
+
"rules": dict(self.rules),
|
|
91
|
+
"indexes": dict(self.indexes),
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def from_dict(cls, d: dict[str, Any]) -> ModelState:
|
|
96
|
+
"""Deserialize from a dict."""
|
|
97
|
+
return cls(
|
|
98
|
+
relations={
|
|
99
|
+
name: [tuple(c) for c in cols]
|
|
100
|
+
for name, cols in d.get("relations", {}).items()
|
|
101
|
+
},
|
|
102
|
+
rules=d.get("rules", {}),
|
|
103
|
+
indexes=d.get("indexes", {}),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def is_empty(self) -> bool:
|
|
107
|
+
return not self.relations and not self.rules and not self.indexes
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Migration file writer - generates Python source for migration files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from inputlayer.migrations.operations import (
|
|
8
|
+
CreateIndex,
|
|
9
|
+
CreateRelation,
|
|
10
|
+
CreateRule,
|
|
11
|
+
DropIndex,
|
|
12
|
+
DropRelation,
|
|
13
|
+
DropRule,
|
|
14
|
+
Operation,
|
|
15
|
+
ReplaceRule,
|
|
16
|
+
RunDatalog,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _repr_str_list(items: list[str], indent: str = " ") -> str:
|
|
21
|
+
"""Render a list of strings as a formatted Python list."""
|
|
22
|
+
if not items:
|
|
23
|
+
return "[]"
|
|
24
|
+
if len(items) == 1:
|
|
25
|
+
return f"[{items[0]!r}]"
|
|
26
|
+
lines = [f"{indent}{item!r}," for item in items]
|
|
27
|
+
return "[\n" + "\n".join(lines) + f"\n{indent[:-4]}]"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _repr_columns(cols: list[tuple[str, str]], indent: str = " ") -> str:
|
|
31
|
+
"""Render column list as Python source."""
|
|
32
|
+
if not cols:
|
|
33
|
+
return "[]"
|
|
34
|
+
lines = [f'{indent}("{c}", "{t}"),' for c, t in cols]
|
|
35
|
+
return "[\n" + "\n".join(lines) + f"\n{indent[:-4]}]"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _render_operation(op: Operation) -> str:
|
|
39
|
+
"""Render a single operation as a Python constructor call."""
|
|
40
|
+
if isinstance(op, CreateRelation):
|
|
41
|
+
cols = _repr_columns(op.columns)
|
|
42
|
+
return f'ops.CreateRelation(\n name="{op.name}",\n columns={cols},\n )'
|
|
43
|
+
if isinstance(op, DropRelation):
|
|
44
|
+
cols = _repr_columns(op.columns)
|
|
45
|
+
return f'ops.DropRelation(\n name="{op.name}",\n columns={cols},\n )'
|
|
46
|
+
if isinstance(op, CreateRule):
|
|
47
|
+
clauses = _repr_str_list(op.clauses)
|
|
48
|
+
return f'ops.CreateRule(\n name="{op.name}",\n clauses={clauses},\n )'
|
|
49
|
+
if isinstance(op, DropRule):
|
|
50
|
+
clauses = _repr_str_list(op.clauses)
|
|
51
|
+
return f'ops.DropRule(\n name="{op.name}",\n clauses={clauses},\n )'
|
|
52
|
+
if isinstance(op, ReplaceRule):
|
|
53
|
+
old = _repr_str_list(op.old_clauses)
|
|
54
|
+
new = _repr_str_list(op.new_clauses)
|
|
55
|
+
return (
|
|
56
|
+
f'ops.ReplaceRule(\n'
|
|
57
|
+
f' name="{op.name}",\n'
|
|
58
|
+
f' old_clauses={old},\n'
|
|
59
|
+
f' new_clauses={new},\n'
|
|
60
|
+
f' )'
|
|
61
|
+
)
|
|
62
|
+
if isinstance(op, CreateIndex):
|
|
63
|
+
return (
|
|
64
|
+
f'ops.CreateIndex(\n'
|
|
65
|
+
f' name="{op.name}",\n'
|
|
66
|
+
f' relation="{op.relation}",\n'
|
|
67
|
+
f' column="{op.column}",\n'
|
|
68
|
+
f' metric="{op.metric}",\n'
|
|
69
|
+
f' m={op.m},\n'
|
|
70
|
+
f' ef_construction={op.ef_construction},\n'
|
|
71
|
+
f' ef_search={op.ef_search},\n'
|
|
72
|
+
f' )'
|
|
73
|
+
)
|
|
74
|
+
if isinstance(op, DropIndex):
|
|
75
|
+
return (
|
|
76
|
+
f'ops.DropIndex(\n'
|
|
77
|
+
f' name="{op.name}",\n'
|
|
78
|
+
f' relation="{op.relation}",\n'
|
|
79
|
+
f' column="{op.column}",\n'
|
|
80
|
+
f' metric="{op.metric}",\n'
|
|
81
|
+
f' m={op.m},\n'
|
|
82
|
+
f' ef_construction={op.ef_construction},\n'
|
|
83
|
+
f' ef_search={op.ef_search},\n'
|
|
84
|
+
f' )'
|
|
85
|
+
)
|
|
86
|
+
if isinstance(op, RunDatalog):
|
|
87
|
+
fwd = _repr_str_list(op.forward)
|
|
88
|
+
bwd = _repr_str_list(op.backward)
|
|
89
|
+
return f'ops.RunDatalog(\n forward={fwd},\n backward={bwd},\n )'
|
|
90
|
+
raise TypeError(f"Unknown operation type: {type(op).__name__}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _render_state(state: dict[str, Any]) -> str:
|
|
94
|
+
"""Render state dict as formatted Python source."""
|
|
95
|
+
lines = [" state = {"]
|
|
96
|
+
|
|
97
|
+
# Relations
|
|
98
|
+
lines.append(' "relations": {')
|
|
99
|
+
for name, cols in sorted(state.get("relations", {}).items()):
|
|
100
|
+
col_strs = [f'("{c}", "{t}")' for c, t in cols]
|
|
101
|
+
lines.append(f' "{name}": [{", ".join(col_strs)}],')
|
|
102
|
+
lines.append(" },")
|
|
103
|
+
|
|
104
|
+
# Rules
|
|
105
|
+
lines.append(' "rules": {')
|
|
106
|
+
for name, clauses in sorted(state.get("rules", {}).items()):
|
|
107
|
+
if len(clauses) == 1:
|
|
108
|
+
lines.append(f' "{name}": [{clauses[0]!r}],')
|
|
109
|
+
else:
|
|
110
|
+
lines.append(f' "{name}": [')
|
|
111
|
+
for c in clauses:
|
|
112
|
+
lines.append(f" {c!r},")
|
|
113
|
+
lines.append(" ],")
|
|
114
|
+
lines.append(" },")
|
|
115
|
+
|
|
116
|
+
# Indexes
|
|
117
|
+
lines.append(' "indexes": {')
|
|
118
|
+
for name, info in sorted(state.get("indexes", {}).items()):
|
|
119
|
+
lines.append(f' "{name}": {{')
|
|
120
|
+
for k, v in info.items():
|
|
121
|
+
lines.append(f' "{k}": {v!r},')
|
|
122
|
+
lines.append(" },")
|
|
123
|
+
lines.append(" },")
|
|
124
|
+
|
|
125
|
+
lines.append(" }")
|
|
126
|
+
return "\n".join(lines)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def generate_migration(
|
|
130
|
+
number: int,
|
|
131
|
+
operations: list[Operation],
|
|
132
|
+
state: dict[str, Any],
|
|
133
|
+
dependencies: list[str],
|
|
134
|
+
*,
|
|
135
|
+
name_suffix: str | None = None,
|
|
136
|
+
) -> tuple[str, str]:
|
|
137
|
+
"""Generate a migration file.
|
|
138
|
+
|
|
139
|
+
Returns (filename, content).
|
|
140
|
+
"""
|
|
141
|
+
if number == 1 and name_suffix is None:
|
|
142
|
+
name_suffix = "initial"
|
|
143
|
+
elif name_suffix is None:
|
|
144
|
+
name_suffix = "auto"
|
|
145
|
+
|
|
146
|
+
filename = f"{number:04d}_{name_suffix}.py"
|
|
147
|
+
|
|
148
|
+
# Build content
|
|
149
|
+
lines = [
|
|
150
|
+
f"# Migration: {filename}",
|
|
151
|
+
"# Auto-generated by inputlayer-migrate",
|
|
152
|
+
"",
|
|
153
|
+
"from inputlayer.migrations import Migration",
|
|
154
|
+
"from inputlayer.migrations import operations as ops",
|
|
155
|
+
"",
|
|
156
|
+
"",
|
|
157
|
+
"class M(Migration):",
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
# Dependencies
|
|
161
|
+
if dependencies:
|
|
162
|
+
deps_str = ", ".join(f'"{d}"' for d in dependencies)
|
|
163
|
+
lines.append(f" dependencies = [{deps_str}]")
|
|
164
|
+
else:
|
|
165
|
+
lines.append(" dependencies = []")
|
|
166
|
+
lines.append("")
|
|
167
|
+
|
|
168
|
+
# Operations
|
|
169
|
+
if operations:
|
|
170
|
+
lines.append(" operations = [")
|
|
171
|
+
for op in operations:
|
|
172
|
+
rendered = _render_operation(op)
|
|
173
|
+
lines.append(f" {rendered},")
|
|
174
|
+
lines.append(" ]")
|
|
175
|
+
else:
|
|
176
|
+
lines.append(" operations = []")
|
|
177
|
+
lines.append("")
|
|
178
|
+
|
|
179
|
+
# State
|
|
180
|
+
lines.append(_render_state(state))
|
|
181
|
+
lines.append("")
|
|
182
|
+
|
|
183
|
+
return filename, "\n".join(lines)
|