sqlspec 0.11.1__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.
- sqlspec/__init__.py +16 -3
- sqlspec/_serialization.py +3 -10
- sqlspec/_sql.py +1147 -0
- sqlspec/_typing.py +343 -41
- sqlspec/adapters/adbc/__init__.py +2 -6
- sqlspec/adapters/adbc/config.py +474 -149
- sqlspec/adapters/adbc/driver.py +330 -621
- sqlspec/adapters/aiosqlite/__init__.py +2 -6
- sqlspec/adapters/aiosqlite/config.py +143 -57
- sqlspec/adapters/aiosqlite/driver.py +269 -431
- sqlspec/adapters/asyncmy/__init__.py +3 -8
- sqlspec/adapters/asyncmy/config.py +247 -202
- sqlspec/adapters/asyncmy/driver.py +218 -436
- sqlspec/adapters/asyncpg/__init__.py +4 -7
- sqlspec/adapters/asyncpg/config.py +329 -176
- sqlspec/adapters/asyncpg/driver.py +417 -487
- sqlspec/adapters/bigquery/__init__.py +2 -2
- sqlspec/adapters/bigquery/config.py +407 -0
- sqlspec/adapters/bigquery/driver.py +600 -553
- sqlspec/adapters/duckdb/__init__.py +4 -1
- sqlspec/adapters/duckdb/config.py +432 -321
- sqlspec/adapters/duckdb/driver.py +392 -406
- sqlspec/adapters/oracledb/__init__.py +3 -8
- sqlspec/adapters/oracledb/config.py +625 -0
- sqlspec/adapters/oracledb/driver.py +548 -921
- sqlspec/adapters/psqlpy/__init__.py +4 -7
- sqlspec/adapters/psqlpy/config.py +372 -203
- sqlspec/adapters/psqlpy/driver.py +197 -533
- sqlspec/adapters/psycopg/__init__.py +3 -8
- sqlspec/adapters/psycopg/config.py +741 -0
- sqlspec/adapters/psycopg/driver.py +734 -694
- sqlspec/adapters/sqlite/__init__.py +2 -6
- sqlspec/adapters/sqlite/config.py +146 -81
- sqlspec/adapters/sqlite/driver.py +242 -405
- sqlspec/base.py +220 -784
- sqlspec/config.py +354 -0
- sqlspec/driver/__init__.py +22 -0
- sqlspec/driver/_async.py +252 -0
- sqlspec/driver/_common.py +338 -0
- sqlspec/driver/_sync.py +261 -0
- sqlspec/driver/mixins/__init__.py +17 -0
- sqlspec/driver/mixins/_pipeline.py +523 -0
- sqlspec/driver/mixins/_result_utils.py +122 -0
- sqlspec/driver/mixins/_sql_translator.py +35 -0
- sqlspec/driver/mixins/_storage.py +993 -0
- sqlspec/driver/mixins/_type_coercion.py +131 -0
- sqlspec/exceptions.py +299 -7
- sqlspec/extensions/aiosql/__init__.py +10 -0
- sqlspec/extensions/aiosql/adapter.py +474 -0
- sqlspec/extensions/litestar/__init__.py +1 -6
- sqlspec/extensions/litestar/_utils.py +1 -5
- sqlspec/extensions/litestar/config.py +5 -6
- sqlspec/extensions/litestar/handlers.py +13 -12
- sqlspec/extensions/litestar/plugin.py +22 -24
- sqlspec/extensions/litestar/providers.py +37 -55
- sqlspec/loader.py +528 -0
- sqlspec/service/__init__.py +3 -0
- sqlspec/service/base.py +24 -0
- sqlspec/service/pagination.py +26 -0
- sqlspec/statement/__init__.py +21 -0
- sqlspec/statement/builder/__init__.py +54 -0
- sqlspec/statement/builder/_ddl_utils.py +119 -0
- sqlspec/statement/builder/_parsing_utils.py +135 -0
- sqlspec/statement/builder/base.py +328 -0
- sqlspec/statement/builder/ddl.py +1379 -0
- sqlspec/statement/builder/delete.py +80 -0
- sqlspec/statement/builder/insert.py +274 -0
- sqlspec/statement/builder/merge.py +95 -0
- sqlspec/statement/builder/mixins/__init__.py +65 -0
- sqlspec/statement/builder/mixins/_aggregate_functions.py +151 -0
- sqlspec/statement/builder/mixins/_case_builder.py +91 -0
- sqlspec/statement/builder/mixins/_common_table_expr.py +91 -0
- sqlspec/statement/builder/mixins/_delete_from.py +34 -0
- sqlspec/statement/builder/mixins/_from.py +61 -0
- sqlspec/statement/builder/mixins/_group_by.py +119 -0
- sqlspec/statement/builder/mixins/_having.py +35 -0
- sqlspec/statement/builder/mixins/_insert_from_select.py +48 -0
- sqlspec/statement/builder/mixins/_insert_into.py +36 -0
- sqlspec/statement/builder/mixins/_insert_values.py +69 -0
- sqlspec/statement/builder/mixins/_join.py +110 -0
- sqlspec/statement/builder/mixins/_limit_offset.py +53 -0
- sqlspec/statement/builder/mixins/_merge_clauses.py +405 -0
- sqlspec/statement/builder/mixins/_order_by.py +46 -0
- sqlspec/statement/builder/mixins/_pivot.py +82 -0
- sqlspec/statement/builder/mixins/_returning.py +37 -0
- sqlspec/statement/builder/mixins/_select_columns.py +60 -0
- sqlspec/statement/builder/mixins/_set_ops.py +122 -0
- sqlspec/statement/builder/mixins/_unpivot.py +80 -0
- sqlspec/statement/builder/mixins/_update_from.py +54 -0
- sqlspec/statement/builder/mixins/_update_set.py +91 -0
- sqlspec/statement/builder/mixins/_update_table.py +29 -0
- sqlspec/statement/builder/mixins/_where.py +374 -0
- sqlspec/statement/builder/mixins/_window_functions.py +86 -0
- sqlspec/statement/builder/protocols.py +20 -0
- sqlspec/statement/builder/select.py +206 -0
- sqlspec/statement/builder/update.py +178 -0
- sqlspec/statement/filters.py +571 -0
- sqlspec/statement/parameters.py +736 -0
- sqlspec/statement/pipelines/__init__.py +67 -0
- sqlspec/statement/pipelines/analyzers/__init__.py +9 -0
- sqlspec/statement/pipelines/analyzers/_analyzer.py +649 -0
- sqlspec/statement/pipelines/base.py +315 -0
- sqlspec/statement/pipelines/context.py +119 -0
- sqlspec/statement/pipelines/result_types.py +41 -0
- sqlspec/statement/pipelines/transformers/__init__.py +8 -0
- sqlspec/statement/pipelines/transformers/_expression_simplifier.py +256 -0
- sqlspec/statement/pipelines/transformers/_literal_parameterizer.py +623 -0
- sqlspec/statement/pipelines/transformers/_remove_comments.py +66 -0
- sqlspec/statement/pipelines/transformers/_remove_hints.py +81 -0
- sqlspec/statement/pipelines/validators/__init__.py +23 -0
- sqlspec/statement/pipelines/validators/_dml_safety.py +275 -0
- sqlspec/statement/pipelines/validators/_parameter_style.py +297 -0
- sqlspec/statement/pipelines/validators/_performance.py +703 -0
- sqlspec/statement/pipelines/validators/_security.py +990 -0
- sqlspec/statement/pipelines/validators/base.py +67 -0
- sqlspec/statement/result.py +527 -0
- sqlspec/statement/splitter.py +701 -0
- sqlspec/statement/sql.py +1198 -0
- sqlspec/storage/__init__.py +15 -0
- sqlspec/storage/backends/__init__.py +0 -0
- sqlspec/storage/backends/base.py +166 -0
- sqlspec/storage/backends/fsspec.py +315 -0
- sqlspec/storage/backends/obstore.py +464 -0
- sqlspec/storage/protocol.py +170 -0
- sqlspec/storage/registry.py +315 -0
- sqlspec/typing.py +157 -36
- sqlspec/utils/correlation.py +155 -0
- sqlspec/utils/deprecation.py +3 -6
- sqlspec/utils/fixtures.py +6 -11
- sqlspec/utils/logging.py +135 -0
- sqlspec/utils/module_loader.py +45 -43
- sqlspec/utils/serializers.py +4 -0
- sqlspec/utils/singleton.py +6 -8
- sqlspec/utils/sync_tools.py +15 -27
- sqlspec/utils/text.py +58 -26
- {sqlspec-0.11.1.dist-info → sqlspec-0.12.0.dist-info}/METADATA +97 -26
- sqlspec-0.12.0.dist-info/RECORD +145 -0
- sqlspec/adapters/bigquery/config/__init__.py +0 -3
- sqlspec/adapters/bigquery/config/_common.py +0 -40
- sqlspec/adapters/bigquery/config/_sync.py +0 -87
- sqlspec/adapters/oracledb/config/__init__.py +0 -9
- sqlspec/adapters/oracledb/config/_asyncio.py +0 -186
- sqlspec/adapters/oracledb/config/_common.py +0 -131
- sqlspec/adapters/oracledb/config/_sync.py +0 -186
- sqlspec/adapters/psycopg/config/__init__.py +0 -19
- sqlspec/adapters/psycopg/config/_async.py +0 -169
- sqlspec/adapters/psycopg/config/_common.py +0 -56
- sqlspec/adapters/psycopg/config/_sync.py +0 -168
- sqlspec/filters.py +0 -331
- sqlspec/mixins.py +0 -305
- sqlspec/statement.py +0 -378
- sqlspec-0.11.1.dist-info/RECORD +0 -69
- {sqlspec-0.11.1.dist-info → sqlspec-0.12.0.dist-info}/WHEEL +0 -0
- {sqlspec-0.11.1.dist-info → sqlspec-0.12.0.dist-info}/licenses/LICENSE +0 -0
- {sqlspec-0.11.1.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
|