diff --git a/mssql_python/cursor.py b/mssql_python/cursor.py index ba5065d56..722747c35 100644 --- a/mssql_python/cursor.py +++ b/mssql_python/cursor.py @@ -885,8 +885,8 @@ def _get_c_type_for_sql_type(self, sql_type: int) -> int: ddbc_sql_const.SQL_WCHAR.value: ddbc_sql_const.SQL_C_WCHAR.value, ddbc_sql_const.SQL_WVARCHAR.value: ddbc_sql_const.SQL_C_WCHAR.value, ddbc_sql_const.SQL_WLONGVARCHAR.value: ddbc_sql_const.SQL_C_WCHAR.value, - ddbc_sql_const.SQL_DECIMAL.value: ddbc_sql_const.SQL_C_NUMERIC.value, - ddbc_sql_const.SQL_NUMERIC.value: ddbc_sql_const.SQL_C_NUMERIC.value, + ddbc_sql_const.SQL_DECIMAL.value: ddbc_sql_const.SQL_C_CHAR.value, + ddbc_sql_const.SQL_NUMERIC.value: ddbc_sql_const.SQL_C_CHAR.value, ddbc_sql_const.SQL_BIT.value: ddbc_sql_const.SQL_C_BIT.value, ddbc_sql_const.SQL_TINYINT.value: ddbc_sql_const.SQL_C_TINYINT.value, ddbc_sql_const.SQL_SMALLINT.value: ddbc_sql_const.SQL_C_SHORT.value, @@ -949,6 +949,14 @@ def _create_parameter_types_list( # pylint: disable=too-many-arguments,too-many # For non-NULL parameters, determine the appropriate C type based on SQL type c_type = self._get_c_type_for_sql_type(sql_type) + # Convert Decimal to string for SQL_C_CHAR binding (GH-503) + if isinstance(parameter, decimal.Decimal) and sql_type in ( + ddbc_sql_const.SQL_DECIMAL.value, + ddbc_sql_const.SQL_NUMERIC.value, + ): + parameters_list[i] = format(parameter, "f") + parameter = parameters_list[i] + # Check if this should be a DAE (data at execution) parameter # For string types with large column sizes if isinstance(parameter, str) and column_size > MAX_INLINE_CHAR: @@ -2306,17 +2314,20 @@ def executemany( # pylint: disable=too-many-locals,too-many-branches,too-many-s and parameters_type[i].paramSQLType == ddbc_sql_const.SQL_VARCHAR.value ): processed_row[i] = format(val, "f") - # Existing numeric conversion + # Convert all values to string for DECIMAL/NUMERIC columns (GH-503) elif parameters_type[i].paramSQLType in ( ddbc_sql_const.SQL_DECIMAL.value, ddbc_sql_const.SQL_NUMERIC.value, - ) and not isinstance(val, decimal.Decimal): - try: - processed_row[i] = decimal.Decimal(str(val)) - except Exception as e: # pylint: disable=broad-exception-caught - raise ValueError( - f"Failed to convert parameter at row {row}, column {i} to Decimal: {e}" - ) from e + ): + if isinstance(val, decimal.Decimal): + processed_row[i] = format(val, "f") + else: + try: + processed_row[i] = format(decimal.Decimal(str(val)), "f") + except Exception as e: # pylint: disable=broad-exception-caught + raise ValueError( + f"Failed to convert parameter at row {row}, column {i} to Decimal: {e}" + ) from e processed_parameters.append(processed_row) # Now transpose the processed parameters diff --git a/tests/test_004_cursor.py b/tests/test_004_cursor.py index 6ac157389..07b087efc 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -9974,6 +9974,166 @@ def test_cursor_setinputsizes_with_executemany_float(db_connection): cursor.execute("DROP TABLE IF EXISTS #test_inputsizes_float") +def test_setinputsizes_sql_decimal_with_executemany(db_connection): + """Test setinputsizes with SQL_DECIMAL accepts Python Decimal values (GH-503). + + Without this fix, passing SQL_DECIMAL or SQL_NUMERIC via setinputsizes() + caused a RuntimeError because Decimal objects were not converted to + NumericData before the C binding validated the C type. + """ + cursor = db_connection.cursor() + + cursor.execute("DROP TABLE IF EXISTS #test_sis_decimal") + try: + cursor.execute(""" + CREATE TABLE #test_sis_decimal ( + Name NVARCHAR(100), + CategoryID INT, + Price DECIMAL(18,2) + ) + """) + + cursor.setinputsizes( + [ + (mssql_python.SQL_WVARCHAR, 100, 0), + (mssql_python.SQL_INTEGER, 0, 0), + (mssql_python.SQL_DECIMAL, 18, 2), + ] + ) + + cursor.executemany( + "INSERT INTO #test_sis_decimal (Name, CategoryID, Price) VALUES (?, ?, ?)", + [ + ("Widget", 1, decimal.Decimal("19.99")), + ("Gadget", 2, decimal.Decimal("29.99")), + ("Gizmo", 3, decimal.Decimal("0.01")), + ], + ) + + cursor.execute("SELECT Name, CategoryID, Price FROM #test_sis_decimal ORDER BY CategoryID") + rows = cursor.fetchall() + + assert len(rows) == 3 + assert rows[0][0] == "Widget" + assert rows[0][1] == 1 + assert rows[0][2] == decimal.Decimal("19.99") + assert rows[1][0] == "Gadget" + assert rows[1][1] == 2 + assert rows[1][2] == decimal.Decimal("29.99") + assert rows[2][0] == "Gizmo" + assert rows[2][1] == 3 + assert rows[2][2] == decimal.Decimal("0.01") + finally: + cursor.execute("DROP TABLE IF EXISTS #test_sis_decimal") + + +def test_setinputsizes_sql_numeric_with_executemany(db_connection): + """Test setinputsizes with SQL_NUMERIC accepts Python Decimal values (GH-503).""" + cursor = db_connection.cursor() + + cursor.execute("DROP TABLE IF EXISTS #test_sis_numeric") + try: + cursor.execute(""" + CREATE TABLE #test_sis_numeric ( + Value NUMERIC(10,4) + ) + """) + + cursor.setinputsizes( + [ + (mssql_python.SQL_NUMERIC, 10, 4), + ] + ) + + cursor.executemany( + "INSERT INTO #test_sis_numeric (Value) VALUES (?)", + [ + (decimal.Decimal("123.4567"),), + (decimal.Decimal("-99.0001"),), + (decimal.Decimal("0.0000"),), + ], + ) + + cursor.execute("SELECT Value FROM #test_sis_numeric ORDER BY Value") + rows = cursor.fetchall() + + assert len(rows) == 3 + assert rows[0][0] == decimal.Decimal("-99.0001") + assert rows[1][0] == decimal.Decimal("0.0000") + assert rows[2][0] == decimal.Decimal("123.4567") + finally: + cursor.execute("DROP TABLE IF EXISTS #test_sis_numeric") + + +def test_setinputsizes_sql_decimal_with_non_decimal_values(db_connection): + """Test setinputsizes with SQL_DECIMAL converts non-Decimal values (int/float) to string (GH-503).""" + cursor = db_connection.cursor() + + cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_nondec") + try: + cursor.execute("CREATE TABLE #test_sis_dec_nondc (Price DECIMAL(18,2))") + + cursor.setinputsizes([(mssql_python.SQL_DECIMAL, 18, 2)]) + + # Pass int and float instead of Decimal — exercises the non-Decimal conversion branch + cursor.executemany( + "INSERT INTO #test_sis_dec_nondc (Price) VALUES (?)", + [(42,), (19.99,), (0,)], + ) + + cursor.execute("SELECT Price FROM #test_sis_dec_nondc ORDER BY Price") + rows = cursor.fetchall() + + assert len(rows) == 3 + assert rows[0][0] == decimal.Decimal("0.00") + assert rows[1][0] == decimal.Decimal("19.99") + assert rows[2][0] == decimal.Decimal("42.00") + finally: + cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_nondc") + + +def test_setinputsizes_sql_decimal_with_execute(db_connection): + """Test setinputsizes with SQL_DECIMAL works with single execute() too (GH-503).""" + cursor = db_connection.cursor() + + cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_exec") + try: + cursor.execute("CREATE TABLE #test_sis_dec_exec (Price DECIMAL(18,2))") + + cursor.setinputsizes([(mssql_python.SQL_DECIMAL, 18, 2)]) + cursor.execute( + "INSERT INTO #test_sis_dec_exec (Price) VALUES (?)", + decimal.Decimal("99.95"), + ) + + cursor.execute("SELECT Price FROM #test_sis_dec_exec") + row = cursor.fetchone() + assert row[0] == decimal.Decimal("99.95") + finally: + cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_exec") + + +def test_setinputsizes_sql_decimal_null(db_connection): + """Test setinputsizes with SQL_DECIMAL handles NULL values correctly (GH-503).""" + cursor = db_connection.cursor() + + cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_null") + try: + cursor.execute("CREATE TABLE #test_sis_dec_null (Price DECIMAL(18,2))") + + cursor.setinputsizes([(mssql_python.SQL_DECIMAL, 18, 2)]) + cursor.execute( + "INSERT INTO #test_sis_dec_null (Price) VALUES (?)", + None, + ) + + cursor.execute("SELECT Price FROM #test_sis_dec_null") + row = cursor.fetchone() + assert row[0] is None + finally: + cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_null") + + def test_cursor_setinputsizes_reset(db_connection): """Test that setinputsizes is reset after execution"""