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.
- sqlspec/__init__.py +7 -15
- sqlspec/_serialization.py +256 -24
- sqlspec/_typing.py +71 -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 +69 -12
- sqlspec/adapters/adbc/data_dictionary.py +340 -0
- sqlspec/adapters/adbc/driver.py +266 -58
- sqlspec/adapters/adbc/litestar/__init__.py +5 -0
- sqlspec/adapters/adbc/litestar/store.py +504 -0
- sqlspec/adapters/adbc/type_converter.py +153 -0
- 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 +88 -15
- sqlspec/adapters/aiosqlite/data_dictionary.py +149 -0
- sqlspec/adapters/aiosqlite/driver.py +143 -40
- 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 +2 -2
- sqlspec/adapters/asyncmy/adk/__init__.py +5 -0
- sqlspec/adapters/asyncmy/adk/store.py +493 -0
- sqlspec/adapters/asyncmy/config.py +68 -23
- sqlspec/adapters/asyncmy/data_dictionary.py +161 -0
- sqlspec/adapters/asyncmy/driver.py +313 -58
- 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 +59 -35
- sqlspec/adapters/asyncpg/data_dictionary.py +173 -0
- sqlspec/adapters/asyncpg/driver.py +170 -25
- 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 +27 -10
- sqlspec/adapters/bigquery/data_dictionary.py +149 -0
- sqlspec/adapters/bigquery/driver.py +368 -142
- sqlspec/adapters/bigquery/litestar/__init__.py +5 -0
- sqlspec/adapters/bigquery/litestar/store.py +327 -0
- sqlspec/adapters/bigquery/type_converter.py +125 -0
- sqlspec/adapters/duckdb/_types.py +1 -1
- sqlspec/adapters/duckdb/adk/__init__.py +14 -0
- sqlspec/adapters/duckdb/adk/store.py +553 -0
- sqlspec/adapters/duckdb/config.py +80 -20
- sqlspec/adapters/duckdb/data_dictionary.py +163 -0
- sqlspec/adapters/duckdb/driver.py +167 -45
- sqlspec/adapters/duckdb/litestar/__init__.py +5 -0
- sqlspec/adapters/duckdb/litestar/store.py +332 -0
- sqlspec/adapters/duckdb/pool.py +4 -4
- sqlspec/adapters/duckdb/type_converter.py +133 -0
- 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 +122 -32
- sqlspec/adapters/oracledb/data_dictionary.py +509 -0
- sqlspec/adapters/oracledb/driver.py +353 -91
- sqlspec/adapters/oracledb/litestar/__init__.py +5 -0
- sqlspec/adapters/oracledb/litestar/store.py +767 -0
- sqlspec/adapters/oracledb/migrations.py +348 -73
- sqlspec/adapters/oracledb/type_converter.py +207 -0
- 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 +46 -17
- sqlspec/adapters/psqlpy/data_dictionary.py +172 -0
- sqlspec/adapters/psqlpy/driver.py +123 -209
- sqlspec/adapters/psqlpy/litestar/__init__.py +5 -0
- sqlspec/adapters/psqlpy/litestar/store.py +272 -0
- sqlspec/adapters/psqlpy/type_converter.py +102 -0
- 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 +69 -35
- sqlspec/adapters/psycopg/data_dictionary.py +331 -0
- sqlspec/adapters/psycopg/driver.py +238 -81
- 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 +87 -15
- sqlspec/adapters/sqlite/data_dictionary.py +149 -0
- sqlspec/adapters/sqlite/driver.py +137 -54
- sqlspec/adapters/sqlite/litestar/__init__.py +5 -0
- sqlspec/adapters/sqlite/litestar/store.py +318 -0
- sqlspec/adapters/sqlite/pool.py +18 -9
- sqlspec/base.py +45 -26
- sqlspec/builder/__init__.py +73 -4
- sqlspec/builder/_base.py +162 -89
- sqlspec/builder/_column.py +62 -29
- sqlspec/builder/_ddl.py +180 -121
- sqlspec/builder/_delete.py +5 -4
- sqlspec/builder/_dml.py +388 -0
- sqlspec/{_sql.py → builder/_factory.py} +53 -94
- sqlspec/builder/_insert.py +32 -131
- sqlspec/builder/_join.py +375 -0
- sqlspec/builder/_merge.py +446 -11
- sqlspec/builder/_parsing_utils.py +111 -17
- sqlspec/builder/_select.py +1457 -24
- sqlspec/builder/_update.py +11 -42
- sqlspec/cli.py +307 -194
- sqlspec/config.py +252 -67
- sqlspec/core/__init__.py +5 -4
- sqlspec/core/cache.py +17 -17
- sqlspec/core/compiler.py +62 -9
- sqlspec/core/filters.py +37 -37
- sqlspec/core/hashing.py +9 -9
- sqlspec/core/parameters.py +83 -48
- sqlspec/core/result.py +102 -46
- sqlspec/core/splitter.py +16 -17
- sqlspec/core/statement.py +36 -30
- sqlspec/core/type_conversion.py +235 -0
- sqlspec/driver/__init__.py +7 -6
- sqlspec/driver/_async.py +188 -151
- sqlspec/driver/_common.py +285 -80
- sqlspec/driver/_sync.py +188 -152
- sqlspec/driver/mixins/_result_tools.py +20 -236
- sqlspec/driver/mixins/_sql_translator.py +4 -4
- sqlspec/exceptions.py +75 -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/__init__.py +4 -3
- sqlspec/migrations/base.py +302 -39
- sqlspec/migrations/commands.py +611 -144
- sqlspec/migrations/context.py +142 -0
- sqlspec/migrations/fix.py +199 -0
- sqlspec/migrations/loaders.py +68 -23
- sqlspec/migrations/runner.py +543 -107
- 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 +16 -84
- sqlspec/utils/config_resolver.py +153 -0
- 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 +50 -2
- 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.25.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 -254
- sqlspec/builder/mixins/_delete_operations.py +0 -50
- sqlspec/builder/mixins/_insert_operations.py +0 -282
- sqlspec/builder/mixins/_join_operations.py +0 -389
- sqlspec/builder/mixins/_merge_operations.py +0 -592
- sqlspec/builder/mixins/_order_limit_operations.py +0 -152
- sqlspec/builder/mixins/_pivot_operations.py +0 -157
- sqlspec/builder/mixins/_select_operations.py +0 -936
- sqlspec/builder/mixins/_update_operations.py +0 -218
- sqlspec/builder/mixins/_where_clause.py +0 -1304
- sqlspec-0.25.0.dist-info/RECORD +0 -139
- sqlspec-0.25.0.dist-info/licenses/NOTICE +0 -29
- {sqlspec-0.25.0.dist-info → sqlspec-0.27.0.dist-info}/WHEEL +0 -0
- {sqlspec-0.25.0.dist-info → sqlspec-0.27.0.dist-info}/entry_points.txt +0 -0
- {sqlspec-0.25.0.dist-info → sqlspec-0.27.0.dist-info}/licenses/LICENSE +0 -0
sqlspec/migrations/commands.py
CHANGED
|
@@ -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,
|
|
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
|
+
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.
|
|
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", "
|
|
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
|
-
|
|
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) -> "
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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,
|
|
476
|
+
def __init__(self, config: "AsyncConfigT") -> None:
|
|
207
477
|
"""Initialize migration commands.
|
|
208
478
|
|
|
209
479
|
Args:
|
|
210
|
-
|
|
480
|
+
config: The SQLSpec configuration.
|
|
211
481
|
"""
|
|
212
|
-
super().__init__(
|
|
213
|
-
self.tracker =
|
|
214
|
-
|
|
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) -> "
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
277
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
334
|
-
|
|
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
|
-
|
|
338
|
-
|
|
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
|
-
|
|
367
|
-
|
|
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
|
-
|
|
374
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
391
|
-
"""Initialize migration directory structure.
|
|
848
|
+
conversion_map = generate_conversion_map(all_migrations)
|
|
392
849
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
408
|
-
|
|
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
|
-
|
|
431
|
-
"""
|
|
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
|
-
|
|
434
|
-
|
|
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
|
-
|
|
442
|
-
"
|
|
865
|
+
console.print(table)
|
|
866
|
+
console.print(f"\n[yellow]{len(renames)} migrations will be converted[/]")
|
|
443
867
|
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
453
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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)))
|