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.
@@ -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)