Skip to content

Commit 93c6627

Browse files
authored
fix: correctly map execute_many parameters for all drivers (#264)
Normalize mapping batches before parameter alignment so `execute_many` with list-of-dicts works with positional execution styles. - The Problem: DuckDB (and other positional adapters) raised identifier/ count mismatches when execute_many received a list of dicts for named placeholders. - The Solution: Core parameter processor now converts mapping payloads to ordered positional values prior to validation; converter preserves unnamed placeholder ordinals; added regression tests.
1 parent a2e3e0b commit 93c6627

File tree

3 files changed

+96
-4
lines changed

3 files changed

+96
-4
lines changed

sqlspec/core/parameters/_converter.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,13 +79,13 @@ def convert_placeholder_style(
7979
current_styles = {p.style for p in param_info}
8080
if len(current_styles) == 1 and target_style in current_styles:
8181
converted_parameters = self._convert_parameter_format(
82-
parameters, param_info, target_style, parameters, preserve_parameter_format=True
82+
parameters, param_info, target_style, parameters, preserve_parameter_format=True, is_many=is_many
8383
)
8484
return sql, converted_parameters
8585

8686
converted_sql = self._convert_placeholders_to_style(sql, param_info, target_style)
8787
converted_parameters = self._convert_parameter_format(
88-
parameters, param_info, target_style, parameters, preserve_parameter_format=True
88+
parameters, param_info, target_style, parameters, preserve_parameter_format=True, is_many=is_many
8989
)
9090
return converted_sql, converted_parameters
9191

@@ -230,10 +230,30 @@ def _convert_parameter_format(
230230
target_style: "ParameterStyle",
231231
original_parameters: Any = None,
232232
preserve_parameter_format: bool = False,
233+
is_many: bool = False,
233234
) -> Any:
234235
if not parameters or not param_info:
235236
return parameters
236237

238+
if (
239+
is_many
240+
and isinstance(parameters, Sequence)
241+
and not isinstance(parameters, (str, bytes, bytearray))
242+
and parameters
243+
and isinstance(parameters[0], Mapping)
244+
):
245+
normalized_sets = [
246+
self._convert_parameter_format(
247+
param_set, param_info, target_style, param_set, preserve_parameter_format, is_many=False
248+
)
249+
if isinstance(param_set, Mapping)
250+
else param_set
251+
for param_set in parameters
252+
]
253+
if preserve_parameter_format and isinstance(parameters, tuple):
254+
return tuple(normalized_sets)
255+
return normalized_sets
256+
237257
is_named_style = target_style in {
238258
ParameterStyle.NAMED_COLON,
239259
ParameterStyle.NAMED_AT,
@@ -261,15 +281,15 @@ def _convert_parameter_format(
261281
if has_mixed_styles:
262282
param_keys = list(parameters.keys())
263283
for param in param_info:
264-
param_key = param.placeholder_text
284+
param_key = param.placeholder_text if param.name else f"{param.placeholder_text}_{param.ordinal}"
265285
if param_key not in unique_params:
266286
value, found = self._extract_param_value_mixed_styles(param, parameters, param_keys)
267287
if found:
268288
unique_params[param_key] = value
269289
param_order.append(param_key)
270290
else:
271291
for param in param_info:
272-
param_key = param.placeholder_text
292+
param_key = param.placeholder_text if param.name else f"{param.placeholder_text}_{param.ordinal}"
273293
if param_key not in unique_params:
274294
value, found = self._extract_param_value_single_style(param, parameters)
275295
if found:

sqlspec/core/parameters/_processor.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from mypy_extensions import mypyc_attr
88

9+
from sqlspec.core.parameters._alignment import looks_like_execute_many
910
from sqlspec.core.parameters._converter import ParameterConverter
1011
from sqlspec.core.parameters._types import (
1112
ParameterInfo,
@@ -169,6 +170,35 @@ def process(
169170

170171
needs_static_embedding = config.needs_static_script_compilation and param_info and parameters and not is_many
171172

173+
def _requires_mapping_normalization(payload: Any) -> bool:
174+
if not payload or not param_info:
175+
return False
176+
177+
has_named_placeholders = any(
178+
param.style
179+
in {
180+
ParameterStyle.NAMED_COLON,
181+
ParameterStyle.NAMED_AT,
182+
ParameterStyle.NAMED_DOLLAR,
183+
ParameterStyle.NAMED_PYFORMAT,
184+
}
185+
for param in param_info
186+
)
187+
if has_named_placeholders:
188+
return False
189+
190+
looks_many = is_many or looks_like_execute_many(payload)
191+
if not looks_many:
192+
return False
193+
194+
if isinstance(payload, Mapping):
195+
return True
196+
197+
if isinstance(payload, Sequence) and not isinstance(payload, (str, bytes, bytearray)):
198+
return any(isinstance(item, Mapping) for item in payload)
199+
200+
return False
201+
172202
if needs_static_embedding:
173203
return self._handle_static_embedding(sql, parameters, config, is_many, cache_key)
174204

@@ -177,6 +207,7 @@ def process(
177207
and not needs_execution_conversion
178208
and not config.type_coercion_map
179209
and not config.output_transformer
210+
and not _requires_mapping_normalization(parameters)
180211
):
181212
result = ParameterProcessingResult(sql, parameters, ParameterProfile(param_info))
182213
if self._cache_size < self.DEFAULT_CACHE_SIZE:
@@ -186,6 +217,12 @@ def process(
186217

187218
processed_sql, processed_parameters = sql, parameters
188219

220+
if _requires_mapping_normalization(processed_parameters):
221+
target_style = self._determine_target_execution_style(original_styles, config)
222+
processed_sql, processed_parameters = self._converter.convert_placeholder_style(
223+
processed_sql, processed_parameters, target_style, is_many
224+
)
225+
189226
if processed_parameters:
190227
processed_parameters = self._apply_type_wrapping(processed_parameters)
191228

tests/unit/test_core/test_parameters.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1026,6 +1026,41 @@ def test_process_full_pipeline(processor: ParameterProcessor) -> None:
10261026
assert len(final_params) > 0
10271027

10281028

1029+
def test_process_execute_many_mapping_payload(
1030+
processor: "ParameterProcessor", basic_config: "ParameterStyleConfig"
1031+
) -> None:
1032+
"""Ensure execute_many normalizes mapping payloads for positional placeholders."""
1033+
1034+
sql = "INSERT INTO metrics (a, b) VALUES (?, ?)"
1035+
parameters = [{"a": 1, "b": "x"}, {"a": 2, "b": "y"}]
1036+
1037+
final_sql, final_params = processor.process(sql, parameters, basic_config, is_many=True)
1038+
1039+
assert final_sql == sql
1040+
assert isinstance(final_params, list)
1041+
assert all(isinstance(param_set, (list, tuple)) for param_set in final_params)
1042+
assert [tuple(param_set) for param_set in final_params] == [(1, "x"), (2, "y")]
1043+
1044+
1045+
def test_process_execute_many_named_to_positional(processor: "ParameterProcessor") -> None:
1046+
"""Execute_many with named placeholders should convert mapping batches to positional values."""
1047+
1048+
config = ParameterStyleConfig(
1049+
default_parameter_style=ParameterStyle.NAMED_DOLLAR,
1050+
supported_parameter_styles={ParameterStyle.NAMED_DOLLAR, ParameterStyle.QMARK},
1051+
default_execution_parameter_style=ParameterStyle.QMARK,
1052+
)
1053+
1054+
sql = "INSERT INTO metrics (a, b) VALUES ($a, $b)"
1055+
parameters = [{"a": 10, "b": 20}, {"b": 40, "a": 30}]
1056+
1057+
final_sql, final_params = processor.process(sql, parameters, config, "duckdb", is_many=True)
1058+
1059+
assert final_sql.count("?") == 2
1060+
assert isinstance(final_params, list)
1061+
assert [tuple(param_set) for param_set in final_params] == [(10, 20), (30, 40)]
1062+
1063+
10291064
def test_list_parameter_preservation(converter: ParameterConverter) -> None:
10301065
"""Test that list parameters are properly handled."""
10311066
sql = "INSERT INTO users (id, name, active) VALUES (?, ?, ?)"

0 commit comments

Comments
 (0)