Skip to content

Commit ed33bc5

Browse files
authored
FIX: Setinputsizes() SQL_DECIMAL crash (#519)
### Work Item / Issue Reference <!-- IMPORTANT: Please follow the PR template guidelines below. For mssql-python maintainers: Insert your ADO Work Item ID below For external contributors: Insert Github Issue number below Only one reference is required - either GitHub issue OR ADO Work Item. --> <!-- mssql-python maintainers: ADO Work Item --> > AB#53990 <!-- External contributors: GitHub Issue --> > GitHub Issue: #503 ------------------------------------------------------------------- ### Summary This pull request improves the handling of Python `Decimal` values when binding to SQL `DECIMAL` and `NUMERIC` types, especially when using `setinputsizes` and `executemany`. It fixes a runtime error by ensuring `Decimal` objects are converted to strings for proper binding, and adds comprehensive tests to verify this behavior. **Decimal binding and conversion improvements:** * Changed the mapping for SQL `DECIMAL` and `NUMERIC` types to use `SQL_C_CHAR` instead of `SQL_C_NUMERIC` in `_get_c_type_for_sql_type`, enabling string-based binding for decimals. * Updated parameter handling in `_create_parameter_types_list` and `executemany` to convert Python `Decimal` objects to strings when binding to SQL `DECIMAL` or `NUMERIC` columns. **Testing enhancements:** * Added new tests in `test_004_cursor.py` to verify that `setinputsizes` with `SQL_DECIMAL` and `SQL_NUMERIC` accepts Python `Decimal` values, works with both `executemany` and `execute`, and correctly handles `NULL` values.
1 parent 97ae703 commit ed33bc5

2 files changed

Lines changed: 324 additions & 8 deletions

File tree

mssql_python/cursor.py

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -949,6 +949,20 @@ def _create_parameter_types_list( # pylint: disable=too-many-arguments,too-many
949949
# For non-NULL parameters, determine the appropriate C type based on SQL type
950950
c_type = self._get_c_type_for_sql_type(sql_type)
951951

952+
# Override DECIMAL/NUMERIC to use SQL_C_CHAR string binding (GH-503).
953+
# The generic mapping returns SQL_C_NUMERIC which requires NumericData
954+
# structs, but setinputsizes declares fixed precision/scale that may
955+
# differ from per-value precision, causing misinterpretation. String
956+
# binding lets ODBC convert using the declared columnSize/decimalDigits.
957+
if sql_type in (
958+
ddbc_sql_const.SQL_DECIMAL.value,
959+
ddbc_sql_const.SQL_NUMERIC.value,
960+
):
961+
c_type = ddbc_sql_const.SQL_C_CHAR.value
962+
if isinstance(parameter, decimal.Decimal):
963+
parameters_list[i] = format(parameter, "f")
964+
parameter = parameters_list[i]
965+
952966
# Check if this should be a DAE (data at execution) parameter
953967
# For string types with large column sizes
954968
if isinstance(parameter, str) and column_size > MAX_INLINE_CHAR:
@@ -2177,6 +2191,13 @@ def executemany( # pylint: disable=too-many-locals,too-many-branches,too-many-s
21772191
# Determine appropriate C type based on SQL type
21782192
c_type = self._get_c_type_for_sql_type(sql_type)
21792193

2194+
# Override DECIMAL/NUMERIC to use SQL_C_CHAR string binding (GH-503)
2195+
if sql_type in (
2196+
ddbc_sql_const.SQL_DECIMAL.value,
2197+
ddbc_sql_const.SQL_NUMERIC.value,
2198+
):
2199+
c_type = ddbc_sql_const.SQL_C_CHAR.value
2200+
21802201
# Check if this should be a DAE (data at execution) parameter based on column size
21812202
if sample_value is not None:
21822203
if isinstance(sample_value, str) and column_size > MAX_INLINE_CHAR:
@@ -2306,17 +2327,20 @@ def executemany( # pylint: disable=too-many-locals,too-many-branches,too-many-s
23062327
and parameters_type[i].paramSQLType == ddbc_sql_const.SQL_VARCHAR.value
23072328
):
23082329
processed_row[i] = format(val, "f")
2309-
# Existing numeric conversion
2330+
# Convert all values to string for DECIMAL/NUMERIC columns (GH-503)
23102331
elif parameters_type[i].paramSQLType in (
23112332
ddbc_sql_const.SQL_DECIMAL.value,
23122333
ddbc_sql_const.SQL_NUMERIC.value,
2313-
) and not isinstance(val, decimal.Decimal):
2314-
try:
2315-
processed_row[i] = decimal.Decimal(str(val))
2316-
except Exception as e: # pylint: disable=broad-exception-caught
2317-
raise ValueError(
2318-
f"Failed to convert parameter at row {row}, column {i} to Decimal: {e}"
2319-
) from e
2334+
):
2335+
if isinstance(val, decimal.Decimal):
2336+
processed_row[i] = format(val, "f")
2337+
else:
2338+
try:
2339+
processed_row[i] = format(decimal.Decimal(str(val)), "f")
2340+
except Exception as e: # pylint: disable=broad-exception-caught
2341+
raise ValueError(
2342+
f"Failed to convert parameter at row {row}, column {i} to Decimal: {e}"
2343+
) from e
23202344
processed_parameters.append(processed_row)
23212345

23222346
# Now transpose the processed parameters

tests/test_004_cursor.py

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9974,6 +9974,298 @@ def test_cursor_setinputsizes_with_executemany_float(db_connection):
99749974
cursor.execute("DROP TABLE IF EXISTS #test_inputsizes_float")
99759975

99769976

9977+
def test_setinputsizes_sql_decimal_with_executemany(db_connection):
9978+
"""Test setinputsizes with SQL_DECIMAL accepts Python Decimal values (GH-503).
9979+
9980+
Without this fix, passing SQL_DECIMAL or SQL_NUMERIC via setinputsizes()
9981+
caused a RuntimeError because Decimal objects were not converted to
9982+
NumericData before the C binding validated the C type.
9983+
"""
9984+
cursor = db_connection.cursor()
9985+
9986+
cursor.execute("DROP TABLE IF EXISTS #test_sis_decimal")
9987+
try:
9988+
cursor.execute("""
9989+
CREATE TABLE #test_sis_decimal (
9990+
Name NVARCHAR(100),
9991+
CategoryID INT,
9992+
Price DECIMAL(18,2)
9993+
)
9994+
""")
9995+
9996+
cursor.setinputsizes(
9997+
[
9998+
(mssql_python.SQL_WVARCHAR, 100, 0),
9999+
(mssql_python.SQL_INTEGER, 0, 0),
10000+
(mssql_python.SQL_DECIMAL, 18, 2),
10001+
]
10002+
)
10003+
10004+
cursor.executemany(
10005+
"INSERT INTO #test_sis_decimal (Name, CategoryID, Price) VALUES (?, ?, ?)",
10006+
[
10007+
("Widget", 1, decimal.Decimal("19.99")),
10008+
("Gadget", 2, decimal.Decimal("29.99")),
10009+
("Gizmo", 3, decimal.Decimal("0.01")),
10010+
],
10011+
)
10012+
10013+
cursor.execute("SELECT Name, CategoryID, Price FROM #test_sis_decimal ORDER BY CategoryID")
10014+
rows = cursor.fetchall()
10015+
10016+
assert len(rows) == 3
10017+
assert rows[0][0] == "Widget"
10018+
assert rows[0][1] == 1
10019+
assert rows[0][2] == decimal.Decimal("19.99")
10020+
assert rows[1][0] == "Gadget"
10021+
assert rows[1][1] == 2
10022+
assert rows[1][2] == decimal.Decimal("29.99")
10023+
assert rows[2][0] == "Gizmo"
10024+
assert rows[2][1] == 3
10025+
assert rows[2][2] == decimal.Decimal("0.01")
10026+
finally:
10027+
cursor.execute("DROP TABLE IF EXISTS #test_sis_decimal")
10028+
10029+
10030+
def test_setinputsizes_sql_numeric_with_executemany(db_connection):
10031+
"""Test setinputsizes with SQL_NUMERIC accepts Python Decimal values (GH-503)."""
10032+
cursor = db_connection.cursor()
10033+
10034+
cursor.execute("DROP TABLE IF EXISTS #test_sis_numeric")
10035+
try:
10036+
cursor.execute("""
10037+
CREATE TABLE #test_sis_numeric (
10038+
Value NUMERIC(10,4)
10039+
)
10040+
""")
10041+
10042+
cursor.setinputsizes(
10043+
[
10044+
(mssql_python.SQL_NUMERIC, 10, 4),
10045+
]
10046+
)
10047+
10048+
cursor.executemany(
10049+
"INSERT INTO #test_sis_numeric (Value) VALUES (?)",
10050+
[
10051+
(decimal.Decimal("123.4567"),),
10052+
(decimal.Decimal("-99.0001"),),
10053+
(decimal.Decimal("0.0000"),),
10054+
],
10055+
)
10056+
10057+
cursor.execute("SELECT Value FROM #test_sis_numeric ORDER BY Value")
10058+
rows = cursor.fetchall()
10059+
10060+
assert len(rows) == 3
10061+
assert rows[0][0] == decimal.Decimal("-99.0001")
10062+
assert rows[1][0] == decimal.Decimal("0.0000")
10063+
assert rows[2][0] == decimal.Decimal("123.4567")
10064+
finally:
10065+
cursor.execute("DROP TABLE IF EXISTS #test_sis_numeric")
10066+
10067+
10068+
def test_setinputsizes_sql_decimal_with_non_decimal_values(db_connection):
10069+
"""Test setinputsizes with SQL_DECIMAL converts non-Decimal values (int/float) to string (GH-503)."""
10070+
cursor = db_connection.cursor()
10071+
10072+
cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_nondec")
10073+
try:
10074+
cursor.execute("CREATE TABLE #test_sis_dec_nondc (Price DECIMAL(18,2))")
10075+
10076+
cursor.setinputsizes([(mssql_python.SQL_DECIMAL, 18, 2)])
10077+
10078+
# Pass int and float instead of Decimal — exercises the non-Decimal conversion branch
10079+
cursor.executemany(
10080+
"INSERT INTO #test_sis_dec_nondc (Price) VALUES (?)",
10081+
[(42,), (19.99,), (0,)],
10082+
)
10083+
10084+
cursor.execute("SELECT Price FROM #test_sis_dec_nondc ORDER BY Price")
10085+
rows = cursor.fetchall()
10086+
10087+
assert len(rows) == 3
10088+
assert rows[0][0] == decimal.Decimal("0.00")
10089+
assert rows[1][0] == decimal.Decimal("19.99")
10090+
assert rows[2][0] == decimal.Decimal("42.00")
10091+
finally:
10092+
cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_nondc")
10093+
10094+
10095+
def test_setinputsizes_sql_decimal_with_execute(db_connection):
10096+
"""Test setinputsizes with SQL_DECIMAL works with single execute() too (GH-503)."""
10097+
cursor = db_connection.cursor()
10098+
10099+
cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_exec")
10100+
try:
10101+
cursor.execute("CREATE TABLE #test_sis_dec_exec (Price DECIMAL(18,2))")
10102+
10103+
cursor.setinputsizes([(mssql_python.SQL_DECIMAL, 18, 2)])
10104+
cursor.execute(
10105+
"INSERT INTO #test_sis_dec_exec (Price) VALUES (?)",
10106+
decimal.Decimal("99.95"),
10107+
)
10108+
10109+
cursor.execute("SELECT Price FROM #test_sis_dec_exec")
10110+
row = cursor.fetchone()
10111+
assert row[0] == decimal.Decimal("99.95")
10112+
finally:
10113+
cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_exec")
10114+
10115+
10116+
def test_setinputsizes_sql_decimal_null(db_connection):
10117+
"""Test setinputsizes with SQL_DECIMAL handles NULL values correctly (GH-503)."""
10118+
cursor = db_connection.cursor()
10119+
10120+
cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_null")
10121+
try:
10122+
cursor.execute("CREATE TABLE #test_sis_dec_null (Price DECIMAL(18,2))")
10123+
10124+
cursor.setinputsizes([(mssql_python.SQL_DECIMAL, 18, 2)])
10125+
cursor.execute(
10126+
"INSERT INTO #test_sis_dec_null (Price) VALUES (?)",
10127+
None,
10128+
)
10129+
10130+
cursor.execute("SELECT Price FROM #test_sis_dec_null")
10131+
row = cursor.fetchone()
10132+
assert row[0] is None
10133+
finally:
10134+
cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_null")
10135+
10136+
10137+
def test_setinputsizes_sql_decimal_unconvertible_value(db_connection):
10138+
"""Test setinputsizes with SQL_DECIMAL raises ValueError for unconvertible values (GH-503)."""
10139+
cursor = db_connection.cursor()
10140+
10141+
cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_bad")
10142+
try:
10143+
cursor.execute("CREATE TABLE #test_sis_dec_bad (Price DECIMAL(18,2))")
10144+
10145+
cursor.setinputsizes([(mssql_python.SQL_DECIMAL, 18, 2)])
10146+
10147+
with pytest.raises(ValueError, match="Failed to convert parameter"):
10148+
cursor.executemany(
10149+
"INSERT INTO #test_sis_dec_bad (Price) VALUES (?)",
10150+
[("not_a_number",)],
10151+
)
10152+
finally:
10153+
cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_bad")
10154+
10155+
10156+
def test_setinputsizes_sql_decimal_high_precision(db_connection):
10157+
"""Test setinputsizes with SQL_DECIMAL preserves full DECIMAL(38,18) precision (GH-503)."""
10158+
cursor = db_connection.cursor()
10159+
10160+
cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_hp")
10161+
try:
10162+
cursor.execute("CREATE TABLE #test_sis_dec_hp (Value DECIMAL(38,18))")
10163+
10164+
cursor.setinputsizes([(mssql_python.SQL_DECIMAL, 38, 18)])
10165+
10166+
high_prec = decimal.Decimal("12345678901234567890.123456789012345678")
10167+
cursor.execute(
10168+
"INSERT INTO #test_sis_dec_hp (Value) VALUES (?)",
10169+
high_prec,
10170+
)
10171+
10172+
cursor.execute("SELECT Value FROM #test_sis_dec_hp")
10173+
row = cursor.fetchone()
10174+
assert row[0] == high_prec
10175+
finally:
10176+
cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_hp")
10177+
10178+
10179+
def test_setinputsizes_sql_decimal_negative_zero(db_connection):
10180+
"""Test setinputsizes with SQL_DECIMAL handles Decimal('-0.00') correctly (GH-503)."""
10181+
cursor = db_connection.cursor()
10182+
10183+
cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_negz")
10184+
try:
10185+
cursor.execute("CREATE TABLE #test_sis_dec_negz (Value DECIMAL(18,2))")
10186+
10187+
cursor.setinputsizes([(mssql_python.SQL_DECIMAL, 18, 2)])
10188+
cursor.execute(
10189+
"INSERT INTO #test_sis_dec_negz (Value) VALUES (?)",
10190+
decimal.Decimal("-0.00"),
10191+
)
10192+
10193+
cursor.execute("SELECT Value FROM #test_sis_dec_negz")
10194+
row = cursor.fetchone()
10195+
# SQL Server normalizes -0.00 to 0.00
10196+
assert row[0] == decimal.Decimal("0.00")
10197+
finally:
10198+
cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_negz")
10199+
10200+
10201+
def test_setinputsizes_sql_decimal_mixed_null_executemany(db_connection):
10202+
"""Test setinputsizes with SQL_DECIMAL handles mixed NULL/non-NULL in executemany (GH-503)."""
10203+
cursor = db_connection.cursor()
10204+
10205+
cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_mix")
10206+
try:
10207+
cursor.execute("CREATE TABLE #test_sis_dec_mix (Id INT, Price DECIMAL(18,2))")
10208+
10209+
cursor.setinputsizes(
10210+
[
10211+
(mssql_python.SQL_INTEGER, 0, 0),
10212+
(mssql_python.SQL_DECIMAL, 18, 2),
10213+
]
10214+
)
10215+
10216+
cursor.executemany(
10217+
"INSERT INTO #test_sis_dec_mix (Id, Price) VALUES (?, ?)",
10218+
[
10219+
(1, decimal.Decimal("10.50")),
10220+
(2, None),
10221+
(3, decimal.Decimal("30.75")),
10222+
(4, None),
10223+
],
10224+
)
10225+
10226+
cursor.execute("SELECT Id, Price FROM #test_sis_dec_mix ORDER BY Id")
10227+
rows = cursor.fetchall()
10228+
10229+
assert len(rows) == 4
10230+
assert rows[0][1] == decimal.Decimal("10.50")
10231+
assert rows[1][1] is None
10232+
assert rows[2][1] == decimal.Decimal("30.75")
10233+
assert rows[3][1] is None
10234+
finally:
10235+
cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_mix")
10236+
10237+
10238+
def test_decimal_without_setinputsizes_no_regression(db_connection):
10239+
"""Verify plain Decimal binding without setinputsizes still works (GH-503 regression check)."""
10240+
cursor = db_connection.cursor()
10241+
10242+
cursor.execute("DROP TABLE IF EXISTS #test_dec_noreg")
10243+
try:
10244+
cursor.execute("CREATE TABLE #test_dec_noreg (Price DECIMAL(18,2))")
10245+
10246+
# Single execute without setinputsizes
10247+
cursor.execute(
10248+
"INSERT INTO #test_dec_noreg (Price) VALUES (?)",
10249+
decimal.Decimal("49.99"),
10250+
)
10251+
10252+
# executemany without setinputsizes
10253+
cursor.executemany(
10254+
"INSERT INTO #test_dec_noreg (Price) VALUES (?)",
10255+
[(decimal.Decimal("99.99"),), (decimal.Decimal("0.01"),)],
10256+
)
10257+
10258+
cursor.execute("SELECT Price FROM #test_dec_noreg ORDER BY Price")
10259+
rows = cursor.fetchall()
10260+
10261+
assert len(rows) == 3
10262+
assert rows[0][0] == decimal.Decimal("0.01")
10263+
assert rows[1][0] == decimal.Decimal("49.99")
10264+
assert rows[2][0] == decimal.Decimal("99.99")
10265+
finally:
10266+
cursor.execute("DROP TABLE IF EXISTS #test_dec_noreg")
10267+
10268+
997710269
def test_cursor_setinputsizes_reset(db_connection):
997810270
"""Test that setinputsizes is reset after execution"""
997910271

0 commit comments

Comments
 (0)