sqlspec 0.26.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.
- sqlspec/__init__.py +7 -15
- sqlspec/_serialization.py +55 -25
- sqlspec/_typing.py +62 -52
- sqlspec/adapters/adbc/_types.py +1 -1
- sqlspec/adapters/adbc/adk/__init__.py +5 -0
- sqlspec/adapters/adbc/adk/store.py +870 -0
- sqlspec/adapters/adbc/config.py +62 -12
- sqlspec/adapters/adbc/data_dictionary.py +52 -2
- sqlspec/adapters/adbc/driver.py +144 -45
- sqlspec/adapters/adbc/litestar/__init__.py +5 -0
- sqlspec/adapters/adbc/litestar/store.py +504 -0
- sqlspec/adapters/adbc/type_converter.py +44 -50
- sqlspec/adapters/aiosqlite/_types.py +1 -1
- sqlspec/adapters/aiosqlite/adk/__init__.py +5 -0
- sqlspec/adapters/aiosqlite/adk/store.py +527 -0
- sqlspec/adapters/aiosqlite/config.py +86 -16
- sqlspec/adapters/aiosqlite/data_dictionary.py +34 -2
- sqlspec/adapters/aiosqlite/driver.py +127 -38
- sqlspec/adapters/aiosqlite/litestar/__init__.py +5 -0
- sqlspec/adapters/aiosqlite/litestar/store.py +281 -0
- sqlspec/adapters/aiosqlite/pool.py +7 -7
- sqlspec/adapters/asyncmy/__init__.py +7 -1
- sqlspec/adapters/asyncmy/_types.py +1 -1
- sqlspec/adapters/asyncmy/adk/__init__.py +5 -0
- sqlspec/adapters/asyncmy/adk/store.py +493 -0
- sqlspec/adapters/asyncmy/config.py +59 -17
- sqlspec/adapters/asyncmy/data_dictionary.py +41 -2
- sqlspec/adapters/asyncmy/driver.py +293 -62
- sqlspec/adapters/asyncmy/litestar/__init__.py +5 -0
- sqlspec/adapters/asyncmy/litestar/store.py +296 -0
- sqlspec/adapters/asyncpg/__init__.py +2 -1
- sqlspec/adapters/asyncpg/_type_handlers.py +71 -0
- sqlspec/adapters/asyncpg/_types.py +11 -7
- sqlspec/adapters/asyncpg/adk/__init__.py +5 -0
- sqlspec/adapters/asyncpg/adk/store.py +450 -0
- sqlspec/adapters/asyncpg/config.py +57 -36
- sqlspec/adapters/asyncpg/data_dictionary.py +41 -2
- sqlspec/adapters/asyncpg/driver.py +153 -23
- sqlspec/adapters/asyncpg/litestar/__init__.py +5 -0
- sqlspec/adapters/asyncpg/litestar/store.py +253 -0
- sqlspec/adapters/bigquery/_types.py +1 -1
- sqlspec/adapters/bigquery/adk/__init__.py +5 -0
- sqlspec/adapters/bigquery/adk/store.py +576 -0
- sqlspec/adapters/bigquery/config.py +25 -11
- sqlspec/adapters/bigquery/data_dictionary.py +42 -2
- sqlspec/adapters/bigquery/driver.py +352 -144
- sqlspec/adapters/bigquery/litestar/__init__.py +5 -0
- sqlspec/adapters/bigquery/litestar/store.py +327 -0
- sqlspec/adapters/bigquery/type_converter.py +55 -23
- sqlspec/adapters/duckdb/_types.py +2 -2
- sqlspec/adapters/duckdb/adk/__init__.py +14 -0
- sqlspec/adapters/duckdb/adk/store.py +553 -0
- sqlspec/adapters/duckdb/config.py +79 -21
- sqlspec/adapters/duckdb/data_dictionary.py +41 -2
- sqlspec/adapters/duckdb/driver.py +138 -43
- sqlspec/adapters/duckdb/litestar/__init__.py +5 -0
- sqlspec/adapters/duckdb/litestar/store.py +332 -0
- sqlspec/adapters/duckdb/pool.py +5 -5
- sqlspec/adapters/duckdb/type_converter.py +51 -21
- sqlspec/adapters/oracledb/_numpy_handlers.py +133 -0
- sqlspec/adapters/oracledb/_types.py +20 -2
- sqlspec/adapters/oracledb/adk/__init__.py +5 -0
- sqlspec/adapters/oracledb/adk/store.py +1745 -0
- sqlspec/adapters/oracledb/config.py +120 -36
- sqlspec/adapters/oracledb/data_dictionary.py +87 -20
- sqlspec/adapters/oracledb/driver.py +292 -84
- sqlspec/adapters/oracledb/litestar/__init__.py +5 -0
- sqlspec/adapters/oracledb/litestar/store.py +767 -0
- sqlspec/adapters/oracledb/migrations.py +316 -25
- sqlspec/adapters/oracledb/type_converter.py +91 -16
- sqlspec/adapters/psqlpy/_type_handlers.py +44 -0
- sqlspec/adapters/psqlpy/_types.py +2 -1
- sqlspec/adapters/psqlpy/adk/__init__.py +5 -0
- sqlspec/adapters/psqlpy/adk/store.py +482 -0
- sqlspec/adapters/psqlpy/config.py +45 -19
- sqlspec/adapters/psqlpy/data_dictionary.py +41 -2
- sqlspec/adapters/psqlpy/driver.py +101 -31
- sqlspec/adapters/psqlpy/litestar/__init__.py +5 -0
- sqlspec/adapters/psqlpy/litestar/store.py +272 -0
- sqlspec/adapters/psqlpy/type_converter.py +40 -11
- sqlspec/adapters/psycopg/_type_handlers.py +80 -0
- sqlspec/adapters/psycopg/_types.py +2 -1
- sqlspec/adapters/psycopg/adk/__init__.py +5 -0
- sqlspec/adapters/psycopg/adk/store.py +944 -0
- sqlspec/adapters/psycopg/config.py +65 -37
- sqlspec/adapters/psycopg/data_dictionary.py +77 -3
- sqlspec/adapters/psycopg/driver.py +200 -78
- sqlspec/adapters/psycopg/litestar/__init__.py +5 -0
- sqlspec/adapters/psycopg/litestar/store.py +554 -0
- sqlspec/adapters/sqlite/__init__.py +2 -1
- sqlspec/adapters/sqlite/_type_handlers.py +86 -0
- sqlspec/adapters/sqlite/_types.py +1 -1
- sqlspec/adapters/sqlite/adk/__init__.py +5 -0
- sqlspec/adapters/sqlite/adk/store.py +572 -0
- sqlspec/adapters/sqlite/config.py +85 -16
- sqlspec/adapters/sqlite/data_dictionary.py +34 -2
- sqlspec/adapters/sqlite/driver.py +120 -52
- sqlspec/adapters/sqlite/litestar/__init__.py +5 -0
- sqlspec/adapters/sqlite/litestar/store.py +318 -0
- sqlspec/adapters/sqlite/pool.py +5 -5
- sqlspec/base.py +45 -26
- sqlspec/builder/__init__.py +73 -4
- sqlspec/builder/_base.py +91 -58
- sqlspec/builder/_column.py +5 -5
- sqlspec/builder/_ddl.py +98 -89
- sqlspec/builder/_delete.py +5 -4
- sqlspec/builder/_dml.py +388 -0
- sqlspec/{_sql.py → builder/_factory.py} +41 -44
- sqlspec/builder/_insert.py +5 -82
- sqlspec/builder/{mixins/_join_operations.py → _join.py} +145 -143
- sqlspec/builder/_merge.py +446 -11
- sqlspec/builder/_parsing_utils.py +9 -11
- sqlspec/builder/_select.py +1313 -25
- sqlspec/builder/_update.py +11 -42
- sqlspec/cli.py +76 -69
- sqlspec/config.py +231 -60
- sqlspec/core/__init__.py +5 -4
- sqlspec/core/cache.py +18 -18
- sqlspec/core/compiler.py +6 -8
- sqlspec/core/filters.py +37 -37
- sqlspec/core/hashing.py +9 -9
- sqlspec/core/parameters.py +76 -45
- sqlspec/core/result.py +102 -46
- sqlspec/core/splitter.py +16 -17
- sqlspec/core/statement.py +32 -31
- sqlspec/core/type_conversion.py +3 -2
- sqlspec/driver/__init__.py +1 -3
- sqlspec/driver/_async.py +95 -161
- sqlspec/driver/_common.py +133 -80
- sqlspec/driver/_sync.py +95 -162
- sqlspec/driver/mixins/_result_tools.py +20 -236
- sqlspec/driver/mixins/_sql_translator.py +4 -4
- sqlspec/exceptions.py +70 -7
- sqlspec/extensions/adk/__init__.py +53 -0
- sqlspec/extensions/adk/_types.py +51 -0
- sqlspec/extensions/adk/converters.py +172 -0
- sqlspec/extensions/adk/migrations/0001_create_adk_tables.py +144 -0
- sqlspec/extensions/adk/migrations/__init__.py +0 -0
- sqlspec/extensions/adk/service.py +181 -0
- sqlspec/extensions/adk/store.py +536 -0
- sqlspec/extensions/aiosql/adapter.py +73 -53
- sqlspec/extensions/litestar/__init__.py +21 -4
- sqlspec/extensions/litestar/cli.py +54 -10
- sqlspec/extensions/litestar/config.py +59 -266
- sqlspec/extensions/litestar/handlers.py +46 -17
- sqlspec/extensions/litestar/migrations/0001_create_session_table.py +137 -0
- sqlspec/extensions/litestar/migrations/__init__.py +3 -0
- sqlspec/extensions/litestar/plugin.py +324 -223
- sqlspec/extensions/litestar/providers.py +25 -25
- sqlspec/extensions/litestar/store.py +265 -0
- sqlspec/loader.py +30 -49
- sqlspec/migrations/base.py +200 -76
- sqlspec/migrations/commands.py +591 -62
- sqlspec/migrations/context.py +6 -9
- sqlspec/migrations/fix.py +199 -0
- sqlspec/migrations/loaders.py +47 -19
- sqlspec/migrations/runner.py +241 -75
- sqlspec/migrations/tracker.py +237 -21
- sqlspec/migrations/utils.py +51 -3
- sqlspec/migrations/validation.py +177 -0
- sqlspec/protocols.py +66 -36
- sqlspec/storage/_utils.py +98 -0
- sqlspec/storage/backends/fsspec.py +134 -106
- sqlspec/storage/backends/local.py +78 -51
- sqlspec/storage/backends/obstore.py +278 -162
- sqlspec/storage/registry.py +75 -39
- sqlspec/typing.py +14 -84
- sqlspec/utils/config_resolver.py +6 -6
- sqlspec/utils/correlation.py +4 -5
- sqlspec/utils/data_transformation.py +3 -2
- sqlspec/utils/deprecation.py +9 -8
- sqlspec/utils/fixtures.py +4 -4
- sqlspec/utils/logging.py +46 -6
- sqlspec/utils/module_loader.py +2 -2
- sqlspec/utils/schema.py +288 -0
- sqlspec/utils/serializers.py +3 -3
- sqlspec/utils/sync_tools.py +21 -17
- sqlspec/utils/text.py +1 -2
- sqlspec/utils/type_guards.py +111 -20
- sqlspec/utils/version.py +433 -0
- {sqlspec-0.26.0.dist-info → sqlspec-0.27.0.dist-info}/METADATA +40 -21
- sqlspec-0.27.0.dist-info/RECORD +207 -0
- sqlspec/builder/mixins/__init__.py +0 -55
- sqlspec/builder/mixins/_cte_and_set_ops.py +0 -253
- sqlspec/builder/mixins/_delete_operations.py +0 -50
- sqlspec/builder/mixins/_insert_operations.py +0 -282
- sqlspec/builder/mixins/_merge_operations.py +0 -698
- sqlspec/builder/mixins/_order_limit_operations.py +0 -145
- sqlspec/builder/mixins/_pivot_operations.py +0 -157
- sqlspec/builder/mixins/_select_operations.py +0 -930
- sqlspec/builder/mixins/_update_operations.py +0 -199
- sqlspec/builder/mixins/_where_clause.py +0 -1298
- sqlspec-0.26.0.dist-info/RECORD +0 -157
- sqlspec-0.26.0.dist-info/licenses/NOTICE +0 -29
- {sqlspec-0.26.0.dist-info → sqlspec-0.27.0.dist-info}/WHEEL +0 -0
- {sqlspec-0.26.0.dist-info → sqlspec-0.27.0.dist-info}/entry_points.txt +0 -0
- {sqlspec-0.26.0.dist-info → sqlspec-0.27.0.dist-info}/licenses/LICENSE +0 -0
sqlspec/migrations/commands.py
CHANGED
|
@@ -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,
|
|
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.
|
|
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) -> "
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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) -> "
|
|
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("
|
|
538
|
+
return cast("str | None", current)
|
|
276
539
|
|
|
277
|
-
async def
|
|
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
|
-
|
|
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
|
|
291
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
307
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
348
|
-
|
|
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
|
-
|
|
352
|
-
|
|
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
|
-
|
|
381
|
-
|
|
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: "
|
|
389
|
-
) -> "
|
|
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)))
|