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