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.

Files changed (197) hide show
  1. sqlspec/__init__.py +7 -15
  2. sqlspec/_serialization.py +55 -25
  3. sqlspec/_typing.py +62 -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 +62 -12
  8. sqlspec/adapters/adbc/data_dictionary.py +52 -2
  9. sqlspec/adapters/adbc/driver.py +144 -45
  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 +44 -50
  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 +86 -16
  17. sqlspec/adapters/aiosqlite/data_dictionary.py +34 -2
  18. sqlspec/adapters/aiosqlite/driver.py +127 -38
  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 +1 -1
  24. sqlspec/adapters/asyncmy/adk/__init__.py +5 -0
  25. sqlspec/adapters/asyncmy/adk/store.py +493 -0
  26. sqlspec/adapters/asyncmy/config.py +59 -17
  27. sqlspec/adapters/asyncmy/data_dictionary.py +41 -2
  28. sqlspec/adapters/asyncmy/driver.py +293 -62
  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 +57 -36
  37. sqlspec/adapters/asyncpg/data_dictionary.py +41 -2
  38. sqlspec/adapters/asyncpg/driver.py +153 -23
  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 +25 -11
  45. sqlspec/adapters/bigquery/data_dictionary.py +42 -2
  46. sqlspec/adapters/bigquery/driver.py +352 -144
  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 +55 -23
  50. sqlspec/adapters/duckdb/_types.py +2 -2
  51. sqlspec/adapters/duckdb/adk/__init__.py +14 -0
  52. sqlspec/adapters/duckdb/adk/store.py +553 -0
  53. sqlspec/adapters/duckdb/config.py +79 -21
  54. sqlspec/adapters/duckdb/data_dictionary.py +41 -2
  55. sqlspec/adapters/duckdb/driver.py +138 -43
  56. sqlspec/adapters/duckdb/litestar/__init__.py +5 -0
  57. sqlspec/adapters/duckdb/litestar/store.py +332 -0
  58. sqlspec/adapters/duckdb/pool.py +5 -5
  59. sqlspec/adapters/duckdb/type_converter.py +51 -21
  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 +120 -36
  65. sqlspec/adapters/oracledb/data_dictionary.py +87 -20
  66. sqlspec/adapters/oracledb/driver.py +292 -84
  67. sqlspec/adapters/oracledb/litestar/__init__.py +5 -0
  68. sqlspec/adapters/oracledb/litestar/store.py +767 -0
  69. sqlspec/adapters/oracledb/migrations.py +316 -25
  70. sqlspec/adapters/oracledb/type_converter.py +91 -16
  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 +45 -19
  76. sqlspec/adapters/psqlpy/data_dictionary.py +41 -2
  77. sqlspec/adapters/psqlpy/driver.py +101 -31
  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 +40 -11
  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 +65 -37
  86. sqlspec/adapters/psycopg/data_dictionary.py +77 -3
  87. sqlspec/adapters/psycopg/driver.py +200 -78
  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 +85 -16
  96. sqlspec/adapters/sqlite/data_dictionary.py +34 -2
  97. sqlspec/adapters/sqlite/driver.py +120 -52
  98. sqlspec/adapters/sqlite/litestar/__init__.py +5 -0
  99. sqlspec/adapters/sqlite/litestar/store.py +318 -0
  100. sqlspec/adapters/sqlite/pool.py +5 -5
  101. sqlspec/base.py +45 -26
  102. sqlspec/builder/__init__.py +73 -4
  103. sqlspec/builder/_base.py +91 -58
  104. sqlspec/builder/_column.py +5 -5
  105. sqlspec/builder/_ddl.py +98 -89
  106. sqlspec/builder/_delete.py +5 -4
  107. sqlspec/builder/_dml.py +388 -0
  108. sqlspec/{_sql.py → builder/_factory.py} +41 -44
  109. sqlspec/builder/_insert.py +5 -82
  110. sqlspec/builder/{mixins/_join_operations.py → _join.py} +145 -143
  111. sqlspec/builder/_merge.py +446 -11
  112. sqlspec/builder/_parsing_utils.py +9 -11
  113. sqlspec/builder/_select.py +1313 -25
  114. sqlspec/builder/_update.py +11 -42
  115. sqlspec/cli.py +76 -69
  116. sqlspec/config.py +231 -60
  117. sqlspec/core/__init__.py +5 -4
  118. sqlspec/core/cache.py +18 -18
  119. sqlspec/core/compiler.py +6 -8
  120. sqlspec/core/filters.py +37 -37
  121. sqlspec/core/hashing.py +9 -9
  122. sqlspec/core/parameters.py +76 -45
  123. sqlspec/core/result.py +102 -46
  124. sqlspec/core/splitter.py +16 -17
  125. sqlspec/core/statement.py +32 -31
  126. sqlspec/core/type_conversion.py +3 -2
  127. sqlspec/driver/__init__.py +1 -3
  128. sqlspec/driver/_async.py +95 -161
  129. sqlspec/driver/_common.py +133 -80
  130. sqlspec/driver/_sync.py +95 -162
  131. sqlspec/driver/mixins/_result_tools.py +20 -236
  132. sqlspec/driver/mixins/_sql_translator.py +4 -4
  133. sqlspec/exceptions.py +70 -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/base.py +200 -76
  153. sqlspec/migrations/commands.py +591 -62
  154. sqlspec/migrations/context.py +6 -9
  155. sqlspec/migrations/fix.py +199 -0
  156. sqlspec/migrations/loaders.py +47 -19
  157. sqlspec/migrations/runner.py +241 -75
  158. sqlspec/migrations/tracker.py +237 -21
  159. sqlspec/migrations/utils.py +51 -3
  160. sqlspec/migrations/validation.py +177 -0
  161. sqlspec/protocols.py +66 -36
  162. sqlspec/storage/_utils.py +98 -0
  163. sqlspec/storage/backends/fsspec.py +134 -106
  164. sqlspec/storage/backends/local.py +78 -51
  165. sqlspec/storage/backends/obstore.py +278 -162
  166. sqlspec/storage/registry.py +75 -39
  167. sqlspec/typing.py +14 -84
  168. sqlspec/utils/config_resolver.py +6 -6
  169. sqlspec/utils/correlation.py +4 -5
  170. sqlspec/utils/data_transformation.py +3 -2
  171. sqlspec/utils/deprecation.py +9 -8
  172. sqlspec/utils/fixtures.py +4 -4
  173. sqlspec/utils/logging.py +46 -6
  174. sqlspec/utils/module_loader.py +2 -2
  175. sqlspec/utils/schema.py +288 -0
  176. sqlspec/utils/serializers.py +3 -3
  177. sqlspec/utils/sync_tools.py +21 -17
  178. sqlspec/utils/text.py +1 -2
  179. sqlspec/utils/type_guards.py +111 -20
  180. sqlspec/utils/version.py +433 -0
  181. {sqlspec-0.26.0.dist-info → sqlspec-0.27.0.dist-info}/METADATA +40 -21
  182. sqlspec-0.27.0.dist-info/RECORD +207 -0
  183. sqlspec/builder/mixins/__init__.py +0 -55
  184. sqlspec/builder/mixins/_cte_and_set_ops.py +0 -253
  185. sqlspec/builder/mixins/_delete_operations.py +0 -50
  186. sqlspec/builder/mixins/_insert_operations.py +0 -282
  187. sqlspec/builder/mixins/_merge_operations.py +0 -698
  188. sqlspec/builder/mixins/_order_limit_operations.py +0 -145
  189. sqlspec/builder/mixins/_pivot_operations.py +0 -157
  190. sqlspec/builder/mixins/_select_operations.py +0 -930
  191. sqlspec/builder/mixins/_update_operations.py +0 -199
  192. sqlspec/builder/mixins/_where_clause.py +0 -1298
  193. sqlspec-0.26.0.dist-info/RECORD +0 -157
  194. sqlspec-0.26.0.dist-info/licenses/NOTICE +0 -29
  195. {sqlspec-0.26.0.dist-info → sqlspec-0.27.0.dist-info}/WHEEL +0 -0
  196. {sqlspec-0.26.0.dist-info → sqlspec-0.27.0.dist-info}/entry_points.txt +0 -0
  197. {sqlspec-0.26.0.dist-info → sqlspec-0.27.0.dist-info}/licenses/LICENSE +0 -0
@@ -3,7 +3,7 @@
3
3
  import asyncio
4
4
  import inspect
5
5
  from dataclasses import dataclass, field
6
- from typing import TYPE_CHECKING, Any, Optional, Union
6
+ from typing import TYPE_CHECKING, Any
7
7
 
8
8
  from sqlspec.utils.logging import get_logger
9
9
 
@@ -23,16 +23,16 @@ class MigrationContext:
23
23
  to migration functions, allowing them to generate dialect-specific SQL.
24
24
  """
25
25
 
26
- config: "Optional[Any]" = None
26
+ config: "Any | None" = None
27
27
  """Database configuration object."""
28
- dialect: "Optional[str]" = None
28
+ dialect: "str | None" = None
29
29
  """Database dialect (e.g., 'postgres', 'mysql', 'sqlite')."""
30
- metadata: "Optional[dict[str, Any]]" = None
30
+ metadata: "dict[str, Any] | None" = None
31
31
  """Additional metadata for the migration."""
32
- extension_config: "Optional[dict[str, Any]]" = None
32
+ extension_config: "dict[str, Any] | None" = None
33
33
  """Extension-specific configuration options."""
34
34
 
35
- driver: "Optional[Union[SyncDriverAdapterBase, AsyncDriverAdapterBase]]" = None
35
+ driver: "SyncDriverAdapterBase | AsyncDriverAdapterBase | None" = None
36
36
  """Database driver instance (available during execution)."""
37
37
 
38
38
  _execution_metadata: "dict[str, Any]" = field(default_factory=dict)
@@ -129,9 +129,6 @@ class MigrationContext:
129
129
 
130
130
  Args:
131
131
  migration_func: The migration function to validate.
132
-
133
- Raises:
134
- RuntimeError: If async function is used inappropriately.
135
132
  """
136
133
  if inspect.iscoroutinefunction(migration_func) and not self.is_async_execution and not self.is_async_driver:
137
134
  msg = (
@@ -0,0 +1,199 @@
1
+ """Migration file fix operations for converting timestamp to sequential versions.
2
+
3
+ This module provides utilities to convert timestamp-format migration files to
4
+ sequential format, supporting the hybrid versioning workflow where development
5
+ uses timestamps and production uses sequential numbers.
6
+ """
7
+
8
+ import logging
9
+ import re
10
+ import shutil
11
+ from dataclasses import dataclass
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+
15
+ __all__ = ("MigrationFixer", "MigrationRename")
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ @dataclass
21
+ class MigrationRename:
22
+ """Represents a planned migration file rename operation.
23
+
24
+ Attributes:
25
+ old_path: Current file path.
26
+ new_path: Target file path after rename.
27
+ old_version: Current version string.
28
+ new_version: Target version string.
29
+ needs_content_update: Whether file content needs updating.
30
+ True for SQL files that contain query names.
31
+ """
32
+
33
+ old_path: Path
34
+ new_path: Path
35
+ old_version: str
36
+ new_version: str
37
+ needs_content_update: bool
38
+
39
+
40
+ class MigrationFixer:
41
+ """Handles atomic migration file conversion operations.
42
+
43
+ Provides backup/rollback functionality and manages conversion from
44
+ timestamp-based migration files to sequential format.
45
+ """
46
+
47
+ def __init__(self, migrations_path: Path) -> None:
48
+ """Initialize migration fixer.
49
+
50
+ Args:
51
+ migrations_path: Path to migrations directory.
52
+ """
53
+ self.migrations_path = migrations_path
54
+ self.backup_path: Path | None = None
55
+
56
+ def plan_renames(self, conversion_map: dict[str, str]) -> list[MigrationRename]:
57
+ """Plan all file rename operations from conversion map.
58
+
59
+ Scans migration directory and builds list of MigrationRename objects
60
+ for all files that need conversion. Validates no target collisions.
61
+
62
+ Args:
63
+ conversion_map: Dictionary mapping old versions to new versions.
64
+
65
+ Returns:
66
+ List of planned rename operations.
67
+
68
+ Raises:
69
+ ValueError: If target file already exists or collision detected.
70
+ """
71
+ if not conversion_map:
72
+ return []
73
+
74
+ renames: list[MigrationRename] = []
75
+
76
+ for old_version, new_version in conversion_map.items():
77
+ matching_files = list(self.migrations_path.glob(f"{old_version}_*"))
78
+
79
+ for old_path in matching_files:
80
+ suffix = old_path.suffix
81
+ description = old_path.stem.replace(f"{old_version}_", "")
82
+
83
+ new_filename = f"{new_version}_{description}{suffix}"
84
+ new_path = self.migrations_path / new_filename
85
+
86
+ if new_path.exists() and new_path != old_path:
87
+ msg = f"Target file already exists: {new_path}"
88
+ raise ValueError(msg)
89
+
90
+ needs_content_update = suffix == ".sql"
91
+
92
+ renames.append(
93
+ MigrationRename(
94
+ old_path=old_path,
95
+ new_path=new_path,
96
+ old_version=old_version,
97
+ new_version=new_version,
98
+ needs_content_update=needs_content_update,
99
+ )
100
+ )
101
+
102
+ return renames
103
+
104
+ def create_backup(self) -> Path:
105
+ """Create timestamped backup directory with all migration files.
106
+
107
+ Returns:
108
+ Path to created backup directory.
109
+
110
+ """
111
+ timestamp = datetime.now(tz=timezone.utc).strftime("%Y%m%d_%H%M%S")
112
+ backup_dir = self.migrations_path / f".backup_{timestamp}"
113
+
114
+ backup_dir.mkdir(parents=True, exist_ok=False)
115
+
116
+ for file_path in self.migrations_path.iterdir():
117
+ if file_path.is_file() and not file_path.name.startswith("."):
118
+ shutil.copy2(file_path, backup_dir / file_path.name)
119
+
120
+ self.backup_path = backup_dir
121
+ return backup_dir
122
+
123
+ def apply_renames(self, renames: "list[MigrationRename]", dry_run: bool = False) -> None:
124
+ """Execute planned rename operations.
125
+
126
+ Args:
127
+ renames: List of planned rename operations.
128
+ dry_run: If True, log operations without executing.
129
+
130
+ """
131
+ if not renames:
132
+ return
133
+
134
+ for rename in renames:
135
+ if dry_run:
136
+ continue
137
+
138
+ if rename.needs_content_update:
139
+ self.update_file_content(rename.old_path, rename.old_version, rename.new_version)
140
+
141
+ rename.old_path.rename(rename.new_path)
142
+
143
+ def update_file_content(self, file_path: Path, old_version: str, new_version: str) -> None:
144
+ """Update SQL query names and version comments in file content.
145
+
146
+ Transforms query names and version metadata from old version to new version:
147
+ -- name: migrate-{old_version}-up → -- name: migrate-{new_version}-up
148
+ -- name: migrate-{old_version}-down → -- name: migrate-{new_version}-down
149
+ -- Version: {old_version} → -- Version: {new_version}
150
+
151
+ Creates version-specific regex patterns to avoid unintended replacements
152
+ of other migrate-* patterns in the file.
153
+
154
+ Args:
155
+ file_path: Path to file to update.
156
+ old_version: Old version string.
157
+ new_version: New version string.
158
+
159
+ """
160
+ content = file_path.read_text(encoding="utf-8")
161
+
162
+ up_pattern = re.compile(rf"(-- name:\s+migrate-){re.escape(old_version)}(-up)")
163
+ down_pattern = re.compile(rf"(-- name:\s+migrate-){re.escape(old_version)}(-down)")
164
+ version_pattern = re.compile(rf"(-- Version:\s+){re.escape(old_version)}")
165
+
166
+ content = up_pattern.sub(rf"\g<1>{new_version}\g<2>", content)
167
+ content = down_pattern.sub(rf"\g<1>{new_version}\g<2>", content)
168
+ content = version_pattern.sub(rf"\g<1>{new_version}", content)
169
+
170
+ file_path.write_text(content, encoding="utf-8")
171
+ logger.debug("Updated content in %s", file_path.name)
172
+
173
+ def rollback(self) -> None:
174
+ """Restore migration files from backup.
175
+
176
+ Deletes current migration files and restores from backup directory.
177
+ Only restores if backup exists.
178
+ """
179
+ if not self.backup_path or not self.backup_path.exists():
180
+ return
181
+
182
+ for file_path in self.migrations_path.iterdir():
183
+ if file_path.is_file() and not file_path.name.startswith("."):
184
+ file_path.unlink()
185
+
186
+ for backup_file in self.backup_path.iterdir():
187
+ if backup_file.is_file():
188
+ shutil.copy2(backup_file, self.migrations_path / backup_file.name)
189
+
190
+ def cleanup(self) -> None:
191
+ """Remove backup directory after successful conversion.
192
+
193
+ Only removes backup if it exists. Logs warning if no backup found.
194
+ """
195
+ if not self.backup_path or not self.backup_path.exists():
196
+ return
197
+
198
+ shutil.rmtree(self.backup_path)
199
+ self.backup_path = None
@@ -10,7 +10,7 @@ import types
10
10
  from collections.abc import Iterator
11
11
  from contextlib import contextmanager
12
12
  from pathlib import Path
13
- from typing import Any, Final, Optional
13
+ from typing import Any, Final
14
14
 
15
15
  from sqlspec.loader import SQLFileLoader as CoreSQLFileLoader
16
16
 
@@ -77,13 +77,22 @@ class SQLFileLoader(BaseMigrationLoader):
77
77
 
78
78
  __slots__ = ("sql_loader",)
79
79
 
80
- def __init__(self) -> None:
81
- """Initialize SQL file loader."""
82
- self.sql_loader: CoreSQLFileLoader = CoreSQLFileLoader()
80
+ def __init__(self, sql_loader: "CoreSQLFileLoader | None" = None) -> None:
81
+ """Initialize SQL file loader.
82
+
83
+ Args:
84
+ sql_loader: Optional shared SQLFileLoader instance to reuse.
85
+ If not provided, creates a new instance.
86
+ """
87
+ self.sql_loader: CoreSQLFileLoader = sql_loader if sql_loader is not None else CoreSQLFileLoader()
83
88
 
84
89
  async def get_up_sql(self, path: Path) -> list[str]:
85
90
  """Extract the 'up' SQL from a SQL migration file.
86
91
 
92
+ The SQL file must already be loaded via validate_migration_file()
93
+ before calling this method. This design ensures the file is loaded
94
+ exactly once during the migration process.
95
+
87
96
  Args:
88
97
  path: Path to SQL migration file.
89
98
 
@@ -93,9 +102,6 @@ class SQLFileLoader(BaseMigrationLoader):
93
102
  Raises:
94
103
  MigrationLoadError: If migration file is invalid or missing up query.
95
104
  """
96
- self.sql_loader.clear_cache()
97
- self.sql_loader.load_sql(path)
98
-
99
105
  version = self._extract_version(path.name)
100
106
  up_query = f"migrate-{version}-up"
101
107
 
@@ -109,15 +115,16 @@ class SQLFileLoader(BaseMigrationLoader):
109
115
  async def get_down_sql(self, path: Path) -> list[str]:
110
116
  """Extract the 'down' SQL from a SQL migration file.
111
117
 
118
+ The SQL file must already be loaded via validate_migration_file()
119
+ before calling this method. This design ensures the file is loaded
120
+ exactly once during the migration process.
121
+
112
122
  Args:
113
123
  path: Path to SQL migration file.
114
124
 
115
125
  Returns:
116
126
  List containing single SQL statement for downgrade, or empty list.
117
127
  """
118
- self.sql_loader.clear_cache()
119
- self.sql_loader.load_sql(path)
120
-
121
128
  version = self._extract_version(path.name)
122
129
  down_query = f"migrate-{version}-down"
123
130
 
@@ -141,7 +148,6 @@ class SQLFileLoader(BaseMigrationLoader):
141
148
  msg = f"Invalid migration filename: {path.name}"
142
149
  raise MigrationLoadError(msg)
143
150
 
144
- self.sql_loader.clear_cache()
145
151
  self.sql_loader.load_sql(path)
146
152
  up_query = f"migrate-{version}-up"
147
153
  if not self.sql_loader.has_query(up_query):
@@ -151,14 +157,31 @@ class SQLFileLoader(BaseMigrationLoader):
151
157
  def _extract_version(self, filename: str) -> str:
152
158
  """Extract version from filename.
153
159
 
160
+ Supports sequential (0001), timestamp (20251011120000), and extension-prefixed
161
+ (ext_litestar_0001) version formats.
162
+
154
163
  Args:
155
164
  filename: Migration filename to parse.
156
165
 
157
166
  Returns:
158
- Zero-padded version string or empty string if invalid.
167
+ Version string or empty string if invalid.
159
168
  """
160
- parts = filename.split("_", 1)
161
- return parts[0].zfill(4) if parts and parts[0].isdigit() else ""
169
+ extension_version_parts = 3
170
+ timestamp_min_length = 4
171
+
172
+ name_without_ext = filename.rsplit(".", 1)[0]
173
+
174
+ if name_without_ext.startswith("ext_"):
175
+ parts = name_without_ext.split("_", 3)
176
+ if len(parts) >= extension_version_parts:
177
+ return f"{parts[0]}_{parts[1]}_{parts[2]}"
178
+ return ""
179
+
180
+ parts = name_without_ext.split("_", 1)
181
+ if parts and parts[0].isdigit():
182
+ return parts[0] if len(parts[0]) > timestamp_min_length else parts[0].zfill(4)
183
+
184
+ return ""
162
185
 
163
186
 
164
187
  class PythonFileLoader(BaseMigrationLoader):
@@ -166,9 +189,7 @@ class PythonFileLoader(BaseMigrationLoader):
166
189
 
167
190
  __slots__ = ("context", "migrations_dir", "project_root")
168
191
 
169
- def __init__(
170
- self, migrations_dir: Path, project_root: "Optional[Path]" = None, context: "Optional[Any]" = None
171
- ) -> None:
192
+ def __init__(self, migrations_dir: Path, project_root: "Path | None" = None, context: "Any | None" = None) -> None:
172
193
  """Initialize Python file loader.
173
194
 
174
195
  Args:
@@ -396,7 +417,11 @@ class PythonFileLoader(BaseMigrationLoader):
396
417
 
397
418
 
398
419
  def get_migration_loader(
399
- file_path: Path, migrations_dir: Path, project_root: "Optional[Path]" = None, context: "Optional[Any]" = None
420
+ file_path: Path,
421
+ migrations_dir: Path,
422
+ project_root: "Path | None" = None,
423
+ context: "Any | None" = None,
424
+ sql_loader: "CoreSQLFileLoader | None" = None,
400
425
  ) -> BaseMigrationLoader:
401
426
  """Factory function to get appropriate loader for migration file.
402
427
 
@@ -405,6 +430,9 @@ def get_migration_loader(
405
430
  migrations_dir: Directory containing migration files.
406
431
  project_root: Optional project root directory for Python imports.
407
432
  context: Optional migration context to pass to Python migrations.
433
+ sql_loader: Optional shared SQLFileLoader instance for SQL migrations.
434
+ When provided, SQL files are loaded using this shared instance,
435
+ avoiding redundant file parsing.
408
436
 
409
437
  Returns:
410
438
  Appropriate loader instance for the file type.
@@ -417,6 +445,6 @@ def get_migration_loader(
417
445
  if suffix == ".py":
418
446
  return PythonFileLoader(migrations_dir, project_root, context)
419
447
  if suffix == ".sql":
420
- return SQLFileLoader()
448
+ return SQLFileLoader(sql_loader)
421
449
  msg = f"Unsupported migration file type: {suffix}"
422
450
  raise MigrationLoadError(msg)