sqlspec 0.26.0__py3-none-any.whl → 0.28.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.

Potentially problematic release.


This version of sqlspec might be problematic. Click here for more details.

Files changed (212) hide show
  1. sqlspec/__init__.py +7 -15
  2. sqlspec/_serialization.py +55 -25
  3. sqlspec/_typing.py +155 -52
  4. sqlspec/adapters/adbc/_types.py +1 -1
  5. sqlspec/adapters/adbc/adk/__init__.py +5 -0
  6. sqlspec/adapters/adbc/adk/store.py +880 -0
  7. sqlspec/adapters/adbc/config.py +62 -12
  8. sqlspec/adapters/adbc/data_dictionary.py +74 -2
  9. sqlspec/adapters/adbc/driver.py +226 -58
  10. sqlspec/adapters/adbc/litestar/__init__.py +5 -0
  11. sqlspec/adapters/adbc/litestar/store.py +504 -0
  12. sqlspec/adapters/adbc/type_converter.py +44 -50
  13. sqlspec/adapters/aiosqlite/_types.py +1 -1
  14. sqlspec/adapters/aiosqlite/adk/__init__.py +5 -0
  15. sqlspec/adapters/aiosqlite/adk/store.py +536 -0
  16. sqlspec/adapters/aiosqlite/config.py +86 -16
  17. sqlspec/adapters/aiosqlite/data_dictionary.py +34 -2
  18. sqlspec/adapters/aiosqlite/driver.py +127 -38
  19. sqlspec/adapters/aiosqlite/litestar/__init__.py +5 -0
  20. sqlspec/adapters/aiosqlite/litestar/store.py +281 -0
  21. sqlspec/adapters/aiosqlite/pool.py +7 -7
  22. sqlspec/adapters/asyncmy/__init__.py +7 -1
  23. sqlspec/adapters/asyncmy/_types.py +1 -1
  24. sqlspec/adapters/asyncmy/adk/__init__.py +5 -0
  25. sqlspec/adapters/asyncmy/adk/store.py +503 -0
  26. sqlspec/adapters/asyncmy/config.py +59 -17
  27. sqlspec/adapters/asyncmy/data_dictionary.py +41 -2
  28. sqlspec/adapters/asyncmy/driver.py +293 -62
  29. sqlspec/adapters/asyncmy/litestar/__init__.py +5 -0
  30. sqlspec/adapters/asyncmy/litestar/store.py +296 -0
  31. sqlspec/adapters/asyncpg/__init__.py +2 -1
  32. sqlspec/adapters/asyncpg/_type_handlers.py +71 -0
  33. sqlspec/adapters/asyncpg/_types.py +11 -7
  34. sqlspec/adapters/asyncpg/adk/__init__.py +5 -0
  35. sqlspec/adapters/asyncpg/adk/store.py +460 -0
  36. sqlspec/adapters/asyncpg/config.py +57 -36
  37. sqlspec/adapters/asyncpg/data_dictionary.py +48 -2
  38. sqlspec/adapters/asyncpg/driver.py +153 -23
  39. sqlspec/adapters/asyncpg/litestar/__init__.py +5 -0
  40. sqlspec/adapters/asyncpg/litestar/store.py +253 -0
  41. sqlspec/adapters/bigquery/_types.py +1 -1
  42. sqlspec/adapters/bigquery/adk/__init__.py +5 -0
  43. sqlspec/adapters/bigquery/adk/store.py +585 -0
  44. sqlspec/adapters/bigquery/config.py +36 -11
  45. sqlspec/adapters/bigquery/data_dictionary.py +42 -2
  46. sqlspec/adapters/bigquery/driver.py +489 -144
  47. sqlspec/adapters/bigquery/litestar/__init__.py +5 -0
  48. sqlspec/adapters/bigquery/litestar/store.py +327 -0
  49. sqlspec/adapters/bigquery/type_converter.py +55 -23
  50. sqlspec/adapters/duckdb/_types.py +2 -2
  51. sqlspec/adapters/duckdb/adk/__init__.py +14 -0
  52. sqlspec/adapters/duckdb/adk/store.py +563 -0
  53. sqlspec/adapters/duckdb/config.py +79 -21
  54. sqlspec/adapters/duckdb/data_dictionary.py +41 -2
  55. sqlspec/adapters/duckdb/driver.py +225 -44
  56. sqlspec/adapters/duckdb/litestar/__init__.py +5 -0
  57. sqlspec/adapters/duckdb/litestar/store.py +332 -0
  58. sqlspec/adapters/duckdb/pool.py +5 -5
  59. sqlspec/adapters/duckdb/type_converter.py +51 -21
  60. sqlspec/adapters/oracledb/_numpy_handlers.py +133 -0
  61. sqlspec/adapters/oracledb/_types.py +20 -2
  62. sqlspec/adapters/oracledb/adk/__init__.py +5 -0
  63. sqlspec/adapters/oracledb/adk/store.py +1628 -0
  64. sqlspec/adapters/oracledb/config.py +120 -36
  65. sqlspec/adapters/oracledb/data_dictionary.py +87 -20
  66. sqlspec/adapters/oracledb/driver.py +475 -86
  67. sqlspec/adapters/oracledb/litestar/__init__.py +5 -0
  68. sqlspec/adapters/oracledb/litestar/store.py +765 -0
  69. sqlspec/adapters/oracledb/migrations.py +316 -25
  70. sqlspec/adapters/oracledb/type_converter.py +91 -16
  71. sqlspec/adapters/psqlpy/_type_handlers.py +44 -0
  72. sqlspec/adapters/psqlpy/_types.py +2 -1
  73. sqlspec/adapters/psqlpy/adk/__init__.py +5 -0
  74. sqlspec/adapters/psqlpy/adk/store.py +483 -0
  75. sqlspec/adapters/psqlpy/config.py +45 -19
  76. sqlspec/adapters/psqlpy/data_dictionary.py +48 -2
  77. sqlspec/adapters/psqlpy/driver.py +108 -41
  78. sqlspec/adapters/psqlpy/litestar/__init__.py +5 -0
  79. sqlspec/adapters/psqlpy/litestar/store.py +272 -0
  80. sqlspec/adapters/psqlpy/type_converter.py +40 -11
  81. sqlspec/adapters/psycopg/_type_handlers.py +80 -0
  82. sqlspec/adapters/psycopg/_types.py +2 -1
  83. sqlspec/adapters/psycopg/adk/__init__.py +5 -0
  84. sqlspec/adapters/psycopg/adk/store.py +962 -0
  85. sqlspec/adapters/psycopg/config.py +65 -37
  86. sqlspec/adapters/psycopg/data_dictionary.py +91 -3
  87. sqlspec/adapters/psycopg/driver.py +200 -78
  88. sqlspec/adapters/psycopg/litestar/__init__.py +5 -0
  89. sqlspec/adapters/psycopg/litestar/store.py +554 -0
  90. sqlspec/adapters/sqlite/__init__.py +2 -1
  91. sqlspec/adapters/sqlite/_type_handlers.py +86 -0
  92. sqlspec/adapters/sqlite/_types.py +1 -1
  93. sqlspec/adapters/sqlite/adk/__init__.py +5 -0
  94. sqlspec/adapters/sqlite/adk/store.py +582 -0
  95. sqlspec/adapters/sqlite/config.py +85 -16
  96. sqlspec/adapters/sqlite/data_dictionary.py +34 -2
  97. sqlspec/adapters/sqlite/driver.py +120 -52
  98. sqlspec/adapters/sqlite/litestar/__init__.py +5 -0
  99. sqlspec/adapters/sqlite/litestar/store.py +318 -0
  100. sqlspec/adapters/sqlite/pool.py +5 -5
  101. sqlspec/base.py +45 -26
  102. sqlspec/builder/__init__.py +73 -4
  103. sqlspec/builder/_base.py +91 -58
  104. sqlspec/builder/_column.py +5 -5
  105. sqlspec/builder/_ddl.py +98 -89
  106. sqlspec/builder/_delete.py +5 -4
  107. sqlspec/builder/_dml.py +388 -0
  108. sqlspec/{_sql.py → builder/_factory.py} +41 -44
  109. sqlspec/builder/_insert.py +5 -82
  110. sqlspec/builder/{mixins/_join_operations.py → _join.py} +145 -143
  111. sqlspec/builder/_merge.py +446 -11
  112. sqlspec/builder/_parsing_utils.py +9 -11
  113. sqlspec/builder/_select.py +1313 -25
  114. sqlspec/builder/_update.py +11 -42
  115. sqlspec/cli.py +76 -69
  116. sqlspec/config.py +331 -62
  117. sqlspec/core/__init__.py +5 -4
  118. sqlspec/core/cache.py +18 -18
  119. sqlspec/core/compiler.py +6 -8
  120. sqlspec/core/filters.py +55 -47
  121. sqlspec/core/hashing.py +9 -9
  122. sqlspec/core/parameters.py +76 -45
  123. sqlspec/core/result.py +234 -47
  124. sqlspec/core/splitter.py +16 -17
  125. sqlspec/core/statement.py +32 -31
  126. sqlspec/core/type_conversion.py +3 -2
  127. sqlspec/driver/__init__.py +1 -3
  128. sqlspec/driver/_async.py +183 -160
  129. sqlspec/driver/_common.py +197 -109
  130. sqlspec/driver/_sync.py +189 -161
  131. sqlspec/driver/mixins/_result_tools.py +20 -236
  132. sqlspec/driver/mixins/_sql_translator.py +4 -4
  133. sqlspec/exceptions.py +70 -7
  134. sqlspec/extensions/adk/__init__.py +53 -0
  135. sqlspec/extensions/adk/_types.py +51 -0
  136. sqlspec/extensions/adk/converters.py +172 -0
  137. sqlspec/extensions/adk/migrations/0001_create_adk_tables.py +144 -0
  138. sqlspec/extensions/adk/migrations/__init__.py +0 -0
  139. sqlspec/extensions/adk/service.py +181 -0
  140. sqlspec/extensions/adk/store.py +536 -0
  141. sqlspec/extensions/aiosql/adapter.py +69 -61
  142. sqlspec/extensions/fastapi/__init__.py +21 -0
  143. sqlspec/extensions/fastapi/extension.py +331 -0
  144. sqlspec/extensions/fastapi/providers.py +543 -0
  145. sqlspec/extensions/flask/__init__.py +36 -0
  146. sqlspec/extensions/flask/_state.py +71 -0
  147. sqlspec/extensions/flask/_utils.py +40 -0
  148. sqlspec/extensions/flask/extension.py +389 -0
  149. sqlspec/extensions/litestar/__init__.py +21 -4
  150. sqlspec/extensions/litestar/cli.py +54 -10
  151. sqlspec/extensions/litestar/config.py +56 -266
  152. sqlspec/extensions/litestar/handlers.py +46 -17
  153. sqlspec/extensions/litestar/migrations/0001_create_session_table.py +137 -0
  154. sqlspec/extensions/litestar/migrations/__init__.py +3 -0
  155. sqlspec/extensions/litestar/plugin.py +349 -224
  156. sqlspec/extensions/litestar/providers.py +25 -25
  157. sqlspec/extensions/litestar/store.py +265 -0
  158. sqlspec/extensions/starlette/__init__.py +10 -0
  159. sqlspec/extensions/starlette/_state.py +25 -0
  160. sqlspec/extensions/starlette/_utils.py +52 -0
  161. sqlspec/extensions/starlette/extension.py +254 -0
  162. sqlspec/extensions/starlette/middleware.py +154 -0
  163. sqlspec/loader.py +30 -49
  164. sqlspec/migrations/base.py +200 -76
  165. sqlspec/migrations/commands.py +591 -62
  166. sqlspec/migrations/context.py +6 -9
  167. sqlspec/migrations/fix.py +199 -0
  168. sqlspec/migrations/loaders.py +47 -19
  169. sqlspec/migrations/runner.py +241 -75
  170. sqlspec/migrations/tracker.py +237 -21
  171. sqlspec/migrations/utils.py +51 -3
  172. sqlspec/migrations/validation.py +177 -0
  173. sqlspec/protocols.py +106 -36
  174. sqlspec/storage/_utils.py +85 -0
  175. sqlspec/storage/backends/fsspec.py +133 -107
  176. sqlspec/storage/backends/local.py +78 -51
  177. sqlspec/storage/backends/obstore.py +276 -168
  178. sqlspec/storage/registry.py +75 -39
  179. sqlspec/typing.py +30 -84
  180. sqlspec/utils/__init__.py +25 -4
  181. sqlspec/utils/arrow_helpers.py +81 -0
  182. sqlspec/utils/config_resolver.py +6 -6
  183. sqlspec/utils/correlation.py +4 -5
  184. sqlspec/utils/data_transformation.py +3 -2
  185. sqlspec/utils/deprecation.py +9 -8
  186. sqlspec/utils/fixtures.py +4 -4
  187. sqlspec/utils/logging.py +46 -6
  188. sqlspec/utils/module_loader.py +205 -5
  189. sqlspec/utils/portal.py +311 -0
  190. sqlspec/utils/schema.py +288 -0
  191. sqlspec/utils/serializers.py +113 -4
  192. sqlspec/utils/sync_tools.py +36 -22
  193. sqlspec/utils/text.py +1 -2
  194. sqlspec/utils/type_guards.py +136 -20
  195. sqlspec/utils/version.py +433 -0
  196. {sqlspec-0.26.0.dist-info → sqlspec-0.28.0.dist-info}/METADATA +41 -22
  197. sqlspec-0.28.0.dist-info/RECORD +221 -0
  198. sqlspec/builder/mixins/__init__.py +0 -55
  199. sqlspec/builder/mixins/_cte_and_set_ops.py +0 -253
  200. sqlspec/builder/mixins/_delete_operations.py +0 -50
  201. sqlspec/builder/mixins/_insert_operations.py +0 -282
  202. sqlspec/builder/mixins/_merge_operations.py +0 -698
  203. sqlspec/builder/mixins/_order_limit_operations.py +0 -145
  204. sqlspec/builder/mixins/_pivot_operations.py +0 -157
  205. sqlspec/builder/mixins/_select_operations.py +0 -930
  206. sqlspec/builder/mixins/_update_operations.py +0 -199
  207. sqlspec/builder/mixins/_where_clause.py +0 -1298
  208. sqlspec-0.26.0.dist-info/RECORD +0 -157
  209. sqlspec-0.26.0.dist-info/licenses/NOTICE +0 -29
  210. {sqlspec-0.26.0.dist-info → sqlspec-0.28.0.dist-info}/WHEEL +0 -0
  211. {sqlspec-0.26.0.dist-info → sqlspec-0.28.0.dist-info}/entry_points.txt +0 -0
  212. {sqlspec-0.26.0.dist-info → sqlspec-0.28.0.dist-info}/licenses/LICENSE +0 -0
@@ -3,19 +3,24 @@
3
3
  This module provides the main command interface for database migrations.
4
4
  """
5
5
 
6
- from typing import TYPE_CHECKING, Any, Optional, Union, cast
6
+ from typing import TYPE_CHECKING, Any, cast
7
7
 
8
8
  from rich.console import Console
9
9
  from rich.table import Table
10
10
 
11
- from sqlspec._sql import sql
11
+ from sqlspec.builder import sql
12
12
  from sqlspec.migrations.base import BaseMigrationCommands
13
13
  from sqlspec.migrations.context import MigrationContext
14
+ from sqlspec.migrations.fix import MigrationFixer
14
15
  from sqlspec.migrations.runner import AsyncMigrationRunner, SyncMigrationRunner
15
16
  from sqlspec.migrations.utils import create_migration_file
17
+ from sqlspec.migrations.validation import validate_migration_order
16
18
  from sqlspec.utils.logging import get_logger
19
+ from sqlspec.utils.version import generate_conversion_map, generate_timestamp_version
17
20
 
18
21
  if TYPE_CHECKING:
22
+ from pathlib import Path
23
+
19
24
  from sqlspec.config import AsyncConfigT, SyncConfigT
20
25
 
21
26
  __all__ = ("AsyncMigrationCommands", "SyncMigrationCommands", "create_migration_commands")
@@ -53,7 +58,7 @@ class SyncMigrationCommands(BaseMigrationCommands["SyncConfigT", Any]):
53
58
  """
54
59
  self.init_directory(directory, package)
55
60
 
56
- def current(self, verbose: bool = False) -> "Optional[str]":
61
+ def current(self, verbose: bool = False) -> "str | None":
57
62
  """Show current migration version.
58
63
 
59
64
  Args:
@@ -93,52 +98,204 @@ class SyncMigrationCommands(BaseMigrationCommands["SyncConfigT", Any]):
93
98
 
94
99
  console.print(table)
95
100
 
96
- return cast("Optional[str]", current)
101
+ return cast("str | None", current)
102
+
103
+ def _load_single_migration_checksum(self, version: str, file_path: "Path") -> "tuple[str, tuple[str, Path]] | None":
104
+ """Load checksum for a single migration.
97
105
 
98
- def upgrade(self, revision: str = "head") -> None:
106
+ Args:
107
+ version: Migration version.
108
+ file_path: Path to migration file.
109
+
110
+ Returns:
111
+ Tuple of (version, (checksum, file_path)) or None if load fails.
112
+ """
113
+ try:
114
+ migration = self.runner.load_migration(file_path, version)
115
+ return (version, (migration["checksum"], file_path))
116
+ except Exception as e:
117
+ logger.debug("Could not load migration %s for auto-sync: %s", version, e)
118
+ return None
119
+
120
+ def _load_migration_checksums(self, all_migrations: "list[tuple[str, Path]]") -> "dict[str, tuple[str, Path]]":
121
+ """Load checksums for all migrations.
122
+
123
+ Args:
124
+ all_migrations: List of (version, file_path) tuples.
125
+
126
+ Returns:
127
+ Dictionary mapping version to (checksum, file_path) tuples.
128
+ """
129
+ file_checksums = {}
130
+ for version, file_path in all_migrations:
131
+ result = self._load_single_migration_checksum(version, file_path)
132
+ if result:
133
+ file_checksums[result[0]] = result[1]
134
+ return file_checksums
135
+
136
+ def _synchronize_version_records(self, driver: Any) -> int:
137
+ """Synchronize database version records with migration files.
138
+
139
+ Auto-updates DB tracking when migrations have been renamed by fix command.
140
+ This allows developers to just run upgrade after pulling changes without
141
+ manually running fix.
142
+
143
+ Validates checksums match before updating to prevent incorrect matches.
144
+
145
+ Args:
146
+ driver: Database driver instance.
147
+
148
+ Returns:
149
+ Number of version records updated.
150
+ """
151
+ all_migrations = self.runner.get_migration_files()
152
+
153
+ try:
154
+ applied_migrations = self.tracker.get_applied_migrations(driver)
155
+ except Exception:
156
+ logger.debug("Could not fetch applied migrations for synchronization (table schema may be migrating)")
157
+ return 0
158
+
159
+ applied_map = {m["version_num"]: m for m in applied_migrations}
160
+
161
+ conversion_map = generate_conversion_map(all_migrations)
162
+
163
+ updated_count = 0
164
+ if conversion_map:
165
+ for old_version, new_version in conversion_map.items():
166
+ if old_version in applied_map and new_version not in applied_map:
167
+ applied_checksum = applied_map[old_version]["checksum"]
168
+
169
+ file_path = next((path for v, path in all_migrations if v == new_version), None)
170
+ if file_path:
171
+ migration = self.runner.load_migration(file_path, new_version)
172
+ if migration["checksum"] == applied_checksum:
173
+ self.tracker.update_version_record(driver, old_version, new_version)
174
+ console.print(f" [dim]Reconciled version:[/] {old_version} → {new_version}")
175
+ updated_count += 1
176
+ else:
177
+ console.print(
178
+ f" [yellow]Warning: Checksum mismatch for {old_version} → {new_version}, skipping auto-sync[/]"
179
+ )
180
+ else:
181
+ file_checksums = self._load_migration_checksums(all_migrations)
182
+
183
+ for applied_version, applied_record in applied_map.items():
184
+ for file_version, (file_checksum, _) in file_checksums.items():
185
+ if file_version not in applied_map and applied_record["checksum"] == file_checksum:
186
+ self.tracker.update_version_record(driver, applied_version, file_version)
187
+ console.print(f" [dim]Reconciled version:[/] {applied_version} → {file_version}")
188
+ updated_count += 1
189
+ break
190
+
191
+ if updated_count > 0:
192
+ console.print(f"[cyan]Reconciled {updated_count} version record(s)[/]")
193
+
194
+ return updated_count
195
+
196
+ def upgrade(
197
+ self, revision: str = "head", allow_missing: bool = False, auto_sync: bool = True, dry_run: bool = False
198
+ ) -> None:
99
199
  """Upgrade to a target revision.
100
200
 
201
+ Validates migration order and warns if out-of-order migrations are detected.
202
+ Out-of-order migrations can occur when branches merge in different orders
203
+ across environments.
204
+
101
205
  Args:
102
206
  revision: Target revision or "head" for latest.
207
+ allow_missing: If True, allow out-of-order migrations even in strict mode.
208
+ Defaults to False.
209
+ auto_sync: If True, automatically reconcile renamed migrations in database.
210
+ Defaults to True. Can be disabled via --no-auto-sync flag.
211
+ dry_run: If True, show what would be done without making changes.
103
212
  """
213
+ if dry_run:
214
+ console.print("[bold yellow]DRY RUN MODE:[/] No database changes will be applied\n")
215
+
104
216
  with self.config.provide_session() as driver:
105
217
  self.tracker.ensure_tracking_table(driver)
106
218
 
107
- current = self.tracker.get_current_version(driver)
219
+ if auto_sync:
220
+ migration_config = getattr(self.config, "migration_config", {}) or {}
221
+ config_auto_sync = migration_config.get("auto_sync", True)
222
+ if config_auto_sync:
223
+ self._synchronize_version_records(driver)
224
+
225
+ applied_migrations = self.tracker.get_applied_migrations(driver)
226
+ applied_versions = [m["version_num"] for m in applied_migrations]
227
+ applied_set = set(applied_versions)
228
+
108
229
  all_migrations = self.runner.get_migration_files()
109
230
  pending = []
110
231
  for version, file_path in all_migrations:
111
- if (current is None or version > current) and (revision == "head" or version <= revision):
112
- pending.append((version, file_path))
232
+ if version not in applied_set:
233
+ if revision == "head":
234
+ pending.append((version, file_path))
235
+ else:
236
+ from sqlspec.utils.version import parse_version
237
+
238
+ parsed_version = parse_version(version)
239
+ parsed_revision = parse_version(revision)
240
+ if parsed_version <= parsed_revision:
241
+ pending.append((version, file_path))
113
242
 
114
243
  if not pending:
115
- console.print("[green]Already at latest version[/]")
244
+ if not all_migrations:
245
+ console.print(
246
+ "[yellow]No migrations found. Create your first migration with 'sqlspec create-migration'.[/]"
247
+ )
248
+ else:
249
+ console.print("[green]Already at latest version[/]")
116
250
  return
251
+ pending_versions = [v for v, _ in pending]
252
+
253
+ migration_config = getattr(self.config, "migration_config", {}) or {}
254
+ strict_ordering = migration_config.get("strict_ordering", False) and not allow_missing
255
+
256
+ validate_migration_order(pending_versions, applied_versions, strict_ordering)
117
257
 
118
258
  console.print(f"[yellow]Found {len(pending)} pending migrations[/]")
119
259
 
120
260
  for version, file_path in pending:
121
- migration = self.runner.load_migration(file_path)
261
+ migration = self.runner.load_migration(file_path, version)
122
262
 
123
- console.print(f"\n[cyan]Applying {version}:[/] {migration['description']}")
263
+ action_verb = "Would apply" if dry_run else "Applying"
264
+ console.print(f"\n[cyan]{action_verb} {version}:[/] {migration['description']}")
265
+
266
+ if dry_run:
267
+ console.print(f"[dim]Migration file: {file_path}[/]")
268
+ continue
124
269
 
125
270
  try:
126
- _, execution_time = self.runner.execute_upgrade(driver, migration)
127
- self.tracker.record_migration(
128
- driver, migration["version"], migration["description"], execution_time, migration["checksum"]
129
- )
271
+
272
+ def record_version(exec_time: int, migration: "dict[str, Any]" = migration) -> None:
273
+ self.tracker.record_migration(
274
+ driver, migration["version"], migration["description"], exec_time, migration["checksum"]
275
+ )
276
+
277
+ _, execution_time = self.runner.execute_upgrade(driver, migration, on_success=record_version)
130
278
  console.print(f"[green]✓ Applied in {execution_time}ms[/]")
131
279
 
132
280
  except Exception as e:
133
- console.print(f"[red]✗ Failed: {e}[/]")
134
- raise
281
+ use_txn = self.runner.should_use_transaction(migration, self.config)
282
+ rollback_msg = " (transaction rolled back)" if use_txn else ""
283
+ console.print(f"[red]✗ Failed{rollback_msg}: {e}[/]")
284
+ return
285
+
286
+ if dry_run:
287
+ console.print("\n[bold yellow]Dry run complete.[/] No changes were made to the database.")
135
288
 
136
- def downgrade(self, revision: str = "-1") -> None:
289
+ def downgrade(self, revision: str = "-1", *, dry_run: bool = False) -> None:
137
290
  """Downgrade to a target revision.
138
291
 
139
292
  Args:
140
293
  revision: Target revision or "-1" for one step back.
294
+ dry_run: If True, show what would be done without making changes.
141
295
  """
296
+ if dry_run:
297
+ console.print("[bold yellow]DRY RUN MODE:[/] No database changes will be applied\n")
298
+
142
299
  with self.config.provide_session() as driver:
143
300
  self.tracker.ensure_tracking_table(driver)
144
301
  applied = self.tracker.get_applied_migrations(driver)
@@ -151,8 +308,12 @@ class SyncMigrationCommands(BaseMigrationCommands["SyncConfigT", Any]):
151
308
  elif revision == "base":
152
309
  to_revert = list(reversed(applied))
153
310
  else:
311
+ from sqlspec.utils.version import parse_version
312
+
313
+ parsed_revision = parse_version(revision)
154
314
  for migration in reversed(applied):
155
- if migration["version_num"] > revision:
315
+ parsed_migration_version = parse_version(migration["version_num"])
316
+ if parsed_migration_version > parsed_revision:
156
317
  to_revert.append(migration)
157
318
 
158
319
  if not to_revert:
@@ -166,15 +327,30 @@ class SyncMigrationCommands(BaseMigrationCommands["SyncConfigT", Any]):
166
327
  if version not in all_files:
167
328
  console.print(f"[red]Migration file not found for {version}[/]")
168
329
  continue
169
- migration = self.runner.load_migration(all_files[version])
170
- console.print(f"\n[cyan]Reverting {version}:[/] {migration['description']}")
330
+ migration = self.runner.load_migration(all_files[version], version)
331
+
332
+ action_verb = "Would revert" if dry_run else "Reverting"
333
+ console.print(f"\n[cyan]{action_verb} {version}:[/] {migration['description']}")
334
+
335
+ if dry_run:
336
+ console.print(f"[dim]Migration file: {all_files[version]}[/]")
337
+ continue
338
+
171
339
  try:
172
- _, execution_time = self.runner.execute_downgrade(driver, migration)
173
- self.tracker.remove_migration(driver, version)
340
+
341
+ def remove_version(exec_time: int, version: str = version) -> None:
342
+ self.tracker.remove_migration(driver, version)
343
+
344
+ _, execution_time = self.runner.execute_downgrade(driver, migration, on_success=remove_version)
174
345
  console.print(f"[green]✓ Reverted in {execution_time}ms[/]")
175
346
  except Exception as e:
176
- console.print(f"[red]✗ Failed: {e}[/]")
177
- raise
347
+ use_txn = self.runner.should_use_transaction(migration, self.config)
348
+ rollback_msg = " (transaction rolled back)" if use_txn else ""
349
+ console.print(f"[red]✗ Failed{rollback_msg}: {e}[/]")
350
+ return
351
+
352
+ if dry_run:
353
+ console.print("\n[bold yellow]Dry run complete.[/] No changes were made to the database.")
178
354
 
179
355
  def stamp(self, revision: str) -> None:
180
356
  """Mark database as being at a specific revision without running migrations.
@@ -194,18 +370,105 @@ class SyncMigrationCommands(BaseMigrationCommands["SyncConfigT", Any]):
194
370
  console.print(f"[green]Database stamped at revision {revision}[/]")
195
371
 
196
372
  def revision(self, message: str, file_type: str = "sql") -> None:
197
- """Create a new migration file.
373
+ """Create a new migration file with timestamp-based versioning.
374
+
375
+ Generates a unique timestamp version (YYYYMMDDHHmmss format) to avoid
376
+ conflicts when multiple developers create migrations concurrently.
198
377
 
199
378
  Args:
200
379
  message: Description for the migration.
201
380
  file_type: Type of migration file to create ('sql' or 'py').
202
381
  """
203
- existing = self.runner.get_migration_files()
204
- next_num = int(existing[-1][0]) + 1 if existing else 1
205
- next_version = str(next_num).zfill(4)
206
- file_path = create_migration_file(self.migrations_path, next_version, message, file_type)
382
+ version = generate_timestamp_version()
383
+ file_path = create_migration_file(self.migrations_path, version, message, file_type)
207
384
  console.print(f"[green]Created migration:[/] {file_path}")
208
385
 
386
+ def fix(self, dry_run: bool = False, update_database: bool = True, yes: bool = False) -> None:
387
+ """Convert timestamp migrations to sequential format.
388
+
389
+ Implements hybrid versioning workflow where development uses timestamps
390
+ and production uses sequential numbers. Creates backup before changes
391
+ and provides rollback on errors.
392
+
393
+ Args:
394
+ dry_run: Preview changes without applying.
395
+ update_database: Update migration records in database.
396
+ yes: Skip confirmation prompt.
397
+
398
+ Examples:
399
+ >>> commands.fix(dry_run=True) # Preview only
400
+ >>> commands.fix(yes=True) # Auto-approve
401
+ >>> commands.fix(update_database=False) # Files only
402
+ """
403
+ all_migrations = self.runner.get_migration_files()
404
+
405
+ conversion_map = generate_conversion_map(all_migrations)
406
+
407
+ if not conversion_map:
408
+ console.print("[yellow]No timestamp migrations found - nothing to convert[/]")
409
+ return
410
+
411
+ fixer = MigrationFixer(self.migrations_path)
412
+ renames = fixer.plan_renames(conversion_map)
413
+
414
+ table = Table(title="Migration Conversions")
415
+ table.add_column("Current Version", style="cyan")
416
+ table.add_column("New Version", style="green")
417
+ table.add_column("File")
418
+
419
+ for rename in renames:
420
+ table.add_row(rename.old_version, rename.new_version, rename.old_path.name)
421
+
422
+ console.print(table)
423
+ console.print(f"\n[yellow]{len(renames)} migrations will be converted[/]")
424
+
425
+ if dry_run:
426
+ console.print("[yellow][Preview Mode - No changes made][/]")
427
+ return
428
+
429
+ if not yes:
430
+ response = input("\nProceed with conversion? [y/N]: ")
431
+ if response.lower() != "y":
432
+ console.print("[yellow]Conversion cancelled[/]")
433
+ return
434
+
435
+ try:
436
+ backup_path = fixer.create_backup()
437
+ console.print(f"[green]✓ Created backup in {backup_path.name}[/]")
438
+
439
+ fixer.apply_renames(renames)
440
+ for rename in renames:
441
+ console.print(f"[green]✓ Renamed {rename.old_path.name} → {rename.new_path.name}[/]")
442
+
443
+ if update_database:
444
+ with self.config.provide_session() as driver:
445
+ self.tracker.ensure_tracking_table(driver)
446
+ applied_migrations = self.tracker.get_applied_migrations(driver)
447
+ applied_versions = {m["version_num"] for m in applied_migrations}
448
+
449
+ updated_count = 0
450
+ for old_version, new_version in conversion_map.items():
451
+ if old_version in applied_versions:
452
+ self.tracker.update_version_record(driver, old_version, new_version)
453
+ updated_count += 1
454
+
455
+ if updated_count > 0:
456
+ console.print(
457
+ f"[green]✓ Updated {updated_count} version records in migration tracking table[/]"
458
+ )
459
+ else:
460
+ console.print("[green]✓ No applied migrations to update in tracking table[/]")
461
+
462
+ fixer.cleanup()
463
+ console.print("[green]✓ Conversion complete![/]")
464
+
465
+ except Exception as e:
466
+ logger.exception("Fix command failed")
467
+ console.print(f"[red]✗ Error: {e}[/]")
468
+ fixer.rollback()
469
+ console.print("[yellow]Restored files from backup[/]")
470
+ raise
471
+
209
472
 
210
473
  class AsyncMigrationCommands(BaseMigrationCommands["AsyncConfigT", Any]):
211
474
  """Asynchronous migration commands."""
@@ -236,7 +499,7 @@ class AsyncMigrationCommands(BaseMigrationCommands["AsyncConfigT", Any]):
236
499
  """
237
500
  self.init_directory(directory, package)
238
501
 
239
- async def current(self, verbose: bool = False) -> "Optional[str]":
502
+ async def current(self, verbose: bool = False) -> "str | None":
240
503
  """Show current migration version.
241
504
 
242
505
  Args:
@@ -272,46 +535,205 @@ class AsyncMigrationCommands(BaseMigrationCommands["AsyncConfigT", Any]):
272
535
  )
273
536
  console.print(table)
274
537
 
275
- return cast("Optional[str]", current)
538
+ return cast("str | None", current)
276
539
 
277
- async def upgrade(self, revision: str = "head") -> None:
540
+ async def _load_single_migration_checksum(
541
+ self, version: str, file_path: "Path"
542
+ ) -> "tuple[str, tuple[str, Path]] | None":
543
+ """Load checksum for a single migration.
544
+
545
+ Args:
546
+ version: Migration version.
547
+ file_path: Path to migration file.
548
+
549
+ Returns:
550
+ Tuple of (version, (checksum, file_path)) or None if load fails.
551
+ """
552
+ try:
553
+ migration = await self.runner.load_migration(file_path, version)
554
+ return (version, (migration["checksum"], file_path))
555
+ except Exception as e:
556
+ logger.debug("Could not load migration %s for auto-sync: %s", version, e)
557
+ return None
558
+
559
+ async def _load_migration_checksums(
560
+ self, all_migrations: "list[tuple[str, Path]]"
561
+ ) -> "dict[str, tuple[str, Path]]":
562
+ """Load checksums for all migrations.
563
+
564
+ Args:
565
+ all_migrations: List of (version, file_path) tuples.
566
+
567
+ Returns:
568
+ Dictionary mapping version to (checksum, file_path) tuples.
569
+ """
570
+ file_checksums = {}
571
+ for version, file_path in all_migrations:
572
+ result = await self._load_single_migration_checksum(version, file_path)
573
+ if result:
574
+ file_checksums[result[0]] = result[1]
575
+ return file_checksums
576
+
577
+ async def _synchronize_version_records(self, driver: Any) -> int:
578
+ """Synchronize database version records with migration files.
579
+
580
+ Auto-updates DB tracking when migrations have been renamed by fix command.
581
+ This allows developers to just run upgrade after pulling changes without
582
+ manually running fix.
583
+
584
+ Validates checksums match before updating to prevent incorrect matches.
585
+
586
+ Args:
587
+ driver: Database driver instance.
588
+
589
+ Returns:
590
+ Number of version records updated.
591
+ """
592
+ all_migrations = await self.runner.get_migration_files()
593
+
594
+ try:
595
+ applied_migrations = await self.tracker.get_applied_migrations(driver)
596
+ except Exception:
597
+ logger.debug("Could not fetch applied migrations for synchronization (table schema may be migrating)")
598
+ return 0
599
+
600
+ applied_map = {m["version_num"]: m for m in applied_migrations}
601
+
602
+ conversion_map = generate_conversion_map(all_migrations)
603
+
604
+ updated_count = 0
605
+ if conversion_map:
606
+ for old_version, new_version in conversion_map.items():
607
+ if old_version in applied_map and new_version not in applied_map:
608
+ applied_checksum = applied_map[old_version]["checksum"]
609
+
610
+ file_path = next((path for v, path in all_migrations if v == new_version), None)
611
+ if file_path:
612
+ migration = await self.runner.load_migration(file_path, new_version)
613
+ if migration["checksum"] == applied_checksum:
614
+ await self.tracker.update_version_record(driver, old_version, new_version)
615
+ console.print(f" [dim]Reconciled version:[/] {old_version} → {new_version}")
616
+ updated_count += 1
617
+ else:
618
+ console.print(
619
+ f" [yellow]Warning: Checksum mismatch for {old_version} → {new_version}, skipping auto-sync[/]"
620
+ )
621
+ else:
622
+ file_checksums = await self._load_migration_checksums(all_migrations)
623
+
624
+ for applied_version, applied_record in applied_map.items():
625
+ for file_version, (file_checksum, _) in file_checksums.items():
626
+ if file_version not in applied_map and applied_record["checksum"] == file_checksum:
627
+ await self.tracker.update_version_record(driver, applied_version, file_version)
628
+ console.print(f" [dim]Reconciled version:[/] {applied_version} → {file_version}")
629
+ updated_count += 1
630
+ break
631
+
632
+ if updated_count > 0:
633
+ console.print(f"[cyan]Reconciled {updated_count} version record(s)[/]")
634
+
635
+ return updated_count
636
+
637
+ async def upgrade(
638
+ self, revision: str = "head", allow_missing: bool = False, auto_sync: bool = True, dry_run: bool = False
639
+ ) -> None:
278
640
  """Upgrade to a target revision.
279
641
 
642
+ Validates migration order and warns if out-of-order migrations are detected.
643
+ Out-of-order migrations can occur when branches merge in different orders
644
+ across environments.
645
+
280
646
  Args:
281
647
  revision: Target revision or "head" for latest.
648
+ allow_missing: If True, allow out-of-order migrations even in strict mode.
649
+ Defaults to False.
650
+ auto_sync: If True, automatically reconcile renamed migrations in database.
651
+ Defaults to True. Can be disabled via --no-auto-sync flag.
652
+ dry_run: If True, show what would be done without making changes.
282
653
  """
654
+ if dry_run:
655
+ console.print("[bold yellow]DRY RUN MODE:[/] No database changes will be applied\n")
656
+
283
657
  async with self.config.provide_session() as driver:
284
658
  await self.tracker.ensure_tracking_table(driver)
285
659
 
286
- current = await self.tracker.get_current_version(driver)
660
+ if auto_sync:
661
+ migration_config = getattr(self.config, "migration_config", {}) or {}
662
+ config_auto_sync = migration_config.get("auto_sync", True)
663
+ if config_auto_sync:
664
+ await self._synchronize_version_records(driver)
665
+
666
+ applied_migrations = await self.tracker.get_applied_migrations(driver)
667
+ applied_versions = [m["version_num"] for m in applied_migrations]
668
+ applied_set = set(applied_versions)
669
+
287
670
  all_migrations = await self.runner.get_migration_files()
288
671
  pending = []
289
672
  for version, file_path in all_migrations:
290
- if (current is None or version > current) and (revision == "head" or version <= revision):
291
- pending.append((version, file_path))
673
+ if version not in applied_set:
674
+ if revision == "head":
675
+ pending.append((version, file_path))
676
+ else:
677
+ from sqlspec.utils.version import parse_version
678
+
679
+ parsed_version = parse_version(version)
680
+ parsed_revision = parse_version(revision)
681
+ if parsed_version <= parsed_revision:
682
+ pending.append((version, file_path))
292
683
  if not pending:
293
- console.print("[green]Already at latest version[/]")
684
+ if not all_migrations:
685
+ console.print(
686
+ "[yellow]No migrations found. Create your first migration with 'sqlspec create-migration'.[/]"
687
+ )
688
+ else:
689
+ console.print("[green]Already at latest version[/]")
294
690
  return
691
+ pending_versions = [v for v, _ in pending]
692
+
693
+ migration_config = getattr(self.config, "migration_config", {}) or {}
694
+ strict_ordering = migration_config.get("strict_ordering", False) and not allow_missing
695
+
696
+ validate_migration_order(pending_versions, applied_versions, strict_ordering)
697
+
295
698
  console.print(f"[yellow]Found {len(pending)} pending migrations[/]")
296
699
  for version, file_path in pending:
297
- migration = await self.runner.load_migration(file_path)
298
- console.print(f"\n[cyan]Applying {version}:[/] {migration['description']}")
700
+ migration = await self.runner.load_migration(file_path, version)
701
+
702
+ action_verb = "Would apply" if dry_run else "Applying"
703
+ console.print(f"\n[cyan]{action_verb} {version}:[/] {migration['description']}")
704
+
705
+ if dry_run:
706
+ console.print(f"[dim]Migration file: {file_path}[/]")
707
+ continue
708
+
299
709
  try:
300
- _, execution_time = await self.runner.execute_upgrade(driver, migration)
301
- await self.tracker.record_migration(
302
- driver, migration["version"], migration["description"], execution_time, migration["checksum"]
303
- )
710
+
711
+ async def record_version(exec_time: int, migration: "dict[str, Any]" = migration) -> None:
712
+ await self.tracker.record_migration(
713
+ driver, migration["version"], migration["description"], exec_time, migration["checksum"]
714
+ )
715
+
716
+ _, execution_time = await self.runner.execute_upgrade(driver, migration, on_success=record_version)
304
717
  console.print(f"[green]✓ Applied in {execution_time}ms[/]")
305
718
  except Exception as e:
306
- console.print(f"[red]✗ Failed: {e}[/]")
307
- raise
719
+ use_txn = self.runner.should_use_transaction(migration, self.config)
720
+ rollback_msg = " (transaction rolled back)" if use_txn else ""
721
+ console.print(f"[red]✗ Failed{rollback_msg}: {e}[/]")
722
+ return
723
+
724
+ if dry_run:
725
+ console.print("\n[bold yellow]Dry run complete.[/] No changes were made to the database.")
308
726
 
309
- async def downgrade(self, revision: str = "-1") -> None:
727
+ async def downgrade(self, revision: str = "-1", *, dry_run: bool = False) -> None:
310
728
  """Downgrade to a target revision.
311
729
 
312
730
  Args:
313
731
  revision: Target revision or "-1" for one step back.
732
+ dry_run: If True, show what would be done without making changes.
314
733
  """
734
+ if dry_run:
735
+ console.print("[bold yellow]DRY RUN MODE:[/] No database changes will be applied\n")
736
+
315
737
  async with self.config.provide_session() as driver:
316
738
  await self.tracker.ensure_tracking_table(driver)
317
739
 
@@ -325,8 +747,12 @@ class AsyncMigrationCommands(BaseMigrationCommands["AsyncConfigT", Any]):
325
747
  elif revision == "base":
326
748
  to_revert = list(reversed(applied))
327
749
  else:
750
+ from sqlspec.utils.version import parse_version
751
+
752
+ parsed_revision = parse_version(revision)
328
753
  for migration in reversed(applied):
329
- if migration["version_num"] > revision:
754
+ parsed_migration_version = parse_version(migration["version_num"])
755
+ if parsed_migration_version > parsed_revision:
330
756
  to_revert.append(migration)
331
757
  if not to_revert:
332
758
  console.print("[yellow]Nothing to downgrade[/]")
@@ -340,16 +766,32 @@ class AsyncMigrationCommands(BaseMigrationCommands["AsyncConfigT", Any]):
340
766
  console.print(f"[red]Migration file not found for {version}[/]")
341
767
  continue
342
768
 
343
- migration = await self.runner.load_migration(all_files[version])
344
- console.print(f"\n[cyan]Reverting {version}:[/] {migration['description']}")
769
+ migration = await self.runner.load_migration(all_files[version], version)
770
+
771
+ action_verb = "Would revert" if dry_run else "Reverting"
772
+ console.print(f"\n[cyan]{action_verb} {version}:[/] {migration['description']}")
773
+
774
+ if dry_run:
775
+ console.print(f"[dim]Migration file: {all_files[version]}[/]")
776
+ continue
345
777
 
346
778
  try:
347
- _, execution_time = await self.runner.execute_downgrade(driver, migration)
348
- await self.tracker.remove_migration(driver, version)
779
+
780
+ async def remove_version(exec_time: int, version: str = version) -> None:
781
+ await self.tracker.remove_migration(driver, version)
782
+
783
+ _, execution_time = await self.runner.execute_downgrade(
784
+ driver, migration, on_success=remove_version
785
+ )
349
786
  console.print(f"[green]✓ Reverted in {execution_time}ms[/]")
350
787
  except Exception as e:
351
- console.print(f"[red]✗ Failed: {e}[/]")
352
- raise
788
+ use_txn = self.runner.should_use_transaction(migration, self.config)
789
+ rollback_msg = " (transaction rolled back)" if use_txn else ""
790
+ console.print(f"[red]✗ Failed{rollback_msg}: {e}[/]")
791
+ return
792
+
793
+ if dry_run:
794
+ console.print("\n[bold yellow]Dry run complete.[/] No changes were made to the database.")
353
795
 
354
796
  async def stamp(self, revision: str) -> None:
355
797
  """Mark database as being at a specific revision without running migrations.
@@ -371,22 +813,109 @@ class AsyncMigrationCommands(BaseMigrationCommands["AsyncConfigT", Any]):
371
813
  console.print(f"[green]Database stamped at revision {revision}[/]")
372
814
 
373
815
  async def revision(self, message: str, file_type: str = "sql") -> None:
374
- """Create a new migration file.
816
+ """Create a new migration file with timestamp-based versioning.
817
+
818
+ Generates a unique timestamp version (YYYYMMDDHHmmss format) to avoid
819
+ conflicts when multiple developers create migrations concurrently.
375
820
 
376
821
  Args:
377
822
  message: Description for the migration.
378
823
  file_type: Type of migration file to create ('sql' or 'py').
379
824
  """
380
- existing = await self.runner.get_migration_files()
381
- next_num = int(existing[-1][0]) + 1 if existing else 1
382
- next_version = str(next_num).zfill(4)
383
- file_path = create_migration_file(self.migrations_path, next_version, message, file_type)
825
+ version = generate_timestamp_version()
826
+ file_path = create_migration_file(self.migrations_path, version, message, file_type)
384
827
  console.print(f"[green]Created migration:[/] {file_path}")
385
828
 
829
+ async def fix(self, dry_run: bool = False, update_database: bool = True, yes: bool = False) -> None:
830
+ """Convert timestamp migrations to sequential format.
831
+
832
+ Implements hybrid versioning workflow where development uses timestamps
833
+ and production uses sequential numbers. Creates backup before changes
834
+ and provides rollback on errors.
835
+
836
+ Args:
837
+ dry_run: Preview changes without applying.
838
+ update_database: Update migration records in database.
839
+ yes: Skip confirmation prompt.
840
+
841
+ Examples:
842
+ >>> await commands.fix(dry_run=True) # Preview only
843
+ >>> await commands.fix(yes=True) # Auto-approve
844
+ >>> await commands.fix(update_database=False) # Files only
845
+ """
846
+ all_migrations = await self.runner.get_migration_files()
847
+
848
+ conversion_map = generate_conversion_map(all_migrations)
849
+
850
+ if not conversion_map:
851
+ console.print("[yellow]No timestamp migrations found - nothing to convert[/]")
852
+ return
853
+
854
+ fixer = MigrationFixer(self.migrations_path)
855
+ renames = fixer.plan_renames(conversion_map)
856
+
857
+ table = Table(title="Migration Conversions")
858
+ table.add_column("Current Version", style="cyan")
859
+ table.add_column("New Version", style="green")
860
+ table.add_column("File")
861
+
862
+ for rename in renames:
863
+ table.add_row(rename.old_version, rename.new_version, rename.old_path.name)
864
+
865
+ console.print(table)
866
+ console.print(f"\n[yellow]{len(renames)} migrations will be converted[/]")
867
+
868
+ if dry_run:
869
+ console.print("[yellow][Preview Mode - No changes made][/]")
870
+ return
871
+
872
+ if not yes:
873
+ response = input("\nProceed with conversion? [y/N]: ")
874
+ if response.lower() != "y":
875
+ console.print("[yellow]Conversion cancelled[/]")
876
+ return
877
+
878
+ try:
879
+ backup_path = fixer.create_backup()
880
+ console.print(f"[green]✓ Created backup in {backup_path.name}[/]")
881
+
882
+ fixer.apply_renames(renames)
883
+ for rename in renames:
884
+ console.print(f"[green]✓ Renamed {rename.old_path.name} → {rename.new_path.name}[/]")
885
+
886
+ if update_database:
887
+ async with self.config.provide_session() as driver:
888
+ await self.tracker.ensure_tracking_table(driver)
889
+ applied_migrations = await self.tracker.get_applied_migrations(driver)
890
+ applied_versions = {m["version_num"] for m in applied_migrations}
891
+
892
+ updated_count = 0
893
+ for old_version, new_version in conversion_map.items():
894
+ if old_version in applied_versions:
895
+ await self.tracker.update_version_record(driver, old_version, new_version)
896
+ updated_count += 1
897
+
898
+ if updated_count > 0:
899
+ console.print(
900
+ f"[green]✓ Updated {updated_count} version records in migration tracking table[/]"
901
+ )
902
+ else:
903
+ console.print("[green]✓ No applied migrations to update in tracking table[/]")
904
+
905
+ fixer.cleanup()
906
+ console.print("[green]✓ Conversion complete![/]")
907
+
908
+ except Exception as e:
909
+ logger.exception("Fix command failed")
910
+ console.print(f"[red]✗ Error: {e}[/]")
911
+ fixer.rollback()
912
+ console.print("[yellow]Restored files from backup[/]")
913
+ raise
914
+
386
915
 
387
916
  def create_migration_commands(
388
- config: "Union[SyncConfigT, AsyncConfigT]",
389
- ) -> "Union[SyncMigrationCommands[Any], AsyncMigrationCommands[Any]]":
917
+ config: "SyncConfigT | AsyncConfigT",
918
+ ) -> "SyncMigrationCommands[SyncConfigT] | AsyncMigrationCommands[AsyncConfigT]":
390
919
  """Factory function to create the appropriate migration commands.
391
920
 
392
921
  Args:
@@ -396,5 +925,5 @@ def create_migration_commands(
396
925
  Appropriate migration commands instance.
397
926
  """
398
927
  if config.is_async:
399
- return AsyncMigrationCommands(cast("AsyncConfigT", config))
400
- return SyncMigrationCommands(cast("SyncConfigT", config))
928
+ return cast("AsyncMigrationCommands[AsyncConfigT]", AsyncMigrationCommands(cast("AsyncConfigT", config)))
929
+ return cast("SyncMigrationCommands[SyncConfigT]", SyncMigrationCommands(cast("SyncConfigT", config)))