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.
Files changed (66) hide show
  1. oxyde/__init__.py +151 -0
  2. oxyde/_msgpack.py +3 -0
  3. oxyde/cli/__init__.py +7 -0
  4. oxyde/cli/app.py +107 -0
  5. oxyde/cli/codegen.py +66 -0
  6. oxyde/cli/config.py +84 -0
  7. oxyde/cli/migrations.py +536 -0
  8. oxyde/codegen/__init__.py +15 -0
  9. oxyde/codegen/stub_generator.py +1229 -0
  10. oxyde/core/__init__.py +90 -0
  11. oxyde/core/column_types.py +367 -0
  12. oxyde/core/ir.py +380 -0
  13. oxyde/core/types.py +69 -0
  14. oxyde/core/wrapper.py +242 -0
  15. oxyde/db/__init__.py +230 -0
  16. oxyde/db/pool.py +305 -0
  17. oxyde/db/registry.py +113 -0
  18. oxyde/db/schema.py +51 -0
  19. oxyde/db/transaction.py +392 -0
  20. oxyde/exceptions.py +89 -0
  21. oxyde/migrations/__init__.py +49 -0
  22. oxyde/migrations/config.py +187 -0
  23. oxyde/migrations/context.py +598 -0
  24. oxyde/migrations/executor.py +456 -0
  25. oxyde/migrations/extract.py +316 -0
  26. oxyde/migrations/generator.py +534 -0
  27. oxyde/migrations/replay.py +367 -0
  28. oxyde/migrations/squash.py +109 -0
  29. oxyde/migrations/tracker.py +162 -0
  30. oxyde/migrations/utils.py +145 -0
  31. oxyde/models/__init__.py +83 -0
  32. oxyde/models/base.py +871 -0
  33. oxyde/models/decorators.py +111 -0
  34. oxyde/models/field.py +204 -0
  35. oxyde/models/lookups.py +667 -0
  36. oxyde/models/metadata.py +174 -0
  37. oxyde/models/registry.py +170 -0
  38. oxyde/models/serializers.py +144 -0
  39. oxyde/models/utils.py +116 -0
  40. oxyde/py.typed +0 -0
  41. oxyde/queries/__init__.py +79 -0
  42. oxyde/queries/aggregates.py +145 -0
  43. oxyde/queries/base.py +249 -0
  44. oxyde/queries/conditions.py +92 -0
  45. oxyde/queries/expressions.py +171 -0
  46. oxyde/queries/insert.py +124 -0
  47. oxyde/queries/joins.py +66 -0
  48. oxyde/queries/manager.py +409 -0
  49. oxyde/queries/mixins/__init__.py +68 -0
  50. oxyde/queries/mixins/aggregation.py +335 -0
  51. oxyde/queries/mixins/debug.py +154 -0
  52. oxyde/queries/mixins/execution.py +701 -0
  53. oxyde/queries/mixins/filtering.py +144 -0
  54. oxyde/queries/mixins/joining.py +160 -0
  55. oxyde/queries/mixins/mutation.py +508 -0
  56. oxyde/queries/mixins/pagination.py +145 -0
  57. oxyde/queries/q.py +250 -0
  58. oxyde/queries/raw.py +114 -0
  59. oxyde/queries/select.py +345 -0
  60. oxyde/queries/typed.py +213 -0
  61. oxyde_postgres-0.1.0.dist-info/METADATA +141 -0
  62. oxyde_postgres-0.1.0.dist-info/RECORD +66 -0
  63. oxyde_postgres-0.1.0.dist-info/WHEEL +5 -0
  64. oxyde_postgres-0.1.0.dist-info/entry_points.txt +2 -0
  65. oxyde_postgres-0.1.0.dist-info/licenses/LICENSE +21 -0
  66. 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
+ ]