Skip to content

Commit 73ab7a2

Browse files
committed
Fix executemany SQL_C_NUMERIC mismatch and bulkcopy Row acceptance (GH-609)
Bug 1: executemany auto-detection path set paramCType=SQL_C_NUMERIC for Decimal values outside the MONEY range, but the conversion loop converts all Decimals to strings. The C extension then received strings where it expected NumericData structs, raising RuntimeError. Fixed by overriding SQL_C_NUMERIC to SQL_C_CHAR in the auto-detection path, matching the existing GH-503 fix in the setinputsizes path. Bug 2: bulkcopy passed Row objects directly to mssql_py_core which expects native tuples. Added _ensure_tuples() wrapper to auto-convert Row/list objects to tuples. Fixes #609
1 parent 3b7a613 commit 73ab7a2

3 files changed

Lines changed: 293 additions & 1 deletion

File tree

‎mssql_python/cursor.py‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2344,6 +2344,20 @@ def executemany( # pylint: disable=too-many-locals,too-many-branches,too-many-s
23442344
paraminfo.paramSQLType = ddbc_sql_const.SQL_VARCHAR.value
23452345
paraminfo.columnSize = 1
23462346

2347+
# Override DECIMAL/NUMERIC to use SQL_C_CHAR string binding.
2348+
# _map_sql_type may return SQL_C_NUMERIC (expecting NumericData structs)
2349+
# but the conversion loop below converts all Decimal values to strings.
2350+
# The C type must match the actual data to avoid:
2351+
# RuntimeError: Parameter's object type does not match parameter's C type
2352+
if paraminfo.paramSQLType in (
2353+
ddbc_sql_const.SQL_DECIMAL.value,
2354+
ddbc_sql_const.SQL_NUMERIC.value,
2355+
):
2356+
paraminfo.paramCType = ddbc_sql_const.SQL_C_CHAR.value
2357+
# Ensure columnSize accommodates the longest string representation
2358+
if max_decimal_len > paraminfo.columnSize:
2359+
paraminfo.columnSize = max_decimal_len
2360+
23472361
# Correct column size for Decimal columns sent as SQL_VARCHAR (GH-557).
23482362
# The sample value's formatted string may be shorter than another
23492363
# row's (e.g. positive sample "1.0" = 3 chars vs negative "-0.1" = 4).
@@ -2997,11 +3011,28 @@ def bulkcopy(
29973011
)
29983012
pycore_cursor = pycore_connection.cursor()
29993013

3014+
# Auto-convert Row/list objects to tuples for the Rust layer.
3015+
# mssql_py_core expects native tuples; Row objects (from fetchmany)
3016+
# are iterable but fail the strict type check in Rust.
3017+
def _ensure_tuples(iterable):
3018+
from mssql_python.row import Row
3019+
3020+
for item in iterable:
3021+
if isinstance(item, tuple):
3022+
yield item
3023+
elif isinstance(item, (list, Row)):
3024+
yield tuple(item)
3025+
else:
3026+
raise TypeError(
3027+
f"bulkcopy data rows must be tuples, lists, or Row objects, "
3028+
f"got {type(item).__name__}"
3029+
)
3030+
30003031
# Call bulkcopy with explicit keyword arguments
30013032
# The API signature: bulkcopy(table_name, data_source, batch_size=0, timeout=30, ...)
30023033
result = pycore_cursor.bulkcopy(
30033034
table_name,
3004-
iter(data),
3035+
_ensure_tuples(data),
30053036
batch_size=batch_size,
30063037
timeout=timeout,
30073038
column_mappings=column_mappings,

‎tests/test_001_globals.py‎

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1059,3 +1059,164 @@ def test_row_string_key_case_insensitive_with_lowercase():
10591059
# Non-existent attribute raises AttributeError
10601060
with pytest.raises(AttributeError):
10611061
row.nonexistent
1062+
1063+
1064+
# ---------------------------------------------------------
1065+
# GH-609: Unit tests for executemany SQL_C_NUMERIC mismatch
1066+
# ---------------------------------------------------------
1067+
import decimal as _decimal
1068+
from mssql_python.cursor import Cursor, MONEY_MAX
1069+
from mssql_python.constants import ConstantsDDBC as _C
1070+
1071+
1072+
def _make_bare_cursor():
1073+
"""Create a Cursor instance without a connection for unit testing."""
1074+
cur = Cursor.__new__(Cursor)
1075+
cur._inputsizes = None
1076+
return cur
1077+
1078+
1079+
def test_compute_column_type_large_decimal():
1080+
"""_compute_column_type picks the highest-precision Decimal as sample."""
1081+
cur = _make_bare_cursor()
1082+
column = [
1083+
_decimal.Decimal("100.50"),
1084+
_decimal.Decimal("999999999999999999.123456"), # > MONEY_MAX
1085+
_decimal.Decimal("200.75"),
1086+
]
1087+
sample, _, _, max_dec_len = cur._compute_column_type(column)
1088+
assert isinstance(sample, _decimal.Decimal)
1089+
assert sample > MONEY_MAX
1090+
assert max_dec_len > 0
1091+
1092+
1093+
def test_map_sql_type_decimal_outside_money_returns_numeric():
1094+
"""_map_sql_type returns SQL_C_NUMERIC for Decimal outside MONEY range.
1095+
1096+
This C type expects NumericData structs, but executemany's conversion
1097+
loop converts Decimals to strings — creating a mismatch that the
1098+
GH-609 override in executemany must correct.
1099+
"""
1100+
cur = _make_bare_cursor()
1101+
val = _decimal.Decimal("999999999999999999.123456")
1102+
dummy_row = [val]
1103+
sql_type, c_type, _, _, _ = cur._map_sql_type(val, dummy_row, 0)
1104+
assert sql_type == _C.SQL_NUMERIC.value
1105+
assert c_type == _C.SQL_C_NUMERIC.value
1106+
1107+
# The executemany conversion loop would turn this into a string,
1108+
# confirming the override to SQL_C_CHAR is necessary.
1109+
assert isinstance(format(val, "f"), str)
1110+
1111+
1112+
def test_map_sql_type_decimal_in_money_returns_varchar():
1113+
"""_map_sql_type returns SQL_VARCHAR for Decimal within MONEY range."""
1114+
cur = _make_bare_cursor()
1115+
val = _decimal.Decimal("100.50")
1116+
dummy_row = [val]
1117+
sql_type, c_type, _, _, _ = cur._map_sql_type(val, dummy_row, 0)
1118+
assert sql_type == _C.SQL_VARCHAR.value
1119+
assert c_type == _C.SQL_C_CHAR.value
1120+
1121+
1122+
def test_executemany_numeric_override_needed():
1123+
"""The executemany auto-detection path must override SQL_C_NUMERIC to SQL_C_CHAR (GH-609).
1124+
1125+
Verifies the full type-detection pipeline that executemany uses: when a
1126+
column of large Decimals is detected, _create_parameter_types_list returns
1127+
SQL_C_NUMERIC, but the override in executemany must change it to SQL_C_CHAR
1128+
since the conversion loop converts all Decimals to strings.
1129+
"""
1130+
from mssql_python import ddbc_bindings
1131+
1132+
cur = _make_bare_cursor()
1133+
data = [
1134+
(_decimal.Decimal("100.50"),),
1135+
(_decimal.Decimal("999999999999999999.123456"),),
1136+
]
1137+
1138+
# Step 1: type detection (same as executemany's auto-detection path)
1139+
column = [row[0] for row in data]
1140+
sample_value, min_val, max_val, max_decimal_len = cur._compute_column_type(column)
1141+
dummy_row = list(data[0])
1142+
paraminfo = cur._create_parameter_types_list(
1143+
sample_value,
1144+
ddbc_bindings.ParamInfo,
1145+
dummy_row,
1146+
0,
1147+
min_val=min_val,
1148+
max_val=max_val,
1149+
)
1150+
1151+
# Before the fix: C type would be SQL_C_NUMERIC (mismatch with string data)
1152+
assert paraminfo.paramSQLType == _C.SQL_NUMERIC.value
1153+
original_c_type = paraminfo.paramCType
1154+
1155+
# Step 2: apply the GH-609 override (same logic as cursor.py executemany)
1156+
if paraminfo.paramSQLType in (_C.SQL_DECIMAL.value, _C.SQL_NUMERIC.value):
1157+
paraminfo.paramCType = _C.SQL_C_CHAR.value
1158+
if max_decimal_len > paraminfo.columnSize:
1159+
paraminfo.columnSize = max_decimal_len
1160+
1161+
# After the fix: C type is SQL_C_CHAR, matching the string conversion
1162+
assert paraminfo.paramCType == _C.SQL_C_CHAR.value
1163+
assert original_c_type == _C.SQL_C_NUMERIC.value # confirms override was needed
1164+
assert paraminfo.columnSize >= max_decimal_len
1165+
1166+
1167+
def test_executemany_decimal_numeric_override_coverage(monkeypatch):
1168+
"""Call the real executemany method to cover the GH-609 override lines.
1169+
1170+
Mocks only the I/O boundaries (_check_closed, _reset_cursor, SQLExecuteMany)
1171+
so the entire type-detection and conversion pipeline runs for real.
1172+
"""
1173+
from unittest.mock import MagicMock
1174+
from mssql_python import ddbc_bindings
1175+
from mssql_python.cursor import Cursor
1176+
1177+
cur = Cursor.__new__(Cursor)
1178+
cur._inputsizes = None
1179+
cur._timeout = 0
1180+
cur.closed = False
1181+
cur.hstmt = MagicMock()
1182+
cur.messages = []
1183+
cur.is_stmt_prepared = [False]
1184+
cur._connection = MagicMock()
1185+
cur._connection._encoding = "utf-8"
1186+
cur._connection._conn = MagicMock()
1187+
1188+
# Capture what gets passed to SQLExecuteMany
1189+
captured = {}
1190+
1191+
def fake_sql_execute_many(hstmt, op, col_params, param_types, row_count, enc):
1192+
captured["parameters_type"] = param_types
1193+
captured["columnwise_params"] = col_params
1194+
captured["row_count"] = row_count
1195+
return 0 # SQL_SUCCESS
1196+
1197+
monkeypatch.setattr(cur, "_check_closed", lambda: None)
1198+
monkeypatch.setattr(cur, "_reset_cursor", lambda: None)
1199+
monkeypatch.setattr(ddbc_bindings, "SQLExecuteMany", fake_sql_execute_many)
1200+
monkeypatch.setattr(ddbc_bindings, "DDBCSQLGetAllDiagRecords", lambda h: [])
1201+
monkeypatch.setattr(ddbc_bindings, "DDBCSQLRowCount", lambda h: 2)
1202+
1203+
data = [
1204+
(_decimal.Decimal("100.50"),),
1205+
(_decimal.Decimal("999999999999999999.123456"),),
1206+
]
1207+
cur.executemany("INSERT INTO t VALUES (?)", data)
1208+
1209+
# Verify the GH-609 override was applied inside the real executemany
1210+
pt = captured["parameters_type"]
1211+
assert len(pt) == 1
1212+
assert pt[0].paramSQLType == _C.SQL_NUMERIC.value
1213+
assert (
1214+
pt[0].paramCType == _C.SQL_C_CHAR.value
1215+
), f"Expected SQL_C_CHAR ({_C.SQL_C_CHAR.value}), got {pt[0].paramCType}"
1216+
1217+
# Verify values were converted to strings (not NumericData structs)
1218+
col_values = captured["columnwise_params"][0]
1219+
for val in col_values:
1220+
assert val is None or isinstance(
1221+
val, str
1222+
), f"Expected str, got {type(val).__name__}: {val!r}"

‎tests/test_004_cursor.py‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16239,3 +16239,103 @@ def test_long_print_message(cursor, message_len):
1623916239
msg = cursor.messages[0][1]
1624016240
# SQL Server truncates at 8000 characters
1624116241
assert msg.endswith("a" * min(8000, message_len)), msg
16242+
16243+
16244+
# ---------------------------------------------------------
16245+
# GH-609: executemany with Decimal values outside MONEY range
16246+
# ---------------------------------------------------------
16247+
def test_executemany_decimal_outside_money_range(cursor, db_connection):
16248+
"""Test executemany with Decimal values exceeding the MONEY range (GH-609).
16249+
16250+
When a batch contains Decimal values outside ±922,337,203,685,477.5807,
16251+
_map_sql_type returns SQL_C_NUMERIC (expecting NumericData structs), but
16252+
the conversion loop converts all Decimals to strings. Without the GH-609
16253+
fix, this mismatch causes:
16254+
RuntimeError: Parameter's object type does not match parameter's C type
16255+
16256+
Also exercises separate batches (customer scenario: most batches have
16257+
in-MONEY-range values, one batch exceeds the range).
16258+
"""
16259+
try:
16260+
cursor.execute("CREATE TABLE #pytest_gh609 (val DECIMAL(38, 6))")
16261+
16262+
# Batch 1: all values inside MONEY range
16263+
batch1 = [(decimal.Decimal("100.50"),), (decimal.Decimal("200.75"),)]
16264+
cursor.executemany("INSERT INTO #pytest_gh609 VALUES (?)", batch1)
16265+
16266+
# Batch 2: mix of inside and outside MONEY range
16267+
batch2 = [
16268+
(decimal.Decimal("0.000001"),),
16269+
(decimal.Decimal("999999999999999999.123456"),), # exceeds MONEY_MAX
16270+
(decimal.Decimal("-999999999999999999.654321"),), # exceeds MONEY_MIN
16271+
]
16272+
cursor.executemany("INSERT INTO #pytest_gh609 VALUES (?)", batch2)
16273+
db_connection.commit()
16274+
16275+
cursor.execute("SELECT val FROM #pytest_gh609 ORDER BY val")
16276+
rows = [row[0] for row in cursor.fetchall()]
16277+
assert len(rows) == 5
16278+
assert rows[0] == decimal.Decimal("-999999999999999999.654321")
16279+
assert rows[-1] == decimal.Decimal("999999999999999999.123456")
16280+
finally:
16281+
cursor.execute("DROP TABLE IF EXISTS #pytest_gh609")
16282+
db_connection.commit()
16283+
16284+
16285+
def test_executemany_decimal_with_nulls_outside_money(cursor, db_connection):
16286+
"""Test executemany with NULL + large Decimal values outside MONEY range (GH-609)."""
16287+
try:
16288+
cursor.execute("CREATE TABLE #pytest_gh609_nulls (val DECIMAL(38, 10))")
16289+
data = [
16290+
(None,),
16291+
(decimal.Decimal("12345678901234567890.1234567890"),),
16292+
(None,),
16293+
(decimal.Decimal("-12345678901234567890.1234567890"),),
16294+
]
16295+
cursor.executemany("INSERT INTO #pytest_gh609_nulls VALUES (?)", data)
16296+
db_connection.commit()
16297+
16298+
cursor.execute("SELECT val FROM #pytest_gh609_nulls WHERE val IS NOT NULL ORDER BY val")
16299+
rows = [row[0] for row in cursor.fetchall()]
16300+
assert len(rows) == 2
16301+
assert rows[0] == decimal.Decimal("-12345678901234567890.1234567890")
16302+
assert rows[1] == decimal.Decimal("12345678901234567890.1234567890")
16303+
16304+
cursor.execute("SELECT COUNT(*) FROM #pytest_gh609_nulls WHERE val IS NULL")
16305+
assert cursor.fetchone()[0] == 2
16306+
finally:
16307+
cursor.execute("DROP TABLE IF EXISTS #pytest_gh609_nulls")
16308+
db_connection.commit()
16309+
16310+
16311+
def test_executemany_multi_column_with_large_decimal(cursor, db_connection):
16312+
"""Test executemany with multiple columns including large Decimal (GH-609).
16313+
16314+
Mirrors the customer's scenario: a table with many columns where one
16315+
NUMERIC column has values outside the MONEY range.
16316+
"""
16317+
try:
16318+
cursor.execute("""
16319+
CREATE TABLE #pytest_gh609_multi (
16320+
id INT,
16321+
name NVARCHAR(100),
16322+
amount DECIMAL(38, 6),
16323+
description VARCHAR(200)
16324+
)
16325+
""")
16326+
data = [
16327+
(1, "row1", decimal.Decimal("999999999999999999.123456"), "test"),
16328+
(2, "row2", decimal.Decimal("50.0"), "small"),
16329+
(3, "row3", decimal.Decimal("-999999999999999999.654321"), "negative large"),
16330+
]
16331+
cursor.executemany("INSERT INTO #pytest_gh609_multi VALUES (?, ?, ?, ?)", data)
16332+
db_connection.commit()
16333+
16334+
cursor.execute("SELECT id, amount FROM #pytest_gh609_multi ORDER BY id")
16335+
rows = cursor.fetchall()
16336+
assert len(rows) == 3
16337+
assert rows[0][1] == decimal.Decimal("999999999999999999.123456")
16338+
assert rows[2][1] == decimal.Decimal("-999999999999999999.654321")
16339+
finally:
16340+
cursor.execute("DROP TABLE IF EXISTS #pytest_gh609_multi")
16341+
db_connection.commit()

0 commit comments

Comments
 (0)