sqlspec 0.13.1__py3-none-any.whl → 0.16.2__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 (185) hide show
  1. sqlspec/__init__.py +71 -8
  2. sqlspec/__main__.py +12 -0
  3. sqlspec/__metadata__.py +1 -3
  4. sqlspec/_serialization.py +1 -2
  5. sqlspec/_sql.py +930 -136
  6. sqlspec/_typing.py +278 -142
  7. sqlspec/adapters/adbc/__init__.py +4 -3
  8. sqlspec/adapters/adbc/_types.py +12 -0
  9. sqlspec/adapters/adbc/config.py +116 -285
  10. sqlspec/adapters/adbc/driver.py +462 -340
  11. sqlspec/adapters/aiosqlite/__init__.py +18 -3
  12. sqlspec/adapters/aiosqlite/_types.py +13 -0
  13. sqlspec/adapters/aiosqlite/config.py +202 -150
  14. sqlspec/adapters/aiosqlite/driver.py +226 -247
  15. sqlspec/adapters/asyncmy/__init__.py +18 -3
  16. sqlspec/adapters/asyncmy/_types.py +12 -0
  17. sqlspec/adapters/asyncmy/config.py +80 -199
  18. sqlspec/adapters/asyncmy/driver.py +257 -215
  19. sqlspec/adapters/asyncpg/__init__.py +19 -4
  20. sqlspec/adapters/asyncpg/_types.py +17 -0
  21. sqlspec/adapters/asyncpg/config.py +81 -214
  22. sqlspec/adapters/asyncpg/driver.py +284 -359
  23. sqlspec/adapters/bigquery/__init__.py +17 -3
  24. sqlspec/adapters/bigquery/_types.py +12 -0
  25. sqlspec/adapters/bigquery/config.py +191 -299
  26. sqlspec/adapters/bigquery/driver.py +474 -634
  27. sqlspec/adapters/duckdb/__init__.py +14 -3
  28. sqlspec/adapters/duckdb/_types.py +12 -0
  29. sqlspec/adapters/duckdb/config.py +414 -397
  30. sqlspec/adapters/duckdb/driver.py +342 -393
  31. sqlspec/adapters/oracledb/__init__.py +19 -5
  32. sqlspec/adapters/oracledb/_types.py +14 -0
  33. sqlspec/adapters/oracledb/config.py +123 -458
  34. sqlspec/adapters/oracledb/driver.py +505 -531
  35. sqlspec/adapters/psqlpy/__init__.py +13 -3
  36. sqlspec/adapters/psqlpy/_types.py +11 -0
  37. sqlspec/adapters/psqlpy/config.py +93 -307
  38. sqlspec/adapters/psqlpy/driver.py +504 -213
  39. sqlspec/adapters/psycopg/__init__.py +19 -5
  40. sqlspec/adapters/psycopg/_types.py +17 -0
  41. sqlspec/adapters/psycopg/config.py +143 -472
  42. sqlspec/adapters/psycopg/driver.py +704 -825
  43. sqlspec/adapters/sqlite/__init__.py +14 -3
  44. sqlspec/adapters/sqlite/_types.py +11 -0
  45. sqlspec/adapters/sqlite/config.py +208 -142
  46. sqlspec/adapters/sqlite/driver.py +263 -278
  47. sqlspec/base.py +105 -9
  48. sqlspec/{statement/builder → builder}/__init__.py +12 -14
  49. sqlspec/{statement/builder/base.py → builder/_base.py} +184 -86
  50. sqlspec/{statement/builder/column.py → builder/_column.py} +97 -60
  51. sqlspec/{statement/builder/ddl.py → builder/_ddl.py} +61 -131
  52. sqlspec/{statement/builder → builder}/_ddl_utils.py +4 -10
  53. sqlspec/{statement/builder/delete.py → builder/_delete.py} +10 -30
  54. sqlspec/builder/_insert.py +421 -0
  55. sqlspec/builder/_merge.py +71 -0
  56. sqlspec/{statement/builder → builder}/_parsing_utils.py +49 -26
  57. sqlspec/builder/_select.py +170 -0
  58. sqlspec/{statement/builder/update.py → builder/_update.py} +16 -20
  59. sqlspec/builder/mixins/__init__.py +55 -0
  60. sqlspec/builder/mixins/_cte_and_set_ops.py +222 -0
  61. sqlspec/{statement/builder/mixins/_delete_from.py → builder/mixins/_delete_operations.py} +8 -1
  62. sqlspec/builder/mixins/_insert_operations.py +244 -0
  63. sqlspec/{statement/builder/mixins/_join.py → builder/mixins/_join_operations.py} +45 -13
  64. sqlspec/{statement/builder/mixins/_merge_clauses.py → builder/mixins/_merge_operations.py} +188 -30
  65. sqlspec/builder/mixins/_order_limit_operations.py +135 -0
  66. sqlspec/builder/mixins/_pivot_operations.py +153 -0
  67. sqlspec/builder/mixins/_select_operations.py +604 -0
  68. sqlspec/builder/mixins/_update_operations.py +202 -0
  69. sqlspec/builder/mixins/_where_clause.py +644 -0
  70. sqlspec/cli.py +247 -0
  71. sqlspec/config.py +183 -138
  72. sqlspec/core/__init__.py +63 -0
  73. sqlspec/core/cache.py +871 -0
  74. sqlspec/core/compiler.py +417 -0
  75. sqlspec/core/filters.py +830 -0
  76. sqlspec/core/hashing.py +310 -0
  77. sqlspec/core/parameters.py +1237 -0
  78. sqlspec/core/result.py +677 -0
  79. sqlspec/{statement → core}/splitter.py +321 -191
  80. sqlspec/core/statement.py +676 -0
  81. sqlspec/driver/__init__.py +7 -10
  82. sqlspec/driver/_async.py +422 -163
  83. sqlspec/driver/_common.py +545 -287
  84. sqlspec/driver/_sync.py +426 -160
  85. sqlspec/driver/mixins/__init__.py +2 -13
  86. sqlspec/driver/mixins/_result_tools.py +193 -0
  87. sqlspec/driver/mixins/_sql_translator.py +65 -14
  88. sqlspec/exceptions.py +5 -252
  89. sqlspec/extensions/aiosql/adapter.py +93 -96
  90. sqlspec/extensions/litestar/__init__.py +2 -1
  91. sqlspec/extensions/litestar/cli.py +48 -0
  92. sqlspec/extensions/litestar/config.py +0 -1
  93. sqlspec/extensions/litestar/handlers.py +15 -26
  94. sqlspec/extensions/litestar/plugin.py +21 -16
  95. sqlspec/extensions/litestar/providers.py +17 -52
  96. sqlspec/loader.py +423 -104
  97. sqlspec/migrations/__init__.py +35 -0
  98. sqlspec/migrations/base.py +414 -0
  99. sqlspec/migrations/commands.py +443 -0
  100. sqlspec/migrations/loaders.py +402 -0
  101. sqlspec/migrations/runner.py +213 -0
  102. sqlspec/migrations/tracker.py +140 -0
  103. sqlspec/migrations/utils.py +129 -0
  104. sqlspec/protocols.py +51 -186
  105. sqlspec/storage/__init__.py +1 -1
  106. sqlspec/storage/backends/base.py +37 -40
  107. sqlspec/storage/backends/fsspec.py +136 -112
  108. sqlspec/storage/backends/obstore.py +138 -160
  109. sqlspec/storage/capabilities.py +5 -4
  110. sqlspec/storage/registry.py +57 -106
  111. sqlspec/typing.py +136 -115
  112. sqlspec/utils/__init__.py +2 -2
  113. sqlspec/utils/correlation.py +0 -3
  114. sqlspec/utils/deprecation.py +6 -6
  115. sqlspec/utils/fixtures.py +6 -6
  116. sqlspec/utils/logging.py +0 -2
  117. sqlspec/utils/module_loader.py +7 -12
  118. sqlspec/utils/singleton.py +0 -1
  119. sqlspec/utils/sync_tools.py +17 -38
  120. sqlspec/utils/text.py +12 -51
  121. sqlspec/utils/type_guards.py +482 -235
  122. {sqlspec-0.13.1.dist-info → sqlspec-0.16.2.dist-info}/METADATA +7 -2
  123. sqlspec-0.16.2.dist-info/RECORD +134 -0
  124. sqlspec-0.16.2.dist-info/entry_points.txt +2 -0
  125. sqlspec/driver/connection.py +0 -207
  126. sqlspec/driver/mixins/_csv_writer.py +0 -91
  127. sqlspec/driver/mixins/_pipeline.py +0 -512
  128. sqlspec/driver/mixins/_result_utils.py +0 -140
  129. sqlspec/driver/mixins/_storage.py +0 -926
  130. sqlspec/driver/mixins/_type_coercion.py +0 -130
  131. sqlspec/driver/parameters.py +0 -138
  132. sqlspec/service/__init__.py +0 -4
  133. sqlspec/service/_util.py +0 -147
  134. sqlspec/service/base.py +0 -1131
  135. sqlspec/service/pagination.py +0 -26
  136. sqlspec/statement/__init__.py +0 -21
  137. sqlspec/statement/builder/insert.py +0 -288
  138. sqlspec/statement/builder/merge.py +0 -95
  139. sqlspec/statement/builder/mixins/__init__.py +0 -65
  140. sqlspec/statement/builder/mixins/_aggregate_functions.py +0 -250
  141. sqlspec/statement/builder/mixins/_case_builder.py +0 -91
  142. sqlspec/statement/builder/mixins/_common_table_expr.py +0 -90
  143. sqlspec/statement/builder/mixins/_from.py +0 -63
  144. sqlspec/statement/builder/mixins/_group_by.py +0 -118
  145. sqlspec/statement/builder/mixins/_having.py +0 -35
  146. sqlspec/statement/builder/mixins/_insert_from_select.py +0 -47
  147. sqlspec/statement/builder/mixins/_insert_into.py +0 -36
  148. sqlspec/statement/builder/mixins/_insert_values.py +0 -67
  149. sqlspec/statement/builder/mixins/_limit_offset.py +0 -53
  150. sqlspec/statement/builder/mixins/_order_by.py +0 -46
  151. sqlspec/statement/builder/mixins/_pivot.py +0 -79
  152. sqlspec/statement/builder/mixins/_returning.py +0 -37
  153. sqlspec/statement/builder/mixins/_select_columns.py +0 -61
  154. sqlspec/statement/builder/mixins/_set_ops.py +0 -122
  155. sqlspec/statement/builder/mixins/_unpivot.py +0 -77
  156. sqlspec/statement/builder/mixins/_update_from.py +0 -55
  157. sqlspec/statement/builder/mixins/_update_set.py +0 -94
  158. sqlspec/statement/builder/mixins/_update_table.py +0 -29
  159. sqlspec/statement/builder/mixins/_where.py +0 -401
  160. sqlspec/statement/builder/mixins/_window_functions.py +0 -86
  161. sqlspec/statement/builder/select.py +0 -221
  162. sqlspec/statement/filters.py +0 -596
  163. sqlspec/statement/parameter_manager.py +0 -220
  164. sqlspec/statement/parameters.py +0 -867
  165. sqlspec/statement/pipelines/__init__.py +0 -210
  166. sqlspec/statement/pipelines/analyzers/__init__.py +0 -9
  167. sqlspec/statement/pipelines/analyzers/_analyzer.py +0 -646
  168. sqlspec/statement/pipelines/context.py +0 -115
  169. sqlspec/statement/pipelines/transformers/__init__.py +0 -7
  170. sqlspec/statement/pipelines/transformers/_expression_simplifier.py +0 -88
  171. sqlspec/statement/pipelines/transformers/_literal_parameterizer.py +0 -1247
  172. sqlspec/statement/pipelines/transformers/_remove_comments_and_hints.py +0 -76
  173. sqlspec/statement/pipelines/validators/__init__.py +0 -23
  174. sqlspec/statement/pipelines/validators/_dml_safety.py +0 -290
  175. sqlspec/statement/pipelines/validators/_parameter_style.py +0 -370
  176. sqlspec/statement/pipelines/validators/_performance.py +0 -718
  177. sqlspec/statement/pipelines/validators/_security.py +0 -967
  178. sqlspec/statement/result.py +0 -435
  179. sqlspec/statement/sql.py +0 -1704
  180. sqlspec/statement/sql_compiler.py +0 -140
  181. sqlspec/utils/cached_property.py +0 -25
  182. sqlspec-0.13.1.dist-info/RECORD +0 -150
  183. {sqlspec-0.13.1.dist-info → sqlspec-0.16.2.dist-info}/WHEEL +0 -0
  184. {sqlspec-0.13.1.dist-info → sqlspec-0.16.2.dist-info}/licenses/LICENSE +0 -0
  185. {sqlspec-0.13.1.dist-info → sqlspec-0.16.2.dist-info}/licenses/NOTICE +0 -0
@@ -1,867 +0,0 @@
1
- # ruff: noqa: RUF100, PLR0912, PLR0915, C901, PLR0911, PLR0914
2
- """High-performance SQL parameter conversion system.
3
-
4
- This module provides bulletproof parameter handling for SQL statements,
5
- supporting all major parameter styles with optimized performance.
6
- """
7
-
8
- import logging
9
- import re
10
- from collections.abc import Mapping, Sequence
11
- from dataclasses import dataclass, field
12
- from enum import Enum
13
- from typing import TYPE_CHECKING, Any, Final, Optional, Union
14
-
15
- from typing_extensions import TypedDict
16
-
17
- from sqlspec.exceptions import ExtraParameterError, MissingParameterError, ParameterStyleMismatchError
18
- from sqlspec.typing import SQLParameterType
19
-
20
- if TYPE_CHECKING:
21
- from sqlglot import exp
22
-
23
- __all__ = (
24
- "ConvertedParameters",
25
- "ParameterConverter",
26
- "ParameterInfo",
27
- "ParameterNormalizationState",
28
- "ParameterStyle",
29
- "ParameterValidator",
30
- "SQLParameterType",
31
- "TypedParameter",
32
- )
33
-
34
- logger = logging.getLogger("sqlspec.sql.parameters")
35
-
36
- # Single comprehensive regex that captures all parameter types in one pass
37
- _PARAMETER_REGEX: Final = re.compile(
38
- r"""
39
- # Literals and Comments (these should be matched first and skipped)
40
- (?P<dquote>"(?:[^"\\]|\\.)*") | # Group 1: Double-quoted strings
41
- (?P<squote>'(?:[^'\\]|\\.)*') | # Group 2: Single-quoted strings
42
- # Group 3: Dollar-quoted strings (e.g., $tag$...$tag$ or $$...$$)
43
- # Group 4 (dollar_quote_tag_inner) is the optional tag, back-referenced by \4
44
- (?P<dollar_quoted_string>\$(?P<dollar_quote_tag_inner>\w*)?\$[\s\S]*?\$\4\$) |
45
- (?P<line_comment>--[^\r\n]*) | # Group 5: Line comments
46
- (?P<block_comment>/\*(?:[^*]|\*(?!/))*\*/) | # Group 6: Block comments
47
- # Specific non-parameter tokens that resemble parameters or contain parameter-like chars
48
- # These are matched to prevent them from being identified as parameters.
49
- (?P<pg_q_operator>\?\?|\?\||\?&) | # Group 7: PostgreSQL JSON operators ??, ?|, ?&
50
- (?P<pg_cast>::(?P<cast_type>\w+)) | # Group 8: PostgreSQL ::type casting (cast_type is Group 9)
51
-
52
- # Parameter Placeholders (order can matter if syntax overlaps)
53
- (?P<pyformat_named>%\((?P<pyformat_name>\w+)\)s) | # Group 10: %(name)s (pyformat_name is Group 11)
54
- (?P<pyformat_pos>%s) | # Group 12: %s
55
- # Oracle numeric parameters MUST come before named_colon to match :1, :2, etc.
56
- (?P<positional_colon>:(?P<colon_num>\d+)) | # Group 13: :1, :2 (colon_num is Group 14)
57
- (?P<named_colon>:(?P<colon_name>\w+)) | # Group 15: :name (colon_name is Group 16)
58
- (?P<named_at>@(?P<at_name>\w+)) | # Group 17: @name (at_name is Group 18)
59
- # Group 17: $name or $1 (dollar_param_name is Group 18)
60
- # Differentiation between $name and $1 is handled in Python code using isdigit()
61
- (?P<named_dollar_param>\$(?P<dollar_param_name>\w+)) |
62
- (?P<qmark>\?) # Group 19: ? (now safer due to pg_q_operator rule above)
63
- """,
64
- re.VERBOSE | re.IGNORECASE | re.MULTILINE | re.DOTALL,
65
- )
66
-
67
-
68
- class ParameterStyle(str, Enum):
69
- """Parameter style enumeration with string values."""
70
-
71
- NONE = "none"
72
- STATIC = "static"
73
- QMARK = "qmark"
74
- NUMERIC = "numeric"
75
- NAMED_COLON = "named_colon"
76
- POSITIONAL_COLON = "positional_colon"
77
- NAMED_AT = "named_at"
78
- NAMED_DOLLAR = "named_dollar"
79
- NAMED_PYFORMAT = "pyformat_named"
80
- POSITIONAL_PYFORMAT = "pyformat_positional"
81
-
82
- def __str__(self) -> str:
83
- """String representation for better error messages.
84
-
85
- Returns:
86
- The enum value as a string.
87
- """
88
- return self.value
89
-
90
-
91
- # Define SQLGlot incompatible styles after ParameterStyle enum
92
- SQLGLOT_INCOMPATIBLE_STYLES: Final = {
93
- ParameterStyle.POSITIONAL_PYFORMAT,
94
- ParameterStyle.NAMED_PYFORMAT,
95
- ParameterStyle.POSITIONAL_COLON,
96
- }
97
-
98
-
99
- @dataclass
100
- class ParameterInfo:
101
- """Immutable parameter information with optimal memory usage."""
102
-
103
- name: "Optional[str]"
104
- """Parameter name for named parameters, None for positional."""
105
-
106
- style: "ParameterStyle"
107
- """The parameter style."""
108
-
109
- position: int
110
- """Position in the SQL string (for error reporting)."""
111
-
112
- ordinal: int = field(compare=False)
113
- """Order of appearance in SQL (0-based)."""
114
-
115
- placeholder_text: str = field(compare=False)
116
- """The original text of the parameter."""
117
-
118
-
119
- @dataclass
120
- class TypedParameter:
121
- """Internal container for parameter values with type metadata.
122
-
123
- This class preserves complete type information from SQL literals and user-provided
124
- parameters, enabling proper type coercion for each database adapter.
125
-
126
- Note:
127
- This is an internal class. Users never create TypedParameter objects directly.
128
- The system automatically wraps parameters with type information.
129
- """
130
-
131
- value: Any
132
- """The actual parameter value."""
133
-
134
- sqlglot_type: "exp.DataType"
135
- """Full SQLGlot DataType instance with all type details."""
136
-
137
- type_hint: str
138
- """Simple string hint for adapter type coercion (e.g., 'integer', 'decimal', 'json')."""
139
-
140
- semantic_name: "Optional[str]" = None
141
- """Optional semantic name derived from SQL context (e.g., 'user_id', 'email')."""
142
-
143
-
144
- class NormalizationInfo(TypedDict, total=False):
145
- """Information about SQL parameter normalization."""
146
-
147
- was_normalized: bool
148
- placeholder_map: dict[str, Union[str, int]]
149
- original_styles: list[ParameterStyle]
150
-
151
-
152
- @dataclass
153
- class ParameterNormalizationState:
154
- """Encapsulates all information about parameter normalization.
155
-
156
- This class provides a single source of truth for parameter style conversions,
157
- making it easier to track and reverse normalizations applied for SQLGlot compatibility.
158
- """
159
-
160
- was_normalized: bool = False
161
- """Whether parameter normalization was applied."""
162
-
163
- original_styles: list[ParameterStyle] = field(default_factory=list)
164
- """Original parameter style(s) detected in the SQL."""
165
-
166
- normalized_style: Optional[ParameterStyle] = None
167
- """Target style used for normalization (if normalized)."""
168
-
169
- placeholder_map: dict[str, Union[str, int]] = field(default_factory=dict)
170
- """Mapping from normalized names to original names/positions."""
171
-
172
- reverse_map: dict[Union[str, int], str] = field(default_factory=dict)
173
- """Reverse mapping for quick lookups."""
174
-
175
- original_param_info: list["ParameterInfo"] = field(default_factory=list)
176
- """Original parameter info before normalization."""
177
-
178
- def __post_init__(self) -> None:
179
- """Build reverse map if not provided."""
180
- if self.placeholder_map and not self.reverse_map:
181
- self.reverse_map = {v: k for k, v in self.placeholder_map.items()}
182
-
183
-
184
- @dataclass
185
- class ConvertedParameters:
186
- """Result of parameter conversion with clear structure."""
187
-
188
- transformed_sql: str
189
- """SQL after any necessary transformations."""
190
-
191
- parameter_info: list["ParameterInfo"]
192
- """Information about parameters found in the SQL."""
193
-
194
- merged_parameters: "SQLParameterType"
195
- """Parameters after merging from various sources."""
196
-
197
- normalization_state: ParameterNormalizationState
198
- """Complete normalization state for tracking conversions."""
199
-
200
-
201
- @dataclass
202
- class ParameterValidator:
203
- """Parameter validation."""
204
-
205
- def __post_init__(self) -> None:
206
- """Initialize validator."""
207
- self._parameter_cache: dict[str, list[ParameterInfo]] = {}
208
-
209
- @staticmethod
210
- def _create_parameter_info_from_match(match: "re.Match[str]", ordinal: int) -> "Optional[ParameterInfo]":
211
- if (
212
- match.group("dquote")
213
- or match.group("squote")
214
- or match.group("dollar_quoted_string")
215
- or match.group("line_comment")
216
- or match.group("block_comment")
217
- or match.group("pg_q_operator")
218
- or match.group("pg_cast")
219
- ):
220
- return None
221
-
222
- position = match.start()
223
- name: Optional[str] = None
224
- style: ParameterStyle
225
-
226
- if match.group("pyformat_named"):
227
- name = match.group("pyformat_name")
228
- style = ParameterStyle.NAMED_PYFORMAT
229
- elif match.group("pyformat_pos"):
230
- style = ParameterStyle.POSITIONAL_PYFORMAT
231
- elif match.group("positional_colon"):
232
- name = match.group("colon_num")
233
- style = ParameterStyle.POSITIONAL_COLON
234
- elif match.group("named_colon"):
235
- name = match.group("colon_name")
236
- style = ParameterStyle.NAMED_COLON
237
- elif match.group("named_at"):
238
- name = match.group("at_name")
239
- style = ParameterStyle.NAMED_AT
240
- elif match.group("named_dollar_param"):
241
- name_candidate = match.group("dollar_param_name")
242
- if not name_candidate.isdigit():
243
- name = name_candidate
244
- style = ParameterStyle.NAMED_DOLLAR
245
- else:
246
- name = name_candidate # Keep the numeric value as name for NUMERIC style
247
- style = ParameterStyle.NUMERIC
248
- elif match.group("qmark"):
249
- style = ParameterStyle.QMARK
250
- else:
251
- logger.warning(
252
- "Unhandled SQL token pattern found by regex. Matched group: %s. Token: '%s'",
253
- match.lastgroup,
254
- match.group(0),
255
- )
256
- return None
257
-
258
- return ParameterInfo(name, style, position, ordinal, match.group(0))
259
-
260
- def extract_parameters(self, sql: str) -> "list[ParameterInfo]":
261
- """Extract all parameters from SQL with single-pass parsing.
262
-
263
- Args:
264
- sql: SQL string to analyze
265
-
266
- Returns:
267
- List of ParameterInfo objects in order of appearance
268
- """
269
- if sql in self._parameter_cache:
270
- return self._parameter_cache[sql]
271
-
272
- parameters: list[ParameterInfo] = []
273
- ordinal = 0
274
- for match in _PARAMETER_REGEX.finditer(sql):
275
- param_info = self._create_parameter_info_from_match(match, ordinal)
276
- if param_info:
277
- parameters.append(param_info)
278
- ordinal += 1
279
-
280
- self._parameter_cache[sql] = parameters
281
- return parameters
282
-
283
- @staticmethod
284
- def get_parameter_style(parameters_info: "list[ParameterInfo]") -> "ParameterStyle":
285
- """Determine overall parameter style from parameter list.
286
-
287
- This typically identifies the dominant style for user-facing messages or general classification.
288
- It differs from `determine_parameter_input_type` which is about expected Python type for params.
289
-
290
- Args:
291
- parameters_info: List of extracted parameters
292
-
293
- Returns:
294
- Overall parameter style
295
- """
296
- if not parameters_info:
297
- return ParameterStyle.NONE
298
-
299
- # Note: This logic prioritizes pyformat if present, then named, then positional.
300
- is_pyformat_named = any(p.style == ParameterStyle.NAMED_PYFORMAT for p in parameters_info)
301
- is_pyformat_positional = any(p.style == ParameterStyle.POSITIONAL_PYFORMAT for p in parameters_info)
302
-
303
- if is_pyformat_named:
304
- return ParameterStyle.NAMED_PYFORMAT
305
- if is_pyformat_positional: # If only PYFORMAT_POSITIONAL and not PYFORMAT_NAMED
306
- return ParameterStyle.POSITIONAL_PYFORMAT
307
-
308
- # Simplified logic if not pyformat, checks for any named or any positional
309
- has_named = any(
310
- p.style
311
- in {
312
- ParameterStyle.NAMED_COLON,
313
- ParameterStyle.POSITIONAL_COLON,
314
- ParameterStyle.NAMED_AT,
315
- ParameterStyle.NAMED_DOLLAR,
316
- }
317
- for p in parameters_info
318
- )
319
- has_positional = any(p.style in {ParameterStyle.QMARK, ParameterStyle.NUMERIC} for p in parameters_info)
320
-
321
- # If mixed named and positional (non-pyformat), prefer named as dominant.
322
- # The choice of NAMED_COLON here is somewhat arbitrary if multiple named styles are mixed.
323
- if has_named:
324
- # Could refine to return the style of the first named param encountered, or most frequent.
325
- # For simplicity, returning a general named style like NAMED_COLON is often sufficient.
326
- # Or, more accurately, find the first named style:
327
- for p_style in (
328
- ParameterStyle.NAMED_COLON,
329
- ParameterStyle.POSITIONAL_COLON,
330
- ParameterStyle.NAMED_DOLLAR,
331
- ParameterStyle.NAMED_AT,
332
- ):
333
- if any(p.style == p_style for p in parameters_info):
334
- return p_style
335
- return ParameterStyle.NAMED_COLON
336
-
337
- if has_positional:
338
- # Similarly, could choose QMARK or NUMERIC based on presence.
339
- if any(p.style == ParameterStyle.NUMERIC for p in parameters_info):
340
- return ParameterStyle.NUMERIC
341
- return ParameterStyle.QMARK # Default positional
342
-
343
- return ParameterStyle.NONE # Should not be reached if parameters_info is not empty
344
-
345
- @staticmethod
346
- def determine_parameter_input_type(parameters_info: "list[ParameterInfo]") -> "Optional[type]":
347
- """Determine if user-provided parameters should be a dict, list/tuple, or None.
348
-
349
- - If any parameter placeholder implies a name (e.g., :name, %(name)s), a dict is expected.
350
- - If all parameter placeholders are strictly positional (e.g., ?, %s, $1), a list/tuple is expected.
351
- - If no parameters, None is expected.
352
-
353
- Args:
354
- parameters_info: List of extracted ParameterInfo objects.
355
-
356
- Returns:
357
- `dict` if named parameters are expected, `list` if positional, `None` if no parameters.
358
- """
359
- if not parameters_info:
360
- return None
361
-
362
- if all(p.style == ParameterStyle.POSITIONAL_COLON for p in parameters_info):
363
- return list
364
-
365
- if any(
366
- p.name is not None and p.style not in {ParameterStyle.POSITIONAL_COLON, ParameterStyle.NUMERIC}
367
- for p in parameters_info
368
- ): # True for NAMED styles and PYFORMAT_NAMED
369
- return dict
370
- # All parameters must have p.name is None or be positional styles (POSITIONAL_COLON, NUMERIC)
371
- if all(
372
- p.name is None or p.style in {ParameterStyle.POSITIONAL_COLON, ParameterStyle.NUMERIC}
373
- for p in parameters_info
374
- ):
375
- return list
376
- # This case implies a mix of parameters where some have names and some don't,
377
- # but not fitting the clear dict/list categories above.
378
- # Example: SQL like "SELECT :name, ?" - this is problematic and usually not supported directly.
379
- # Standard DBAPIs typically don't mix named and unnamed placeholders in the same query (outside pyformat).
380
- logger.warning(
381
- "Ambiguous parameter structure for determining input type. "
382
- "Query might contain a mix of named and unnamed styles not typically supported together."
383
- )
384
- # Defaulting to dict if any named param is found, as that's the more common requirement for mixed scenarios.
385
- # However, strict validation should ideally prevent such mixed styles from being valid.
386
- return dict # Or raise an error for unsupported mixed styles.
387
-
388
- def validate_parameters(
389
- self,
390
- parameters_info: "list[ParameterInfo]",
391
- provided_params: "SQLParameterType",
392
- original_sql_for_error: "Optional[str]" = None,
393
- ) -> None:
394
- """Validate provided parameters against SQL requirements.
395
-
396
- Args:
397
- parameters_info: Extracted parameter info
398
- provided_params: Parameters provided by user
399
- original_sql_for_error: Original SQL for error context
400
-
401
- Raises:
402
- ParameterStyleMismatchError: When style doesn't match
403
- """
404
- expected_input_type = self.determine_parameter_input_type(parameters_info)
405
-
406
- # Allow creating SQL statements with placeholders but no parameters
407
- # This enables patterns like SQL("SELECT * FROM users WHERE id = ?").as_many([...])
408
- # Validation will happen later when parameters are actually provided
409
- if provided_params is None and parameters_info:
410
- # Don't raise an error, just return - validation will happen later
411
- return
412
-
413
- if (
414
- len(parameters_info) == 1
415
- and provided_params is not None
416
- and not isinstance(provided_params, (dict, list, tuple, Mapping))
417
- and (not isinstance(provided_params, Sequence) or isinstance(provided_params, (str, bytes)))
418
- ):
419
- return
420
-
421
- if expected_input_type is dict:
422
- if not isinstance(provided_params, Mapping):
423
- msg = (
424
- f"SQL expects named parameters (dictionary/mapping), but received {type(provided_params).__name__}"
425
- )
426
- raise ParameterStyleMismatchError(msg, original_sql_for_error)
427
- self._validate_named_parameters(parameters_info, provided_params, original_sql_for_error)
428
- elif expected_input_type is list:
429
- if not isinstance(provided_params, Sequence) or isinstance(provided_params, (str, bytes)):
430
- msg = f"SQL expects positional parameters (list/tuple), but received {type(provided_params).__name__}"
431
- raise ParameterStyleMismatchError(msg, original_sql_for_error)
432
- self._validate_positional_parameters(parameters_info, provided_params, original_sql_for_error)
433
- elif expected_input_type is None and parameters_info:
434
- logger.error(
435
- "Parameter validation encountered an unexpected state: placeholders exist, "
436
- "but expected input type could not be determined. SQL: %s",
437
- original_sql_for_error,
438
- )
439
- msg = "Could not determine expected parameter type for the given SQL."
440
- raise ParameterStyleMismatchError(msg, original_sql_for_error)
441
-
442
- @staticmethod
443
- def _has_actual_params(params: SQLParameterType) -> bool:
444
- """Check if parameters contain actual values.
445
-
446
- Returns:
447
- True if parameters contain actual values.
448
- """
449
- if isinstance(params, (Mapping, Sequence)) and not isinstance(params, (str, bytes)):
450
- return bool(params) # True for non-empty dict/list/tuple
451
- return params is not None # True for scalar values other than None
452
-
453
- @staticmethod
454
- def _validate_named_parameters(
455
- parameters_info: "list[ParameterInfo]", provided_params: "Mapping[str, Any]", original_sql: "Optional[str]"
456
- ) -> None:
457
- """Validate named parameters.
458
-
459
- Raises:
460
- MissingParameterError: When required parameters are missing
461
- ExtraParameterError: When extra parameters are provided
462
- """
463
- required_names = {p.name for p in parameters_info if p.name is not None}
464
- provided_names = set(provided_params.keys())
465
-
466
- positional_count = sum(1 for p in parameters_info if p.name is None)
467
- expected_positional_names = {f"arg_{p.ordinal}" for p in parameters_info if p.name is None}
468
- if positional_count > 0 and required_names:
469
- all_expected_names = required_names | expected_positional_names
470
-
471
- missing = all_expected_names - provided_names
472
- if missing:
473
- msg = f"Missing required parameters: {sorted(missing)}"
474
- raise MissingParameterError(msg, original_sql)
475
-
476
- extra = provided_names - all_expected_names
477
- if extra:
478
- msg = f"Extra parameters provided: {sorted(extra)}"
479
- raise ExtraParameterError(msg, original_sql)
480
- else:
481
- missing = required_names - provided_names
482
- if missing:
483
- msg = f"Missing required named parameters: {sorted(missing)}"
484
- raise MissingParameterError(msg, original_sql)
485
-
486
- extra = provided_names - required_names
487
- if extra:
488
- msg = f"Extra parameters provided: {sorted(extra)}"
489
- raise ExtraParameterError(msg, original_sql)
490
-
491
- @staticmethod
492
- def _validate_positional_parameters(
493
- parameters_info: "list[ParameterInfo]", provided_params: "Sequence[Any]", original_sql: "Optional[str]"
494
- ) -> None:
495
- """Validate positional parameters.
496
-
497
- Raises:
498
- MissingParameterError: When required parameters are missing.
499
- ExtraParameterError: When extra parameters are provided.
500
- """
501
- expected_positional_params_count = sum(
502
- 1
503
- for p in parameters_info
504
- if p.name is None or p.style in {ParameterStyle.POSITIONAL_COLON, ParameterStyle.NUMERIC}
505
- )
506
- actual_count = len(provided_params)
507
-
508
- if actual_count != expected_positional_params_count:
509
- if actual_count > expected_positional_params_count:
510
- msg = (
511
- f"SQL requires {expected_positional_params_count} positional parameters "
512
- f"but {actual_count} were provided."
513
- )
514
- raise ExtraParameterError(msg, original_sql)
515
-
516
- msg = (
517
- f"SQL requires {expected_positional_params_count} positional parameters "
518
- f"but {actual_count} were provided."
519
- )
520
- raise MissingParameterError(msg, original_sql)
521
-
522
-
523
- @dataclass
524
- class ParameterConverter:
525
- """Parameter parameter conversion with caching and validation."""
526
-
527
- def __init__(self) -> None:
528
- """Initialize converter with validator."""
529
- self.validator = ParameterValidator()
530
-
531
- @staticmethod
532
- def _transform_sql_for_parsing(
533
- original_sql: str, parameters_info: "list[ParameterInfo]"
534
- ) -> tuple[str, dict[str, Union[str, int]]]:
535
- """Transform SQL to use unique named placeholders for sqlglot parsing.
536
-
537
- Args:
538
- original_sql: The original SQL string.
539
- parameters_info: List of ParameterInfo objects for the SQL.
540
- Assumed to be sorted by position as extracted.
541
-
542
- Returns:
543
- A tuple containing:
544
- - transformed_sql: SQL string with unique named placeholders (e.g., :param_0).
545
- - placeholder_map: Dictionary mapping new unique names to original names or ordinal index.
546
- """
547
- transformed_sql_parts = []
548
- placeholder_map: dict[str, Union[str, int]] = {}
549
- current_pos = 0
550
- for i, p_info in enumerate(parameters_info):
551
- transformed_sql_parts.append(original_sql[current_pos : p_info.position])
552
-
553
- unique_placeholder_name = f":param_{i}"
554
- map_key = f"param_{i}"
555
-
556
- if p_info.name:
557
- placeholder_map[map_key] = p_info.name
558
- else:
559
- placeholder_map[map_key] = p_info.ordinal
560
-
561
- transformed_sql_parts.append(unique_placeholder_name)
562
- current_pos = p_info.position + len(p_info.placeholder_text)
563
-
564
- transformed_sql_parts.append(original_sql[current_pos:])
565
- return "".join(transformed_sql_parts), placeholder_map
566
-
567
- def convert_placeholders(
568
- self, sql: str, target_style: "ParameterStyle", parameter_info: "Optional[list[ParameterInfo]]" = None
569
- ) -> str:
570
- """Convert SQL placeholders to a target style.
571
-
572
- Args:
573
- sql: The SQL string with placeholders
574
- target_style: The target parameter style to convert to
575
- parameter_info: Optional list of parameter info (will be extracted if not provided)
576
-
577
- Returns:
578
- SQL string with converted placeholders
579
- """
580
- if parameter_info is None:
581
- parameter_info = self.validator.extract_parameters(sql)
582
-
583
- if not parameter_info:
584
- return sql
585
-
586
- result_parts = []
587
- current_pos = 0
588
-
589
- for i, param in enumerate(parameter_info):
590
- result_parts.append(sql[current_pos : param.position])
591
-
592
- if target_style == ParameterStyle.QMARK:
593
- placeholder = "?"
594
- elif target_style == ParameterStyle.NUMERIC:
595
- placeholder = f"${i + 1}"
596
- elif target_style == ParameterStyle.POSITIONAL_PYFORMAT:
597
- placeholder = "%s"
598
- elif target_style == ParameterStyle.NAMED_COLON:
599
- if param.style in {
600
- ParameterStyle.POSITIONAL_COLON,
601
- ParameterStyle.QMARK,
602
- ParameterStyle.NUMERIC,
603
- ParameterStyle.POSITIONAL_PYFORMAT,
604
- }:
605
- name = f"param_{i}"
606
- else:
607
- name = param.name or f"param_{i}"
608
- placeholder = f":{name}"
609
- elif target_style == ParameterStyle.NAMED_PYFORMAT:
610
- if param.style in {
611
- ParameterStyle.POSITIONAL_COLON,
612
- ParameterStyle.QMARK,
613
- ParameterStyle.NUMERIC,
614
- ParameterStyle.POSITIONAL_PYFORMAT,
615
- }:
616
- name = f"param_{i}"
617
- else:
618
- name = param.name or f"param_{i}"
619
- placeholder = f"%({name})s"
620
- elif target_style == ParameterStyle.NAMED_AT:
621
- if param.style in {
622
- ParameterStyle.POSITIONAL_COLON,
623
- ParameterStyle.QMARK,
624
- ParameterStyle.NUMERIC,
625
- ParameterStyle.POSITIONAL_PYFORMAT,
626
- }:
627
- name = f"param_{i}"
628
- else:
629
- name = param.name or f"param_{i}"
630
- placeholder = f"@{name}"
631
- elif target_style == ParameterStyle.NAMED_DOLLAR:
632
- if param.style in {
633
- ParameterStyle.POSITIONAL_COLON,
634
- ParameterStyle.QMARK,
635
- ParameterStyle.NUMERIC,
636
- ParameterStyle.POSITIONAL_PYFORMAT,
637
- }:
638
- name = f"param_{i}"
639
- else:
640
- name = param.name or f"param_{i}"
641
- placeholder = f"${name}"
642
- elif target_style == ParameterStyle.POSITIONAL_COLON:
643
- placeholder = f":{i + 1}"
644
- else:
645
- placeholder = param.placeholder_text
646
-
647
- result_parts.append(placeholder)
648
- current_pos = param.position + len(param.placeholder_text)
649
-
650
- result_parts.append(sql[current_pos:])
651
-
652
- return "".join(result_parts)
653
-
654
- def convert_parameters(
655
- self,
656
- sql: str,
657
- parameters: "SQLParameterType" = None,
658
- args: "Optional[Sequence[Any]]" = None,
659
- kwargs: "Optional[Mapping[str, Any]]" = None,
660
- validate: bool = True,
661
- ) -> ConvertedParameters:
662
- """Convert and merge parameters, and transform SQL for parsing.
663
-
664
- Args:
665
- sql: SQL string to analyze
666
- parameters: Primary parameters
667
- args: Positional arguments (for compatibility)
668
- kwargs: Keyword arguments
669
- validate: Whether to validate parameters
670
-
671
- Returns:
672
- ConvertedParameters object with all conversion information
673
- """
674
- parameters_info = self.validator.extract_parameters(sql)
675
-
676
- needs_normalization = any(p.style in SQLGLOT_INCOMPATIBLE_STYLES for p in parameters_info)
677
-
678
- has_positional = any(p.name is None for p in parameters_info)
679
- has_named = any(p.name is not None for p in parameters_info)
680
- has_mixed_styles = has_positional and has_named
681
-
682
- if has_mixed_styles and args and kwargs and parameters is None:
683
- merged_params = self._merge_mixed_parameters(parameters_info, args, kwargs)
684
- else:
685
- merged_params = self.merge_parameters(parameters, args, kwargs) # type: ignore[assignment]
686
-
687
- if validate:
688
- self.validator.validate_parameters(parameters_info, merged_params, sql)
689
- if needs_normalization:
690
- transformed_sql, placeholder_map = self._transform_sql_for_parsing(sql, parameters_info)
691
- normalization_state = ParameterNormalizationState(
692
- was_normalized=True,
693
- original_styles=list({p.style for p in parameters_info}),
694
- normalized_style=ParameterStyle.NAMED_COLON,
695
- placeholder_map=placeholder_map,
696
- original_param_info=parameters_info,
697
- )
698
- else:
699
- transformed_sql = sql
700
- normalization_state = ParameterNormalizationState(
701
- was_normalized=False,
702
- original_styles=list({p.style for p in parameters_info}),
703
- original_param_info=parameters_info,
704
- )
705
-
706
- return ConvertedParameters(
707
- transformed_sql=transformed_sql,
708
- parameter_info=parameters_info,
709
- merged_parameters=merged_params,
710
- normalization_state=normalization_state,
711
- )
712
-
713
- @staticmethod
714
- def _merge_mixed_parameters(
715
- parameters_info: "list[ParameterInfo]", args: "Sequence[Any]", kwargs: "Mapping[str, Any]"
716
- ) -> dict[str, Any]:
717
- """Merge args and kwargs for mixed parameter styles.
718
-
719
- Args:
720
- parameters_info: List of parameter information from SQL
721
- args: Positional arguments
722
- kwargs: Keyword arguments
723
-
724
- Returns:
725
- Dictionary with merged parameters
726
- """
727
- merged: dict[str, Any] = {}
728
-
729
- merged.update(kwargs)
730
-
731
- positional_count = 0
732
- for param_info in parameters_info:
733
- if param_info.name is None and positional_count < len(args):
734
- param_name = f"arg_{param_info.ordinal}"
735
- merged[param_name] = args[positional_count]
736
- positional_count += 1
737
-
738
- return merged
739
-
740
- @staticmethod
741
- def merge_parameters(
742
- parameters: "SQLParameterType", args: "Optional[Sequence[Any]]", kwargs: "Optional[Mapping[str, Any]]"
743
- ) -> "SQLParameterType":
744
- """Merge parameters from different sources with proper precedence.
745
-
746
- Precedence order (highest to lowest):
747
- 1. parameters (primary source - always wins)
748
- 2. kwargs (secondary source)
749
- 3. args (only used if parameters is None and no kwargs)
750
-
751
- Returns:
752
- Merged parameters as a dictionary or list/tuple, or None.
753
- """
754
- # If parameters is provided, it takes precedence over everything
755
- if parameters is not None:
756
- return parameters
757
-
758
- if kwargs is not None:
759
- return dict(kwargs) # Make a copy
760
-
761
- if args is not None:
762
- return list(args) # Convert tuple of args to list for consistency and mutability if needed later
763
-
764
- return None
765
-
766
- @staticmethod
767
- def wrap_parameters_with_types(
768
- parameters: "SQLParameterType",
769
- parameters_info: "list[ParameterInfo]", # noqa: ARG004
770
- ) -> "SQLParameterType":
771
- """Wrap user-provided parameters with TypedParameter objects when needed.
772
-
773
- This is called internally by the SQL processing pipeline after parameter
774
- extraction and merging. It preserves the original parameter structure
775
- while adding type information where beneficial.
776
-
777
- Args:
778
- parameters: User-provided parameters (dict, list, or scalar)
779
- parameters_info: Extracted parameter information from SQL
780
-
781
- Returns:
782
- Parameters with TypedParameter wrapping where appropriate
783
- """
784
- return None if parameters is None else parameters
785
-
786
- def _convert_sql_placeholders(
787
- self, rendered_sql: str, final_parameter_info: "list[ParameterInfo]", target_style: "ParameterStyle"
788
- ) -> str:
789
- """Internal method to convert SQL from canonical format to target style.
790
-
791
- Args:
792
- rendered_sql: SQL with canonical placeholders (:param_N)
793
- final_parameter_info: Complete parameter info list
794
- target_style: Target parameter style
795
-
796
- Returns:
797
- SQL with target style placeholders
798
- """
799
- canonical_params = self.validator.extract_parameters(rendered_sql)
800
-
801
- # When we have more canonical parameters than final_parameter_info,
802
- # it's likely because the ParameterizeLiterals transformer added extra parameters.
803
- # We need to denormalize ALL parameters to ensure proper placeholder conversion.
804
- # The final_parameter_info only contains the original parameters, but we need
805
- # to handle all placeholders in the SQL (including those added by transformers).
806
- if len(canonical_params) > len(final_parameter_info):
807
- # Extend final_parameter_info to match canonical_params
808
- # Use the canonical param info for the extra parameters
809
- final_parameter_info = list(final_parameter_info)
810
- for i in range(len(final_parameter_info), len(canonical_params)):
811
- # Create a synthetic ParameterInfo for the extra parameter
812
- canonical = canonical_params[i]
813
- # Use the ordinal from the canonical parameter
814
- final_parameter_info.append(canonical)
815
- elif len(canonical_params) < len(final_parameter_info):
816
- from sqlspec.exceptions import SQLTransformationError
817
-
818
- msg = (
819
- f"Parameter count mismatch during denormalization. "
820
- f"Expected at least {len(final_parameter_info)} parameters, "
821
- f"found {len(canonical_params)} in SQL"
822
- )
823
- raise SQLTransformationError(msg)
824
-
825
- result_sql = rendered_sql
826
-
827
- for i in range(len(canonical_params) - 1, -1, -1):
828
- canonical = canonical_params[i]
829
- source_info = final_parameter_info[i]
830
-
831
- start = canonical.position
832
- end = start + len(canonical.placeholder_text)
833
- new_placeholder = self._get_placeholder_for_style(target_style, source_info)
834
- result_sql = result_sql[:start] + new_placeholder + result_sql[end:]
835
-
836
- return result_sql
837
-
838
- @staticmethod
839
- def _get_placeholder_for_style(target_style: "ParameterStyle", param_info: "ParameterInfo") -> str:
840
- """Generate placeholder text for a specific parameter style.
841
-
842
- Args:
843
- target_style: Target parameter style
844
- param_info: Parameter information
845
-
846
- Returns:
847
- Placeholder string for the target style
848
- """
849
- if target_style == ParameterStyle.QMARK:
850
- return "?"
851
- if target_style == ParameterStyle.NUMERIC:
852
- return f"${param_info.ordinal + 1}"
853
- if target_style == ParameterStyle.NAMED_COLON:
854
- return f":{param_info.name}" if param_info.name else f":arg_{param_info.ordinal}"
855
- if target_style == ParameterStyle.POSITIONAL_COLON:
856
- if param_info.style == ParameterStyle.POSITIONAL_COLON and param_info.name and param_info.name.isdigit():
857
- return f":{param_info.name}"
858
- return f":{param_info.ordinal + 1}"
859
- if target_style == ParameterStyle.NAMED_AT:
860
- return f"@{param_info.name}" if param_info.name else f"@arg_{param_info.ordinal}"
861
- if target_style == ParameterStyle.NAMED_DOLLAR:
862
- return f"${param_info.name}" if param_info.name else f"$arg_{param_info.ordinal}"
863
- if target_style == ParameterStyle.NAMED_PYFORMAT:
864
- return f"%({param_info.name})s" if param_info.name else f"%(arg_{param_info.ordinal})s"
865
- if target_style == ParameterStyle.POSITIONAL_PYFORMAT:
866
- return "%s"
867
- return param_info.placeholder_text