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.
- sqlspec/__init__.py +50 -25
- sqlspec/__main__.py +12 -0
- sqlspec/__metadata__.py +1 -3
- sqlspec/_serialization.py +1 -2
- sqlspec/_sql.py +256 -120
- sqlspec/_typing.py +278 -142
- sqlspec/adapters/adbc/__init__.py +4 -3
- sqlspec/adapters/adbc/_types.py +12 -0
- sqlspec/adapters/adbc/config.py +115 -248
- sqlspec/adapters/adbc/driver.py +462 -353
- sqlspec/adapters/aiosqlite/__init__.py +18 -3
- sqlspec/adapters/aiosqlite/_types.py +13 -0
- sqlspec/adapters/aiosqlite/config.py +199 -129
- sqlspec/adapters/aiosqlite/driver.py +230 -269
- sqlspec/adapters/asyncmy/__init__.py +18 -3
- sqlspec/adapters/asyncmy/_types.py +12 -0
- sqlspec/adapters/asyncmy/config.py +80 -168
- sqlspec/adapters/asyncmy/driver.py +260 -225
- sqlspec/adapters/asyncpg/__init__.py +19 -4
- sqlspec/adapters/asyncpg/_types.py +17 -0
- sqlspec/adapters/asyncpg/config.py +82 -181
- sqlspec/adapters/asyncpg/driver.py +285 -383
- sqlspec/adapters/bigquery/__init__.py +17 -3
- sqlspec/adapters/bigquery/_types.py +12 -0
- sqlspec/adapters/bigquery/config.py +191 -258
- sqlspec/adapters/bigquery/driver.py +474 -646
- sqlspec/adapters/duckdb/__init__.py +14 -3
- sqlspec/adapters/duckdb/_types.py +12 -0
- sqlspec/adapters/duckdb/config.py +415 -351
- sqlspec/adapters/duckdb/driver.py +343 -413
- sqlspec/adapters/oracledb/__init__.py +19 -5
- sqlspec/adapters/oracledb/_types.py +14 -0
- sqlspec/adapters/oracledb/config.py +123 -379
- sqlspec/adapters/oracledb/driver.py +507 -560
- sqlspec/adapters/psqlpy/__init__.py +13 -3
- sqlspec/adapters/psqlpy/_types.py +11 -0
- sqlspec/adapters/psqlpy/config.py +93 -254
- sqlspec/adapters/psqlpy/driver.py +505 -234
- sqlspec/adapters/psycopg/__init__.py +19 -5
- sqlspec/adapters/psycopg/_types.py +17 -0
- sqlspec/adapters/psycopg/config.py +143 -403
- sqlspec/adapters/psycopg/driver.py +706 -872
- sqlspec/adapters/sqlite/__init__.py +14 -3
- sqlspec/adapters/sqlite/_types.py +11 -0
- sqlspec/adapters/sqlite/config.py +202 -118
- sqlspec/adapters/sqlite/driver.py +264 -303
- sqlspec/base.py +105 -9
- sqlspec/{statement/builder → builder}/__init__.py +12 -14
- sqlspec/{statement/builder → builder}/_base.py +120 -55
- sqlspec/{statement/builder → builder}/_column.py +17 -6
- sqlspec/{statement/builder → builder}/_ddl.py +46 -79
- sqlspec/{statement/builder → builder}/_ddl_utils.py +5 -10
- sqlspec/{statement/builder → builder}/_delete.py +6 -25
- sqlspec/{statement/builder → builder}/_insert.py +6 -64
- sqlspec/builder/_merge.py +56 -0
- sqlspec/{statement/builder → builder}/_parsing_utils.py +3 -10
- sqlspec/{statement/builder → builder}/_select.py +11 -56
- sqlspec/{statement/builder → builder}/_update.py +12 -18
- sqlspec/{statement/builder → builder}/mixins/__init__.py +10 -14
- sqlspec/{statement/builder → builder}/mixins/_cte_and_set_ops.py +48 -59
- sqlspec/{statement/builder → builder}/mixins/_insert_operations.py +22 -16
- sqlspec/{statement/builder → builder}/mixins/_join_operations.py +1 -3
- sqlspec/{statement/builder → builder}/mixins/_merge_operations.py +3 -5
- sqlspec/{statement/builder → builder}/mixins/_order_limit_operations.py +3 -3
- sqlspec/{statement/builder → builder}/mixins/_pivot_operations.py +4 -8
- sqlspec/{statement/builder → builder}/mixins/_select_operations.py +21 -36
- sqlspec/{statement/builder → builder}/mixins/_update_operations.py +3 -14
- sqlspec/{statement/builder → builder}/mixins/_where_clause.py +52 -79
- sqlspec/cli.py +4 -5
- sqlspec/config.py +180 -133
- sqlspec/core/__init__.py +63 -0
- sqlspec/core/cache.py +873 -0
- sqlspec/core/compiler.py +396 -0
- sqlspec/core/filters.py +828 -0
- sqlspec/core/hashing.py +310 -0
- sqlspec/core/parameters.py +1209 -0
- sqlspec/core/result.py +664 -0
- sqlspec/{statement → core}/splitter.py +321 -191
- sqlspec/core/statement.py +651 -0
- sqlspec/driver/__init__.py +7 -10
- sqlspec/driver/_async.py +387 -176
- sqlspec/driver/_common.py +527 -289
- sqlspec/driver/_sync.py +390 -172
- sqlspec/driver/mixins/__init__.py +2 -19
- sqlspec/driver/mixins/_result_tools.py +168 -0
- sqlspec/driver/mixins/_sql_translator.py +6 -3
- sqlspec/exceptions.py +5 -252
- sqlspec/extensions/aiosql/adapter.py +93 -96
- sqlspec/extensions/litestar/config.py +0 -1
- sqlspec/extensions/litestar/handlers.py +15 -26
- sqlspec/extensions/litestar/plugin.py +16 -14
- sqlspec/extensions/litestar/providers.py +17 -52
- sqlspec/loader.py +424 -105
- sqlspec/migrations/__init__.py +12 -0
- sqlspec/migrations/base.py +92 -68
- sqlspec/migrations/commands.py +24 -106
- sqlspec/migrations/loaders.py +402 -0
- sqlspec/migrations/runner.py +49 -51
- sqlspec/migrations/tracker.py +31 -44
- sqlspec/migrations/utils.py +64 -24
- sqlspec/protocols.py +7 -183
- sqlspec/storage/__init__.py +1 -1
- sqlspec/storage/backends/base.py +37 -40
- sqlspec/storage/backends/fsspec.py +136 -112
- sqlspec/storage/backends/obstore.py +138 -160
- sqlspec/storage/capabilities.py +5 -4
- sqlspec/storage/registry.py +57 -106
- sqlspec/typing.py +136 -115
- sqlspec/utils/__init__.py +2 -3
- sqlspec/utils/correlation.py +0 -3
- sqlspec/utils/deprecation.py +6 -6
- sqlspec/utils/fixtures.py +6 -6
- sqlspec/utils/logging.py +0 -2
- sqlspec/utils/module_loader.py +7 -12
- sqlspec/utils/singleton.py +0 -1
- sqlspec/utils/sync_tools.py +16 -37
- sqlspec/utils/text.py +12 -51
- sqlspec/utils/type_guards.py +443 -232
- {sqlspec-0.14.0.dist-info → sqlspec-0.15.0.dist-info}/METADATA +7 -2
- sqlspec-0.15.0.dist-info/RECORD +134 -0
- sqlspec-0.15.0.dist-info/entry_points.txt +2 -0
- sqlspec/driver/connection.py +0 -207
- sqlspec/driver/mixins/_cache.py +0 -114
- sqlspec/driver/mixins/_csv_writer.py +0 -91
- sqlspec/driver/mixins/_pipeline.py +0 -508
- sqlspec/driver/mixins/_query_tools.py +0 -796
- sqlspec/driver/mixins/_result_utils.py +0 -138
- sqlspec/driver/mixins/_storage.py +0 -912
- sqlspec/driver/mixins/_type_coercion.py +0 -128
- sqlspec/driver/parameters.py +0 -138
- sqlspec/statement/__init__.py +0 -21
- sqlspec/statement/builder/_merge.py +0 -95
- sqlspec/statement/cache.py +0 -50
- sqlspec/statement/filters.py +0 -625
- sqlspec/statement/parameters.py +0 -996
- sqlspec/statement/pipelines/__init__.py +0 -210
- sqlspec/statement/pipelines/analyzers/__init__.py +0 -9
- sqlspec/statement/pipelines/analyzers/_analyzer.py +0 -646
- sqlspec/statement/pipelines/context.py +0 -115
- sqlspec/statement/pipelines/transformers/__init__.py +0 -7
- sqlspec/statement/pipelines/transformers/_expression_simplifier.py +0 -88
- sqlspec/statement/pipelines/transformers/_literal_parameterizer.py +0 -1247
- sqlspec/statement/pipelines/transformers/_remove_comments_and_hints.py +0 -76
- sqlspec/statement/pipelines/validators/__init__.py +0 -23
- sqlspec/statement/pipelines/validators/_dml_safety.py +0 -290
- sqlspec/statement/pipelines/validators/_parameter_style.py +0 -370
- sqlspec/statement/pipelines/validators/_performance.py +0 -714
- sqlspec/statement/pipelines/validators/_security.py +0 -967
- sqlspec/statement/result.py +0 -435
- sqlspec/statement/sql.py +0 -1774
- sqlspec/utils/cached_property.py +0 -25
- sqlspec/utils/statement_hashing.py +0 -203
- sqlspec-0.14.0.dist-info/RECORD +0 -143
- sqlspec-0.14.0.dist-info/entry_points.txt +0 -2
- /sqlspec/{statement/builder → builder}/mixins/_delete_operations.py +0 -0
- {sqlspec-0.14.0.dist-info → sqlspec-0.15.0.dist-info}/WHEEL +0 -0
- {sqlspec-0.14.0.dist-info → sqlspec-0.15.0.dist-info}/licenses/LICENSE +0 -0
- {sqlspec-0.14.0.dist-info → sqlspec-0.15.0.dist-info}/licenses/NOTICE +0 -0
sqlspec/statement/parameters.py
DELETED
|
@@ -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
|