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