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
@@ -4,18 +4,18 @@ This module provides abstract base classes for migration components.
4
4
  """
5
5
 
6
6
  import hashlib
7
- import operator
8
7
  from abc import ABC, abstractmethod
9
8
  from pathlib import Path
10
- from typing import Any, Generic, Optional, TypeVar, cast
9
+ from typing import Any, Generic, TypeVar, cast
11
10
 
12
- from sqlspec._sql import sql
13
- from sqlspec.builder import Delete, Insert, Select
11
+ from sqlspec.builder import Delete, Insert, Select, Update, sql
14
12
  from sqlspec.builder._ddl import CreateTable
15
13
  from sqlspec.loader import SQLFileLoader
16
14
  from sqlspec.migrations.loaders import get_migration_loader
17
15
  from sqlspec.utils.logging import get_logger
16
+ from sqlspec.utils.module_loader import module_to_os_path
18
17
  from sqlspec.utils.sync_tools import await_
18
+ from sqlspec.utils.version import parse_version
19
19
 
20
20
  __all__ = ("BaseMigrationCommands", "BaseMigrationRunner", "BaseMigrationTracker")
21
21
 
@@ -42,6 +42,16 @@ class BaseMigrationTracker(ABC, Generic[DriverT]):
42
42
  def _get_create_table_sql(self) -> CreateTable:
43
43
  """Get SQL builder for creating the tracking table.
44
44
 
45
+ Schema includes both legacy and new versioning columns:
46
+ - version_num: Migration version (sequential or timestamp format)
47
+ - version_type: Format indicator ('sequential' or 'timestamp')
48
+ - execution_sequence: Auto-incrementing application order
49
+ - description: Human-readable migration description
50
+ - applied_at: Timestamp when migration was applied
51
+ - execution_time_ms: Migration execution duration
52
+ - checksum: MD5 hash for content verification
53
+ - applied_by: User who applied the migration
54
+
45
55
  Returns:
46
56
  SQL builder object for table creation.
47
57
  """
@@ -49,6 +59,8 @@ class BaseMigrationTracker(ABC, Generic[DriverT]):
49
59
  sql.create_table(self.version_table)
50
60
  .if_not_exists()
51
61
  .column("version_num", "VARCHAR(32)", primary_key=True)
62
+ .column("version_type", "VARCHAR(16)")
63
+ .column("execution_sequence", "INTEGER")
52
64
  .column("description", "TEXT")
53
65
  .column("applied_at", "TIMESTAMP", default="CURRENT_TIMESTAMP", not_null=True)
54
66
  .column("execution_time_ms", "INTEGER")
@@ -59,26 +71,49 @@ class BaseMigrationTracker(ABC, Generic[DriverT]):
59
71
  def _get_current_version_sql(self) -> Select:
60
72
  """Get SQL builder for retrieving current version.
61
73
 
74
+ Uses execution_sequence to get the last applied migration,
75
+ which may differ from version_num order due to out-of-order migrations.
76
+
62
77
  Returns:
63
78
  SQL builder object for version query.
64
79
  """
65
- return sql.select("version_num").from_(self.version_table).order_by("version_num DESC").limit(1)
80
+ return sql.select("version_num").from_(self.version_table).order_by("execution_sequence DESC").limit(1)
66
81
 
67
82
  def _get_applied_migrations_sql(self) -> Select:
68
83
  """Get SQL builder for retrieving all applied migrations.
69
84
 
85
+ Orders by execution_sequence to show migrations in application order,
86
+ which preserves the actual execution history for out-of-order migrations.
87
+
70
88
  Returns:
71
89
  SQL builder object for migrations query.
72
90
  """
73
- return sql.select("*").from_(self.version_table).order_by("version_num")
91
+ return sql.select("*").from_(self.version_table).order_by("execution_sequence")
92
+
93
+ def _get_next_execution_sequence_sql(self) -> Select:
94
+ """Get SQL builder for retrieving next execution sequence.
95
+
96
+ Returns:
97
+ SQL builder object for sequence query.
98
+ """
99
+ return sql.select("COALESCE(MAX(execution_sequence), 0) + 1 AS next_seq").from_(self.version_table)
74
100
 
75
101
  def _get_record_migration_sql(
76
- self, version: str, description: str, execution_time_ms: int, checksum: str, applied_by: str
102
+ self,
103
+ version: str,
104
+ version_type: str,
105
+ execution_sequence: int,
106
+ description: str,
107
+ execution_time_ms: int,
108
+ checksum: str,
109
+ applied_by: str,
77
110
  ) -> Insert:
78
111
  """Get SQL builder for recording a migration.
79
112
 
80
113
  Args:
81
114
  version: Version number of the migration.
115
+ version_type: Version format type ('sequential' or 'timestamp').
116
+ execution_sequence: Auto-incrementing application order.
82
117
  description: Description of the migration.
83
118
  execution_time_ms: Execution time in milliseconds.
84
119
  checksum: MD5 checksum of the migration content.
@@ -89,8 +124,16 @@ class BaseMigrationTracker(ABC, Generic[DriverT]):
89
124
  """
90
125
  return (
91
126
  sql.insert(self.version_table)
92
- .columns("version_num", "description", "execution_time_ms", "checksum", "applied_by")
93
- .values(version, description, execution_time_ms, checksum, applied_by)
127
+ .columns(
128
+ "version_num",
129
+ "version_type",
130
+ "execution_sequence",
131
+ "description",
132
+ "execution_time_ms",
133
+ "checksum",
134
+ "applied_by",
135
+ )
136
+ .values(version, version_type, execution_sequence, description, execution_time_ms, checksum, applied_by)
94
137
  )
95
138
 
96
139
  def _get_remove_migration_sql(self, version: str) -> Delete:
@@ -104,9 +147,90 @@ class BaseMigrationTracker(ABC, Generic[DriverT]):
104
147
  """
105
148
  return sql.delete().from_(self.version_table).where(sql.version_num == version)
106
149
 
150
+ def _get_update_version_sql(self, old_version: str, new_version: str, new_version_type: str) -> Update:
151
+ """Get SQL builder for updating version record.
152
+
153
+ Updates version_num and version_type while preserving execution_sequence,
154
+ applied_at, and other metadata. Used during fix command to convert
155
+ timestamp versions to sequential format.
156
+
157
+ Args:
158
+ old_version: Current version string.
159
+ new_version: New version string.
160
+ new_version_type: New version type ('sequential' or 'timestamp').
161
+
162
+ Returns:
163
+ SQL builder object for update.
164
+ """
165
+ return (
166
+ sql.update(self.version_table)
167
+ .set("version_num", new_version)
168
+ .set("version_type", new_version_type)
169
+ .where(sql.version_num == old_version)
170
+ )
171
+
172
+ def _get_check_column_exists_sql(self) -> Select:
173
+ """Get SQL to check what columns exist in the tracking table.
174
+
175
+ Returns a query that will fail gracefully if the table doesn't exist,
176
+ and returns column names if it does.
177
+
178
+ Returns:
179
+ SQL builder object for column check query.
180
+ """
181
+ return sql.select("*").from_(self.version_table).limit(0)
182
+
183
+ def _get_add_missing_columns_sql(self, missing_columns: "set[str]") -> "list[str]":
184
+ """Generate ALTER TABLE statements to add missing columns.
185
+
186
+ Args:
187
+ missing_columns: Set of column names that need to be added.
188
+
189
+ Returns:
190
+ List of SQL statements to execute.
191
+ """
192
+
193
+ statements = []
194
+ target_create = self._get_create_table_sql()
195
+
196
+ column_definitions = {col.name.lower(): col for col in target_create.columns}
197
+
198
+ for col_name in sorted(missing_columns):
199
+ if col_name in column_definitions:
200
+ col_def = column_definitions[col_name]
201
+ alter = sql.alter_table(self.version_table).add_column(
202
+ name=col_def.name,
203
+ dtype=col_def.dtype,
204
+ default=col_def.default,
205
+ not_null=col_def.not_null,
206
+ unique=col_def.unique,
207
+ comment=col_def.comment,
208
+ )
209
+ statements.append(str(alter))
210
+
211
+ return statements
212
+
213
+ def _detect_missing_columns(self, existing_columns: "set[str]") -> "set[str]":
214
+ """Detect which columns are missing from the current schema.
215
+
216
+ Args:
217
+ existing_columns: Set of existing column names (may be uppercase/lowercase).
218
+
219
+ Returns:
220
+ Set of missing column names (lowercase).
221
+ """
222
+ target_create = self._get_create_table_sql()
223
+ target_columns = {col.name.lower() for col in target_create.columns}
224
+ existing_lower = {col.lower() for col in existing_columns}
225
+ return target_columns - existing_lower
226
+
107
227
  @abstractmethod
108
228
  def ensure_tracking_table(self, driver: DriverT) -> Any:
109
- """Create the migration tracking table if it doesn't exist."""
229
+ """Create the migration tracking table if it doesn't exist.
230
+
231
+ Implementations should also check for and add any missing columns
232
+ to support schema migrations from older versions.
233
+ """
110
234
  ...
111
235
 
112
236
  @abstractmethod
@@ -135,17 +259,31 @@ class BaseMigrationTracker(ABC, Generic[DriverT]):
135
259
  class BaseMigrationRunner(ABC, Generic[DriverT]):
136
260
  """Base class for migration execution."""
137
261
 
138
- def __init__(self, migrations_path: Path) -> None:
262
+ extension_configs: "dict[str, dict[str, Any]]"
263
+
264
+ def __init__(
265
+ self,
266
+ migrations_path: Path,
267
+ extension_migrations: "dict[str, Path] | None" = None,
268
+ context: "Any | None" = None,
269
+ extension_configs: "dict[str, dict[str, Any]] | None" = None,
270
+ ) -> None:
139
271
  """Initialize the migration runner.
140
272
 
141
273
  Args:
142
274
  migrations_path: Path to the directory containing migration files.
275
+ extension_migrations: Optional mapping of extension names to their migration paths.
276
+ context: Optional migration context for Python migrations.
277
+ extension_configs: Optional mapping of extension names to their configurations.
143
278
  """
144
279
  self.migrations_path = migrations_path
280
+ self.extension_migrations = extension_migrations or {}
145
281
  self.loader = SQLFileLoader()
146
- self.project_root: Optional[Path] = None
282
+ self.project_root: Path | None = None
283
+ self.context = context
284
+ self.extension_configs = extension_configs or {}
147
285
 
148
- def _extract_version(self, filename: str) -> Optional[str]:
286
+ def _extract_version(self, filename: str) -> str | None:
149
287
  """Extract version from filename.
150
288
 
151
289
  Args:
@@ -154,7 +292,14 @@ class BaseMigrationRunner(ABC, Generic[DriverT]):
154
292
  Returns:
155
293
  The extracted version string or None.
156
294
  """
157
- parts = filename.split("_", 1)
295
+ from pathlib import Path
296
+
297
+ stem = Path(filename).stem
298
+
299
+ if stem.startswith("ext_"):
300
+ return stem
301
+
302
+ parts = stem.split("_", 1)
158
303
  return parts[0].zfill(4) if parts and parts[0].isdigit() else None
159
304
 
160
305
  def _calculate_checksum(self, content: str) -> str:
@@ -172,38 +317,73 @@ class BaseMigrationRunner(ABC, Generic[DriverT]):
172
317
  def _get_migration_files_sync(self) -> "list[tuple[str, Path]]":
173
318
  """Get all migration files sorted by version.
174
319
 
320
+ Uses version-aware sorting that handles both sequential and timestamp
321
+ formats correctly, with extension migrations sorted by extension name.
322
+
175
323
  Returns:
176
324
  List of tuples containing (version, file_path).
177
325
  """
178
- if not self.migrations_path.exists():
179
- return []
180
-
181
326
  migrations = []
182
- for pattern in ["*.sql", "*.py"]:
183
- for file_path in self.migrations_path.glob(pattern):
184
- if file_path.name.startswith("."):
185
- continue
186
- version = self._extract_version(file_path.name)
187
- if version:
188
- migrations.append((version, file_path))
189
-
190
- return sorted(migrations, key=operator.itemgetter(0))
191
327
 
192
- def _load_migration_metadata(self, file_path: Path) -> "dict[str, Any]":
328
+ # Scan primary migration path
329
+ if self.migrations_path.exists():
330
+ for pattern in ("*.sql", "*.py"):
331
+ for file_path in self.migrations_path.glob(pattern):
332
+ if file_path.name.startswith("."):
333
+ continue
334
+ version = self._extract_version(file_path.name)
335
+ if version:
336
+ migrations.append((version, file_path))
337
+
338
+ # Scan extension migration paths
339
+ for ext_name, ext_path in self.extension_migrations.items():
340
+ if ext_path.exists():
341
+ for pattern in ("*.sql", "*.py"):
342
+ for file_path in ext_path.glob(pattern):
343
+ if file_path.name.startswith("."):
344
+ continue
345
+ # Prefix extension migrations to avoid version conflicts
346
+ version = self._extract_version(file_path.name)
347
+ if version:
348
+ # Use ext_ prefix to distinguish extension migrations
349
+ prefixed_version = f"ext_{ext_name}_{version}"
350
+ migrations.append((prefixed_version, file_path))
351
+
352
+ return sorted(migrations, key=lambda m: parse_version(m[0]))
353
+
354
+ def _load_migration_metadata(self, file_path: Path, version: "str | None" = None) -> "dict[str, Any]":
193
355
  """Load migration metadata from file.
194
356
 
195
357
  Args:
196
358
  file_path: Path to the migration file.
359
+ version: Optional pre-extracted version (preserves prefixes like ext_adk_0001).
197
360
 
198
361
  Returns:
199
362
  Migration metadata dictionary.
200
363
  """
201
-
202
- loader = get_migration_loader(file_path, self.migrations_path, self.project_root)
364
+ if version is None:
365
+ version = self._extract_version(file_path.name)
366
+
367
+ context_to_use = self.context
368
+
369
+ for ext_name, ext_path in self.extension_migrations.items():
370
+ if file_path.parent == ext_path:
371
+ if ext_name in self.extension_configs and self.context:
372
+ from sqlspec.migrations.context import MigrationContext
373
+
374
+ context_to_use = MigrationContext(
375
+ dialect=self.context.dialect,
376
+ config=self.context.config,
377
+ driver=self.context.driver,
378
+ metadata=self.context.metadata.copy() if self.context.metadata else {},
379
+ extension_config=self.extension_configs[ext_name],
380
+ )
381
+ break
382
+
383
+ loader = get_migration_loader(file_path, self.migrations_path, self.project_root, context_to_use)
203
384
  loader.validate_migration_file(file_path)
204
385
  content = file_path.read_text(encoding="utf-8")
205
386
  checksum = self._calculate_checksum(content)
206
- version = self._extract_version(file_path.name)
207
387
  description = file_path.stem.split("_", 1)[1] if "_" in file_path.stem else ""
208
388
 
209
389
  has_upgrade, has_downgrade = True, False
@@ -229,7 +409,7 @@ class BaseMigrationRunner(ABC, Generic[DriverT]):
229
409
  "loader": loader,
230
410
  }
231
411
 
232
- def _get_migration_sql(self, migration: "dict[str, Any]", direction: str) -> "Optional[list[str]]":
412
+ def _get_migration_sql(self, migration: "dict[str, Any]", direction: str) -> "list[str] | None":
233
413
  """Get migration SQL for given direction.
234
414
 
235
415
  Args:
@@ -292,6 +472,8 @@ class BaseMigrationRunner(ABC, Generic[DriverT]):
292
472
  class BaseMigrationCommands(ABC, Generic[ConfigT, DriverT]):
293
473
  """Base class for migration commands."""
294
474
 
475
+ extension_configs: "dict[str, dict[str, Any]]"
476
+
295
477
  def __init__(self, config: ConfigT) -> None:
296
478
  """Initialize migration commands.
297
479
 
@@ -304,6 +486,56 @@ class BaseMigrationCommands(ABC, Generic[ConfigT, DriverT]):
304
486
  self.version_table = migration_config.get("version_table_name", "ddl_migrations")
305
487
  self.migrations_path = Path(migration_config.get("script_location", "migrations"))
306
488
  self.project_root = Path(migration_config["project_root"]) if "project_root" in migration_config else None
489
+ self.include_extensions = migration_config.get("include_extensions", [])
490
+ self.extension_configs = self._parse_extension_configs()
491
+
492
+ def _parse_extension_configs(self) -> "dict[str, dict[str, Any]]":
493
+ """Parse extension configurations from include_extensions.
494
+
495
+ Reads extension configuration from config.extension_config for each
496
+ extension listed in include_extensions.
497
+
498
+ Returns:
499
+ Dictionary mapping extension names to their configurations.
500
+ """
501
+ configs = {}
502
+
503
+ for ext_config in self.include_extensions:
504
+ if not isinstance(ext_config, str):
505
+ logger.warning("Extension must be a string name, got: %s", ext_config)
506
+ continue
507
+
508
+ ext_name = ext_config
509
+ ext_options = getattr(self.config, "extension_config", {}).get(ext_name, {})
510
+ configs[ext_name] = ext_options
511
+
512
+ return configs
513
+
514
+ def _discover_extension_migrations(self) -> "dict[str, Path]":
515
+ """Discover migration paths for configured extensions.
516
+
517
+ Returns:
518
+ Dictionary mapping extension names to their migration paths.
519
+ """
520
+
521
+ extension_migrations = {}
522
+
523
+ for ext_name in self.extension_configs:
524
+ module_name = "sqlspec.extensions.litestar" if ext_name == "litestar" else f"sqlspec.extensions.{ext_name}"
525
+
526
+ try:
527
+ module_path = module_to_os_path(module_name)
528
+ migrations_dir = module_path / "migrations"
529
+
530
+ if migrations_dir.exists():
531
+ extension_migrations[ext_name] = migrations_dir
532
+ logger.debug("Found migrations for extension %s at %s", ext_name, migrations_dir)
533
+ else:
534
+ logger.warning("No migrations directory found for extension %s", ext_name)
535
+ except TypeError:
536
+ logger.warning("Extension %s not found", ext_name)
537
+
538
+ return extension_migrations
307
539
 
308
540
  def _get_init_readme_content(self) -> str:
309
541
  """Get README content for migration directory initialization.
@@ -320,13 +552,13 @@ This directory contains database migration files.
320
552
  Migration files use SQLFileLoader's named query syntax with versioned names:
321
553
 
322
554
  ```sql
323
- -- name: migrate-0001-up
555
+ -- name: migrate-20251011120000-up
324
556
  CREATE TABLE example (
325
557
  id INTEGER PRIMARY KEY,
326
558
  name TEXT NOT NULL
327
559
  );
328
560
 
329
- -- name: migrate-0001-down
561
+ -- name: migrate-20251011120000-down
330
562
  DROP TABLE example;
331
563
  ```
332
564
 
@@ -336,16 +568,48 @@ DROP TABLE example;
336
568
 
337
569
  Format: `{version}_{description}.sql`
338
570
 
339
- - Version: Zero-padded 4-digit number (0001, 0002, etc.)
571
+ - Version: Timestamp in YYYYMMDDHHmmss format (UTC)
340
572
  - Description: Brief description using underscores
341
- - Example: `0001_create_users_table.sql`
573
+ - Example: `20251011120000_create_users_table.sql`
342
574
 
343
575
  ### Query Names
344
576
 
345
577
  - Upgrade: `migrate-{version}-up`
346
578
  - Downgrade: `migrate-{version}-down`
347
579
 
348
- This naming ensures proper sorting and avoids conflicts when loading multiple files.
580
+ ## Version Format
581
+
582
+ Migrations use **timestamp-based versioning** (YYYYMMDDHHmmss):
583
+
584
+ - **Format**: 14-digit UTC timestamp
585
+ - **Example**: `20251011120000` (October 11, 2025 at 12:00:00 UTC)
586
+ - **Benefits**: Eliminates merge conflicts when multiple developers create migrations concurrently
587
+
588
+ ### Creating Migrations
589
+
590
+ Use the CLI to generate timestamped migrations:
591
+
592
+ ```bash
593
+ sqlspec create-migration "add user table"
594
+ # Creates: 20251011120000_add_user_table.sql
595
+ ```
596
+
597
+ The timestamp is automatically generated in UTC timezone.
598
+
599
+ ## Migration Execution
600
+
601
+ Migrations are applied in chronological order based on their timestamps.
602
+ The database tracks both version and execution order separately to handle
603
+ out-of-order migrations gracefully (e.g., from late-merging branches).
604
+ """
605
+
606
+ def _get_init_init_content(self) -> str:
607
+ """Get __init__.py content for migration directory initialization.
608
+
609
+ Returns:
610
+ Python module docstring content for the __init__.py file.
611
+ """
612
+ return """Migrations.
349
613
  """
350
614
 
351
615
  def init_directory(self, directory: str, package: bool = True) -> None:
@@ -363,13 +627,12 @@ This naming ensures proper sorting and avoids conflicts when loading multiple fi
363
627
  migrations_dir.mkdir(parents=True, exist_ok=True)
364
628
 
365
629
  if package:
366
- (migrations_dir / "__init__.py").touch()
630
+ init = migrations_dir / "__init__.py"
631
+ init.write_text(self._get_init_init_content())
367
632
 
368
633
  readme = migrations_dir / "README.md"
369
634
  readme.write_text(self._get_init_readme_content())
370
635
 
371
- (migrations_dir / ".gitkeep").touch()
372
-
373
636
  console.print(f"[green]Initialized migrations in {directory}[/]")
374
637
 
375
638
  @abstractmethod