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/tracker.py
CHANGED
|
@@ -4,10 +4,14 @@ This module provides functionality to track applied migrations in the database.
|
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
import os
|
|
7
|
-
from typing import TYPE_CHECKING, Any
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
8
|
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from sqlspec.builder import sql
|
|
9
12
|
from sqlspec.migrations.base import BaseMigrationTracker
|
|
10
13
|
from sqlspec.utils.logging import get_logger
|
|
14
|
+
from sqlspec.utils.version import parse_version
|
|
11
15
|
|
|
12
16
|
if TYPE_CHECKING:
|
|
13
17
|
from sqlspec.driver import AsyncDriverAdapterBase, SyncDriverAdapterBase
|
|
@@ -20,16 +24,75 @@ logger = get_logger("migrations.tracker")
|
|
|
20
24
|
class SyncMigrationTracker(BaseMigrationTracker["SyncDriverAdapterBase"]):
|
|
21
25
|
"""Synchronous migration version tracker."""
|
|
22
26
|
|
|
27
|
+
def _migrate_schema_if_needed(self, driver: "SyncDriverAdapterBase") -> None:
|
|
28
|
+
"""Check for and add any missing columns to the tracking table.
|
|
29
|
+
|
|
30
|
+
Uses the adapter's data_dictionary to query existing columns,
|
|
31
|
+
then compares to the target schema and adds missing columns one by one.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
driver: The database driver to use.
|
|
35
|
+
"""
|
|
36
|
+
try:
|
|
37
|
+
columns_data = driver.data_dictionary.get_columns(driver, self.version_table)
|
|
38
|
+
if not columns_data:
|
|
39
|
+
logger.debug("Migration tracking table does not exist yet")
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
existing_columns = {col["column_name"] for col in columns_data}
|
|
43
|
+
missing_columns = self._detect_missing_columns(existing_columns)
|
|
44
|
+
|
|
45
|
+
if not missing_columns:
|
|
46
|
+
logger.debug("Migration tracking table schema is up-to-date")
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
console = Console()
|
|
50
|
+
console.print(
|
|
51
|
+
f"[cyan]Migrating tracking table schema, adding columns: {', '.join(sorted(missing_columns))}[/]"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
for col_name in sorted(missing_columns):
|
|
55
|
+
self._add_column(driver, col_name)
|
|
56
|
+
|
|
57
|
+
driver.commit()
|
|
58
|
+
console.print("[green]Migration tracking table schema updated successfully[/]")
|
|
59
|
+
|
|
60
|
+
except Exception as e:
|
|
61
|
+
logger.warning("Could not check or migrate tracking table schema: %s", e)
|
|
62
|
+
|
|
63
|
+
def _add_column(self, driver: "SyncDriverAdapterBase", column_name: str) -> None:
|
|
64
|
+
"""Add a single column to the tracking table.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
driver: The database driver to use.
|
|
68
|
+
column_name: Name of the column to add (lowercase).
|
|
69
|
+
"""
|
|
70
|
+
target_create = self._get_create_table_sql()
|
|
71
|
+
column_def = next((col for col in target_create.columns if col.name.lower() == column_name), None)
|
|
72
|
+
|
|
73
|
+
if not column_def:
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
alter_sql = sql.alter_table(self.version_table).add_column(
|
|
77
|
+
name=column_def.name, dtype=column_def.dtype, default=column_def.default, not_null=column_def.not_null
|
|
78
|
+
)
|
|
79
|
+
driver.execute(alter_sql)
|
|
80
|
+
logger.debug("Added column %s to tracking table", column_name)
|
|
81
|
+
|
|
23
82
|
def ensure_tracking_table(self, driver: "SyncDriverAdapterBase") -> None:
|
|
24
83
|
"""Create the migration tracking table if it doesn't exist.
|
|
25
84
|
|
|
85
|
+
Also checks for and adds any missing columns to support schema migrations.
|
|
86
|
+
|
|
26
87
|
Args:
|
|
27
88
|
driver: The database driver to use.
|
|
28
89
|
"""
|
|
29
90
|
driver.execute(self._get_create_table_sql())
|
|
30
91
|
self._safe_commit(driver)
|
|
31
92
|
|
|
32
|
-
|
|
93
|
+
self._migrate_schema_if_needed(driver)
|
|
94
|
+
|
|
95
|
+
def get_current_version(self, driver: "SyncDriverAdapterBase") -> str | None:
|
|
33
96
|
"""Get the latest applied migration version.
|
|
34
97
|
|
|
35
98
|
Args:
|
|
@@ -58,6 +121,9 @@ class SyncMigrationTracker(BaseMigrationTracker["SyncDriverAdapterBase"]):
|
|
|
58
121
|
) -> None:
|
|
59
122
|
"""Record a successfully applied migration.
|
|
60
123
|
|
|
124
|
+
Parses version to determine type (sequential or timestamp) and
|
|
125
|
+
auto-increments execution_sequence for application order tracking.
|
|
126
|
+
|
|
61
127
|
Args:
|
|
62
128
|
driver: The database driver to use.
|
|
63
129
|
version: Version number of the migration.
|
|
@@ -65,9 +131,21 @@ class SyncMigrationTracker(BaseMigrationTracker["SyncDriverAdapterBase"]):
|
|
|
65
131
|
execution_time_ms: Execution time in milliseconds.
|
|
66
132
|
checksum: MD5 checksum of the migration content.
|
|
67
133
|
"""
|
|
134
|
+
parsed_version = parse_version(version)
|
|
135
|
+
version_type = parsed_version.type.value
|
|
136
|
+
|
|
137
|
+
result = driver.execute(self._get_next_execution_sequence_sql())
|
|
138
|
+
next_sequence = result.data[0]["next_seq"] if result.data else 1
|
|
139
|
+
|
|
68
140
|
driver.execute(
|
|
69
141
|
self._get_record_migration_sql(
|
|
70
|
-
version,
|
|
142
|
+
version,
|
|
143
|
+
version_type,
|
|
144
|
+
next_sequence,
|
|
145
|
+
description,
|
|
146
|
+
execution_time_ms,
|
|
147
|
+
checksum,
|
|
148
|
+
os.environ.get("USER", "unknown"),
|
|
71
149
|
)
|
|
72
150
|
)
|
|
73
151
|
self._safe_commit(driver)
|
|
@@ -82,21 +160,52 @@ class SyncMigrationTracker(BaseMigrationTracker["SyncDriverAdapterBase"]):
|
|
|
82
160
|
driver.execute(self._get_remove_migration_sql(version))
|
|
83
161
|
self._safe_commit(driver)
|
|
84
162
|
|
|
85
|
-
def
|
|
86
|
-
"""
|
|
163
|
+
def update_version_record(self, driver: "SyncDriverAdapterBase", old_version: str, new_version: str) -> None:
|
|
164
|
+
"""Update migration version record from timestamp to sequential.
|
|
165
|
+
|
|
166
|
+
Updates version_num and version_type while preserving execution_sequence,
|
|
167
|
+
applied_at, and other tracking metadata. Used during fix command.
|
|
168
|
+
|
|
169
|
+
Idempotent: If the version is already updated, logs and continues without error.
|
|
170
|
+
This allows fix command to be safely re-run after pulling changes.
|
|
87
171
|
|
|
88
172
|
Args:
|
|
89
173
|
driver: The database driver to use.
|
|
174
|
+
old_version: Current timestamp version string.
|
|
175
|
+
new_version: New sequential version string.
|
|
176
|
+
|
|
177
|
+
Raises:
|
|
178
|
+
ValueError: If neither old_version nor new_version found in database.
|
|
90
179
|
"""
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
180
|
+
parsed_new_version = parse_version(new_version)
|
|
181
|
+
new_version_type = parsed_new_version.type.value
|
|
182
|
+
|
|
183
|
+
result = driver.execute(self._get_update_version_sql(old_version, new_version, new_version_type))
|
|
95
184
|
|
|
96
|
-
|
|
97
|
-
|
|
185
|
+
if result.rows_affected == 0:
|
|
186
|
+
check_result = driver.execute(self._get_applied_migrations_sql())
|
|
187
|
+
applied_versions = {row["version_num"] for row in check_result.data} if check_result.data else set()
|
|
188
|
+
|
|
189
|
+
if new_version in applied_versions:
|
|
190
|
+
logger.debug("Version already updated: %s -> %s", old_version, new_version)
|
|
98
191
|
return
|
|
99
192
|
|
|
193
|
+
msg = f"Migration version {old_version} not found in database"
|
|
194
|
+
raise ValueError(msg)
|
|
195
|
+
|
|
196
|
+
self._safe_commit(driver)
|
|
197
|
+
logger.debug("Updated version record: %s -> %s", old_version, new_version)
|
|
198
|
+
|
|
199
|
+
def _safe_commit(self, driver: "SyncDriverAdapterBase") -> None:
|
|
200
|
+
"""Safely commit a transaction only if autocommit is disabled.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
driver: The database driver to use.
|
|
204
|
+
"""
|
|
205
|
+
if driver.driver_features.get("autocommit", False):
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
try:
|
|
100
209
|
driver.commit()
|
|
101
210
|
except Exception:
|
|
102
211
|
logger.debug("Failed to commit transaction, likely due to autocommit being enabled")
|
|
@@ -105,16 +214,77 @@ class SyncMigrationTracker(BaseMigrationTracker["SyncDriverAdapterBase"]):
|
|
|
105
214
|
class AsyncMigrationTracker(BaseMigrationTracker["AsyncDriverAdapterBase"]):
|
|
106
215
|
"""Asynchronous migration version tracker."""
|
|
107
216
|
|
|
217
|
+
async def _migrate_schema_if_needed(self, driver: "AsyncDriverAdapterBase") -> None:
|
|
218
|
+
"""Check for and add any missing columns to the tracking table.
|
|
219
|
+
|
|
220
|
+
Uses the driver's data_dictionary to query existing columns,
|
|
221
|
+
then compares to the target schema and adds missing columns one by one.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
driver: The database driver to use.
|
|
225
|
+
"""
|
|
226
|
+
try:
|
|
227
|
+
columns_data = await driver.data_dictionary.get_columns(driver, self.version_table)
|
|
228
|
+
if not columns_data:
|
|
229
|
+
logger.debug("Migration tracking table does not exist yet")
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
existing_columns = {col["column_name"] for col in columns_data}
|
|
233
|
+
missing_columns = self._detect_missing_columns(existing_columns)
|
|
234
|
+
|
|
235
|
+
if not missing_columns:
|
|
236
|
+
logger.debug("Migration tracking table schema is up-to-date")
|
|
237
|
+
return
|
|
238
|
+
|
|
239
|
+
from rich.console import Console
|
|
240
|
+
|
|
241
|
+
console = Console()
|
|
242
|
+
console.print(
|
|
243
|
+
f"[cyan]Migrating tracking table schema, adding columns: {', '.join(sorted(missing_columns))}[/]"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
for col_name in sorted(missing_columns):
|
|
247
|
+
await self._add_column(driver, col_name)
|
|
248
|
+
|
|
249
|
+
await driver.commit()
|
|
250
|
+
console.print("[green]Migration tracking table schema updated successfully[/]")
|
|
251
|
+
|
|
252
|
+
except Exception as e:
|
|
253
|
+
logger.warning("Could not check or migrate tracking table schema: %s", e)
|
|
254
|
+
|
|
255
|
+
async def _add_column(self, driver: "AsyncDriverAdapterBase", column_name: str) -> None:
|
|
256
|
+
"""Add a single column to the tracking table.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
driver: The database driver to use.
|
|
260
|
+
column_name: Name of the column to add (lowercase).
|
|
261
|
+
"""
|
|
262
|
+
target_create = self._get_create_table_sql()
|
|
263
|
+
column_def = next((col for col in target_create.columns if col.name.lower() == column_name), None)
|
|
264
|
+
|
|
265
|
+
if not column_def:
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
alter_sql = sql.alter_table(self.version_table).add_column(
|
|
269
|
+
name=column_def.name, dtype=column_def.dtype, default=column_def.default, not_null=column_def.not_null
|
|
270
|
+
)
|
|
271
|
+
await driver.execute(alter_sql)
|
|
272
|
+
logger.debug("Added column %s to tracking table", column_name)
|
|
273
|
+
|
|
108
274
|
async def ensure_tracking_table(self, driver: "AsyncDriverAdapterBase") -> None:
|
|
109
275
|
"""Create the migration tracking table if it doesn't exist.
|
|
110
276
|
|
|
277
|
+
Also checks for and adds any missing columns to support schema migrations.
|
|
278
|
+
|
|
111
279
|
Args:
|
|
112
280
|
driver: The database driver to use.
|
|
113
281
|
"""
|
|
114
282
|
await driver.execute(self._get_create_table_sql())
|
|
115
283
|
await self._safe_commit_async(driver)
|
|
116
284
|
|
|
117
|
-
|
|
285
|
+
await self._migrate_schema_if_needed(driver)
|
|
286
|
+
|
|
287
|
+
async def get_current_version(self, driver: "AsyncDriverAdapterBase") -> str | None:
|
|
118
288
|
"""Get the latest applied migration version.
|
|
119
289
|
|
|
120
290
|
Args:
|
|
@@ -143,6 +313,9 @@ class AsyncMigrationTracker(BaseMigrationTracker["AsyncDriverAdapterBase"]):
|
|
|
143
313
|
) -> None:
|
|
144
314
|
"""Record a successfully applied migration.
|
|
145
315
|
|
|
316
|
+
Parses version to determine type (sequential or timestamp) and
|
|
317
|
+
auto-increments execution_sequence for application order tracking.
|
|
318
|
+
|
|
146
319
|
Args:
|
|
147
320
|
driver: The database driver to use.
|
|
148
321
|
version: Version number of the migration.
|
|
@@ -150,9 +323,21 @@ class AsyncMigrationTracker(BaseMigrationTracker["AsyncDriverAdapterBase"]):
|
|
|
150
323
|
execution_time_ms: Execution time in milliseconds.
|
|
151
324
|
checksum: MD5 checksum of the migration content.
|
|
152
325
|
"""
|
|
326
|
+
parsed_version = parse_version(version)
|
|
327
|
+
version_type = parsed_version.type.value
|
|
328
|
+
|
|
329
|
+
result = await driver.execute(self._get_next_execution_sequence_sql())
|
|
330
|
+
next_sequence = result.data[0]["next_seq"] if result.data else 1
|
|
331
|
+
|
|
153
332
|
await driver.execute(
|
|
154
333
|
self._get_record_migration_sql(
|
|
155
|
-
version,
|
|
334
|
+
version,
|
|
335
|
+
version_type,
|
|
336
|
+
next_sequence,
|
|
337
|
+
description,
|
|
338
|
+
execution_time_ms,
|
|
339
|
+
checksum,
|
|
340
|
+
os.environ.get("USER", "unknown"),
|
|
156
341
|
)
|
|
157
342
|
)
|
|
158
343
|
await self._safe_commit_async(driver)
|
|
@@ -167,21 +352,52 @@ class AsyncMigrationTracker(BaseMigrationTracker["AsyncDriverAdapterBase"]):
|
|
|
167
352
|
await driver.execute(self._get_remove_migration_sql(version))
|
|
168
353
|
await self._safe_commit_async(driver)
|
|
169
354
|
|
|
170
|
-
async def
|
|
171
|
-
"""
|
|
355
|
+
async def update_version_record(self, driver: "AsyncDriverAdapterBase", old_version: str, new_version: str) -> None:
|
|
356
|
+
"""Update migration version record from timestamp to sequential.
|
|
357
|
+
|
|
358
|
+
Updates version_num and version_type while preserving execution_sequence,
|
|
359
|
+
applied_at, and other tracking metadata. Used during fix command.
|
|
360
|
+
|
|
361
|
+
Idempotent: If the version is already updated, logs and continues without error.
|
|
362
|
+
This allows fix command to be safely re-run after pulling changes.
|
|
172
363
|
|
|
173
364
|
Args:
|
|
174
365
|
driver: The database driver to use.
|
|
366
|
+
old_version: Current timestamp version string.
|
|
367
|
+
new_version: New sequential version string.
|
|
368
|
+
|
|
369
|
+
Raises:
|
|
370
|
+
ValueError: If neither old_version nor new_version found in database.
|
|
175
371
|
"""
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
372
|
+
parsed_new_version = parse_version(new_version)
|
|
373
|
+
new_version_type = parsed_new_version.type.value
|
|
374
|
+
|
|
375
|
+
result = await driver.execute(self._get_update_version_sql(old_version, new_version, new_version_type))
|
|
180
376
|
|
|
181
|
-
|
|
182
|
-
|
|
377
|
+
if result.rows_affected == 0:
|
|
378
|
+
check_result = await driver.execute(self._get_applied_migrations_sql())
|
|
379
|
+
applied_versions = {row["version_num"] for row in check_result.data} if check_result.data else set()
|
|
380
|
+
|
|
381
|
+
if new_version in applied_versions:
|
|
382
|
+
logger.debug("Version already updated: %s -> %s", old_version, new_version)
|
|
183
383
|
return
|
|
184
384
|
|
|
385
|
+
msg = f"Migration version {old_version} not found in database"
|
|
386
|
+
raise ValueError(msg)
|
|
387
|
+
|
|
388
|
+
await self._safe_commit_async(driver)
|
|
389
|
+
logger.debug("Updated version record: %s -> %s", old_version, new_version)
|
|
390
|
+
|
|
391
|
+
async def _safe_commit_async(self, driver: "AsyncDriverAdapterBase") -> None:
|
|
392
|
+
"""Safely commit a transaction only if autocommit is disabled.
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
driver: The database driver to use.
|
|
396
|
+
"""
|
|
397
|
+
if driver.driver_features.get("autocommit", False):
|
|
398
|
+
return
|
|
399
|
+
|
|
400
|
+
try:
|
|
185
401
|
await driver.commit()
|
|
186
402
|
except Exception:
|
|
187
403
|
logger.debug("Failed to commit transaction, likely due to autocommit being enabled")
|
sqlspec/migrations/utils.py
CHANGED
|
@@ -3,16 +3,20 @@
|
|
|
3
3
|
This module provides helper functions for migration operations.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
+
import logging
|
|
6
7
|
import os
|
|
8
|
+
import subprocess
|
|
7
9
|
from datetime import datetime, timezone
|
|
8
10
|
from pathlib import Path
|
|
9
|
-
from typing import TYPE_CHECKING, Any
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
10
12
|
|
|
11
13
|
if TYPE_CHECKING:
|
|
12
14
|
from sqlspec.driver import AsyncDriverAdapterBase
|
|
13
15
|
|
|
14
16
|
__all__ = ("create_migration_file", "drop_all", "get_author")
|
|
15
17
|
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
16
20
|
|
|
17
21
|
def create_migration_file(migrations_dir: Path, version: str, message: str, file_type: str = "sql") -> Path:
|
|
18
22
|
"""Create a new migration file from template.
|
|
@@ -108,13 +112,57 @@ DROP TABLE placeholder;
|
|
|
108
112
|
def get_author() -> str:
|
|
109
113
|
"""Get current user for migration metadata.
|
|
110
114
|
|
|
115
|
+
Attempts to retrieve git user configuration (name and email).
|
|
116
|
+
Falls back to system username if git is not configured or unavailable.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
Author string in format 'Name <email>' if git configured,
|
|
120
|
+
otherwise system username from environment.
|
|
121
|
+
"""
|
|
122
|
+
git_name = _get_git_config("user.name")
|
|
123
|
+
git_email = _get_git_config("user.email")
|
|
124
|
+
|
|
125
|
+
if git_name and git_email:
|
|
126
|
+
return f"{git_name} <{git_email}>"
|
|
127
|
+
|
|
128
|
+
return _get_system_username()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _get_git_config(config_key: str) -> str | None:
|
|
132
|
+
"""Retrieve git configuration value.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
config_key: Git config key (e.g., 'user.name', 'user.email').
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
Configuration value if found, None otherwise.
|
|
139
|
+
"""
|
|
140
|
+
try:
|
|
141
|
+
result = subprocess.run( # noqa: S603
|
|
142
|
+
["git", "config", config_key], # noqa: S607
|
|
143
|
+
capture_output=True,
|
|
144
|
+
text=True,
|
|
145
|
+
timeout=2,
|
|
146
|
+
check=False,
|
|
147
|
+
)
|
|
148
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
149
|
+
return result.stdout.strip()
|
|
150
|
+
except (subprocess.SubprocessError, FileNotFoundError, OSError) as e:
|
|
151
|
+
logger.debug("Failed to get git config %s: %s", config_key, e)
|
|
152
|
+
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _get_system_username() -> str:
|
|
157
|
+
"""Get system username from environment.
|
|
158
|
+
|
|
111
159
|
Returns:
|
|
112
|
-
Username from environment or 'unknown'.
|
|
160
|
+
Username from USER environment variable, or 'unknown' if not set.
|
|
113
161
|
"""
|
|
114
162
|
return os.environ.get("USER", "unknown")
|
|
115
163
|
|
|
116
164
|
|
|
117
|
-
async def drop_all(engine: "AsyncDriverAdapterBase", version_table_name: str, metadata:
|
|
165
|
+
async def drop_all(engine: "AsyncDriverAdapterBase", version_table_name: str, metadata: Any | None = None) -> None:
|
|
118
166
|
"""Drop all tables from the database.
|
|
119
167
|
|
|
120
168
|
Args:
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Migration validation and out-of-order detection for SQLSpec.
|
|
2
|
+
|
|
3
|
+
This module provides functionality to detect and handle out-of-order migrations,
|
|
4
|
+
which can occur when branches with migrations merge in different orders across
|
|
5
|
+
staging and production environments.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
from sqlspec.exceptions import OutOfOrderMigrationError
|
|
14
|
+
from sqlspec.utils.version import parse_version
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from sqlspec.utils.version import MigrationVersion
|
|
18
|
+
|
|
19
|
+
__all__ = ("MigrationGap", "detect_out_of_order_migrations", "format_out_of_order_warning")
|
|
20
|
+
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class MigrationGap:
|
|
26
|
+
"""Represents a migration that is out of order.
|
|
27
|
+
|
|
28
|
+
An out-of-order migration occurs when a pending migration has a timestamp
|
|
29
|
+
earlier than already-applied migrations, indicating it was created in a branch
|
|
30
|
+
that merged after other migrations were already applied.
|
|
31
|
+
|
|
32
|
+
Attributes:
|
|
33
|
+
missing_version: The out-of-order migration version.
|
|
34
|
+
applied_after: List of already-applied migrations with later timestamps.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
missing_version: "MigrationVersion"
|
|
38
|
+
applied_after: "list[MigrationVersion]"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def detect_out_of_order_migrations(
|
|
42
|
+
pending_versions: "list[str]", applied_versions: "list[str]"
|
|
43
|
+
) -> "list[MigrationGap]":
|
|
44
|
+
"""Detect migrations created before already-applied migrations.
|
|
45
|
+
|
|
46
|
+
Identifies pending migrations with timestamps earlier than the latest applied
|
|
47
|
+
migration, which indicates they were created in branches that merged late or
|
|
48
|
+
were cherry-picked across environments.
|
|
49
|
+
|
|
50
|
+
Extension migrations are excluded from out-of-order detection as they maintain
|
|
51
|
+
independent sequences within their own namespaces.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
pending_versions: List of migration versions not yet applied.
|
|
55
|
+
applied_versions: List of migration versions already applied.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
List of migration gaps representing out-of-order migrations.
|
|
59
|
+
Empty list if no out-of-order migrations detected.
|
|
60
|
+
|
|
61
|
+
Example:
|
|
62
|
+
Applied: [20251011120000, 20251012140000]
|
|
63
|
+
Pending: [20251011130000, 20251013090000]
|
|
64
|
+
Result: Gap for 20251011130000 (created between applied migrations)
|
|
65
|
+
|
|
66
|
+
Applied: [ext_litestar_0001, 0001, 0002]
|
|
67
|
+
Pending: [ext_adk_0001]
|
|
68
|
+
Result: [] (extensions excluded from out-of-order detection)
|
|
69
|
+
"""
|
|
70
|
+
if not applied_versions or not pending_versions:
|
|
71
|
+
return []
|
|
72
|
+
|
|
73
|
+
gaps: list[MigrationGap] = []
|
|
74
|
+
|
|
75
|
+
parsed_applied = [parse_version(v) for v in applied_versions]
|
|
76
|
+
parsed_pending = [parse_version(v) for v in pending_versions]
|
|
77
|
+
|
|
78
|
+
core_applied = [v for v in parsed_applied if v.extension is None]
|
|
79
|
+
core_pending = [v for v in parsed_pending if v.extension is None]
|
|
80
|
+
|
|
81
|
+
if not core_applied or not core_pending:
|
|
82
|
+
return []
|
|
83
|
+
|
|
84
|
+
latest_applied = max(core_applied)
|
|
85
|
+
|
|
86
|
+
for pending in core_pending:
|
|
87
|
+
if pending < latest_applied:
|
|
88
|
+
applied_after = [a for a in core_applied if a > pending]
|
|
89
|
+
if applied_after:
|
|
90
|
+
gaps.append(MigrationGap(missing_version=pending, applied_after=applied_after))
|
|
91
|
+
|
|
92
|
+
return gaps
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def format_out_of_order_warning(gaps: "list[MigrationGap]") -> str:
|
|
96
|
+
"""Create user-friendly warning message for out-of-order migrations.
|
|
97
|
+
|
|
98
|
+
Formats migration gaps into a clear warning message explaining which migrations
|
|
99
|
+
are out of order and what migrations were already applied after them.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
gaps: List of migration gaps to format.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Formatted warning message string.
|
|
106
|
+
|
|
107
|
+
Example:
|
|
108
|
+
>>> gaps = [MigrationGap(version1, [version2, version3])]
|
|
109
|
+
>>> print(format_out_of_order_warning(gaps))
|
|
110
|
+
Out-of-order migrations detected:
|
|
111
|
+
|
|
112
|
+
- 20251011130000 created before:
|
|
113
|
+
- 20251012140000
|
|
114
|
+
- 20251013090000
|
|
115
|
+
"""
|
|
116
|
+
if not gaps:
|
|
117
|
+
return ""
|
|
118
|
+
|
|
119
|
+
lines = ["Out-of-order migrations detected:", ""]
|
|
120
|
+
|
|
121
|
+
for gap in gaps:
|
|
122
|
+
lines.append(f"- {gap.missing_version.raw} created before:")
|
|
123
|
+
lines.extend(f" - {applied.raw}" for applied in gap.applied_after)
|
|
124
|
+
lines.append("")
|
|
125
|
+
|
|
126
|
+
lines.extend(
|
|
127
|
+
(
|
|
128
|
+
"These migrations will be applied but may cause issues if they",
|
|
129
|
+
"depend on schema changes from later migrations.",
|
|
130
|
+
"",
|
|
131
|
+
"To prevent this in the future, ensure migrations are merged in",
|
|
132
|
+
"chronological order or use strict_ordering mode in migration_config.",
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
return "\n".join(lines)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def validate_migration_order(
|
|
140
|
+
pending_versions: "list[str]", applied_versions: "list[str]", strict_ordering: bool = False
|
|
141
|
+
) -> None:
|
|
142
|
+
"""Validate migration order and raise error if out-of-order in strict mode.
|
|
143
|
+
|
|
144
|
+
Checks for out-of-order migrations and either warns or raises an error
|
|
145
|
+
depending on the strict_ordering configuration.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
pending_versions: List of migration versions not yet applied.
|
|
149
|
+
applied_versions: List of migration versions already applied.
|
|
150
|
+
strict_ordering: If True, raise error for out-of-order migrations.
|
|
151
|
+
If False (default), log warning but allow.
|
|
152
|
+
|
|
153
|
+
Raises:
|
|
154
|
+
OutOfOrderMigrationError: If out-of-order migrations detected and
|
|
155
|
+
strict_ordering is True.
|
|
156
|
+
|
|
157
|
+
Example:
|
|
158
|
+
>>> validate_migration_order(
|
|
159
|
+
... ["20251011130000"],
|
|
160
|
+
... ["20251012140000"],
|
|
161
|
+
... strict_ordering=True,
|
|
162
|
+
... )
|
|
163
|
+
OutOfOrderMigrationError: Out-of-order migrations detected...
|
|
164
|
+
"""
|
|
165
|
+
gaps = detect_out_of_order_migrations(pending_versions, applied_versions)
|
|
166
|
+
|
|
167
|
+
if not gaps:
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
warning_message = format_out_of_order_warning(gaps)
|
|
171
|
+
|
|
172
|
+
if strict_ordering:
|
|
173
|
+
msg = f"{warning_message}\n\nStrict ordering is enabled. Use --allow-missing to override."
|
|
174
|
+
raise OutOfOrderMigrationError(msg)
|
|
175
|
+
|
|
176
|
+
console.print("[yellow]Out-of-order migrations detected[/]")
|
|
177
|
+
console.print(f"[yellow]{warning_message}[/]")
|