diff --git a/mssql_python/cursor.py b/mssql_python/cursor.py index f3c76853..fe9cc435 100644 --- a/mssql_python/cursor.py +++ b/mssql_python/cursor.py @@ -20,6 +20,10 @@ # Constants for string handling MAX_INLINE_CHAR = 4000 # NVARCHAR/VARCHAR inline limit; this triggers NVARCHAR(MAX)/VARCHAR(MAX) + DAE +SMALLMONEY_MIN = decimal.Decimal('-214748.3648') +SMALLMONEY_MAX = decimal.Decimal('214748.3647') +MONEY_MIN = decimal.Decimal('-922337203685477.5808') +MONEY_MAX = decimal.Decimal('922337203685477.5807') class Cursor: """ @@ -282,18 +286,39 @@ def _map_sql_type(self, param, parameters_list, i): 0, False, ) - + if isinstance(param, decimal.Decimal): - parameters_list[i] = self._get_numeric_data( - param - ) # Replace the parameter with the dictionary - return ( - ddbc_sql_const.SQL_NUMERIC.value, - ddbc_sql_const.SQL_C_NUMERIC.value, - parameters_list[i].precision, - parameters_list[i].scale, - False, - ) + # Detect MONEY / SMALLMONEY range + if SMALLMONEY_MIN <= param <= SMALLMONEY_MAX: + # smallmoney + parameters_list[i] = str(param) + return ( + ddbc_sql_const.SQL_VARCHAR.value, + ddbc_sql_const.SQL_C_CHAR.value, + len(parameters_list[i]), + 0, + False, + ) + elif MONEY_MIN <= param <= MONEY_MAX: + # money + parameters_list[i] = str(param) + return ( + ddbc_sql_const.SQL_VARCHAR.value, + ddbc_sql_const.SQL_C_CHAR.value, + len(parameters_list[i]), + 0, + False, + ) + else: + # fallback to generic numeric binding + parameters_list[i] = self._get_numeric_data(param) + return ( + ddbc_sql_const.SQL_NUMERIC.value, + ddbc_sql_const.SQL_C_NUMERIC.value, + parameters_list[i].precision, + parameters_list[i].scale, + False, + ) if isinstance(param, str): if ( diff --git a/mssql_python/pybind/ddbc_bindings.cpp b/mssql_python/pybind/ddbc_bindings.cpp index bbc3a2f5..0fc56fd9 100644 --- a/mssql_python/pybind/ddbc_bindings.cpp +++ b/mssql_python/pybind/ddbc_bindings.cpp @@ -480,9 +480,6 @@ SQLRETURN BindParameters(SQLHANDLE hStmt, const py::list& params, reinterpret_cast(&decimalParam.val), sizeof(decimalParam.val)); dataPtr = static_cast(decimalPtr); - // TODO: Remove these lines - //strLenOrIndPtr = AllocateParamBuffer(paramBuffers); - //*strLenOrIndPtr = sizeof(SQL_NUMERIC_STRUCT); break; } case SQL_C_GUID: { @@ -1913,28 +1910,31 @@ SQLRETURN SQLGetData_wrap(SqlHandlePtr StatementHandle, SQLUSMALLINT colCount, p case SQL_DECIMAL: case SQL_NUMERIC: { SQLCHAR numericStr[MAX_DIGITS_IN_NUMERIC] = {0}; - SQLLEN indicator; + SQLLEN indicator = 0; + ret = SQLGetData_ptr(hStmt, i, SQL_C_CHAR, numericStr, sizeof(numericStr), &indicator); if (SQL_SUCCEEDED(ret)) { - try{ - // Convert numericStr to py::decimal.Decimal and append to row - row.append(py::module_::import("decimal").attr("Decimal")( - std::string(reinterpret_cast(numericStr), indicator))); - } catch (const py::error_already_set& e) { - // If the conversion fails, append None - LOG("Error converting to decimal: {}", e.what()); + if (indicator == SQL_NULL_DATA) { row.append(py::none()); + } else { + try { + std::string s(reinterpret_cast(numericStr)); + auto Decimal = py::module_::import("decimal").attr("Decimal"); + row.append(Decimal(s)); + } catch (const py::error_already_set& e) { + LOG("Error converting to Decimal: {}", e.what()); + row.append(py::none()); + } } - } - else { - LOG("Error retrieving data for column - {}, data type - {}, SQLGetData return " - "code - {}. Returning NULL value instead", + } else { + LOG("Error retrieving data for column - {}, data type - {}, SQLGetData rc - {}", i, dataType, ret); row.append(py::none()); } break; } + case SQL_DOUBLE: case SQL_FLOAT: { SQLDOUBLE doubleValue; diff --git a/tests/test_004_cursor.py b/tests/test_004_cursor.py index 864a42a9..fe5cd4a1 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -6548,6 +6548,225 @@ def test_only_null_and_empty_binary(cursor, db_connection): drop_table_if_exists(cursor, "#pytest_null_empty_binary") db_connection.commit() + +def test_money_smallmoney_insert_fetch(cursor, db_connection): + """Test inserting and retrieving valid MONEY and SMALLMONEY values including boundaries and typical data""" + try: + drop_table_if_exists(cursor, "dbo.money_test") + cursor.execute(""" + CREATE TABLE dbo.money_test ( + id INT IDENTITY PRIMARY KEY, + m MONEY, + sm SMALLMONEY, + d DECIMAL(19,4), + n NUMERIC(10,4) + ) + """) + db_connection.commit() + + # Max values + cursor.execute("INSERT INTO dbo.money_test (m, sm, d, n) VALUES (?, ?, ?, ?)", + (decimal.Decimal("922337203685477.5807"), decimal.Decimal("214748.3647"), + decimal.Decimal("9999999999999.9999"), decimal.Decimal("1234.5678"))) + + # Min values + cursor.execute("INSERT INTO dbo.money_test (m, sm, d, n) VALUES (?, ?, ?, ?)", + (decimal.Decimal("-922337203685477.5808"), decimal.Decimal("-214748.3648"), + decimal.Decimal("-9999999999999.9999"), decimal.Decimal("-1234.5678"))) + + # Typical values + cursor.execute("INSERT INTO dbo.money_test (m, sm, d, n) VALUES (?, ?, ?, ?)", + (decimal.Decimal("1234567.8901"), decimal.Decimal("12345.6789"), + decimal.Decimal("42.4242"), decimal.Decimal("3.1415"))) + + # NULL values + cursor.execute("INSERT INTO dbo.money_test (m, sm, d, n) VALUES (?, ?, ?, ?)", + (None, None, None, None)) + + db_connection.commit() + + cursor.execute("SELECT m, sm, d, n FROM dbo.money_test ORDER BY id") + results = cursor.fetchall() + assert len(results) == 4, f"Expected 4 rows, got {len(results)}" + + expected = [ + (decimal.Decimal("922337203685477.5807"), decimal.Decimal("214748.3647"), + decimal.Decimal("9999999999999.9999"), decimal.Decimal("1234.5678")), + (decimal.Decimal("-922337203685477.5808"), decimal.Decimal("-214748.3648"), + decimal.Decimal("-9999999999999.9999"), decimal.Decimal("-1234.5678")), + (decimal.Decimal("1234567.8901"), decimal.Decimal("12345.6789"), + decimal.Decimal("42.4242"), decimal.Decimal("3.1415")), + (None, None, None, None) + ] + + for i, (row, exp) in enumerate(zip(results, expected)): + for j, (val, exp_val) in enumerate(zip(row, exp), 1): + if exp_val is None: + assert val is None, f"Row {i+1} col{j}: expected None, got {val}" + else: + assert val == exp_val, f"Row {i+1} col{j}: expected {exp_val}, got {val}" + assert isinstance(val, decimal.Decimal), f"Row {i+1} col{j}: expected Decimal, got {type(val)}" + + except Exception as e: + pytest.fail(f"MONEY and SMALLMONEY insert/fetch test failed: {e}") + finally: + drop_table_if_exists(cursor, "dbo.money_test") + db_connection.commit() + + +def test_money_smallmoney_null_handling(cursor, db_connection): + """Test that NULL values for MONEY and SMALLMONEY are stored and retrieved correctly""" + try: + drop_table_if_exists(cursor, "dbo.money_test") + cursor.execute(""" + CREATE TABLE dbo.money_test ( + id INT IDENTITY PRIMARY KEY, + m MONEY, + sm SMALLMONEY + ) + """) + db_connection.commit() + + # Row with both NULLs + cursor.execute("INSERT INTO dbo.money_test (m, sm) VALUES (?, ?)", (None, None)) + + # Row with m filled, sm NULL + cursor.execute("INSERT INTO dbo.money_test (m, sm) VALUES (?, ?)", + (decimal.Decimal("123.4500"), None)) + + # Row with m NULL, sm filled + cursor.execute("INSERT INTO dbo.money_test (m, sm) VALUES (?, ?)", + (None, decimal.Decimal("67.8900"))) + + db_connection.commit() + + cursor.execute("SELECT m, sm FROM dbo.money_test ORDER BY id") + results = cursor.fetchall() + assert len(results) == 3, f"Expected 3 rows, got {len(results)}" + + expected = [ + (None, None), + (decimal.Decimal("123.4500"), None), + (None, decimal.Decimal("67.8900")) + ] + + for i, (row, exp) in enumerate(zip(results, expected)): + for j, (val, exp_val) in enumerate(zip(row, exp), 1): + if exp_val is None: + assert val is None, f"Row {i+1} col{j}: expected None, got {val}" + else: + assert val == exp_val, f"Row {i+1} col{j}: expected {exp_val}, got {val}" + assert isinstance(val, decimal.Decimal), f"Row {i+1} col{j}: expected Decimal, got {type(val)}" + + except Exception as e: + pytest.fail(f"MONEY and SMALLMONEY NULL handling test failed: {e}") + finally: + drop_table_if_exists(cursor, "dbo.money_test") + db_connection.commit() + + +def test_money_smallmoney_roundtrip(cursor, db_connection): + """Test inserting and retrieving MONEY and SMALLMONEY using decimal.Decimal roundtrip""" + try: + drop_table_if_exists(cursor, "dbo.money_test") + cursor.execute(""" + CREATE TABLE dbo.money_test ( + id INT IDENTITY PRIMARY KEY, + m MONEY, + sm SMALLMONEY + ) + """) + db_connection.commit() + + values = (decimal.Decimal("12345.6789"), decimal.Decimal("987.6543")) + cursor.execute("INSERT INTO dbo.money_test (m, sm) VALUES (?, ?)", values) + db_connection.commit() + + cursor.execute("SELECT m, sm FROM dbo.money_test ORDER BY id DESC") + row = cursor.fetchone() + for i, (val, exp_val) in enumerate(zip(row, values), 1): + assert val == exp_val, f"col{i} roundtrip mismatch, got {val}, expected {exp_val}" + assert isinstance(val, decimal.Decimal), f"col{i} should be Decimal, got {type(val)}" + + except Exception as e: + pytest.fail(f"MONEY and SMALLMONEY roundtrip test failed: {e}") + finally: + drop_table_if_exists(cursor, "dbo.money_test") + db_connection.commit() + + +def test_money_smallmoney_boundaries(cursor, db_connection): + """Test boundary values for MONEY and SMALLMONEY types are handled correctly""" + try: + drop_table_if_exists(cursor, "dbo.money_test") + cursor.execute(""" + CREATE TABLE dbo.money_test ( + id INT IDENTITY PRIMARY KEY, + m MONEY, + sm SMALLMONEY + ) + """) + db_connection.commit() + + # Insert max boundary + cursor.execute("INSERT INTO dbo.money_test (m, sm) VALUES (?, ?)", + (decimal.Decimal("922337203685477.5807"), decimal.Decimal("214748.3647"))) + + # Insert min boundary + cursor.execute("INSERT INTO dbo.money_test (m, sm) VALUES (?, ?)", + (decimal.Decimal("-922337203685477.5808"), decimal.Decimal("-214748.3648"))) + + db_connection.commit() + + cursor.execute("SELECT m, sm FROM dbo.money_test ORDER BY id DESC") + results = cursor.fetchall() + expected = [ + (decimal.Decimal("-922337203685477.5808"), decimal.Decimal("-214748.3648")), + (decimal.Decimal("922337203685477.5807"), decimal.Decimal("214748.3647")) + ] + for i, (row, exp_row) in enumerate(zip(results, expected), 1): + for j, (val, exp_val) in enumerate(zip(row, exp_row), 1): + assert val == exp_val, f"Row {i} col{j} mismatch, got {val}, expected {exp_val}" + assert isinstance(val, decimal.Decimal), f"Row {i} col{j} should be Decimal, got {type(val)}" + + except Exception as e: + pytest.fail(f"MONEY and SMALLMONEY boundary values test failed: {e}") + finally: + drop_table_if_exists(cursor, "dbo.money_test") + db_connection.commit() + + +def test_money_smallmoney_invalid_values(cursor, db_connection): + """Test that invalid or out-of-range MONEY and SMALLMONEY values raise errors""" + try: + drop_table_if_exists(cursor, "dbo.money_test") + cursor.execute(""" + CREATE TABLE dbo.money_test ( + id INT IDENTITY PRIMARY KEY, + m MONEY, + sm SMALLMONEY + ) + """) + db_connection.commit() + + # Out of range MONEY + with pytest.raises(Exception): + cursor.execute("INSERT INTO dbo.money_test (m) VALUES (?)", (decimal.Decimal("922337203685477.5808"),)) + + # Out of range SMALLMONEY + with pytest.raises(Exception): + cursor.execute("INSERT INTO dbo.money_test (sm) VALUES (?)", (decimal.Decimal("214748.3648"),)) + + # Invalid string + with pytest.raises(Exception): + cursor.execute("INSERT INTO dbo.money_test (m) VALUES (?)", ("invalid_string",)) + + except Exception as e: + pytest.fail(f"MONEY and SMALLMONEY invalid values test failed: {e}") + finally: + drop_table_if_exists(cursor, "dbo.money_test") + db_connection.commit() + def test_close(db_connection): """Test closing the cursor""" try: