sqlspec 0.25.0__py3-none-any.whl → 0.27.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

Files changed (199) hide show
  1. sqlspec/__init__.py +7 -15
  2. sqlspec/_serialization.py +256 -24
  3. sqlspec/_typing.py +71 -52
  4. sqlspec/adapters/adbc/_types.py +1 -1
  5. sqlspec/adapters/adbc/adk/__init__.py +5 -0
  6. sqlspec/adapters/adbc/adk/store.py +870 -0
  7. sqlspec/adapters/adbc/config.py +69 -12
  8. sqlspec/adapters/adbc/data_dictionary.py +340 -0
  9. sqlspec/adapters/adbc/driver.py +266 -58
  10. sqlspec/adapters/adbc/litestar/__init__.py +5 -0
  11. sqlspec/adapters/adbc/litestar/store.py +504 -0
  12. sqlspec/adapters/adbc/type_converter.py +153 -0
  13. sqlspec/adapters/aiosqlite/_types.py +1 -1
  14. sqlspec/adapters/aiosqlite/adk/__init__.py +5 -0
  15. sqlspec/adapters/aiosqlite/adk/store.py +527 -0
  16. sqlspec/adapters/aiosqlite/config.py +88 -15
  17. sqlspec/adapters/aiosqlite/data_dictionary.py +149 -0
  18. sqlspec/adapters/aiosqlite/driver.py +143 -40
  19. sqlspec/adapters/aiosqlite/litestar/__init__.py +5 -0
  20. sqlspec/adapters/aiosqlite/litestar/store.py +281 -0
  21. sqlspec/adapters/aiosqlite/pool.py +7 -7
  22. sqlspec/adapters/asyncmy/__init__.py +7 -1
  23. sqlspec/adapters/asyncmy/_types.py +2 -2
  24. sqlspec/adapters/asyncmy/adk/__init__.py +5 -0
  25. sqlspec/adapters/asyncmy/adk/store.py +493 -0
  26. sqlspec/adapters/asyncmy/config.py +68 -23
  27. sqlspec/adapters/asyncmy/data_dictionary.py +161 -0
  28. sqlspec/adapters/asyncmy/driver.py +313 -58
  29. sqlspec/adapters/asyncmy/litestar/__init__.py +5 -0
  30. sqlspec/adapters/asyncmy/litestar/store.py +296 -0
  31. sqlspec/adapters/asyncpg/__init__.py +2 -1
  32. sqlspec/adapters/asyncpg/_type_handlers.py +71 -0
  33. sqlspec/adapters/asyncpg/_types.py +11 -7
  34. sqlspec/adapters/asyncpg/adk/__init__.py +5 -0
  35. sqlspec/adapters/asyncpg/adk/store.py +450 -0
  36. sqlspec/adapters/asyncpg/config.py +59 -35
  37. sqlspec/adapters/asyncpg/data_dictionary.py +173 -0
  38. sqlspec/adapters/asyncpg/driver.py +170 -25
  39. sqlspec/adapters/asyncpg/litestar/__init__.py +5 -0
  40. sqlspec/adapters/asyncpg/litestar/store.py +253 -0
  41. sqlspec/adapters/bigquery/_types.py +1 -1
  42. sqlspec/adapters/bigquery/adk/__init__.py +5 -0
  43. sqlspec/adapters/bigquery/adk/store.py +576 -0
  44. sqlspec/adapters/bigquery/config.py +27 -10
  45. sqlspec/adapters/bigquery/data_dictionary.py +149 -0
  46. sqlspec/adapters/bigquery/driver.py +368 -142
  47. sqlspec/adapters/bigquery/litestar/__init__.py +5 -0
  48. sqlspec/adapters/bigquery/litestar/store.py +327 -0
  49. sqlspec/adapters/bigquery/type_converter.py +125 -0
  50. sqlspec/adapters/duckdb/_types.py +1 -1
  51. sqlspec/adapters/duckdb/adk/__init__.py +14 -0
  52. sqlspec/adapters/duckdb/adk/store.py +553 -0
  53. sqlspec/adapters/duckdb/config.py +80 -20
  54. sqlspec/adapters/duckdb/data_dictionary.py +163 -0
  55. sqlspec/adapters/duckdb/driver.py +167 -45
  56. sqlspec/adapters/duckdb/litestar/__init__.py +5 -0
  57. sqlspec/adapters/duckdb/litestar/store.py +332 -0
  58. sqlspec/adapters/duckdb/pool.py +4 -4
  59. sqlspec/adapters/duckdb/type_converter.py +133 -0
  60. sqlspec/adapters/oracledb/_numpy_handlers.py +133 -0
  61. sqlspec/adapters/oracledb/_types.py +20 -2
  62. sqlspec/adapters/oracledb/adk/__init__.py +5 -0
  63. sqlspec/adapters/oracledb/adk/store.py +1745 -0
  64. sqlspec/adapters/oracledb/config.py +122 -32
  65. sqlspec/adapters/oracledb/data_dictionary.py +509 -0
  66. sqlspec/adapters/oracledb/driver.py +353 -91
  67. sqlspec/adapters/oracledb/litestar/__init__.py +5 -0
  68. sqlspec/adapters/oracledb/litestar/store.py +767 -0
  69. sqlspec/adapters/oracledb/migrations.py +348 -73
  70. sqlspec/adapters/oracledb/type_converter.py +207 -0
  71. sqlspec/adapters/psqlpy/_type_handlers.py +44 -0
  72. sqlspec/adapters/psqlpy/_types.py +2 -1
  73. sqlspec/adapters/psqlpy/adk/__init__.py +5 -0
  74. sqlspec/adapters/psqlpy/adk/store.py +482 -0
  75. sqlspec/adapters/psqlpy/config.py +46 -17
  76. sqlspec/adapters/psqlpy/data_dictionary.py +172 -0
  77. sqlspec/adapters/psqlpy/driver.py +123 -209
  78. sqlspec/adapters/psqlpy/litestar/__init__.py +5 -0
  79. sqlspec/adapters/psqlpy/litestar/store.py +272 -0
  80. sqlspec/adapters/psqlpy/type_converter.py +102 -0
  81. sqlspec/adapters/psycopg/_type_handlers.py +80 -0
  82. sqlspec/adapters/psycopg/_types.py +2 -1
  83. sqlspec/adapters/psycopg/adk/__init__.py +5 -0
  84. sqlspec/adapters/psycopg/adk/store.py +944 -0
  85. sqlspec/adapters/psycopg/config.py +69 -35
  86. sqlspec/adapters/psycopg/data_dictionary.py +331 -0
  87. sqlspec/adapters/psycopg/driver.py +238 -81
  88. sqlspec/adapters/psycopg/litestar/__init__.py +5 -0
  89. sqlspec/adapters/psycopg/litestar/store.py +554 -0
  90. sqlspec/adapters/sqlite/__init__.py +2 -1
  91. sqlspec/adapters/sqlite/_type_handlers.py +86 -0
  92. sqlspec/adapters/sqlite/_types.py +1 -1
  93. sqlspec/adapters/sqlite/adk/__init__.py +5 -0
  94. sqlspec/adapters/sqlite/adk/store.py +572 -0
  95. sqlspec/adapters/sqlite/config.py +87 -15
  96. sqlspec/adapters/sqlite/data_dictionary.py +149 -0
  97. sqlspec/adapters/sqlite/driver.py +137 -54
  98. sqlspec/adapters/sqlite/litestar/__init__.py +5 -0
  99. sqlspec/adapters/sqlite/litestar/store.py +318 -0
  100. sqlspec/adapters/sqlite/pool.py +18 -9
  101. sqlspec/base.py +45 -26
  102. sqlspec/builder/__init__.py +73 -4
  103. sqlspec/builder/_base.py +162 -89
  104. sqlspec/builder/_column.py +62 -29
  105. sqlspec/builder/_ddl.py +180 -121
  106. sqlspec/builder/_delete.py +5 -4
  107. sqlspec/builder/_dml.py +388 -0
  108. sqlspec/{_sql.py → builder/_factory.py} +53 -94
  109. sqlspec/builder/_insert.py +32 -131
  110. sqlspec/builder/_join.py +375 -0
  111. sqlspec/builder/_merge.py +446 -11
  112. sqlspec/builder/_parsing_utils.py +111 -17
  113. sqlspec/builder/_select.py +1457 -24
  114. sqlspec/builder/_update.py +11 -42
  115. sqlspec/cli.py +307 -194
  116. sqlspec/config.py +252 -67
  117. sqlspec/core/__init__.py +5 -4
  118. sqlspec/core/cache.py +17 -17
  119. sqlspec/core/compiler.py +62 -9
  120. sqlspec/core/filters.py +37 -37
  121. sqlspec/core/hashing.py +9 -9
  122. sqlspec/core/parameters.py +83 -48
  123. sqlspec/core/result.py +102 -46
  124. sqlspec/core/splitter.py +16 -17
  125. sqlspec/core/statement.py +36 -30
  126. sqlspec/core/type_conversion.py +235 -0
  127. sqlspec/driver/__init__.py +7 -6
  128. sqlspec/driver/_async.py +188 -151
  129. sqlspec/driver/_common.py +285 -80
  130. sqlspec/driver/_sync.py +188 -152
  131. sqlspec/driver/mixins/_result_tools.py +20 -236
  132. sqlspec/driver/mixins/_sql_translator.py +4 -4
  133. sqlspec/exceptions.py +75 -7
  134. sqlspec/extensions/adk/__init__.py +53 -0
  135. sqlspec/extensions/adk/_types.py +51 -0
  136. sqlspec/extensions/adk/converters.py +172 -0
  137. sqlspec/extensions/adk/migrations/0001_create_adk_tables.py +144 -0
  138. sqlspec/extensions/adk/migrations/__init__.py +0 -0
  139. sqlspec/extensions/adk/service.py +181 -0
  140. sqlspec/extensions/adk/store.py +536 -0
  141. sqlspec/extensions/aiosql/adapter.py +73 -53
  142. sqlspec/extensions/litestar/__init__.py +21 -4
  143. sqlspec/extensions/litestar/cli.py +54 -10
  144. sqlspec/extensions/litestar/config.py +59 -266
  145. sqlspec/extensions/litestar/handlers.py +46 -17
  146. sqlspec/extensions/litestar/migrations/0001_create_session_table.py +137 -0
  147. sqlspec/extensions/litestar/migrations/__init__.py +3 -0
  148. sqlspec/extensions/litestar/plugin.py +324 -223
  149. sqlspec/extensions/litestar/providers.py +25 -25
  150. sqlspec/extensions/litestar/store.py +265 -0
  151. sqlspec/loader.py +30 -49
  152. sqlspec/migrations/__init__.py +4 -3
  153. sqlspec/migrations/base.py +302 -39
  154. sqlspec/migrations/commands.py +611 -144
  155. sqlspec/migrations/context.py +142 -0
  156. sqlspec/migrations/fix.py +199 -0
  157. sqlspec/migrations/loaders.py +68 -23
  158. sqlspec/migrations/runner.py +543 -107
  159. sqlspec/migrations/tracker.py +237 -21
  160. sqlspec/migrations/utils.py +51 -3
  161. sqlspec/migrations/validation.py +177 -0
  162. sqlspec/protocols.py +66 -36
  163. sqlspec/storage/_utils.py +98 -0
  164. sqlspec/storage/backends/fsspec.py +134 -106
  165. sqlspec/storage/backends/local.py +78 -51
  166. sqlspec/storage/backends/obstore.py +278 -162
  167. sqlspec/storage/registry.py +75 -39
  168. sqlspec/typing.py +16 -84
  169. sqlspec/utils/config_resolver.py +153 -0
  170. sqlspec/utils/correlation.py +4 -5
  171. sqlspec/utils/data_transformation.py +3 -2
  172. sqlspec/utils/deprecation.py +9 -8
  173. sqlspec/utils/fixtures.py +4 -4
  174. sqlspec/utils/logging.py +46 -6
  175. sqlspec/utils/module_loader.py +2 -2
  176. sqlspec/utils/schema.py +288 -0
  177. sqlspec/utils/serializers.py +50 -2
  178. sqlspec/utils/sync_tools.py +21 -17
  179. sqlspec/utils/text.py +1 -2
  180. sqlspec/utils/type_guards.py +111 -20
  181. sqlspec/utils/version.py +433 -0
  182. {sqlspec-0.25.0.dist-info → sqlspec-0.27.0.dist-info}/METADATA +40 -21
  183. sqlspec-0.27.0.dist-info/RECORD +207 -0
  184. sqlspec/builder/mixins/__init__.py +0 -55
  185. sqlspec/builder/mixins/_cte_and_set_ops.py +0 -254
  186. sqlspec/builder/mixins/_delete_operations.py +0 -50
  187. sqlspec/builder/mixins/_insert_operations.py +0 -282
  188. sqlspec/builder/mixins/_join_operations.py +0 -389
  189. sqlspec/builder/mixins/_merge_operations.py +0 -592
  190. sqlspec/builder/mixins/_order_limit_operations.py +0 -152
  191. sqlspec/builder/mixins/_pivot_operations.py +0 -157
  192. sqlspec/builder/mixins/_select_operations.py +0 -936
  193. sqlspec/builder/mixins/_update_operations.py +0 -218
  194. sqlspec/builder/mixins/_where_clause.py +0 -1304
  195. sqlspec-0.25.0.dist-info/RECORD +0 -139
  196. sqlspec-0.25.0.dist-info/licenses/NOTICE +0 -29
  197. {sqlspec-0.25.0.dist-info → sqlspec-0.27.0.dist-info}/WHEEL +0 -0
  198. {sqlspec-0.25.0.dist-info → sqlspec-0.27.0.dist-info}/entry_points.txt +0 -0
  199. {sqlspec-0.25.0.dist-info → sqlspec-0.27.0.dist-info}/licenses/LICENSE +0 -0
@@ -5,12 +5,14 @@ to handle Oracle's unique SQL syntax requirements.
5
5
  """
6
6
 
7
7
  import getpass
8
- from typing import TYPE_CHECKING, Any, Optional, cast
8
+ from typing import TYPE_CHECKING, Any
9
9
 
10
- from sqlspec._sql import sql
11
- from sqlspec.builder._ddl import CreateTable
10
+ from rich.console import Console
11
+
12
+ from sqlspec.builder import CreateTable, Select, sql
12
13
  from sqlspec.migrations.base import BaseMigrationTracker
13
14
  from sqlspec.utils.logging import get_logger
15
+ from sqlspec.utils.version import parse_version
14
16
 
15
17
  if TYPE_CHECKING:
16
18
  from sqlspec.driver import AsyncDriverAdapterBase, SyncDriverAdapterBase
@@ -18,10 +20,20 @@ if TYPE_CHECKING:
18
20
  __all__ = ("OracleAsyncMigrationTracker", "OracleSyncMigrationTracker")
19
21
 
20
22
  logger = get_logger("migrations.oracle")
23
+ console = Console()
21
24
 
22
25
 
23
26
  class OracleMigrationTrackerMixin:
24
- """Mixin providing Oracle-specific migration table creation."""
27
+ """Mixin providing Oracle-specific migration table creation and querying.
28
+
29
+ Oracle has unique identifier handling rules:
30
+ - Unquoted identifiers are case-insensitive and stored as UPPERCASE
31
+ - Quoted identifiers are case-sensitive and stored exactly as written
32
+
33
+ This mixin overrides SQL builder methods to add quoted identifiers for
34
+ all column references, ensuring they match the lowercase column names
35
+ created by the migration table.
36
+ """
25
37
 
26
38
  __slots__ = ()
27
39
 
@@ -41,6 +53,8 @@ class OracleMigrationTrackerMixin:
41
53
  return (
42
54
  sql.create_table(self.version_table)
43
55
  .column("version_num", "VARCHAR2(32)", primary_key=True)
56
+ .column("version_type", "VARCHAR2(16)")
57
+ .column("execution_sequence", "INTEGER")
44
58
  .column("description", "VARCHAR2(2000)")
45
59
  .column("applied_at", "TIMESTAMP", default="CURRENT_TIMESTAMP")
46
60
  .column("execution_time_ms", "INTEGER")
@@ -48,33 +62,184 @@ class OracleMigrationTrackerMixin:
48
62
  .column("applied_by", "VARCHAR2(255)")
49
63
  )
50
64
 
65
+ def _get_current_version_sql(self) -> Select:
66
+ """Get Oracle-specific SQL for retrieving current version.
67
+
68
+ Uses uppercase column names with lowercase aliases to match Python expectations.
69
+ Oracle stores unquoted identifiers as UPPERCASE, so we query UPPERCASE columns
70
+ and alias them as quoted "lowercase" for result consistency.
71
+
72
+ Returns:
73
+ SQL builder object for version query.
74
+ """
75
+ return (
76
+ sql.select('VERSION_NUM AS "version_num"')
77
+ .from_(self.version_table)
78
+ .order_by("EXECUTION_SEQUENCE DESC")
79
+ .limit(1)
80
+ )
81
+
82
+ def _get_applied_migrations_sql(self) -> Select:
83
+ """Get Oracle-specific SQL for retrieving all applied migrations.
84
+
85
+ Uses uppercase column names with lowercase aliases to match Python expectations.
86
+ Oracle stores unquoted identifiers as UPPERCASE, so we query UPPERCASE columns
87
+ and alias them as quoted "lowercase" for result consistency.
88
+
89
+ Returns:
90
+ SQL builder object for migrations query.
91
+ """
92
+ return (
93
+ sql.select(
94
+ 'VERSION_NUM AS "version_num"',
95
+ 'VERSION_TYPE AS "version_type"',
96
+ 'EXECUTION_SEQUENCE AS "execution_sequence"',
97
+ 'DESCRIPTION AS "description"',
98
+ 'APPLIED_AT AS "applied_at"',
99
+ 'EXECUTION_TIME_MS AS "execution_time_ms"',
100
+ 'CHECKSUM AS "checksum"',
101
+ 'APPLIED_BY AS "applied_by"',
102
+ )
103
+ .from_(self.version_table)
104
+ .order_by("EXECUTION_SEQUENCE")
105
+ )
106
+
107
+ def _get_next_execution_sequence_sql(self) -> Select:
108
+ """Get Oracle-specific SQL for retrieving next execution sequence.
109
+
110
+ Uses uppercase column names with lowercase alias to match Python expectations.
111
+ Oracle stores unquoted identifiers as UPPERCASE, so we query UPPERCASE columns
112
+ and alias them as quoted "lowercase" for result consistency.
113
+
114
+ Returns:
115
+ SQL builder object for sequence query.
116
+ """
117
+ return sql.select('COALESCE(MAX(EXECUTION_SEQUENCE), 0) + 1 AS "next_seq"').from_(self.version_table)
118
+
119
+ def _get_existing_columns_sql(self) -> str:
120
+ """Get SQL to query existing columns in the tracking table.
121
+
122
+ Returns:
123
+ Raw SQL string for Oracle's USER_TAB_COLUMNS query.
124
+ """
125
+ return f"""
126
+ SELECT column_name
127
+ FROM user_tab_columns
128
+ WHERE table_name = '{self.version_table.upper()}'
129
+ """
130
+
131
+ def _detect_missing_columns(self, existing_columns: "set[str]") -> "set[str]":
132
+ """Detect which columns are missing from the current schema.
133
+
134
+ Args:
135
+ existing_columns: Set of existing column names (uppercase).
136
+
137
+ Returns:
138
+ Set of missing column names (lowercase).
139
+ """
140
+ target_create = self._get_create_table_sql()
141
+ target_columns = {col.name.lower() for col in target_create.columns}
142
+ existing_lower = {col.lower() for col in existing_columns}
143
+ return target_columns - existing_lower
144
+
51
145
 
52
146
  class OracleSyncMigrationTracker(OracleMigrationTrackerMixin, BaseMigrationTracker["SyncDriverAdapterBase"]):
53
147
  """Oracle-specific sync migration tracker."""
54
148
 
55
149
  __slots__ = ()
56
150
 
151
+ def _migrate_schema_if_needed(self, driver: "SyncDriverAdapterBase") -> None:
152
+ """Check for and add any missing columns to the tracking table.
153
+
154
+ Uses the driver's data dictionary to query existing columns from Oracle's
155
+ USER_TAB_COLUMNS metadata table.
156
+
157
+ Args:
158
+ driver: The database driver to use.
159
+ """
160
+ try:
161
+ columns_data = driver.data_dictionary.get_columns(driver, self.version_table)
162
+ existing_columns = {str(row["column_name"]).upper() for row in columns_data}
163
+ missing_columns = self._detect_missing_columns(existing_columns)
164
+
165
+ if not missing_columns:
166
+ logger.debug("Migration tracking table schema is up-to-date")
167
+ return
168
+
169
+ console.print(
170
+ f"[cyan]Migrating tracking table schema, adding columns: {', '.join(sorted(missing_columns))}[/]"
171
+ )
172
+
173
+ for col_name in sorted(missing_columns):
174
+ self._add_column(driver, col_name)
175
+
176
+ driver.commit()
177
+ console.print("[green]Migration tracking table schema updated successfully[/]")
178
+
179
+ except Exception as e:
180
+ logger.warning("Could not check or migrate tracking table schema: %s", e)
181
+
182
+ def _add_column(self, driver: "SyncDriverAdapterBase", column_name: str) -> None:
183
+ """Add a single column to the tracking table.
184
+
185
+ Args:
186
+ driver: The database driver to use.
187
+ column_name: Name of the column to add (lowercase).
188
+ """
189
+ target_create = self._get_create_table_sql()
190
+ column_def = next((col for col in target_create.columns if col.name.lower() == column_name), None)
191
+
192
+ if not column_def:
193
+ return
194
+
195
+ default_clause = f" DEFAULT {column_def.default}" if column_def.default else ""
196
+ not_null_clause = " NOT NULL" if column_def.not_null else ""
197
+
198
+ alter_sql = f"""
199
+ ALTER TABLE {self.version_table}
200
+ ADD {column_def.name} {column_def.dtype}{default_clause}{not_null_clause}
201
+ """
202
+
203
+ driver.execute(alter_sql)
204
+ logger.debug("Added column %s to tracking table", column_name)
205
+
57
206
  def ensure_tracking_table(self, driver: "SyncDriverAdapterBase") -> None:
58
207
  """Create the migration tracking table if it doesn't exist.
59
208
 
60
- Oracle doesn't support IF NOT EXISTS, so we check for table existence first.
209
+ Uses a PL/SQL block to make the operation atomic and prevent race conditions.
210
+ Also checks for and adds missing columns to support schema migrations.
61
211
 
62
212
  Args:
63
213
  driver: The database driver to use.
64
214
  """
215
+ create_script = f"""
216
+ BEGIN
217
+ EXECUTE IMMEDIATE '
218
+ CREATE TABLE {self.version_table} (
219
+ version_num VARCHAR2(32) PRIMARY KEY,
220
+ version_type VARCHAR2(16),
221
+ execution_sequence INTEGER,
222
+ description VARCHAR2(2000),
223
+ applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
224
+ execution_time_ms INTEGER,
225
+ checksum VARCHAR2(64),
226
+ applied_by VARCHAR2(255)
227
+ )';
228
+ EXCEPTION
229
+ WHEN OTHERS THEN
230
+ IF SQLCODE = -955 THEN
231
+ NULL; -- Table already exists
232
+ ELSE
233
+ RAISE;
234
+ END IF;
235
+ END;
236
+ """
237
+ driver.execute_script(create_script)
238
+ driver.commit()
65
239
 
66
- check_sql = (
67
- sql.select(sql.count().as_("table_count"))
68
- .from_("user_tables")
69
- .where(sql.column("table_name") == self.version_table.upper())
70
- )
71
- result = driver.execute(check_sql)
72
-
73
- if result.data[0]["TABLE_COUNT"] == 0:
74
- driver.execute(self._get_create_table_sql())
75
- self._safe_commit(driver)
240
+ self._migrate_schema_if_needed(driver)
76
241
 
77
- def get_current_version(self, driver: "SyncDriverAdapterBase") -> "Optional[str]":
242
+ def get_current_version(self, driver: "SyncDriverAdapterBase") -> "str | None":
78
243
  """Get the latest applied migration version.
79
244
 
80
245
  Args:
@@ -84,7 +249,8 @@ class OracleSyncMigrationTracker(OracleMigrationTrackerMixin, BaseMigrationTrack
84
249
  The current migration version or None if no migrations applied.
85
250
  """
86
251
  result = driver.execute(self._get_current_version_sql())
87
- return result.data[0]["VERSION_NUM"] if result.data else None
252
+ data = result.get_data()
253
+ return data[0]["version_num"] if data else None
88
254
 
89
255
  def get_applied_migrations(self, driver: "SyncDriverAdapterBase") -> "list[dict[str, Any]]":
90
256
  """Get all applied migrations in order.
@@ -93,15 +259,10 @@ class OracleSyncMigrationTracker(OracleMigrationTrackerMixin, BaseMigrationTrack
93
259
  driver: The database driver to use.
94
260
 
95
261
  Returns:
96
- List of migration records as dictionaries.
262
+ List of migration records as dictionaries with lowercase keys.
97
263
  """
98
264
  result = driver.execute(self._get_applied_migrations_sql())
99
- if not result.data:
100
- return []
101
-
102
- normalized_data = [{key.lower(): value for key, value in row.items()} for row in result.data]
103
-
104
- return cast("list[dict[str, Any]]", normalized_data)
265
+ return result.get_data()
105
266
 
106
267
  def record_migration(
107
268
  self, driver: "SyncDriverAdapterBase", version: str, description: str, execution_time_ms: int, checksum: str
@@ -115,12 +276,19 @@ class OracleSyncMigrationTracker(OracleMigrationTrackerMixin, BaseMigrationTrack
115
276
  execution_time_ms: Execution time in milliseconds.
116
277
  checksum: MD5 checksum of the migration content.
117
278
  """
118
-
119
279
  applied_by = getpass.getuser()
280
+ parsed_version = parse_version(version)
281
+ version_type = parsed_version.type.value
120
282
 
121
- record_sql = self._get_record_migration_sql(version, description, execution_time_ms, checksum, applied_by)
283
+ next_seq_result = driver.execute(self._get_next_execution_sequence_sql())
284
+ seq_data = next_seq_result.get_data()
285
+ execution_sequence = seq_data[0]["next_seq"] if seq_data else 1
286
+
287
+ record_sql = self._get_record_migration_sql(
288
+ version, version_type, execution_sequence, description, execution_time_ms, checksum, applied_by
289
+ )
122
290
  driver.execute(record_sql)
123
- self._safe_commit(driver)
291
+ driver.commit()
124
292
 
125
293
  def remove_migration(self, driver: "SyncDriverAdapterBase", version: str) -> None:
126
294
  """Remove a migration record.
@@ -131,26 +299,42 @@ class OracleSyncMigrationTracker(OracleMigrationTrackerMixin, BaseMigrationTrack
131
299
  """
132
300
  remove_sql = self._get_remove_migration_sql(version)
133
301
  driver.execute(remove_sql)
134
- self._safe_commit(driver)
302
+ driver.commit()
135
303
 
136
- def _safe_commit(self, driver: "SyncDriverAdapterBase") -> None:
137
- """Safely commit a transaction only if autocommit is disabled.
304
+ def update_version_record(self, driver: "SyncDriverAdapterBase", old_version: str, new_version: str) -> None:
305
+ """Update migration version record from timestamp to sequential.
306
+
307
+ Updates version_num and version_type while preserving execution_sequence,
308
+ applied_at, and other tracking metadata. Used during fix command.
309
+
310
+ Idempotent: If the version is already updated, logs and continues without error.
311
+ This allows fix command to be safely re-run after pulling changes.
138
312
 
139
313
  Args:
140
314
  driver: The database driver to use.
315
+ old_version: Current timestamp version string.
316
+ new_version: New sequential version string.
317
+
318
+ Raises:
319
+ ValueError: If neither old_version nor new_version found in database.
141
320
  """
142
- try:
143
- # Check driver features first (preferred approach)
144
- if driver.driver_features.get("autocommit", False):
145
- return
321
+ parsed_new_version = parse_version(new_version)
322
+ new_version_type = parsed_new_version.type.value
323
+
324
+ result = driver.execute(self._get_update_version_sql(old_version, new_version, new_version_type))
325
+
326
+ if result.rows_affected == 0:
327
+ check_result = driver.execute(self._get_applied_migrations_sql())
328
+ applied_versions = {row["version_num"] for row in check_result.data} if check_result.data else set()
146
329
 
147
- # Fallback to connection-level autocommit check
148
- if driver.connection and driver.connection.autocommit:
330
+ if new_version in applied_versions:
331
+ logger.debug("Version already updated: %s -> %s", old_version, new_version)
149
332
  return
150
333
 
151
- driver.commit()
152
- except Exception:
153
- logger.debug("Failed to commit transaction, likely due to autocommit being enabled")
334
+ msg = f"Migration {old_version} not found in database for update to {new_version}"
335
+ raise ValueError(msg)
336
+
337
+ driver.commit()
154
338
 
155
339
 
156
340
  class OracleAsyncMigrationTracker(OracleMigrationTrackerMixin, BaseMigrationTracker["AsyncDriverAdapterBase"]):
@@ -158,27 +342,98 @@ class OracleAsyncMigrationTracker(OracleMigrationTrackerMixin, BaseMigrationTrac
158
342
 
159
343
  __slots__ = ()
160
344
 
345
+ async def _migrate_schema_if_needed(self, driver: "AsyncDriverAdapterBase") -> None:
346
+ """Check for and add any missing columns to the tracking table.
347
+
348
+ Uses the driver's data dictionary to query existing columns from Oracle's
349
+ USER_TAB_COLUMNS metadata table.
350
+
351
+ Args:
352
+ driver: The database driver to use.
353
+ """
354
+ try:
355
+ columns_data = await driver.data_dictionary.get_columns(driver, self.version_table)
356
+ existing_columns = {str(row["column_name"]).upper() for row in columns_data}
357
+ missing_columns = self._detect_missing_columns(existing_columns)
358
+
359
+ if not missing_columns:
360
+ logger.debug("Migration tracking table schema is up-to-date")
361
+ return
362
+
363
+ console.print(
364
+ f"[cyan]Migrating tracking table schema, adding columns: {', '.join(sorted(missing_columns))}[/]"
365
+ )
366
+
367
+ for col_name in sorted(missing_columns):
368
+ await self._add_column(driver, col_name)
369
+
370
+ await driver.commit()
371
+ console.print("[green]Migration tracking table schema updated successfully[/]")
372
+
373
+ except Exception as e:
374
+ logger.warning("Could not check or migrate tracking table schema: %s", e)
375
+
376
+ async def _add_column(self, driver: "AsyncDriverAdapterBase", column_name: str) -> None:
377
+ """Add a single column to the tracking table.
378
+
379
+ Args:
380
+ driver: The database driver to use.
381
+ column_name: Name of the column to add (lowercase).
382
+ """
383
+ target_create = self._get_create_table_sql()
384
+ column_def = next((col for col in target_create.columns if col.name.lower() == column_name), None)
385
+
386
+ if not column_def:
387
+ return
388
+
389
+ default_clause = f" DEFAULT {column_def.default}" if column_def.default else ""
390
+ not_null_clause = " NOT NULL" if column_def.not_null else ""
391
+
392
+ alter_sql = f"""
393
+ ALTER TABLE {self.version_table}
394
+ ADD {column_def.name} {column_def.dtype}{default_clause}{not_null_clause}
395
+ """
396
+
397
+ await driver.execute(alter_sql)
398
+ logger.debug("Added column %s to tracking table", column_name)
399
+
161
400
  async def ensure_tracking_table(self, driver: "AsyncDriverAdapterBase") -> None:
162
401
  """Create the migration tracking table if it doesn't exist.
163
402
 
164
- Oracle doesn't support IF NOT EXISTS, so we check for table existence first.
403
+ Uses a PL/SQL block to make the operation atomic and prevent race conditions.
404
+ Also checks for and adds missing columns to support schema migrations.
165
405
 
166
406
  Args:
167
407
  driver: The database driver to use.
168
408
  """
409
+ create_script = f"""
410
+ BEGIN
411
+ EXECUTE IMMEDIATE '
412
+ CREATE TABLE {self.version_table} (
413
+ version_num VARCHAR2(32) PRIMARY KEY,
414
+ version_type VARCHAR2(16),
415
+ execution_sequence INTEGER,
416
+ description VARCHAR2(2000),
417
+ applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
418
+ execution_time_ms INTEGER,
419
+ checksum VARCHAR2(64),
420
+ applied_by VARCHAR2(255)
421
+ )';
422
+ EXCEPTION
423
+ WHEN OTHERS THEN
424
+ IF SQLCODE = -955 THEN
425
+ NULL; -- Table already exists
426
+ ELSE
427
+ RAISE;
428
+ END IF;
429
+ END;
430
+ """
431
+ await driver.execute_script(create_script)
432
+ await driver.commit()
169
433
 
170
- check_sql = (
171
- sql.select(sql.count().as_("table_count"))
172
- .from_("user_tables")
173
- .where(sql.column("table_name") == self.version_table.upper())
174
- )
175
- result = await driver.execute(check_sql)
176
-
177
- if result.data[0]["TABLE_COUNT"] == 0:
178
- await driver.execute(self._get_create_table_sql())
179
- await self._safe_commit_async(driver)
434
+ await self._migrate_schema_if_needed(driver)
180
435
 
181
- async def get_current_version(self, driver: "AsyncDriverAdapterBase") -> "Optional[str]":
436
+ async def get_current_version(self, driver: "AsyncDriverAdapterBase") -> "str | None":
182
437
  """Get the latest applied migration version.
183
438
 
184
439
  Args:
@@ -188,7 +443,8 @@ class OracleAsyncMigrationTracker(OracleMigrationTrackerMixin, BaseMigrationTrac
188
443
  The current migration version or None if no migrations applied.
189
444
  """
190
445
  result = await driver.execute(self._get_current_version_sql())
191
- return result.data[0]["VERSION_NUM"] if result.data else None
446
+ data = result.get_data()
447
+ return data[0]["version_num"] if data else None
192
448
 
193
449
  async def get_applied_migrations(self, driver: "AsyncDriverAdapterBase") -> "list[dict[str, Any]]":
194
450
  """Get all applied migrations in order.
@@ -197,15 +453,10 @@ class OracleAsyncMigrationTracker(OracleMigrationTrackerMixin, BaseMigrationTrac
197
453
  driver: The database driver to use.
198
454
 
199
455
  Returns:
200
- List of migration records as dictionaries.
456
+ List of migration records as dictionaries with lowercase keys.
201
457
  """
202
458
  result = await driver.execute(self._get_applied_migrations_sql())
203
- if not result.data:
204
- return []
205
-
206
- normalized_data = [{key.lower(): value for key, value in row.items()} for row in result.data]
207
-
208
- return cast("list[dict[str, Any]]", normalized_data)
459
+ return result.get_data()
209
460
 
210
461
  async def record_migration(
211
462
  self, driver: "AsyncDriverAdapterBase", version: str, description: str, execution_time_ms: int, checksum: str
@@ -221,10 +472,18 @@ class OracleAsyncMigrationTracker(OracleMigrationTrackerMixin, BaseMigrationTrac
221
472
  """
222
473
 
223
474
  applied_by = getpass.getuser()
475
+ parsed_version = parse_version(version)
476
+ version_type = parsed_version.type.value
477
+
478
+ next_seq_result = await driver.execute(self._get_next_execution_sequence_sql())
479
+ seq_data = next_seq_result.get_data()
480
+ execution_sequence = seq_data[0]["next_seq"] if seq_data else 1
224
481
 
225
- record_sql = self._get_record_migration_sql(version, description, execution_time_ms, checksum, applied_by)
482
+ record_sql = self._get_record_migration_sql(
483
+ version, version_type, execution_sequence, description, execution_time_ms, checksum, applied_by
484
+ )
226
485
  await driver.execute(record_sql)
227
- await self._safe_commit_async(driver)
486
+ await driver.commit()
228
487
 
229
488
  async def remove_migration(self, driver: "AsyncDriverAdapterBase", version: str) -> None:
230
489
  """Remove a migration record.
@@ -235,23 +494,39 @@ class OracleAsyncMigrationTracker(OracleMigrationTrackerMixin, BaseMigrationTrac
235
494
  """
236
495
  remove_sql = self._get_remove_migration_sql(version)
237
496
  await driver.execute(remove_sql)
238
- await self._safe_commit_async(driver)
497
+ await driver.commit()
498
+
499
+ async def update_version_record(self, driver: "AsyncDriverAdapterBase", old_version: str, new_version: str) -> None:
500
+ """Update migration version record from timestamp to sequential.
501
+
502
+ Updates version_num and version_type while preserving execution_sequence,
503
+ applied_at, and other tracking metadata. Used during fix command.
239
504
 
240
- async def _safe_commit_async(self, driver: "AsyncDriverAdapterBase") -> None:
241
- """Safely commit a transaction only if autocommit is disabled.
505
+ Idempotent: If the version is already updated, logs and continues without error.
506
+ This allows fix command to be safely re-run after pulling changes.
242
507
 
243
508
  Args:
244
509
  driver: The database driver to use.
510
+ old_version: Current timestamp version string.
511
+ new_version: New sequential version string.
512
+
513
+ Raises:
514
+ ValueError: If neither old_version nor new_version found in database.
245
515
  """
246
- try:
247
- # Check driver features first (preferred approach)
248
- if driver.driver_features.get("autocommit", False):
249
- return
516
+ parsed_new_version = parse_version(new_version)
517
+ new_version_type = parsed_new_version.type.value
518
+
519
+ result = await driver.execute(self._get_update_version_sql(old_version, new_version, new_version_type))
250
520
 
251
- # Fallback to connection-level autocommit check
252
- if driver.connection and driver.connection.autocommit:
521
+ if result.rows_affected == 0:
522
+ check_result = await driver.execute(self._get_applied_migrations_sql())
523
+ applied_versions = {row["version_num"] for row in check_result.data} if check_result.data else set()
524
+
525
+ if new_version in applied_versions:
526
+ logger.debug("Version already updated: %s -> %s", old_version, new_version)
253
527
  return
254
528
 
255
- await driver.commit()
256
- except Exception:
257
- logger.debug("Failed to commit transaction, likely due to autocommit being enabled")
529
+ msg = f"Migration {old_version} not found in database for update to {new_version}"
530
+ raise ValueError(msg)
531
+
532
+ await driver.commit()