sqlspec 0.25.0__py3-none-any.whl → 0.27.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 (199) hide show
  1. sqlspec/__init__.py +7 -15
  2. sqlspec/_serialization.py +256 -24
  3. sqlspec/_typing.py +71 -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 +870 -0
  7. sqlspec/adapters/adbc/config.py +69 -12
  8. sqlspec/adapters/adbc/data_dictionary.py +340 -0
  9. sqlspec/adapters/adbc/driver.py +266 -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 +153 -0
  13. sqlspec/adapters/aiosqlite/_types.py +1 -1
  14. sqlspec/adapters/aiosqlite/adk/__init__.py +5 -0
  15. sqlspec/adapters/aiosqlite/adk/store.py +527 -0
  16. sqlspec/adapters/aiosqlite/config.py +88 -15
  17. sqlspec/adapters/aiosqlite/data_dictionary.py +149 -0
  18. sqlspec/adapters/aiosqlite/driver.py +143 -40
  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 +2 -2
  24. sqlspec/adapters/asyncmy/adk/__init__.py +5 -0
  25. sqlspec/adapters/asyncmy/adk/store.py +493 -0
  26. sqlspec/adapters/asyncmy/config.py +68 -23
  27. sqlspec/adapters/asyncmy/data_dictionary.py +161 -0
  28. sqlspec/adapters/asyncmy/driver.py +313 -58
  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 +450 -0
  36. sqlspec/adapters/asyncpg/config.py +59 -35
  37. sqlspec/adapters/asyncpg/data_dictionary.py +173 -0
  38. sqlspec/adapters/asyncpg/driver.py +170 -25
  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 +576 -0
  44. sqlspec/adapters/bigquery/config.py +27 -10
  45. sqlspec/adapters/bigquery/data_dictionary.py +149 -0
  46. sqlspec/adapters/bigquery/driver.py +368 -142
  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 +125 -0
  50. sqlspec/adapters/duckdb/_types.py +1 -1
  51. sqlspec/adapters/duckdb/adk/__init__.py +14 -0
  52. sqlspec/adapters/duckdb/adk/store.py +553 -0
  53. sqlspec/adapters/duckdb/config.py +80 -20
  54. sqlspec/adapters/duckdb/data_dictionary.py +163 -0
  55. sqlspec/adapters/duckdb/driver.py +167 -45
  56. sqlspec/adapters/duckdb/litestar/__init__.py +5 -0
  57. sqlspec/adapters/duckdb/litestar/store.py +332 -0
  58. sqlspec/adapters/duckdb/pool.py +4 -4
  59. sqlspec/adapters/duckdb/type_converter.py +133 -0
  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 +1745 -0
  64. sqlspec/adapters/oracledb/config.py +122 -32
  65. sqlspec/adapters/oracledb/data_dictionary.py +509 -0
  66. sqlspec/adapters/oracledb/driver.py +353 -91
  67. sqlspec/adapters/oracledb/litestar/__init__.py +5 -0
  68. sqlspec/adapters/oracledb/litestar/store.py +767 -0
  69. sqlspec/adapters/oracledb/migrations.py +348 -73
  70. sqlspec/adapters/oracledb/type_converter.py +207 -0
  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 +482 -0
  75. sqlspec/adapters/psqlpy/config.py +46 -17
  76. sqlspec/adapters/psqlpy/data_dictionary.py +172 -0
  77. sqlspec/adapters/psqlpy/driver.py +123 -209
  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 +102 -0
  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 +944 -0
  85. sqlspec/adapters/psycopg/config.py +69 -35
  86. sqlspec/adapters/psycopg/data_dictionary.py +331 -0
  87. sqlspec/adapters/psycopg/driver.py +238 -81
  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 +572 -0
  95. sqlspec/adapters/sqlite/config.py +87 -15
  96. sqlspec/adapters/sqlite/data_dictionary.py +149 -0
  97. sqlspec/adapters/sqlite/driver.py +137 -54
  98. sqlspec/adapters/sqlite/litestar/__init__.py +5 -0
  99. sqlspec/adapters/sqlite/litestar/store.py +318 -0
  100. sqlspec/adapters/sqlite/pool.py +18 -9
  101. sqlspec/base.py +45 -26
  102. sqlspec/builder/__init__.py +73 -4
  103. sqlspec/builder/_base.py +162 -89
  104. sqlspec/builder/_column.py +62 -29
  105. sqlspec/builder/_ddl.py +180 -121
  106. sqlspec/builder/_delete.py +5 -4
  107. sqlspec/builder/_dml.py +388 -0
  108. sqlspec/{_sql.py → builder/_factory.py} +53 -94
  109. sqlspec/builder/_insert.py +32 -131
  110. sqlspec/builder/_join.py +375 -0
  111. sqlspec/builder/_merge.py +446 -11
  112. sqlspec/builder/_parsing_utils.py +111 -17
  113. sqlspec/builder/_select.py +1457 -24
  114. sqlspec/builder/_update.py +11 -42
  115. sqlspec/cli.py +307 -194
  116. sqlspec/config.py +252 -67
  117. sqlspec/core/__init__.py +5 -4
  118. sqlspec/core/cache.py +17 -17
  119. sqlspec/core/compiler.py +62 -9
  120. sqlspec/core/filters.py +37 -37
  121. sqlspec/core/hashing.py +9 -9
  122. sqlspec/core/parameters.py +83 -48
  123. sqlspec/core/result.py +102 -46
  124. sqlspec/core/splitter.py +16 -17
  125. sqlspec/core/statement.py +36 -30
  126. sqlspec/core/type_conversion.py +235 -0
  127. sqlspec/driver/__init__.py +7 -6
  128. sqlspec/driver/_async.py +188 -151
  129. sqlspec/driver/_common.py +285 -80
  130. sqlspec/driver/_sync.py +188 -152
  131. sqlspec/driver/mixins/_result_tools.py +20 -236
  132. sqlspec/driver/mixins/_sql_translator.py +4 -4
  133. sqlspec/exceptions.py +75 -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 +73 -53
  142. sqlspec/extensions/litestar/__init__.py +21 -4
  143. sqlspec/extensions/litestar/cli.py +54 -10
  144. sqlspec/extensions/litestar/config.py +59 -266
  145. sqlspec/extensions/litestar/handlers.py +46 -17
  146. sqlspec/extensions/litestar/migrations/0001_create_session_table.py +137 -0
  147. sqlspec/extensions/litestar/migrations/__init__.py +3 -0
  148. sqlspec/extensions/litestar/plugin.py +324 -223
  149. sqlspec/extensions/litestar/providers.py +25 -25
  150. sqlspec/extensions/litestar/store.py +265 -0
  151. sqlspec/loader.py +30 -49
  152. sqlspec/migrations/__init__.py +4 -3
  153. sqlspec/migrations/base.py +302 -39
  154. sqlspec/migrations/commands.py +611 -144
  155. sqlspec/migrations/context.py +142 -0
  156. sqlspec/migrations/fix.py +199 -0
  157. sqlspec/migrations/loaders.py +68 -23
  158. sqlspec/migrations/runner.py +543 -107
  159. sqlspec/migrations/tracker.py +237 -21
  160. sqlspec/migrations/utils.py +51 -3
  161. sqlspec/migrations/validation.py +177 -0
  162. sqlspec/protocols.py +66 -36
  163. sqlspec/storage/_utils.py +98 -0
  164. sqlspec/storage/backends/fsspec.py +134 -106
  165. sqlspec/storage/backends/local.py +78 -51
  166. sqlspec/storage/backends/obstore.py +278 -162
  167. sqlspec/storage/registry.py +75 -39
  168. sqlspec/typing.py +16 -84
  169. sqlspec/utils/config_resolver.py +153 -0
  170. sqlspec/utils/correlation.py +4 -5
  171. sqlspec/utils/data_transformation.py +3 -2
  172. sqlspec/utils/deprecation.py +9 -8
  173. sqlspec/utils/fixtures.py +4 -4
  174. sqlspec/utils/logging.py +46 -6
  175. sqlspec/utils/module_loader.py +2 -2
  176. sqlspec/utils/schema.py +288 -0
  177. sqlspec/utils/serializers.py +50 -2
  178. sqlspec/utils/sync_tools.py +21 -17
  179. sqlspec/utils/text.py +1 -2
  180. sqlspec/utils/type_guards.py +111 -20
  181. sqlspec/utils/version.py +433 -0
  182. {sqlspec-0.25.0.dist-info → sqlspec-0.27.0.dist-info}/METADATA +40 -21
  183. sqlspec-0.27.0.dist-info/RECORD +207 -0
  184. sqlspec/builder/mixins/__init__.py +0 -55
  185. sqlspec/builder/mixins/_cte_and_set_ops.py +0 -254
  186. sqlspec/builder/mixins/_delete_operations.py +0 -50
  187. sqlspec/builder/mixins/_insert_operations.py +0 -282
  188. sqlspec/builder/mixins/_join_operations.py +0 -389
  189. sqlspec/builder/mixins/_merge_operations.py +0 -592
  190. sqlspec/builder/mixins/_order_limit_operations.py +0 -152
  191. sqlspec/builder/mixins/_pivot_operations.py +0 -157
  192. sqlspec/builder/mixins/_select_operations.py +0 -936
  193. sqlspec/builder/mixins/_update_operations.py +0 -218
  194. sqlspec/builder/mixins/_where_clause.py +0 -1304
  195. sqlspec-0.25.0.dist-info/RECORD +0 -139
  196. sqlspec-0.25.0.dist-info/licenses/NOTICE +0 -29
  197. {sqlspec-0.25.0.dist-info → sqlspec-0.27.0.dist-info}/WHEEL +0 -0
  198. {sqlspec-0.25.0.dist-info → sqlspec-0.27.0.dist-info}/entry_points.txt +0 -0
  199. {sqlspec-0.25.0.dist-info → sqlspec-0.27.0.dist-info}/licenses/LICENSE +0 -0
@@ -3,22 +3,27 @@
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
+ from sqlspec.migrations.context import MigrationContext
14
+ from sqlspec.migrations.fix import MigrationFixer
13
15
  from sqlspec.migrations.runner import AsyncMigrationRunner, SyncMigrationRunner
14
16
  from sqlspec.migrations.utils import create_migration_file
17
+ from sqlspec.migrations.validation import validate_migration_order
15
18
  from sqlspec.utils.logging import get_logger
16
- from sqlspec.utils.sync_tools import await_
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
- __all__ = ("AsyncMigrationCommands", "MigrationCommands", "SyncMigrationCommands")
26
+ __all__ = ("AsyncMigrationCommands", "SyncMigrationCommands", "create_migration_commands")
22
27
 
23
28
  logger = get_logger("migrations.commands")
24
29
  console = Console()
@@ -35,7 +40,14 @@ class SyncMigrationCommands(BaseMigrationCommands["SyncConfigT", Any]):
35
40
  """
36
41
  super().__init__(config)
37
42
  self.tracker = config.migration_tracker_type(self.version_table)
38
- self.runner = SyncMigrationRunner(self.migrations_path)
43
+
44
+ # Create context with extension configurations
45
+ context = MigrationContext.from_config(config)
46
+ context.extension_config = self.extension_configs
47
+
48
+ self.runner = SyncMigrationRunner(
49
+ self.migrations_path, self._discover_extension_migrations(), context, self.extension_configs
50
+ )
39
51
 
40
52
  def init(self, directory: str, package: bool = True) -> None:
41
53
  """Initialize migration directory structure.
@@ -46,7 +58,7 @@ class SyncMigrationCommands(BaseMigrationCommands["SyncConfigT", Any]):
46
58
  """
47
59
  self.init_directory(directory, package)
48
60
 
49
- def current(self, verbose: bool = False) -> "Optional[str]":
61
+ def current(self, verbose: bool = False) -> "str | None":
50
62
  """Show current migration version.
51
63
 
52
64
  Args:
@@ -86,52 +98,204 @@ class SyncMigrationCommands(BaseMigrationCommands["SyncConfigT", Any]):
86
98
 
87
99
  console.print(table)
88
100
 
89
- 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.
105
+
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.
90
142
 
91
- def upgrade(self, revision: str = "head") -> None:
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:
92
199
  """Upgrade to a target revision.
93
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
+
94
205
  Args:
95
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.
96
212
  """
213
+ if dry_run:
214
+ console.print("[bold yellow]DRY RUN MODE:[/] No database changes will be applied\n")
215
+
97
216
  with self.config.provide_session() as driver:
98
217
  self.tracker.ensure_tracking_table(driver)
99
218
 
100
- 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
+
101
229
  all_migrations = self.runner.get_migration_files()
102
230
  pending = []
103
231
  for version, file_path in all_migrations:
104
- if (current is None or version > current) and (revision == "head" or version <= revision):
105
- 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))
106
242
 
107
243
  if not pending:
108
- 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[/]")
109
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)
110
257
 
111
258
  console.print(f"[yellow]Found {len(pending)} pending migrations[/]")
112
259
 
113
260
  for version, file_path in pending:
114
- migration = self.runner.load_migration(file_path)
261
+ migration = self.runner.load_migration(file_path, version)
115
262
 
116
- 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
117
269
 
118
270
  try:
119
- _, execution_time = self.runner.execute_upgrade(driver, migration)
120
- self.tracker.record_migration(
121
- driver, migration["version"], migration["description"], execution_time, migration["checksum"]
122
- )
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)
123
278
  console.print(f"[green]✓ Applied in {execution_time}ms[/]")
124
279
 
125
280
  except Exception as e:
126
- console.print(f"[red]✗ Failed: {e}[/]")
127
- 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
128
285
 
129
- def downgrade(self, revision: str = "-1") -> None:
286
+ if dry_run:
287
+ console.print("\n[bold yellow]Dry run complete.[/] No changes were made to the database.")
288
+
289
+ def downgrade(self, revision: str = "-1", *, dry_run: bool = False) -> None:
130
290
  """Downgrade to a target revision.
131
291
 
132
292
  Args:
133
293
  revision: Target revision or "-1" for one step back.
294
+ dry_run: If True, show what would be done without making changes.
134
295
  """
296
+ if dry_run:
297
+ console.print("[bold yellow]DRY RUN MODE:[/] No database changes will be applied\n")
298
+
135
299
  with self.config.provide_session() as driver:
136
300
  self.tracker.ensure_tracking_table(driver)
137
301
  applied = self.tracker.get_applied_migrations(driver)
@@ -144,8 +308,12 @@ class SyncMigrationCommands(BaseMigrationCommands["SyncConfigT", Any]):
144
308
  elif revision == "base":
145
309
  to_revert = list(reversed(applied))
146
310
  else:
311
+ from sqlspec.utils.version import parse_version
312
+
313
+ parsed_revision = parse_version(revision)
147
314
  for migration in reversed(applied):
148
- if migration["version_num"] > revision:
315
+ parsed_migration_version = parse_version(migration["version_num"])
316
+ if parsed_migration_version > parsed_revision:
149
317
  to_revert.append(migration)
150
318
 
151
319
  if not to_revert:
@@ -159,15 +327,30 @@ class SyncMigrationCommands(BaseMigrationCommands["SyncConfigT", Any]):
159
327
  if version not in all_files:
160
328
  console.print(f"[red]Migration file not found for {version}[/]")
161
329
  continue
162
- migration = self.runner.load_migration(all_files[version])
163
- 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
+
164
339
  try:
165
- _, execution_time = self.runner.execute_downgrade(driver, migration)
166
- 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)
167
345
  console.print(f"[green]✓ Reverted in {execution_time}ms[/]")
168
346
  except Exception as e:
169
- console.print(f"[red]✗ Failed: {e}[/]")
170
- 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.")
171
354
 
172
355
  def stamp(self, revision: str) -> None:
173
356
  """Mark database as being at a specific revision without running migrations.
@@ -187,31 +370,125 @@ class SyncMigrationCommands(BaseMigrationCommands["SyncConfigT", Any]):
187
370
  console.print(f"[green]Database stamped at revision {revision}[/]")
188
371
 
189
372
  def revision(self, message: str, file_type: str = "sql") -> None:
190
- """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.
191
377
 
192
378
  Args:
193
379
  message: Description for the migration.
194
380
  file_type: Type of migration file to create ('sql' or 'py').
195
381
  """
196
- existing = self.runner.get_migration_files()
197
- next_num = int(existing[-1][0]) + 1 if existing else 1
198
- next_version = str(next_num).zfill(4)
199
- 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)
200
384
  console.print(f"[green]Created migration:[/] {file_path}")
201
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
+
202
472
 
203
473
  class AsyncMigrationCommands(BaseMigrationCommands["AsyncConfigT", Any]):
204
474
  """Asynchronous migration commands."""
205
475
 
206
- def __init__(self, sqlspec_config: "AsyncConfigT") -> None:
476
+ def __init__(self, config: "AsyncConfigT") -> None:
207
477
  """Initialize migration commands.
208
478
 
209
479
  Args:
210
- sqlspec_config: The SQLSpec configuration.
480
+ config: The SQLSpec configuration.
211
481
  """
212
- super().__init__(sqlspec_config)
213
- self.tracker = sqlspec_config.migration_tracker_type(self.version_table)
214
- self.runner = AsyncMigrationRunner(self.migrations_path)
482
+ super().__init__(config)
483
+ self.tracker = config.migration_tracker_type(self.version_table)
484
+
485
+ # Create context with extension configurations
486
+ context = MigrationContext.from_config(config)
487
+ context.extension_config = self.extension_configs
488
+
489
+ self.runner = AsyncMigrationRunner(
490
+ self.migrations_path, self._discover_extension_migrations(), context, self.extension_configs
491
+ )
215
492
 
216
493
  async def init(self, directory: str, package: bool = True) -> None:
217
494
  """Initialize migration directory structure.
@@ -222,7 +499,7 @@ class AsyncMigrationCommands(BaseMigrationCommands["AsyncConfigT", Any]):
222
499
  """
223
500
  self.init_directory(directory, package)
224
501
 
225
- async def current(self, verbose: bool = False) -> "Optional[str]":
502
+ async def current(self, verbose: bool = False) -> "str | None":
226
503
  """Show current migration version.
227
504
 
228
505
  Args:
@@ -258,46 +535,205 @@ class AsyncMigrationCommands(BaseMigrationCommands["AsyncConfigT", Any]):
258
535
  )
259
536
  console.print(table)
260
537
 
261
- return cast("Optional[str]", current)
538
+ return cast("str | None", current)
539
+
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.
262
566
 
263
- async def upgrade(self, revision: str = "head") -> None:
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:
264
640
  """Upgrade to a target revision.
265
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
+
266
646
  Args:
267
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.
268
653
  """
654
+ if dry_run:
655
+ console.print("[bold yellow]DRY RUN MODE:[/] No database changes will be applied\n")
656
+
269
657
  async with self.config.provide_session() as driver:
270
658
  await self.tracker.ensure_tracking_table(driver)
271
659
 
272
- 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
+
273
670
  all_migrations = await self.runner.get_migration_files()
274
671
  pending = []
275
672
  for version, file_path in all_migrations:
276
- if (current is None or version > current) and (revision == "head" or version <= revision):
277
- 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))
278
683
  if not pending:
279
- 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[/]")
280
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
+
281
698
  console.print(f"[yellow]Found {len(pending)} pending migrations[/]")
282
699
  for version, file_path in pending:
283
- migration = await self.runner.load_migration(file_path)
284
- 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
+
285
709
  try:
286
- _, execution_time = await self.runner.execute_upgrade(driver, migration)
287
- await self.tracker.record_migration(
288
- driver, migration["version"], migration["description"], execution_time, migration["checksum"]
289
- )
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)
290
717
  console.print(f"[green]✓ Applied in {execution_time}ms[/]")
291
718
  except Exception as e:
292
- console.print(f"[red]✗ Failed: {e}[/]")
293
- 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
294
723
 
295
- async def downgrade(self, revision: str = "-1") -> None:
724
+ if dry_run:
725
+ console.print("\n[bold yellow]Dry run complete.[/] No changes were made to the database.")
726
+
727
+ async def downgrade(self, revision: str = "-1", *, dry_run: bool = False) -> None:
296
728
  """Downgrade to a target revision.
297
729
 
298
730
  Args:
299
731
  revision: Target revision or "-1" for one step back.
732
+ dry_run: If True, show what would be done without making changes.
300
733
  """
734
+ if dry_run:
735
+ console.print("[bold yellow]DRY RUN MODE:[/] No database changes will be applied\n")
736
+
301
737
  async with self.config.provide_session() as driver:
302
738
  await self.tracker.ensure_tracking_table(driver)
303
739
 
@@ -311,8 +747,12 @@ class AsyncMigrationCommands(BaseMigrationCommands["AsyncConfigT", Any]):
311
747
  elif revision == "base":
312
748
  to_revert = list(reversed(applied))
313
749
  else:
750
+ from sqlspec.utils.version import parse_version
751
+
752
+ parsed_revision = parse_version(revision)
314
753
  for migration in reversed(applied):
315
- if migration["version_num"] > revision:
754
+ parsed_migration_version = parse_version(migration["version_num"])
755
+ if parsed_migration_version > parsed_revision:
316
756
  to_revert.append(migration)
317
757
  if not to_revert:
318
758
  console.print("[yellow]Nothing to downgrade[/]")
@@ -326,16 +766,32 @@ class AsyncMigrationCommands(BaseMigrationCommands["AsyncConfigT", Any]):
326
766
  console.print(f"[red]Migration file not found for {version}[/]")
327
767
  continue
328
768
 
329
- migration = await self.runner.load_migration(all_files[version])
330
- 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
331
777
 
332
778
  try:
333
- _, execution_time = await self.runner.execute_downgrade(driver, migration)
334
- 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
+ )
335
786
  console.print(f"[green]✓ Reverted in {execution_time}ms[/]")
336
787
  except Exception as e:
337
- console.print(f"[red]✗ Failed: {e}[/]")
338
- 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.")
339
795
 
340
796
  async def stamp(self, revision: str) -> None:
341
797
  """Mark database as being at a specific revision without running migrations.
@@ -357,106 +813,117 @@ class AsyncMigrationCommands(BaseMigrationCommands["AsyncConfigT", Any]):
357
813
  console.print(f"[green]Database stamped at revision {revision}[/]")
358
814
 
359
815
  async def revision(self, message: str, file_type: str = "sql") -> None:
360
- """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.
361
820
 
362
821
  Args:
363
822
  message: Description for the migration.
364
823
  file_type: Type of migration file to create ('sql' or 'py').
365
824
  """
366
- existing = await self.runner.get_migration_files()
367
- next_num = int(existing[-1][0]) + 1 if existing else 1
368
- next_version = str(next_num).zfill(4)
369
- 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)
370
827
  console.print(f"[green]Created migration:[/] {file_path}")
371
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.
372
831
 
373
- class MigrationCommands:
374
- """Unified migration commands that adapt to sync/async configs."""
375
-
376
- def __init__(self, config: "Union[SyncConfigT, AsyncConfigT]") -> None:
377
- """Initialize migration commands with sync/async implementation.
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.
378
835
 
379
836
  Args:
380
- config: The SQLSpec configuration.
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
381
845
  """
382
- if config.is_async:
383
- self._impl: Union[AsyncMigrationCommands[Any], SyncMigrationCommands[Any]] = AsyncMigrationCommands(
384
- cast("AsyncConfigT", config)
385
- )
386
- else:
387
- self._impl = SyncMigrationCommands(cast("SyncConfigT", config))
388
- self._is_async = config.is_async
846
+ all_migrations = await self.runner.get_migration_files()
389
847
 
390
- def init(self, directory: str, package: bool = True) -> None:
391
- """Initialize migration directory structure.
848
+ conversion_map = generate_conversion_map(all_migrations)
392
849
 
393
- Args:
394
- directory: Directory to initialize migrations in.
395
- package: Whether to create __init__.py file.
396
- """
397
- if self._is_async:
398
- await_(cast("AsyncMigrationCommands[Any]", self._impl).init, raise_sync_error=False)(
399
- directory, package=package
400
- )
401
- else:
402
- cast("SyncMigrationCommands[Any]", self._impl).init(directory, package=package)
403
-
404
- def current(self, verbose: bool = False) -> "Optional[str]":
405
- """Show current migration version.
850
+ if not conversion_map:
851
+ console.print("[yellow]No timestamp migrations found - nothing to convert[/]")
852
+ return
406
853
 
407
- Args:
408
- verbose: Whether to show detailed migration history.
409
-
410
- Returns:
411
- The current migration version or None if no migrations applied.
412
- """
413
- if self._is_async:
414
- return await_(cast("AsyncMigrationCommands[Any]", self._impl).current, raise_sync_error=False)(
415
- verbose=verbose
416
- )
417
- return cast("SyncMigrationCommands[Any]", self._impl).current(verbose=verbose)
418
-
419
- def upgrade(self, revision: str = "head") -> None:
420
- """Upgrade to a target revision.
421
-
422
- Args:
423
- revision: Target revision or "head" for latest.
424
- """
425
- if self._is_async:
426
- await_(cast("AsyncMigrationCommands[Any]", self._impl).upgrade, raise_sync_error=False)(revision=revision)
427
- else:
428
- cast("SyncMigrationCommands[Any]", self._impl).upgrade(revision=revision)
854
+ fixer = MigrationFixer(self.migrations_path)
855
+ renames = fixer.plan_renames(conversion_map)
429
856
 
430
- def downgrade(self, revision: str = "-1") -> None:
431
- """Downgrade to a target revision.
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")
432
861
 
433
- Args:
434
- revision: Target revision or "-1" for one step back.
435
- """
436
- if self._is_async:
437
- await_(cast("AsyncMigrationCommands[Any]", self._impl).downgrade, raise_sync_error=False)(revision=revision)
438
- else:
439
- cast("SyncMigrationCommands[Any]", self._impl).downgrade(revision=revision)
862
+ for rename in renames:
863
+ table.add_row(rename.old_version, rename.new_version, rename.old_path.name)
440
864
 
441
- def stamp(self, revision: str) -> None:
442
- """Mark database as being at a specific revision without running migrations.
865
+ console.print(table)
866
+ console.print(f"\n[yellow]{len(renames)} migrations will be converted[/]")
443
867
 
444
- Args:
445
- revision: The revision to stamp.
446
- """
447
- if self._is_async:
448
- await_(cast("AsyncMigrationCommands[Any]", self._impl).stamp, raise_sync_error=False)(revision)
449
- else:
450
- cast("SyncMigrationCommands[Any]", self._impl).stamp(revision)
868
+ if dry_run:
869
+ console.print("[yellow][Preview Mode - No changes made][/]")
870
+ return
451
871
 
452
- def revision(self, message: str, file_type: str = "sql") -> None:
453
- """Create a new migration file.
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
454
877
 
455
- Args:
456
- message: Description for the migration.
457
- file_type: Type of migration file to create ('sql' or 'py').
458
- """
459
- if self._is_async:
460
- await_(cast("AsyncMigrationCommands[Any]", self._impl).revision, raise_sync_error=False)(message, file_type)
461
- else:
462
- cast("SyncMigrationCommands[Any]", self._impl).revision(message, file_type)
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
+
915
+
916
+ def create_migration_commands(
917
+ config: "SyncConfigT | AsyncConfigT",
918
+ ) -> "SyncMigrationCommands[SyncConfigT] | AsyncMigrationCommands[AsyncConfigT]":
919
+ """Factory function to create the appropriate migration commands.
920
+
921
+ Args:
922
+ config: The SQLSpec configuration.
923
+
924
+ Returns:
925
+ Appropriate migration commands instance.
926
+ """
927
+ if config.is_async:
928
+ return cast("AsyncMigrationCommands[AsyncConfigT]", AsyncMigrationCommands(cast("AsyncConfigT", config)))
929
+ return cast("SyncMigrationCommands[SyncConfigT]", SyncMigrationCommands(cast("SyncConfigT", config)))