oxyde-postgres 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.
- oxyde/__init__.py +151 -0
- oxyde/_msgpack.py +3 -0
- oxyde/cli/__init__.py +7 -0
- oxyde/cli/app.py +107 -0
- oxyde/cli/codegen.py +66 -0
- oxyde/cli/config.py +84 -0
- oxyde/cli/migrations.py +536 -0
- oxyde/codegen/__init__.py +15 -0
- oxyde/codegen/stub_generator.py +1229 -0
- oxyde/core/__init__.py +90 -0
- oxyde/core/column_types.py +367 -0
- oxyde/core/ir.py +380 -0
- oxyde/core/types.py +69 -0
- oxyde/core/wrapper.py +242 -0
- oxyde/db/__init__.py +230 -0
- oxyde/db/pool.py +305 -0
- oxyde/db/registry.py +113 -0
- oxyde/db/schema.py +51 -0
- oxyde/db/transaction.py +392 -0
- oxyde/exceptions.py +89 -0
- oxyde/migrations/__init__.py +49 -0
- oxyde/migrations/config.py +187 -0
- oxyde/migrations/context.py +598 -0
- oxyde/migrations/executor.py +456 -0
- oxyde/migrations/extract.py +316 -0
- oxyde/migrations/generator.py +534 -0
- oxyde/migrations/replay.py +367 -0
- oxyde/migrations/squash.py +109 -0
- oxyde/migrations/tracker.py +162 -0
- oxyde/migrations/utils.py +145 -0
- oxyde/models/__init__.py +83 -0
- oxyde/models/base.py +871 -0
- oxyde/models/decorators.py +111 -0
- oxyde/models/field.py +204 -0
- oxyde/models/lookups.py +667 -0
- oxyde/models/metadata.py +174 -0
- oxyde/models/registry.py +170 -0
- oxyde/models/serializers.py +144 -0
- oxyde/models/utils.py +116 -0
- oxyde/py.typed +0 -0
- oxyde/queries/__init__.py +79 -0
- oxyde/queries/aggregates.py +145 -0
- oxyde/queries/base.py +249 -0
- oxyde/queries/conditions.py +92 -0
- oxyde/queries/expressions.py +171 -0
- oxyde/queries/insert.py +124 -0
- oxyde/queries/joins.py +66 -0
- oxyde/queries/manager.py +409 -0
- oxyde/queries/mixins/__init__.py +68 -0
- oxyde/queries/mixins/aggregation.py +335 -0
- oxyde/queries/mixins/debug.py +154 -0
- oxyde/queries/mixins/execution.py +701 -0
- oxyde/queries/mixins/filtering.py +144 -0
- oxyde/queries/mixins/joining.py +160 -0
- oxyde/queries/mixins/mutation.py +508 -0
- oxyde/queries/mixins/pagination.py +145 -0
- oxyde/queries/q.py +250 -0
- oxyde/queries/raw.py +114 -0
- oxyde/queries/select.py +345 -0
- oxyde/queries/typed.py +213 -0
- oxyde_postgres-0.1.0.dist-info/METADATA +141 -0
- oxyde_postgres-0.1.0.dist-info/RECORD +66 -0
- oxyde_postgres-0.1.0.dist-info/WHEEL +5 -0
- oxyde_postgres-0.1.0.dist-info/entry_points.txt +2 -0
- oxyde_postgres-0.1.0.dist-info/licenses/LICENSE +21 -0
- oxyde_postgres-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""Replay migrations in memory to build schema state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import warnings
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from oxyde.migrations.context import MigrationContext
|
|
10
|
+
from oxyde.migrations.utils import (
|
|
11
|
+
load_migration_module,
|
|
12
|
+
normalize_field_dict,
|
|
13
|
+
op_uses_legacy_fields,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _add_enum_value_to_spec(
|
|
18
|
+
spec: dict[str, Any], name: str, value: str
|
|
19
|
+
) -> dict[str, Any]:
|
|
20
|
+
if spec.get("kind") == "enum" and spec.get("name") == name:
|
|
21
|
+
updated = dict(spec)
|
|
22
|
+
values = list(updated.get("values", []))
|
|
23
|
+
if value not in values:
|
|
24
|
+
values.append(value)
|
|
25
|
+
updated["values"] = values
|
|
26
|
+
return updated
|
|
27
|
+
if spec.get("kind") == "array" and isinstance(spec.get("item"), dict):
|
|
28
|
+
updated = dict(spec)
|
|
29
|
+
updated["item"] = _add_enum_value_to_spec(spec["item"], name, value)
|
|
30
|
+
return updated
|
|
31
|
+
return spec
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _replace_enum_values_in_spec(
|
|
35
|
+
spec: dict[str, Any],
|
|
36
|
+
name: str,
|
|
37
|
+
values: list[str],
|
|
38
|
+
) -> dict[str, Any]:
|
|
39
|
+
if spec.get("kind") == "enum" and spec.get("name") == name:
|
|
40
|
+
updated = dict(spec)
|
|
41
|
+
updated["values"] = list(values)
|
|
42
|
+
return updated
|
|
43
|
+
if spec.get("kind") == "array" and isinstance(spec.get("item"), dict):
|
|
44
|
+
updated = dict(spec)
|
|
45
|
+
updated["item"] = _replace_enum_values_in_spec(spec["item"], name, values)
|
|
46
|
+
return updated
|
|
47
|
+
return spec
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SchemaState:
|
|
51
|
+
"""Represents database schema in memory.
|
|
52
|
+
|
|
53
|
+
This class applies migration operations to build a virtual schema
|
|
54
|
+
without touching the actual database.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self) -> None:
|
|
58
|
+
self.tables: dict[str, dict[str, Any]] = {}
|
|
59
|
+
|
|
60
|
+
def apply_operation(self, op: dict[str, Any]) -> None:
|
|
61
|
+
"""Apply a migration operation to the virtual schema.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
op: Operation dictionary from MigrationContext.get_collected_operations()
|
|
65
|
+
"""
|
|
66
|
+
op_type = op.get("type")
|
|
67
|
+
|
|
68
|
+
if op_type == "create_enum_type":
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
elif op_type == "drop_enum_type":
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
elif op_type == "add_enum_value":
|
|
75
|
+
enum_name = op["name"]
|
|
76
|
+
enum_value = op["value"]
|
|
77
|
+
for table in self.tables.values():
|
|
78
|
+
for field in table["fields"]:
|
|
79
|
+
if column_type := field.get("column_type"):
|
|
80
|
+
field["column_type"] = _add_enum_value_to_spec(
|
|
81
|
+
column_type,
|
|
82
|
+
enum_name,
|
|
83
|
+
enum_value,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
elif op_type == "alter_enum_type":
|
|
87
|
+
enum_name = op["name"]
|
|
88
|
+
enum_values = op["new_values"]
|
|
89
|
+
for table in self.tables.values():
|
|
90
|
+
for field in table["fields"]:
|
|
91
|
+
if column_type := field.get("column_type"):
|
|
92
|
+
field["column_type"] = _replace_enum_values_in_spec(
|
|
93
|
+
column_type,
|
|
94
|
+
enum_name,
|
|
95
|
+
enum_values,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
elif op_type == "create_table":
|
|
99
|
+
table = op["table"]
|
|
100
|
+
self.tables[table["name"]] = {
|
|
101
|
+
"name": table["name"],
|
|
102
|
+
"fields": list(table["fields"]),
|
|
103
|
+
"indexes": list(table.get("indexes", [])),
|
|
104
|
+
"foreign_keys": list(table.get("foreign_keys", [])),
|
|
105
|
+
"checks": list(table.get("checks", [])),
|
|
106
|
+
"comment": table.get("comment"),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
elif op_type == "drop_table":
|
|
110
|
+
self.tables.pop(op["name"], None)
|
|
111
|
+
|
|
112
|
+
elif op_type == "rename_table":
|
|
113
|
+
old_name = op["old_name"]
|
|
114
|
+
new_name = op["new_name"]
|
|
115
|
+
if old_name in self.tables:
|
|
116
|
+
table = self.tables.pop(old_name)
|
|
117
|
+
table["name"] = new_name
|
|
118
|
+
self.tables[new_name] = table
|
|
119
|
+
|
|
120
|
+
elif op_type == "add_column":
|
|
121
|
+
table_name = op["table"]
|
|
122
|
+
if table_name in self.tables:
|
|
123
|
+
self.tables[table_name]["fields"].append(dict(op["field"]))
|
|
124
|
+
|
|
125
|
+
elif op_type == "drop_column":
|
|
126
|
+
table_name = op["table"]
|
|
127
|
+
field_name = op["field"]
|
|
128
|
+
if table_name in self.tables:
|
|
129
|
+
self.tables[table_name]["fields"] = [
|
|
130
|
+
f
|
|
131
|
+
for f in self.tables[table_name]["fields"]
|
|
132
|
+
if f["name"] != field_name
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
elif op_type == "rename_column":
|
|
136
|
+
table_name = op["table"]
|
|
137
|
+
old_name = op["old_name"]
|
|
138
|
+
new_name = op["new_name"]
|
|
139
|
+
if table_name in self.tables:
|
|
140
|
+
for field in self.tables[table_name]["fields"]:
|
|
141
|
+
if field["name"] == old_name:
|
|
142
|
+
field["name"] = new_name
|
|
143
|
+
break
|
|
144
|
+
|
|
145
|
+
elif op_type == "alter_column":
|
|
146
|
+
table_name = op["table"]
|
|
147
|
+
column_name = op["column"]
|
|
148
|
+
changes = op.get("changes", {})
|
|
149
|
+
if table_name in self.tables:
|
|
150
|
+
for field in self.tables[table_name]["fields"]:
|
|
151
|
+
if field["name"] == column_name:
|
|
152
|
+
# Map changes keys to field keys
|
|
153
|
+
if "column_type" in changes:
|
|
154
|
+
field["column_type"] = changes["column_type"]
|
|
155
|
+
# Legacy keys from old migration files
|
|
156
|
+
if "type" in changes:
|
|
157
|
+
field["python_type"] = changes["type"]
|
|
158
|
+
field.pop("column_type", None)
|
|
159
|
+
if "python_type" in changes:
|
|
160
|
+
field["python_type"] = changes["python_type"]
|
|
161
|
+
field.pop("column_type", None)
|
|
162
|
+
if "db_type" in changes:
|
|
163
|
+
field["db_type"] = changes["db_type"]
|
|
164
|
+
if "nullable" in changes:
|
|
165
|
+
field["nullable"] = changes["nullable"]
|
|
166
|
+
if "default" in changes:
|
|
167
|
+
field["default"] = changes["default"]
|
|
168
|
+
if "unique" in changes:
|
|
169
|
+
field["unique"] = changes["unique"]
|
|
170
|
+
if "max_length" in changes:
|
|
171
|
+
field["max_length"] = changes["max_length"]
|
|
172
|
+
if "max_digits" in changes:
|
|
173
|
+
field["max_digits"] = changes["max_digits"]
|
|
174
|
+
if "decimal_places" in changes:
|
|
175
|
+
field["decimal_places"] = changes["decimal_places"]
|
|
176
|
+
break
|
|
177
|
+
|
|
178
|
+
elif op_type == "create_index":
|
|
179
|
+
table_name = op["table"]
|
|
180
|
+
if table_name in self.tables:
|
|
181
|
+
self.tables[table_name]["indexes"].append(dict(op["index"]))
|
|
182
|
+
|
|
183
|
+
elif op_type == "drop_index":
|
|
184
|
+
table_name = op["table"]
|
|
185
|
+
index_name = op["name"]
|
|
186
|
+
if table_name in self.tables:
|
|
187
|
+
self.tables[table_name]["indexes"] = [
|
|
188
|
+
idx
|
|
189
|
+
for idx in self.tables[table_name]["indexes"]
|
|
190
|
+
if idx["name"] != index_name
|
|
191
|
+
]
|
|
192
|
+
|
|
193
|
+
elif op_type == "add_foreign_key":
|
|
194
|
+
table_name = op["table"]
|
|
195
|
+
if table_name in self.tables:
|
|
196
|
+
if "foreign_keys" not in self.tables[table_name]:
|
|
197
|
+
self.tables[table_name]["foreign_keys"] = []
|
|
198
|
+
self.tables[table_name]["foreign_keys"].append(dict(op["fk"]))
|
|
199
|
+
|
|
200
|
+
elif op_type == "drop_foreign_key":
|
|
201
|
+
table_name = op["table"]
|
|
202
|
+
fk_name = op["name"]
|
|
203
|
+
if table_name in self.tables and "foreign_keys" in self.tables[table_name]:
|
|
204
|
+
self.tables[table_name]["foreign_keys"] = [
|
|
205
|
+
fk
|
|
206
|
+
for fk in self.tables[table_name]["foreign_keys"]
|
|
207
|
+
if fk["name"] != fk_name
|
|
208
|
+
]
|
|
209
|
+
|
|
210
|
+
elif op_type == "add_check":
|
|
211
|
+
table_name = op["table"]
|
|
212
|
+
if table_name in self.tables:
|
|
213
|
+
if "checks" not in self.tables[table_name]:
|
|
214
|
+
self.tables[table_name]["checks"] = []
|
|
215
|
+
self.tables[table_name]["checks"].append(dict(op["check"]))
|
|
216
|
+
|
|
217
|
+
elif op_type == "drop_check":
|
|
218
|
+
table_name = op["table"]
|
|
219
|
+
check_name = op["name"]
|
|
220
|
+
if table_name in self.tables and "checks" in self.tables[table_name]:
|
|
221
|
+
self.tables[table_name]["checks"] = [
|
|
222
|
+
c
|
|
223
|
+
for c in self.tables[table_name]["checks"]
|
|
224
|
+
if c["name"] != check_name
|
|
225
|
+
]
|
|
226
|
+
|
|
227
|
+
# Note: ctx.execute() and Python code are ignored - they don't affect schema
|
|
228
|
+
|
|
229
|
+
def to_snapshot(self) -> dict[str, Any]:
|
|
230
|
+
"""Convert to Rust-compatible snapshot format.
|
|
231
|
+
|
|
232
|
+
Normalizes legacy field dicts (python_type form from old migration
|
|
233
|
+
files) into the ColumnTypeSpec form Rust requires. This is the single
|
|
234
|
+
legacy-reading point; it becomes a hard error in 1.0.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
Snapshot dictionary compatible with Rust Snapshot structure
|
|
238
|
+
"""
|
|
239
|
+
tables: dict[str, Any] = {}
|
|
240
|
+
for name, table in self.tables.items():
|
|
241
|
+
table = dict(table)
|
|
242
|
+
table["fields"] = [normalize_field_dict(f) for f in table["fields"]]
|
|
243
|
+
# Copy mutable collections: a snapshot must not alias live state
|
|
244
|
+
for key in ("indexes", "foreign_keys", "checks"):
|
|
245
|
+
table[key] = [dict(item) for item in table.get(key, [])]
|
|
246
|
+
tables[name] = table
|
|
247
|
+
return {
|
|
248
|
+
"version": 1,
|
|
249
|
+
"tables": tables,
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _get_migration_dependency(module: Any) -> str | None:
|
|
254
|
+
"""Get the depends_on value from a migration module.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
module: Loaded migration module
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
Name of dependency migration or None
|
|
261
|
+
"""
|
|
262
|
+
return getattr(module, "depends_on", None)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _topological_sort_migrations(
|
|
266
|
+
migration_files: list[Path],
|
|
267
|
+
) -> list[Path]:
|
|
268
|
+
"""Sort migrations in topological order based on depends_on.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
migration_files: List of migration file paths
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
Migrations sorted in dependency order
|
|
275
|
+
|
|
276
|
+
Raises:
|
|
277
|
+
ValueError: If circular dependency detected
|
|
278
|
+
"""
|
|
279
|
+
# Build dependency graph
|
|
280
|
+
modules: dict[str, tuple[Path, Any]] = {}
|
|
281
|
+
for file in migration_files:
|
|
282
|
+
module = load_migration_module(file)
|
|
283
|
+
if module is not None:
|
|
284
|
+
modules[file.stem] = (file, module)
|
|
285
|
+
|
|
286
|
+
# Build adjacency list (name -> depends_on)
|
|
287
|
+
dependencies: dict[str, str | None] = {}
|
|
288
|
+
for name, (_, module) in modules.items():
|
|
289
|
+
dependencies[name] = _get_migration_dependency(module)
|
|
290
|
+
|
|
291
|
+
# Topological sort using Kahn's algorithm
|
|
292
|
+
# Count incoming edges
|
|
293
|
+
in_degree: dict[str, int] = {name: 0 for name in modules}
|
|
294
|
+
for name, dep in dependencies.items():
|
|
295
|
+
if dep is not None and dep in in_degree:
|
|
296
|
+
in_degree[name] = 1 # Has one dependency
|
|
297
|
+
|
|
298
|
+
# Start with nodes that have no dependencies
|
|
299
|
+
queue = [name for name, degree in in_degree.items() if degree == 0]
|
|
300
|
+
result: list[Path] = []
|
|
301
|
+
|
|
302
|
+
while queue:
|
|
303
|
+
# Sort queue to ensure deterministic order (by migration name)
|
|
304
|
+
queue.sort()
|
|
305
|
+
current = queue.pop(0)
|
|
306
|
+
result.append(modules[current][0])
|
|
307
|
+
|
|
308
|
+
# Find migrations that depend on current
|
|
309
|
+
for name, dep in dependencies.items():
|
|
310
|
+
if dep == current:
|
|
311
|
+
in_degree[name] -= 1
|
|
312
|
+
if in_degree[name] == 0:
|
|
313
|
+
queue.append(name)
|
|
314
|
+
|
|
315
|
+
# Check for circular dependencies
|
|
316
|
+
if len(result) != len(modules):
|
|
317
|
+
applied = {f.stem for f in result}
|
|
318
|
+
remaining = set(modules.keys()) - applied
|
|
319
|
+
raise ValueError(f"Circular dependency detected in migrations: {remaining}")
|
|
320
|
+
|
|
321
|
+
return result
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def replay_migrations(migrations_dir: str = "migrations") -> dict[str, Any]:
|
|
325
|
+
"""Replay all migrations and return final schema as snapshot.
|
|
326
|
+
|
|
327
|
+
Migrations are applied in topological order based on their depends_on field.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
migrations_dir: Directory containing migration files
|
|
331
|
+
|
|
332
|
+
Returns:
|
|
333
|
+
Schema snapshot after replaying all migrations
|
|
334
|
+
"""
|
|
335
|
+
state = SchemaState()
|
|
336
|
+
migration_files = sorted(Path(migrations_dir).glob("[0-9]*.py"))
|
|
337
|
+
|
|
338
|
+
# Sort migrations by dependencies
|
|
339
|
+
sorted_files = _topological_sort_migrations(migration_files)
|
|
340
|
+
|
|
341
|
+
for file in sorted_files:
|
|
342
|
+
module = load_migration_module(file)
|
|
343
|
+
if module is None:
|
|
344
|
+
continue
|
|
345
|
+
|
|
346
|
+
# Run upgrade() in "collect" mode
|
|
347
|
+
if hasattr(module, "upgrade"):
|
|
348
|
+
ctx = MigrationContext(mode="collect")
|
|
349
|
+
module.upgrade(ctx)
|
|
350
|
+
|
|
351
|
+
# Apply collected operations to virtual schema
|
|
352
|
+
operations = ctx.get_collected_operations()
|
|
353
|
+
if any(op_uses_legacy_fields(op) for op in operations):
|
|
354
|
+
warnings.warn(
|
|
355
|
+
f"Migration file '{file.name}' uses the legacy field format "
|
|
356
|
+
"(python_type). Support will be removed in oxyde 1.0 — "
|
|
357
|
+
"run `oxyde migrations squash` to convert.",
|
|
358
|
+
FutureWarning,
|
|
359
|
+
stacklevel=2,
|
|
360
|
+
)
|
|
361
|
+
for op in operations:
|
|
362
|
+
state.apply_operation(op)
|
|
363
|
+
|
|
364
|
+
return state.to_snapshot()
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
__all__ = ["SchemaState", "replay_migrations", "_topological_sort_migrations"]
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Squash migration history into a single initial migration.
|
|
2
|
+
|
|
3
|
+
Replays all migration files in memory, computes the final schema, and
|
|
4
|
+
replaces the whole history with one ``0001_<name>.py`` written in the
|
|
5
|
+
current (ColumnTypeSpec) format. This is the conversion path the legacy
|
|
6
|
+
format FutureWarning points to; legacy-format support is removed in 1.0.
|
|
7
|
+
|
|
8
|
+
Manual operations (``ctx.execute()`` with raw SQL) are schema-neutral and
|
|
9
|
+
are NOT carried over — affected files are reported so the user can move
|
|
10
|
+
that logic manually.
|
|
11
|
+
|
|
12
|
+
Old files are deleted after the new file is generated successfully
|
|
13
|
+
(version control is the backup). The new file is rendered into a temporary
|
|
14
|
+
directory first, so a generation failure cannot lose existing history.
|
|
15
|
+
|
|
16
|
+
For already-deployed databases the new initial migration must be recorded
|
|
17
|
+
without executing: ``oxyde migrate --fake``. Orphaned tracker records of
|
|
18
|
+
the old migration names are harmless (pending = files − applied).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import tempfile
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from oxyde.core import migration_compute_diff
|
|
29
|
+
from oxyde.migrations.context import MigrationContext
|
|
30
|
+
from oxyde.migrations.generator import generate_migration_file
|
|
31
|
+
from oxyde.migrations.replay import SchemaState, _topological_sort_migrations
|
|
32
|
+
from oxyde.migrations.utils import load_migration_module, op_uses_legacy_fields
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class SquashResult:
|
|
37
|
+
"""Outcome of a squash run."""
|
|
38
|
+
|
|
39
|
+
new_file: Path | None
|
|
40
|
+
deleted_files: list[str] = field(default_factory=list)
|
|
41
|
+
raw_sql_files: list[str] = field(default_factory=list)
|
|
42
|
+
legacy_files: list[str] = field(default_factory=list)
|
|
43
|
+
table_count: int = 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def squash_migrations(
|
|
47
|
+
migrations_dir: str | Path,
|
|
48
|
+
name: str = "squashed",
|
|
49
|
+
) -> SquashResult:
|
|
50
|
+
"""Squash all migrations in ``migrations_dir`` into one initial file.
|
|
51
|
+
|
|
52
|
+
Returns a SquashResult; ``new_file is None`` means there was nothing
|
|
53
|
+
to squash (no migration files found).
|
|
54
|
+
"""
|
|
55
|
+
migrations_path = Path(migrations_dir)
|
|
56
|
+
files = sorted(migrations_path.glob("[0-9]*.py"))
|
|
57
|
+
if not files:
|
|
58
|
+
return SquashResult(new_file=None)
|
|
59
|
+
|
|
60
|
+
# Replay history per file, tracking raw-SQL and legacy-format usage
|
|
61
|
+
state = SchemaState()
|
|
62
|
+
raw_sql_files: list[str] = []
|
|
63
|
+
legacy_files: list[str] = []
|
|
64
|
+
|
|
65
|
+
for file in _topological_sort_migrations(files):
|
|
66
|
+
module = load_migration_module(file)
|
|
67
|
+
if module is None or not hasattr(module, "upgrade"):
|
|
68
|
+
continue
|
|
69
|
+
ctx = MigrationContext(mode="collect")
|
|
70
|
+
module.upgrade(ctx)
|
|
71
|
+
if ctx.has_raw_sql:
|
|
72
|
+
raw_sql_files.append(file.name)
|
|
73
|
+
operations = ctx.get_collected_operations()
|
|
74
|
+
if any(op_uses_legacy_fields(op) for op in operations):
|
|
75
|
+
legacy_files.append(file.name)
|
|
76
|
+
for op in operations:
|
|
77
|
+
state.apply_operation(op)
|
|
78
|
+
|
|
79
|
+
# Final schema → create ops via the Rust diff (empty → state):
|
|
80
|
+
# CreateTable operations come out topologically sorted by FK deps.
|
|
81
|
+
snapshot = state.to_snapshot()
|
|
82
|
+
empty = {"version": 1, "tables": {}}
|
|
83
|
+
ops_json = migration_compute_diff(json.dumps(empty), json.dumps(snapshot))
|
|
84
|
+
ops = json.loads(ops_json)
|
|
85
|
+
|
|
86
|
+
# Render into a temp dir first: numbering restarts at 0001 and a
|
|
87
|
+
# generation failure cannot lose the existing history.
|
|
88
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
89
|
+
tmp_file = generate_migration_file(ops, migrations_dir=tmp, name=name)
|
|
90
|
+
content = tmp_file.read_text()
|
|
91
|
+
new_name = tmp_file.name
|
|
92
|
+
|
|
93
|
+
deleted = [f.name for f in files]
|
|
94
|
+
for f in files:
|
|
95
|
+
f.unlink()
|
|
96
|
+
|
|
97
|
+
new_file = migrations_path / new_name
|
|
98
|
+
new_file.write_text(content)
|
|
99
|
+
|
|
100
|
+
return SquashResult(
|
|
101
|
+
new_file=new_file,
|
|
102
|
+
deleted_files=deleted,
|
|
103
|
+
raw_sql_files=raw_sql_files,
|
|
104
|
+
legacy_files=legacy_files,
|
|
105
|
+
table_count=len(snapshot["tables"]),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
__all__ = ["SquashResult", "squash_migrations"]
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Migration history tracking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from oxyde.core.ir import build_raw_sql_ir
|
|
8
|
+
from oxyde.db.registry import get_connection as _get_connection_async
|
|
9
|
+
from oxyde.migrations.replay import _topological_sort_migrations
|
|
10
|
+
from oxyde.migrations.utils import detect_dialect, parse_query_result
|
|
11
|
+
|
|
12
|
+
MIGRATIONS_TABLE = "oxyde_migrations"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def ensure_migrations_table(db_alias: str = "default") -> None:
|
|
16
|
+
"""Create migrations tracking table if it doesn't exist.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
db_alias: Database connection alias
|
|
20
|
+
"""
|
|
21
|
+
db_conn = await _get_connection_async(db_alias)
|
|
22
|
+
|
|
23
|
+
detect_dialect(db_conn.url)
|
|
24
|
+
|
|
25
|
+
# Generate dialect-specific SQL
|
|
26
|
+
create_sql = f"""
|
|
27
|
+
CREATE TABLE IF NOT EXISTS {MIGRATIONS_TABLE} (
|
|
28
|
+
id SERIAL PRIMARY KEY,
|
|
29
|
+
name VARCHAR(255) NOT NULL UNIQUE,
|
|
30
|
+
applied_at TIMESTAMP NOT NULL
|
|
31
|
+
)
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
# Execute using IR
|
|
35
|
+
create_ir = build_raw_sql_ir(sql=create_sql)
|
|
36
|
+
await db_conn.execute(create_ir)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def get_applied_migrations(db_alias: str = "default") -> list[str]:
|
|
40
|
+
"""Get list of applied migration names.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
db_alias: Database connection alias
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
List of applied migration names (sorted by applied_at)
|
|
47
|
+
"""
|
|
48
|
+
await ensure_migrations_table(db_alias)
|
|
49
|
+
|
|
50
|
+
db_conn = await _get_connection_async(db_alias)
|
|
51
|
+
|
|
52
|
+
# Query applied migrations
|
|
53
|
+
query_sql = f"""
|
|
54
|
+
SELECT name FROM {MIGRATIONS_TABLE}
|
|
55
|
+
ORDER BY id ASC
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
query_ir = build_raw_sql_ir(sql=query_sql)
|
|
59
|
+
result_bytes = await db_conn.execute(query_ir)
|
|
60
|
+
rows = parse_query_result(result_bytes)
|
|
61
|
+
return [row["name"] for row in rows if "name" in row]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def record_migration(name: str, db_alias: str = "default") -> None:
|
|
65
|
+
"""Record that a migration has been applied.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
name: Migration name (e.g., "0001_initial")
|
|
69
|
+
db_alias: Database connection alias
|
|
70
|
+
"""
|
|
71
|
+
await ensure_migrations_table(db_alias)
|
|
72
|
+
|
|
73
|
+
db_conn = await _get_connection_async(db_alias)
|
|
74
|
+
|
|
75
|
+
detect_dialect(db_conn.url)
|
|
76
|
+
|
|
77
|
+
# Insert migration record with dialect-specific SQL
|
|
78
|
+
insert_sql = f"""
|
|
79
|
+
INSERT INTO {MIGRATIONS_TABLE} (name, applied_at)
|
|
80
|
+
VALUES ($1, NOW())
|
|
81
|
+
"""
|
|
82
|
+
params = [name]
|
|
83
|
+
|
|
84
|
+
insert_ir = build_raw_sql_ir(
|
|
85
|
+
sql=insert_sql,
|
|
86
|
+
params=params,
|
|
87
|
+
)
|
|
88
|
+
await db_conn.execute(insert_ir)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def remove_migration(name: str, db_alias: str = "default") -> None:
|
|
92
|
+
"""Remove a migration record (for rollback).
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
name: Migration name to remove
|
|
96
|
+
db_alias: Database connection alias
|
|
97
|
+
"""
|
|
98
|
+
db_conn = await _get_connection_async(db_alias)
|
|
99
|
+
|
|
100
|
+
detect_dialect(db_conn.url)
|
|
101
|
+
|
|
102
|
+
delete_sql = f"""
|
|
103
|
+
DELETE FROM {MIGRATIONS_TABLE}
|
|
104
|
+
WHERE name = $1
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
delete_ir = build_raw_sql_ir(sql=delete_sql, params=[name])
|
|
108
|
+
await db_conn.execute(delete_ir)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def get_migration_files(migrations_dir: str = "migrations") -> list[Path]:
|
|
112
|
+
"""Get list of migration files sorted by number.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
migrations_dir: Path to migrations directory
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
List of migration file paths
|
|
119
|
+
"""
|
|
120
|
+
migrations_path = Path(migrations_dir)
|
|
121
|
+
if not migrations_path.exists():
|
|
122
|
+
return []
|
|
123
|
+
|
|
124
|
+
# Find all migration files (0001_*.py, 0002_*.py, etc.)
|
|
125
|
+
migration_files = sorted(migrations_path.glob("[0-9]*.py"))
|
|
126
|
+
return _topological_sort_migrations(migration_files)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def get_pending_migrations(
|
|
130
|
+
migrations_dir: str = "migrations",
|
|
131
|
+
applied: list[str] | None = None,
|
|
132
|
+
) -> list[Path]:
|
|
133
|
+
"""Get list of pending (unapplied) migrations.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
migrations_dir: Path to migrations directory
|
|
137
|
+
applied: List of applied migration names (if None, will query database)
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
List of pending migration file paths
|
|
141
|
+
"""
|
|
142
|
+
all_migrations = get_migration_files(migrations_dir)
|
|
143
|
+
applied_set = set(applied or [])
|
|
144
|
+
|
|
145
|
+
pending = []
|
|
146
|
+
for filepath in all_migrations:
|
|
147
|
+
# Extract migration name (0001_initial.py -> 0001_initial)
|
|
148
|
+
name = filepath.stem
|
|
149
|
+
if name not in applied_set:
|
|
150
|
+
pending.append(filepath)
|
|
151
|
+
|
|
152
|
+
return pending
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
__all__ = [
|
|
156
|
+
"ensure_migrations_table",
|
|
157
|
+
"get_applied_migrations",
|
|
158
|
+
"record_migration",
|
|
159
|
+
"remove_migration",
|
|
160
|
+
"get_migration_files",
|
|
161
|
+
"get_pending_migrations",
|
|
162
|
+
]
|