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,534 @@
1
+ """Generate Python migration files from operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ def _python_repr(obj: Any, indent: int = 0, base_indent: int = 4) -> str:
11
+ """Convert Python object to proper Python repr string.
12
+
13
+ Args:
14
+ obj: Object to convert
15
+ indent: Current indentation level (in spaces)
16
+ base_indent: Base indentation increment
17
+
18
+ Returns:
19
+ Python code string
20
+ """
21
+ if isinstance(obj, dict):
22
+ if not obj:
23
+ return "{}"
24
+
25
+ items = []
26
+ for key, value in obj.items():
27
+ key_repr = repr(key) if isinstance(key, str) else str(key)
28
+ value_repr = _python_repr(value, indent + base_indent, base_indent)
29
+ items.append(f"{' ' * (indent + base_indent)}{key_repr}: {value_repr}")
30
+
31
+ return "{\n" + ",\n".join(items) + f"\n{' ' * indent}" + "}"
32
+
33
+ elif isinstance(obj, list):
34
+ if not obj:
35
+ return "[]"
36
+
37
+ items = []
38
+ for item in obj:
39
+ item_repr = _python_repr(item, indent + base_indent, base_indent)
40
+ items.append(f"{' ' * (indent + base_indent)}{item_repr}")
41
+
42
+ return "[\n" + ",\n".join(items) + f"\n{' ' * indent}" + "]"
43
+
44
+ elif isinstance(obj, str):
45
+ return repr(obj)
46
+
47
+ elif isinstance(obj, bool):
48
+ return "True" if obj else "False"
49
+
50
+ elif obj is None:
51
+ return "None"
52
+
53
+ else:
54
+ return repr(obj)
55
+
56
+
57
+ def _operation_to_python(op: dict[str, Any], indent: str = " ") -> str:
58
+ """Convert a single operation to Python code.
59
+
60
+ Args:
61
+ op: Operation dictionary
62
+ indent: Indentation string
63
+
64
+ Returns:
65
+ Python code string for this operation
66
+ """
67
+ op_type = op.get("type")
68
+
69
+ if op_type == "create_enum_type":
70
+ values_repr = _python_repr(op["values"])
71
+ return (
72
+ f"{indent}ctx.create_enum_type({_python_repr(op['name'])}, {values_repr})"
73
+ )
74
+
75
+ elif op_type == "drop_enum_type":
76
+ return f"{indent}ctx.drop_enum_type({_python_repr(op['name'])})"
77
+
78
+ elif op_type == "add_enum_value":
79
+ args = f"{_python_repr(op['name'])}, {_python_repr(op['value'])}"
80
+ if fields := op.get("fields"):
81
+ return (
82
+ f"{indent}ctx.add_enum_value(\n"
83
+ f"{indent} {args},\n"
84
+ f"{indent} fields={_python_repr(fields, indent=len(indent) + 11)},\n"
85
+ f"{indent})"
86
+ )
87
+ return f"{indent}ctx.add_enum_value({args})"
88
+
89
+ elif op_type == "alter_enum_type":
90
+ message = (
91
+ f"Manual enum migration required for {op['name']}: "
92
+ f"{op['old_values']!r} -> {op['new_values']!r}. "
93
+ "Replace ctx.require_manual(...) with ctx.execute(...) statements and keep "
94
+ "ctx.alter_enum_type(...) to update migration replay state."
95
+ )
96
+ return (
97
+ f"{indent}ctx.alter_enum_type(\n"
98
+ f"{indent} {_python_repr(op['name'])},\n"
99
+ f"{indent} old_values={_python_repr(op['old_values'], indent=len(indent) + 15)},\n"
100
+ f"{indent} new_values={_python_repr(op['new_values'], indent=len(indent) + 15)},\n"
101
+ f"{indent})\n"
102
+ f"{indent}ctx.require_manual({_python_repr(message)})"
103
+ )
104
+
105
+ elif op_type == "create_table":
106
+ table = op["table"]
107
+ tname = table["name"]
108
+ # Use consistent indentation for all kwargs
109
+ inner_indent = len(indent) + 4
110
+
111
+ lines = [f"{indent}ctx.create_table(", f'{indent} "{tname}",']
112
+
113
+ fields_repr = _python_repr(table["fields"], indent=inner_indent)
114
+ lines.append(f"{indent} fields={fields_repr},")
115
+
116
+ if indexes := table.get("indexes", []):
117
+ indexes_repr = _python_repr(indexes, indent=inner_indent)
118
+ lines.append(f"{indent} indexes={indexes_repr},")
119
+
120
+ if foreign_keys := table.get("foreign_keys", []):
121
+ fk_repr = _python_repr(foreign_keys, indent=inner_indent)
122
+ lines.append(f"{indent} foreign_keys={fk_repr},")
123
+
124
+ if checks := table.get("checks", []):
125
+ checks_repr = _python_repr(checks, indent=inner_indent)
126
+ lines.append(f"{indent} checks={checks_repr},")
127
+
128
+ lines.append(f"{indent})")
129
+ return "\n".join(lines)
130
+
131
+ elif op_type == "drop_table":
132
+ return f'{indent}ctx.drop_table("{op["name"]}")'
133
+
134
+ elif op_type == "rename_table":
135
+ return f'{indent}ctx.rename_table("{op["old_name"]}", "{op["new_name"]}")'
136
+
137
+ elif op_type == "add_column":
138
+ field_repr = _python_repr(op["field"])
139
+ return f'{indent}ctx.add_column("{op["table"]}", {field_repr})'
140
+
141
+ elif op_type == "drop_column":
142
+ return f'{indent}ctx.drop_column("{op["table"]}", "{op["field"]}")'
143
+
144
+ elif op_type == "rename_column":
145
+ t, old, new = op["table"], op["old_name"], op["new_name"]
146
+ return f'{indent}ctx.rename_column("{t}", "{old}", "{new}")'
147
+
148
+ elif op_type == "alter_column":
149
+ # Handle both formats:
150
+ # 1. Rust format: old_field/new_field FieldDef dicts
151
+ # 2. Python format: column + changes dict
152
+ if "old_field" in op and "new_field" in op:
153
+ # Rust format - extract column and build changes
154
+ old_field = op["old_field"]
155
+ new_field = op["new_field"]
156
+ column = new_field["name"]
157
+ changes = {}
158
+
159
+ # Compare fields and build changes dict
160
+ if old_field.get("column_type") != new_field.get("column_type"):
161
+ changes["column_type"] = new_field.get("column_type")
162
+ if old_field.get("db_type") != new_field.get("db_type"):
163
+ changes["db_type"] = new_field.get("db_type")
164
+ if old_field.get("nullable") != new_field.get("nullable"):
165
+ changes["nullable"] = new_field["nullable"]
166
+ if old_field.get("default") != new_field.get("default"):
167
+ changes["default"] = new_field.get("default")
168
+ if old_field.get("unique") != new_field.get("unique"):
169
+ changes["unique"] = new_field["unique"]
170
+ if old_field.get("max_length") != new_field.get("max_length"):
171
+ changes["max_length"] = new_field.get("max_length")
172
+ if old_field.get("max_digits") != new_field.get("max_digits"):
173
+ changes["max_digits"] = new_field.get("max_digits")
174
+ if old_field.get("decimal_places") != new_field.get("decimal_places"):
175
+ changes["decimal_places"] = new_field.get("decimal_places")
176
+ else:
177
+ # Python format - use as-is
178
+ column = op["column"]
179
+ changes = op.get("changes", {})
180
+
181
+ ch_repr = ", ".join(f"{k}={_python_repr(v)}" for k, v in changes.items())
182
+ return f'{indent}ctx.alter_column("{op["table"]}", "{column}", {ch_repr})'
183
+
184
+ elif op_type == "create_index":
185
+ index_repr = _python_repr(op["index"])
186
+ return f'{indent}ctx.create_index("{op["table"]}", {index_repr})'
187
+
188
+ elif op_type == "drop_index":
189
+ return f'{indent}ctx.drop_index("{op["table"]}", "{op["name"]}")'
190
+
191
+ elif op_type == "add_foreign_key":
192
+ fk = op["fk"]
193
+ columns_repr = repr(fk["columns"])
194
+ ref_columns_repr = repr(fk["ref_columns"])
195
+ on_delete = fk.get("on_delete", "NO ACTION")
196
+ on_update = fk.get("on_update", "NO ACTION")
197
+ return (
198
+ f"{indent}ctx.add_foreign_key(\n"
199
+ f'{indent} "{op["table"]}",\n'
200
+ f'{indent} "{fk["name"]}",\n'
201
+ f"{indent} {columns_repr},\n"
202
+ f'{indent} "{fk["ref_table"]}",\n'
203
+ f"{indent} {ref_columns_repr},\n"
204
+ f'{indent} on_delete="{on_delete}",\n'
205
+ f'{indent} on_update="{on_update}",\n'
206
+ f"{indent})"
207
+ )
208
+
209
+ elif op_type == "drop_foreign_key":
210
+ return f'{indent}ctx.drop_foreign_key("{op["table"]}", "{op["name"]}")'
211
+
212
+ elif op_type == "add_check":
213
+ check = op["check"]
214
+ # Escape quotes in expression
215
+ expr = check["expression"].replace('"', '\\"')
216
+ return f'{indent}ctx.add_check("{op["table"]}", "{check["name"]}", "{expr}")'
217
+
218
+ elif op_type == "drop_check":
219
+ return f'{indent}ctx.drop_check("{op["table"]}", "{op["name"]}")'
220
+
221
+ return f"{indent}# Unknown operation: {op_type}"
222
+
223
+
224
+ def _infer_migration_name(operations: list[dict[str, Any]]) -> str:
225
+ """Infer migration name from operations.
226
+
227
+ Args:
228
+ operations: List of migration operations
229
+
230
+ Returns:
231
+ Migration name (e.g., "create_user_table")
232
+ """
233
+ if not operations:
234
+ return "empty"
235
+
236
+ # Take first operation as basis
237
+ first_op = operations[0]
238
+ op_type = first_op.get("type")
239
+
240
+ if op_type == "create_enum_type":
241
+ return f"create_{first_op['name']}_enum"
242
+ elif op_type == "drop_enum_type":
243
+ return f"drop_{first_op['name']}_enum"
244
+ elif op_type == "add_enum_value":
245
+ return f"add_{first_op['value']}_to_{first_op['name']}_enum"
246
+ elif op_type == "alter_enum_type":
247
+ return f"alter_{first_op['name']}_enum"
248
+ elif op_type == "create_table":
249
+ table_name = first_op["table"]["name"]
250
+ return f"create_{table_name}_table"
251
+ elif op_type == "drop_table":
252
+ return f"drop_{first_op['name']}_table"
253
+ elif op_type == "add_column":
254
+ return f"add_{first_op['field']['name']}_to_{first_op['table']}"
255
+ elif op_type == "drop_column":
256
+ return f"drop_{first_op['field']}_from_{first_op['table']}"
257
+ elif op_type == "rename_table":
258
+ return f"rename_{first_op['old_name']}_to_{first_op['new_name']}"
259
+ elif op_type == "rename_column":
260
+ return f"rename_column_{first_op['old_name']}_to_{first_op['new_name']}"
261
+
262
+ # Multiple operations - use generic name
263
+ if len(operations) > 1:
264
+ return "auto"
265
+
266
+ return "migration"
267
+
268
+
269
+ def _get_next_migration_number(migrations_dir: str | Path) -> str:
270
+ """Get next migration number.
271
+
272
+ Args:
273
+ migrations_dir: Path to migrations directory
274
+
275
+ Returns:
276
+ Next migration number as 4-digit string (e.g., "0001")
277
+ """
278
+ migrations_path = Path(migrations_dir)
279
+ if not migrations_path.exists():
280
+ return "0001"
281
+
282
+ # Find existing migrations
283
+ existing = sorted(migrations_path.glob("[0-9]*.py"))
284
+ if not existing:
285
+ return "0001"
286
+
287
+ # Extract number from last migration
288
+ last_file = existing[-1].stem
289
+ try:
290
+ last_num = int(last_file.split("_")[0])
291
+ return f"{last_num + 1:04d}"
292
+ except (ValueError, IndexError):
293
+ return "0001"
294
+
295
+
296
+ def _get_previous_migration(migrations_dir: str | Path) -> str | None:
297
+ """Get the name of the previous (latest) migration.
298
+
299
+ Args:
300
+ migrations_dir: Path to migrations directory
301
+
302
+ Returns:
303
+ Name of the previous migration (without .py) or None if no migrations exist
304
+ """
305
+ migrations_path = Path(migrations_dir)
306
+ if not migrations_path.exists():
307
+ return None
308
+
309
+ existing = sorted(migrations_path.glob("[0-9]*.py"))
310
+ if not existing:
311
+ return None
312
+
313
+ return existing[-1].stem
314
+
315
+
316
+ def generate_migration_file(
317
+ operations: list[dict[str, Any]],
318
+ migrations_dir: str | Path = "migrations",
319
+ name: str | None = None,
320
+ ) -> Path:
321
+ """Generate a Python migration file.
322
+
323
+ Args:
324
+ operations: List of migration operations (from compute_diff)
325
+ migrations_dir: Directory to write migration file
326
+ name: Optional migration name (auto-inferred if not provided)
327
+
328
+ Returns:
329
+ Path to generated migration file
330
+ """
331
+ migrations_path = Path(migrations_dir)
332
+ migrations_path.mkdir(parents=True, exist_ok=True)
333
+
334
+ # Determine migration name and number
335
+ migration_name = name or _infer_migration_name(operations)
336
+ migration_number = _get_next_migration_number(migrations_path)
337
+ filename = f"{migration_number}_{migration_name}.py"
338
+ filepath = migrations_path / filename
339
+
340
+ # Generate upgrade code
341
+ if operations:
342
+ upgrade_lines = [_operation_to_python(op) for op in operations]
343
+ upgrade_body = "\n".join(upgrade_lines)
344
+ else:
345
+ upgrade_body = " pass # No operations"
346
+
347
+ # Generate downgrade code (reverse operations)
348
+ downgrade_lines = []
349
+ for op in reversed(operations):
350
+ op_type = op.get("type")
351
+
352
+ # Generate reverse operation
353
+ if op_type == "create_enum_type":
354
+ downgrade_lines.append(
355
+ f" ctx.drop_enum_type({_python_repr(op['name'])})"
356
+ )
357
+ elif op_type == "drop_enum_type":
358
+ values = op.get("values")
359
+ if values:
360
+ values_repr = _python_repr(values)
361
+ downgrade_lines.append(
362
+ f" ctx.create_enum_type({_python_repr(op['name'])}, {values_repr})"
363
+ )
364
+ else:
365
+ message = f"Cannot recreate enum type {op['name']} without its values"
366
+ downgrade_lines.append(
367
+ f" raise RuntimeError({_python_repr(message)})"
368
+ )
369
+ elif op_type == "add_enum_value":
370
+ message = (
371
+ f"Cannot automatically remove enum value {op['value']} "
372
+ f"from {op['name']}"
373
+ )
374
+ downgrade_lines.append(f" raise RuntimeError({_python_repr(message)})")
375
+ elif op_type == "alter_enum_type":
376
+ message = (
377
+ f"Manual enum migration required for {op['name']}: "
378
+ f"{op['new_values']!r} -> {op['old_values']!r}. "
379
+ "Replace ctx.require_manual(...) with ctx.execute(...) statements and keep "
380
+ "ctx.alter_enum_type(...) to update migration replay state."
381
+ )
382
+ downgrade_lines.append(
383
+ f" ctx.alter_enum_type(\n"
384
+ f" {_python_repr(op['name'])},\n"
385
+ f" old_values={_python_repr(op['new_values'], indent=19)},\n"
386
+ f" new_values={_python_repr(op['old_values'], indent=19)},\n"
387
+ f" )\n"
388
+ f" ctx.require_manual({_python_repr(message)})"
389
+ )
390
+ elif op_type == "create_table":
391
+ downgrade_lines.append(f' ctx.drop_table("{op["table"]["name"]}")')
392
+ elif op_type == "drop_table":
393
+ # Reverse drop_table by recreating the table from stored structure
394
+ table_def = op.get("table")
395
+ if table_def:
396
+ fields_repr = _python_repr(table_def["fields"], indent=8 + 11)
397
+ indexes = table_def.get("indexes", [])
398
+ tname = table_def["name"]
399
+ if indexes:
400
+ indexes_repr = _python_repr(indexes, indent=8 + 13)
401
+ downgrade_lines.append(
402
+ f' ctx.create_table(\n "{tname}",\n'
403
+ f" fields={fields_repr},\n"
404
+ f" indexes={indexes_repr},\n )"
405
+ )
406
+ else:
407
+ downgrade_lines.append(
408
+ f' ctx.create_table(\n "{tname}",\n'
409
+ f" fields={fields_repr},\n )"
410
+ )
411
+ else:
412
+ downgrade_lines.append(
413
+ f" # TODO: Reverse drop_table for {op['name']}"
414
+ )
415
+ elif op_type == "add_column":
416
+ downgrade_lines.append(
417
+ f' ctx.drop_column("{op["table"]}", "{op["field"]["name"]}")'
418
+ )
419
+ elif op_type == "drop_column":
420
+ # Reverse drop_column by adding the column back from stored definition
421
+ field_def = op.get("field_def")
422
+ if field_def:
423
+ field_repr = _python_repr(field_def)
424
+ downgrade_lines.append(
425
+ f' ctx.add_column("{op["table"]}", {field_repr})'
426
+ )
427
+ else:
428
+ downgrade_lines.append(
429
+ f" # TODO: Reverse drop_column for {op['table']}.{op['field']}"
430
+ )
431
+ elif op_type == "rename_table":
432
+ downgrade_lines.append(
433
+ f' ctx.rename_table("{op["new_name"]}", "{op["old_name"]}")'
434
+ )
435
+ elif op_type == "rename_column":
436
+ t, new, old = op["table"], op["new_name"], op["old_name"]
437
+ downgrade_lines.append(f' ctx.rename_column("{t}", "{new}", "{old}")')
438
+ elif op_type == "create_index":
439
+ downgrade_lines.append(
440
+ f' ctx.drop_index("{op["table"]}", "{op["index"]["name"]}")'
441
+ )
442
+ elif op_type == "drop_index":
443
+ # Reverse drop_index by recreating the index from stored definition
444
+ index_def = op.get("index_def")
445
+ if index_def:
446
+ index_repr = _python_repr(index_def)
447
+ downgrade_lines.append(
448
+ f' ctx.create_index("{op["table"]}", {index_repr})'
449
+ )
450
+ else:
451
+ downgrade_lines.append(
452
+ f" # TODO: Reverse drop_index for {op['table']}.{op['index']}"
453
+ )
454
+ elif op_type == "add_foreign_key":
455
+ downgrade_lines.append(
456
+ f' ctx.drop_foreign_key("{op["table"]}", "{op["fk"]["name"]}")'
457
+ )
458
+ elif op_type == "drop_foreign_key":
459
+ # Reverse drop_foreign_key by recreating the FK from stored definition
460
+ fk_def = op.get("fk_def")
461
+ if fk_def:
462
+ columns_repr = repr(fk_def["columns"])
463
+ ref_columns_repr = repr(fk_def["ref_columns"])
464
+ on_delete = fk_def.get("on_delete", "NO ACTION")
465
+ on_update = fk_def.get("on_update", "NO ACTION")
466
+ downgrade_lines.append(
467
+ f" ctx.add_foreign_key(\n"
468
+ f' "{op["table"]}",\n'
469
+ f' "{fk_def["name"]}",\n'
470
+ f" {columns_repr},\n"
471
+ f' "{fk_def["ref_table"]}",\n'
472
+ f" {ref_columns_repr},\n"
473
+ f' on_delete="{on_delete}",\n'
474
+ f' on_update="{on_update}",\n'
475
+ f" )"
476
+ )
477
+ else:
478
+ t, n = op["table"], op["name"]
479
+ downgrade_lines.append(
480
+ f" # TODO: Reverse drop_foreign_key for {t}.{n}"
481
+ )
482
+ elif op_type == "add_check":
483
+ downgrade_lines.append(
484
+ f' ctx.drop_check("{op["table"]}", "{op["check"]["name"]}")'
485
+ )
486
+ elif op_type == "drop_check":
487
+ # Reverse drop_check by recreating the check from stored definition
488
+ check_def = op.get("check_def")
489
+ if check_def:
490
+ # Escape quotes in expression
491
+ expr = check_def["expression"].replace('"', '\\"')
492
+ t, name = op["table"], check_def["name"]
493
+ downgrade_lines.append(f' ctx.add_check("{t}", "{name}", "{expr}")')
494
+ else:
495
+ downgrade_lines.append(
496
+ f" # TODO: Reverse drop_check for {op['table']}.{op['name']}"
497
+ )
498
+
499
+ downgrade_body = "\n".join(downgrade_lines) if downgrade_lines else " pass"
500
+
501
+ # Get previous migration for dependency
502
+ previous_migration = _get_previous_migration(migrations_path)
503
+ depends_on_line = (
504
+ f'depends_on = "{previous_migration}"'
505
+ if previous_migration
506
+ else "depends_on = None"
507
+ )
508
+
509
+ # Generate file content
510
+ content = f'''"""Auto-generated migration.
511
+
512
+ Created: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
513
+ """
514
+
515
+ {depends_on_line}
516
+
517
+
518
+ def upgrade(ctx):
519
+ """Apply migration."""
520
+ {upgrade_body}
521
+
522
+
523
+ def downgrade(ctx):
524
+ """Revert migration."""
525
+ {downgrade_body}
526
+ '''
527
+
528
+ # Write file
529
+ filepath.write_text(content)
530
+
531
+ return filepath
532
+
533
+
534
+ __all__ = ["generate_migration_file"]