diff --git a/CHANGELOG.md b/CHANGELOG.md index f4ebfb81..f4381a38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), before; users should call `cursor.setinputsizes()` to work around this. ### Fixed +- **GH-745:** `executemany` auto-detect binds money-range `Decimal` + values as `SQL_NUMERIC` with a batch-wide precision/scale (still via + `SQL_C_CHAR` string values), so a comparison against a smaller numeric + column no longer overflows. SQL precision/scale stay on `columnSize` / + `decimalDigits`; the CHAR array stride uses a separate `bufferSize` + sized from the longest fixed-point encoding (e.g. `Decimal("1E-38")`), + so near-max precision values are not rejected by the array buffer. + The `setinputsizes` DECIMAL/NUMERIC string path uses the same buffer-width + split; buffer width is derived from text produced by the protected + conversion path so failed conversions still raise a sanitized `ValueError` + with row/column details (no raw MemoryError/RuntimeError leakage). - **GH-740:** A Python `Decimal` whose value falls in the SQL Server MONEY / SMALLMONEY range is now bound as `SQL_NUMERIC` with its own precision and scale on both `execute()` paths (native detection, and the legacy path reached when diff --git a/mssql_python/cursor.py b/mssql_python/cursor.py index 605120bf..9ab43f19 100644 --- a/mssql_python/cursor.py +++ b/mssql_python/cursor.py @@ -666,9 +666,9 @@ def _map_sql_type( # pylint: disable=too-many-arguments,too-many-positional-arg - i: The index of the parameter in the list. - decimal_as_numeric: When True, bind a Decimal as SQL_NUMERIC regardless of value, skipping the MONEY/SMALLMONEY-range VARCHAR shortcut. The execute() - path sets this so a money-range Decimal compared against a numeric column - does not overflow (GH-740). executemany() leaves it False because it - string-binds Decimals for the whole batch (GH-503). + path and executemany() auto-detect path set this so a money-range Decimal + compared against a numeric column does not overflow (GH-740, GH-745). + setinputsizes DECIMAL/NUMERIC still string-binds (GH-503). Returns: - A tuple containing the SQL type, C type, column size, and decimal digits. """ @@ -804,11 +804,11 @@ def _map_sql_type( # pylint: disable=too-many-arguments,too-many-positional-arg f"The maximum precision supported by SQL Server is 38, but got {precision}." ) - # Detect MONEY / SMALLMONEY range. Skipped on the execute() path - # (decimal_as_numeric=True), where a money-range Decimal must bind as - # SQL_NUMERIC so a comparison against a smaller numeric column returns no - # match instead of overflowing (GH-740). executemany keeps the VARCHAR - # shortcut because it string-binds Decimals for the batch (GH-503). + # Detect MONEY / SMALLMONEY range. Skipped when decimal_as_numeric=True + # (execute() and executemany auto-detect), where a money-range Decimal must + # bind as SQL_NUMERIC so a comparison against a smaller numeric column + # returns no match instead of overflowing (GH-740, GH-745). The + # setinputsizes DECIMAL path still string-binds (GH-503). if not decimal_as_numeric and SMALLMONEY_MIN <= param <= SMALLMONEY_MAX: logger.debug("_map_sql_type: DECIMAL -> SMALLMONEY - index=%d", i) # smallmoney @@ -1794,6 +1794,7 @@ def execute( # pylint: disable=too-many-locals,too-many-branches,too-many-state ) else: ret = ddbc_bindings.DDBCSQLExecDirect(self.hstmt, operation) + # Check return code try: @@ -2287,6 +2288,53 @@ def _transpose_rowwise_to_columnwise( return columnwise, row_count + @staticmethod + def _decimal_sql_precision_scale(value: decimal.Decimal) -> Tuple[int, int]: + """Return SQL NUMERIC (precision, scale) for a finite Decimal. + + Matches the precision/scale rules used by _map_sql_type / _get_numeric_data. + """ + decimal_as_tuple = value.as_tuple() + digits_tuple = decimal_as_tuple.digits + num_digits = len(digits_tuple) + exponent = decimal_as_tuple.exponent + if isinstance(exponent, str): + raise ValueError("Cannot bind non-finite Decimal (NaN/Infinity) as SQL NUMERIC") + if exponent >= 0: + precision = num_digits + exponent + scale = 0 + elif (-1 * exponent) <= num_digits: + precision = num_digits + scale = exponent * -1 + else: + precision = exponent * -1 + scale = exponent * -1 + return precision, scale + + def _batch_decimal_precision_scale(self, column) -> Tuple[int, int]: + """Derive one NUMERIC(precision, scale) that fits every Decimal in a column. + + Used by executemany so money-range Decimals can bind as SQL_NUMERIC with a + single batch-wide type (GH-745) without shrinking any row's digits. + + Non-finite Decimals (NaN/Infinity) raise ValueError rather than being + skipped. Callers must enforce SQL Server's precision limit (<= 38). + """ + max_scale = 0 + max_int_digits = 0 + found = False + for value in column: + if not isinstance(value, decimal.Decimal): + continue + # Propagate non-finite errors; do not silently skip them. + precision, scale = self._decimal_sql_precision_scale(value) + found = True + max_scale = max(max_scale, scale) + max_int_digits = max(max_int_digits, precision - scale) + if not found: + return 0, 0 + return max(max_int_digits + max_scale, 1), max_scale + def _compute_column_type(self, column): """ Determine representative value and integer min/max for a column. @@ -2500,12 +2548,25 @@ def executemany( # pylint: disable=too-many-locals,too-many-branches,too-many-s is_dae = True # Sanitize precision/scale for numeric types + numeric_buffer_size = 0 if sql_type in ( ddbc_sql_const.SQL_DECIMAL.value, ddbc_sql_const.SQL_NUMERIC.value, ): column_size = max(1, min(int(column_size) if column_size > 0 else 18, 38)) decimal_digits = min(max(0, decimal_digits), column_size) + # Provisional SQL_C_CHAR stride: size only from values that + # are already Decimal. Do NOT convert non-Decimals here — + # that bypasses the protected conversion loop below and can + # leak MemoryError/RuntimeError (and value-bearing messages). + # After conversion, bufferSize is widened from the produced + # fixed-point text (same path that sanitizes failures). + max_encoded = 0 + for row in seq_of_parameters: + value = row[col_index] + if isinstance(value, decimal.Decimal): + max_encoded = max(max_encoded, len(format(value, "f"))) + numeric_buffer_size = max(max_encoded, column_size + 3, 1) # For binary data columns with mixed content, we need to find max size if sql_type in ( @@ -2536,6 +2597,8 @@ def executemany( # pylint: disable=too-many-locals,too-many-branches,too-many-s paraminfo.columnSize = column_size paraminfo.decimalDigits = decimal_digits paraminfo.isDAE = is_dae + if numeric_buffer_size: + paraminfo.bufferSize = numeric_buffer_size # Ensure we never have SQL_C_DEFAULT (0) for C-type if paraminfo.paramCType == 0: @@ -2551,6 +2614,18 @@ def executemany( # pylint: disable=too-many-locals,too-many-branches,too-many-s ) sample_value, min_val, max_val, max_decimal_len = self._compute_column_type(column) + # GH-745: auto-detected Decimal columns bind as SQL_NUMERIC (skipping + # the money-range VARCHAR shortcut) so a money-range value compared + # against a smaller numeric column does not overflow. executemany still + # string-binds via SQL_C_CHAR below; setinputsizes DECIMAL stays on the + # GH-503 string path above. + # Only force NUMERIC when every non-NULL value in the column is Decimal; + # a heterogeneous column keeps the prior sample-driven path. + non_null_values = [v for v in column if v is not None] + decimal_as_numeric = bool(non_null_values) and all( + isinstance(v, decimal.Decimal) for v in non_null_values + ) + dummy_row = list(sample_row) paraminfo = self._create_parameter_types_list( sample_value, @@ -2559,6 +2634,7 @@ def executemany( # pylint: disable=too-many-locals,too-many-branches,too-many-s col_index, min_val=min_val, max_val=max_val, + decimal_as_numeric=decimal_as_numeric, ) # GH-610: all-NULL columns now pass SQL_UNKNOWN_TYPE to C++, @@ -2576,9 +2652,26 @@ def executemany( # pylint: disable=too-many-locals,too-many-branches,too-many-s ddbc_sql_const.SQL_NUMERIC.value, ): paraminfo.paramCType = ddbc_sql_const.SQL_C_CHAR.value - # Ensure columnSize accommodates the longest string representation - if max_decimal_len > paraminfo.columnSize: - paraminfo.columnSize = max_decimal_len + # One NUMERIC(precision, scale) must fit every Decimal in the + # batch (GH-745). Sample-only precision/scale is not enough. + # columnSize is NUMERIC precision for SQLBindParameter, not a + # string buffer length — do not widen it with max_decimal_len. + batch_precision, batch_scale = self._batch_decimal_precision_scale(column) + if batch_precision > 38: + raise ValueError( + "Precision of the numeric value is too high. " + "The maximum precision supported by SQL Server is 38, " + f"but got {batch_precision}." + ) + if batch_precision > paraminfo.columnSize: + paraminfo.columnSize = batch_precision + if batch_scale > paraminfo.decimalDigits: + paraminfo.decimalDigits = batch_scale + # SQL_C_CHAR array stride is separate from SQL precision. + # Fixed-point strings need room for sign, '.', and a leading + # zero (e.g. Decimal("1E-38") -> 40 chars with precision 38). + # Size from the longest encoded value in the batch. + paraminfo.bufferSize = max(max_decimal_len, 1) # Correct column size for Decimal columns sent as SQL_VARCHAR (GH-557). # The sample value's formatted string may be shorter than another @@ -2678,6 +2771,25 @@ def executemany( # pylint: disable=too-many-locals,too-many-branches,too-many-s raise ValueError(err_msg) from None processed_parameters.append(processed_row) + # Derive/widen SQL_C_CHAR bufferSize from text produced by the protected + # DECIMAL/NUMERIC conversion above. setinputsizes previously sized by + # converting independently (leaking raw MemoryError/RuntimeError); the + # auto-detect path already had a Decimal-only provisional size. + for col_index, ptype in enumerate(parameters_type): + if ptype.paramSQLType not in ( + ddbc_sql_const.SQL_DECIMAL.value, + ddbc_sql_const.SQL_NUMERIC.value, + ): + continue + max_encoded = 0 + for row in processed_parameters: + val = row[col_index] + if isinstance(val, str): + max_encoded = max(max_encoded, len(val)) + if max_encoded: + prior = getattr(ptype, "bufferSize", 0) or 0 + ptype.bufferSize = max(prior, max_encoded, 1) + # Now transpose the processed parameters columnwise_params, row_count = self._transpose_rowwise_to_columnwise(processed_parameters) diff --git a/mssql_python/pybind/ddbc_bindings.cpp b/mssql_python/pybind/ddbc_bindings.cpp index 9908d842..23439c8a 100644 --- a/mssql_python/pybind/ddbc_bindings.cpp +++ b/mssql_python/pybind/ddbc_bindings.cpp @@ -2178,6 +2178,22 @@ SQLRETURN SQLExecute_wrap(const SqlHandlePtr statementHandle, return exec_rc; } +// Array element width for SQL_C_CHAR / SQL_C_WCHAR / SQL_C_BINARY parameter +// arrays. columnSize is the SQLBindParameter ColumnSize (NUMERIC precision for +// SQL_NUMERIC/SQL_DECIMAL). bufferSize, when set, is the encoded-string stride. +// For NUMERIC/DECIMAL string binds with bufferSize==0, reserve precision+3 so +// sign, decimal point, and a leading zero always fit (e.g. "-0." + 38 digits). +static inline SQLULEN ArrayDataBufferWidth(const ParamInfo& info) { + if (info.bufferSize > 0) { + return info.bufferSize; + } + if ((info.paramSQLType == SQL_NUMERIC || info.paramSQLType == SQL_DECIMAL) && + (info.paramCType == SQL_C_CHAR || info.paramCType == SQL_C_WCHAR)) { + return info.columnSize + 3; + } + return info.columnSize; +} + SQLRETURN BindParameterArray(SqlHandle& handle, SQLHANDLE hStmt, const py::list& columnwise_params, std::vector& paramInfos, size_t paramSetSize, std::vector>& paramBuffers, @@ -2249,27 +2265,28 @@ SQLRETURN BindParameterArray(SqlHandle& handle, SQLHANDLE hStmt, const py::list& break; } case SQL_C_WCHAR: { + const SQLULEN dataWidth = ArrayDataBufferWidth(info); LOG("BindParameterArray: Binding SQL_C_WCHAR array - " - "param_index=%d, count=%zu, column_size=%zu", - paramIndex, paramSetSize, info.columnSize); + "param_index=%d, count=%zu, column_size=%zu, buffer_width=%zu", + paramIndex, paramSetSize, info.columnSize, dataWidth); SQLWCHAR* wcharArray = AllocateParamBufferArray( - tempBuffers, paramSetSize * (info.columnSize + 1)); + tempBuffers, paramSetSize * (dataWidth + 1)); strLenOrIndArray = AllocateParamBufferArray(tempBuffers, paramSetSize); for (size_t i = 0; i < paramSetSize; ++i) { if (columnValues[i].is_none()) { strLenOrIndArray[i] = SQL_NULL_DATA; - std::memset(wcharArray + i * (info.columnSize + 1), 0, - (info.columnSize + 1) * sizeof(SQLWCHAR)); + std::memset(wcharArray + i * (dataWidth + 1), 0, + (dataWidth + 1) * sizeof(SQLWCHAR)); } else { std::u16string wstr = columnValues[i].cast(); // u16string is already UTF-16, so the // original check is sufficient - if (wstr.length() > info.columnSize) { + if (wstr.length() > dataWidth) { ThrowStdException("Input string exceeds allowed column size " "at parameter index " + std::to_string(paramIndex)); } - std::memcpy(wcharArray + i * (info.columnSize + 1), wstr.c_str(), + std::memcpy(wcharArray + i * (dataWidth + 1), wstr.c_str(), (wstr.length() + 1) * sizeof(SQLWCHAR)); strLenOrIndArray[i] = SQL_NTS; } @@ -2278,7 +2295,7 @@ SQLRETURN BindParameterArray(SqlHandle& handle, SQLHANDLE hStmt, const py::list& "param_index=%d", paramIndex); dataPtr = wcharArray; - bufferLength = (info.columnSize + 1) * sizeof(SQLWCHAR); + bufferLength = (dataWidth + 1) * sizeof(SQLWCHAR); break; } case SQL_C_TINYINT: @@ -2346,17 +2363,23 @@ SQLRETURN BindParameterArray(SqlHandle& handle, SQLHANDLE hStmt, const py::list& } case SQL_C_CHAR: case SQL_C_BINARY: { + // dataWidth is the per-row buffer stride. For SQL_NUMERIC / + // SQL_DECIMAL + SQL_C_CHAR, columnSize stays as SQL precision + // for SQLBindParameter; buffer width comes from bufferSize + // (longest fixed-point encoding) or precision+3 fallback. + const SQLULEN dataWidth = ArrayDataBufferWidth(info); LOG("BindParameterArray: Binding SQL_C_CHAR/BINARY array - " - "param_index=%d, count=%zu, column_size=%zu, encoding='%s'", - paramIndex, paramSetSize, info.columnSize, charEncoding.c_str()); + "param_index=%d, count=%zu, column_size=%zu, buffer_width=%zu, " + "encoding='%s'", + paramIndex, paramSetSize, info.columnSize, dataWidth, + charEncoding.c_str()); char* charArray = AllocateParamBufferArray( - tempBuffers, paramSetSize * (info.columnSize + 1)); + tempBuffers, paramSetSize * (dataWidth + 1)); strLenOrIndArray = AllocateParamBufferArray(tempBuffers, paramSetSize); for (size_t i = 0; i < paramSetSize; ++i) { if (columnValues[i].is_none()) { strLenOrIndArray[i] = SQL_NULL_DATA; - std::memset(charArray + i * (info.columnSize + 1), 0, - info.columnSize + 1); + std::memset(charArray + i * (dataWidth + 1), 0, dataWidth + 1); } else { std::string encodedStr; @@ -2386,15 +2409,15 @@ SQLRETURN BindParameterArray(SqlHandle& handle, SQLHANDLE hStmt, const py::list& encodedStr = columnValues[i].cast(); } - if (encodedStr.size() > info.columnSize) { + if (encodedStr.size() > dataWidth) { LOG("BindParameterArray: String/binary too " "long - param_index=%d, row=%zu, size=%zu, " "max=%zu", - paramIndex, i, encodedStr.size(), info.columnSize); + paramIndex, i, encodedStr.size(), dataWidth); ThrowStdException("Input exceeds column size at index " + std::to_string(i)); } - std::memcpy(charArray + i * (info.columnSize + 1), encodedStr.c_str(), + std::memcpy(charArray + i * (dataWidth + 1), encodedStr.c_str(), encodedStr.size()); strLenOrIndArray[i] = static_cast(encodedStr.size()); } @@ -2403,7 +2426,7 @@ SQLRETURN BindParameterArray(SqlHandle& handle, SQLHANDLE hStmt, const py::list& "param_index=%d", paramIndex); dataPtr = charArray; - bufferLength = info.columnSize + 1; + bufferLength = static_cast(dataWidth + 1); break; } case SQL_C_BIT: { @@ -6006,6 +6029,7 @@ PYBIND11_MODULE(ddbc_bindings, m) { .def_readwrite("paramCType", &ParamInfo::paramCType) .def_readwrite("paramSQLType", &ParamInfo::paramSQLType) .def_readwrite("columnSize", &ParamInfo::columnSize) + .def_readwrite("bufferSize", &ParamInfo::bufferSize) .def_readwrite("decimalDigits", &ParamInfo::decimalDigits) .def_readwrite("strLenOrInd", &ParamInfo::strLenOrInd) .def_property( diff --git a/mssql_python/pybind/param_detect.hpp b/mssql_python/pybind/param_detect.hpp index 172a98b5..cb779d71 100644 --- a/mssql_python/pybind/param_detect.hpp +++ b/mssql_python/pybind/param_detect.hpp @@ -51,6 +51,13 @@ struct ParamInfo { SQLSMALLINT paramCType = SQL_C_DEFAULT; SQLSMALLINT paramSQLType = SQL_UNKNOWN_TYPE; SQLULEN columnSize = 0; + // Character/binary array element width for BindParameterArray (bytes for + // SQL_C_CHAR/BINARY, code units for SQL_C_WCHAR). Distinct from columnSize: + // for SQL_NUMERIC/SQL_DECIMAL, columnSize is SQL precision (digit count) + // while the SQL_C_CHAR buffer must also fit sign, decimal point, leading + // zero, and the encoded digits (e.g. Decimal("1E-38") -> 40 chars). + // 0 means "derive from columnSize" (with a NUMERIC/DECIMAL fallback). + SQLULEN bufferSize = 0; SQLSMALLINT decimalDigits = 0; SQLLEN strLenOrInd = 0; // Required for DAE bool isDAE = false; // Indicates if we need to stream diff --git a/tests/test_004_cursor.py b/tests/test_004_cursor.py index 219d4833..ffa26c94 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -10558,20 +10558,21 @@ def test_setinputsizes_sql_decimal_unconvertible_value(db_connection): def test_setinputsizes_sql_decimal_str_raises_no_leak(db_connection): - """A parameter whose str() raises must not leak the exception text (GH-503). + """A parameter whose str() raises RuntimeError must not leak the text (GH-503). Exception chaining (raise ... from e) can surface a value-bearing cause through __cause__ and formatted tracebacks. For a value whose str() raises, the chain must be suppressed so the metadata-only guarantee holds across - tracebacks and APM/log shippers, not just str(exc). + tracebacks and APM/log shippers, not just str(exc). The sizing pass must + not convert independently, or a raw RuntimeError escapes sanitization. """ cursor = db_connection.cursor() - secret = "secret-987-65-4321" + secret = "synthetic-private-parameter" class ExplodingStr: def __str__(self): - raise ValueError(secret) + raise RuntimeError(secret) cursor.execute("DROP TABLE IF EXISTS #test_sis_dec_explode") try: @@ -17234,6 +17235,365 @@ def test_map_sql_type_decimal_in_money_returns_varchar(): assert c_type == _C.SQL_C_CHAR.value +def test_gh745_batch_decimal_precision_scale_covers_all_rows(): + """_batch_decimal_precision_scale fits every Decimal in the column.""" + cur = _make_bare_cursor() + column = [ + decimal.Decimal("1.0"), + decimal.Decimal("12345.6789"), + decimal.Decimal("-0.1"), + ] + precision, scale = cur._batch_decimal_precision_scale(column) + assert scale >= 4 + assert precision >= 9 + + +def test_gh745_executemany_money_range_binds_as_numeric(monkeypatch): + """executemany auto-detect binds money-range Decimals as SQL_NUMERIC (GH-745).""" + from unittest.mock import MagicMock + from mssql_python import ddbc_bindings + from mssql_python.cursor import Cursor + + cur = Cursor.__new__(Cursor) + cur._inputsizes = None + cur._timeout = 0 + cur.closed = False + cur.hstmt = MagicMock() + cur.messages = [] + cur.is_stmt_prepared = [False] + cur._connection = MagicMock() + cur._connection._encoding = "utf-8" + cur._connection._conn = MagicMock() + captured = {} + + def fake_sql_execute_many(hstmt, op, col_params, param_types, row_count, enc): + captured["parameters_type"] = param_types + captured["columnwise_params"] = col_params + return 0 + + monkeypatch.setattr(cur, "_check_closed", lambda: None) + monkeypatch.setattr(cur, "_reset_cursor", lambda: None) + monkeypatch.setattr(ddbc_bindings, "SQLExecuteMany", fake_sql_execute_many) + monkeypatch.setattr(ddbc_bindings, "DDBCSQLGetAllDiagRecords", lambda h: []) + monkeypatch.setattr(ddbc_bindings, "DDBCSQLRowCount", lambda h: 2) + data = [ + (decimal.Decimal("12.34"),), + (decimal.Decimal("12345.6789"),), + (decimal.Decimal("-0.1"),), + ] + cur.executemany("UPDATE t SET x = 1 WHERE v = ?", data) + pt = captured["parameters_type"] + assert len(pt) == 1 + assert pt[0].paramSQLType == _C.SQL_NUMERIC.value + assert pt[0].paramCType == _C.SQL_C_CHAR.value + # columnSize is NUMERIC precision (not formatted-string length). + assert pt[0].columnSize == 9 + assert pt[0].columnSize <= 38 + assert pt[0].decimalDigits >= 4 + longest = max(len(v) for v in captured["columnwise_params"][0]) + assert pt[0].bufferSize >= longest + for val in captured["columnwise_params"][0]: + assert isinstance(val, str) + + +def test_gh745_batch_decimal_rejects_non_finite(): + """_batch_decimal_precision_scale must not silently skip NaN/Infinity.""" + cur = _make_bare_cursor() + column = [decimal.Decimal("1.0"), decimal.Decimal("NaN")] + with pytest.raises(ValueError, match="non-finite"): + cur._batch_decimal_precision_scale(column) + + +def test_gh745_executemany_near_max_precision_stays_within_38(monkeypatch): + """NUMERIC columnSize stays <= 38; CHAR bufferWidth fits Decimal("1E-38").""" + from unittest.mock import MagicMock + from mssql_python import ddbc_bindings + from mssql_python.cursor import Cursor + + cur = Cursor.__new__(Cursor) + cur._inputsizes = None + cur._timeout = 0 + cur.closed = False + cur.hstmt = MagicMock() + cur.messages = [] + cur.is_stmt_prepared = [False] + cur._connection = MagicMock() + cur._connection._encoding = "utf-8" + cur._connection._conn = MagicMock() + captured = {} + + def fake_sql_execute_many(hstmt, op, col_params, param_types, row_count, enc): + captured["parameters_type"] = param_types + captured["columnwise_params"] = col_params + return 0 + + monkeypatch.setattr(cur, "_check_closed", lambda: None) + monkeypatch.setattr(cur, "_reset_cursor", lambda: None) + monkeypatch.setattr(ddbc_bindings, "SQLExecuteMany", fake_sql_execute_many) + monkeypatch.setattr(ddbc_bindings, "DDBCSQLGetAllDiagRecords", lambda h: []) + monkeypatch.setattr(ddbc_bindings, "DDBCSQLRowCount", lambda h: 1) + # Decimal('1E-38') needs precision=38, scale=38. Formatted string length is > 38, + # so columnSize must stay at precision while bufferSize covers the encoding. + tiny = decimal.Decimal("1E-38") + encoded = format(tiny, "f") + data = [(tiny,)] + cur.executemany("UPDATE t SET x = 1 WHERE v = ?", data) + pt = captured["parameters_type"][0] + assert pt.paramSQLType == _C.SQL_NUMERIC.value + assert pt.columnSize == 38 + assert pt.decimalDigits == 38 + assert len(encoded) == 40 + assert pt.bufferSize >= len(encoded) + assert captured["columnwise_params"][0][0] == encoded + + +def test_gh745_executemany_buffer_fits_mixed_sign_short_precision(monkeypatch): + """Mixed-sign Decimals must fit CHAR buffer even when precision is small. + + e.g. [-12.34, 56.78] -> NUMERIC(4,2) but "-12.34" is 6 characters. + """ + from unittest.mock import MagicMock + from mssql_python import ddbc_bindings + from mssql_python.cursor import Cursor + + cur = Cursor.__new__(Cursor) + cur._inputsizes = None + cur._timeout = 0 + cur.closed = False + cur.hstmt = MagicMock() + cur.messages = [] + cur.is_stmt_prepared = [False] + cur._connection = MagicMock() + cur._connection._encoding = "utf-8" + cur._connection._conn = MagicMock() + captured = {} + + def fake_sql_execute_many(hstmt, op, col_params, param_types, row_count, enc): + captured["parameters_type"] = param_types + captured["columnwise_params"] = col_params + return 0 + + monkeypatch.setattr(cur, "_check_closed", lambda: None) + monkeypatch.setattr(cur, "_reset_cursor", lambda: None) + monkeypatch.setattr(ddbc_bindings, "SQLExecuteMany", fake_sql_execute_many) + monkeypatch.setattr(ddbc_bindings, "DDBCSQLGetAllDiagRecords", lambda h: []) + monkeypatch.setattr(ddbc_bindings, "DDBCSQLRowCount", lambda h: 2) + data = [(decimal.Decimal("-12.34"),), (decimal.Decimal("56.78"),)] + cur.executemany("UPDATE t SET x = 1 WHERE v = ?", data) + pt = captured["parameters_type"][0] + encoded = [format(decimal.Decimal("-12.34"), "f"), format(decimal.Decimal("56.78"), "f")] + assert pt.paramSQLType == _C.SQL_NUMERIC.value + assert pt.columnSize == 4 + assert pt.decimalDigits == 2 + assert pt.bufferSize >= max(len(s) for s in encoded) + assert pt.bufferSize > pt.columnSize + assert captured["columnwise_params"][0] == encoded + + +def test_setinputsizes_sql_decimal_memoryerror_no_leak_unit(monkeypatch): + """Huge scientific string must raise sanitized ValueError, not MemoryError.""" + from unittest.mock import MagicMock + from mssql_python import ddbc_bindings + from mssql_python.cursor import Cursor + + cur = Cursor.__new__(Cursor) + cur._timeout = 0 + cur.closed = False + cur.hstmt = MagicMock() + cur.messages = [] + cur.is_stmt_prepared = [False] + cur._connection = MagicMock() + cur._connection._encoding = "utf-8" + cur._connection._conn = MagicMock() + + monkeypatch.setattr(cur, "_check_closed", lambda: None) + monkeypatch.setattr(cur, "_reset_cursor", lambda: None) + monkeypatch.setattr(ddbc_bindings, "SQLExecuteMany", lambda *a, **k: 0) + monkeypatch.setattr(ddbc_bindings, "DDBCSQLGetAllDiagRecords", lambda h: []) + monkeypatch.setattr(ddbc_bindings, "DDBCSQLRowCount", lambda h: 0) + + sensitive_value = "1e999999999999999999" + cur.setinputsizes([(mssql_python.SQL_DECIMAL, 18, 2)]) + with pytest.raises(ValueError) as exc_info: + cur.executemany("INSERT INTO t VALUES (?)", [(sensitive_value,)]) + + message = str(exc_info.value) + assert "Failed to convert parameter" in message + assert "row 0" in message + assert "column 0" in message + assert exc_info.value.__cause__ is None + assert sensitive_value not in message + formatted = "".join( + traceback.format_exception( + type(exc_info.value), exc_info.value, exc_info.value.__traceback__ + ) + ) + assert sensitive_value not in formatted + + +def test_setinputsizes_sql_decimal_runtimeerror_no_leak_unit(monkeypatch): + """str() raising RuntimeError must become sanitized ValueError (no marker leak).""" + from unittest.mock import MagicMock + from mssql_python import ddbc_bindings + from mssql_python.cursor import Cursor + + cur = Cursor.__new__(Cursor) + cur._timeout = 0 + cur.closed = False + cur.hstmt = MagicMock() + cur.messages = [] + cur.is_stmt_prepared = [False] + cur._connection = MagicMock() + cur._connection._encoding = "utf-8" + cur._connection._conn = MagicMock() + + monkeypatch.setattr(cur, "_check_closed", lambda: None) + monkeypatch.setattr(cur, "_reset_cursor", lambda: None) + monkeypatch.setattr(ddbc_bindings, "SQLExecuteMany", lambda *a, **k: 0) + monkeypatch.setattr(ddbc_bindings, "DDBCSQLGetAllDiagRecords", lambda h: []) + monkeypatch.setattr(ddbc_bindings, "DDBCSQLRowCount", lambda h: 0) + + marker = "synthetic-private-parameter" + + class ExplodingStr: + def __str__(self): + raise RuntimeError(marker) + + cur.setinputsizes([(mssql_python.SQL_DECIMAL, 18, 2)]) + with pytest.raises(ValueError) as exc_info: + cur.executemany("INSERT INTO t VALUES (?)", [(ExplodingStr(),)]) + + assert marker not in str(exc_info.value) + assert exc_info.value.__cause__ is None + assert "row 0" in str(exc_info.value) + assert "column 0" in str(exc_info.value) + formatted = "".join( + traceback.format_exception( + type(exc_info.value), exc_info.value, exc_info.value.__traceback__ + ) + ) + assert marker not in formatted + + +def test_setinputsizes_sql_decimal_buffer_from_protected_conversion(monkeypatch): + """setinputsizes DECIMAL bufferSize comes from protected conversion text. + + Provisional sizing must not convert non-Decimals (that leaked MemoryError / + RuntimeError). After the protected loop, bufferSize must still fit + Decimal("1E-38") and string inputs like "1E-38". + """ + from unittest.mock import MagicMock + from mssql_python import ddbc_bindings + from mssql_python.cursor import Cursor + + def _run(data): + cur = Cursor.__new__(Cursor) + cur._timeout = 0 + cur.closed = False + cur.hstmt = MagicMock() + cur.messages = [] + cur.is_stmt_prepared = [False] + cur._connection = MagicMock() + cur._connection._encoding = "utf-8" + cur._connection._conn = MagicMock() + captured = {} + + def fake_sql_execute_many(hstmt, op, col_params, param_types, row_count, enc): + captured["parameters_type"] = param_types + captured["columnwise_params"] = col_params + return 0 + + monkeypatch.setattr(cur, "_check_closed", lambda: None) + monkeypatch.setattr(cur, "_reset_cursor", lambda: None) + monkeypatch.setattr(ddbc_bindings, "SQLExecuteMany", fake_sql_execute_many) + monkeypatch.setattr(ddbc_bindings, "DDBCSQLGetAllDiagRecords", lambda h: []) + monkeypatch.setattr(ddbc_bindings, "DDBCSQLRowCount", lambda h: len(data)) + cur.setinputsizes([(mssql_python.SQL_DECIMAL, 38, 38)]) + cur.executemany("INSERT INTO t VALUES (?)", data) + return captured + + tiny = decimal.Decimal("1E-38") + encoded = format(tiny, "f") + assert len(encoded) == 40 + + for payload in ([(tiny,)], [("1E-38",)]): + captured = _run(payload) + pt = captured["parameters_type"][0] + assert pt.paramSQLType == _C.SQL_DECIMAL.value + assert pt.columnSize == 38 + assert pt.decimalDigits == 38 + assert pt.bufferSize >= len(encoded) + assert captured["columnwise_params"][0][0] == encoded + + +def test_gh745_executemany_batch_precision_over_38_raises(monkeypatch): + """Mixed batch whose combined precision exceeds 38 must raise ValueError.""" + from unittest.mock import MagicMock + from mssql_python import ddbc_bindings + from mssql_python.cursor import Cursor + + cur = Cursor.__new__(Cursor) + cur._inputsizes = None + cur._timeout = 0 + cur.closed = False + cur.hstmt = MagicMock() + cur.messages = [] + cur.is_stmt_prepared = [False] + cur._connection = MagicMock() + cur._connection._encoding = "utf-8" + cur._connection._conn = MagicMock() + + monkeypatch.setattr(cur, "_check_closed", lambda: None) + monkeypatch.setattr(cur, "_reset_cursor", lambda: None) + monkeypatch.setattr(ddbc_bindings, "SQLExecuteMany", lambda *a, **k: 0) + monkeypatch.setattr(ddbc_bindings, "DDBCSQLGetAllDiagRecords", lambda h: []) + monkeypatch.setattr(ddbc_bindings, "DDBCSQLRowCount", lambda h: 0) + # 20 integer digits + 20 fractional digits across rows => batch precision 40. + data = [ + (decimal.Decimal("1" * 20),), + (decimal.Decimal("0." + ("1" * 20)),), + ] + with pytest.raises(ValueError, match="maximum precision supported by SQL Server is 38"): + cur.executemany("UPDATE t SET x = 1 WHERE v = ?", data) + + +def test_gh745_executemany_heterogeneous_column_skips_numeric_force(monkeypatch): + """A Decimal sample plus a non-Decimal value must not force the NUMERIC path.""" + from unittest.mock import MagicMock + from mssql_python import ddbc_bindings + from mssql_python.cursor import Cursor + + cur = Cursor.__new__(Cursor) + cur._inputsizes = None + cur._timeout = 0 + cur.closed = False + cur.hstmt = MagicMock() + cur.messages = [] + cur.is_stmt_prepared = [False] + cur._connection = MagicMock() + cur._connection._encoding = "utf-8" + cur._connection._conn = MagicMock() + captured = {} + + def fake_sql_execute_many(hstmt, op, col_params, param_types, row_count, enc): + captured["parameters_type"] = param_types + return 0 + + monkeypatch.setattr(cur, "_check_closed", lambda: None) + monkeypatch.setattr(cur, "_reset_cursor", lambda: None) + monkeypatch.setattr(ddbc_bindings, "SQLExecuteMany", fake_sql_execute_many) + monkeypatch.setattr(ddbc_bindings, "DDBCSQLGetAllDiagRecords", lambda h: []) + monkeypatch.setattr(ddbc_bindings, "DDBCSQLRowCount", lambda h: 2) + data = [ + (decimal.Decimal("12.34"),), + ("not-a-decimal",), + ] + cur.executemany("UPDATE t SET x = 1 WHERE v = ?", data) + pt = captured["parameters_type"][0] + # Sample is Decimal but column is heterogeneous — stay off the forced NUMERIC path. + assert pt.paramSQLType != _C.SQL_NUMERIC.value + + def test_executemany_numeric_override_needed(): """The executemany auto-detection path must override SQL_C_NUMERIC to SQL_C_CHAR (GH-609).""" from mssql_python import ddbc_bindings diff --git a/tests/test_020_money_smallmoney.py b/tests/test_020_money_smallmoney.py index f60d37e0..89b4d576 100644 --- a/tests/test_020_money_smallmoney.py +++ b/tests/test_020_money_smallmoney.py @@ -8,8 +8,9 @@ SQL_NUMERIC using its own precision and scale, regardless of value. Binding no longer depends on whether the value falls in the MONEY/SMALLMONEY range, so an in-range value compared against a smaller numeric column returns no match instead of a varchar->numeric -overflow (GH-740). executemany still string-binds Decimals (SQL_VARCHAR) to preserve -scale-38 precision (GH-503), so that path is unchanged here. +overflow (GH-740). executemany auto-detect likewise binds Decimals as SQL_NUMERIC with +a batch-wide precision/scale and SQL_C_CHAR string values (GH-745); setinputsizes +DECIMAL/NUMERIC still string-binds for fixed precision (GH-503). """ import pytest @@ -812,3 +813,86 @@ def test_gh740_signed_zero_normalizes(cursor, db_connection): finally: drop_table_if_exists(cursor, table_name) db_connection.commit() + + +# ============================================================================= +# GH-745: executemany money-range Decimal must bind as SQL_NUMERIC, not VARCHAR +# ============================================================================= + + +def test_gh745_executemany_in_range_decimal_numeric_comparison_no_overflow(cursor, db_connection): + """executemany must not overflow money-range Decimals against a smaller numeric. + + Before the fix, executemany still used the MONEY-range VARCHAR shortcut, so + SQL Server did a varchar->numeric conversion that overflowed instead of simply + not matching (the execute() path was fixed in GH-740 / #742). + """ + table_name = "#pytest_gh745_cmp" + try: + drop_table_if_exists(cursor, table_name) + cursor.execute(f"CREATE TABLE {table_name} (v numeric(5,2))") # max 999.99 + cursor.execute(f"INSERT INTO {table_name} VALUES (?)", [Decimal("12.34")]) + db_connection.commit() + + # Comparison via executemany is an unnatural shape, but it is the path that + # still carried the VARCHAR shortcut. UPDATE ... WHERE keeps the binding. + cursor.executemany( + f"UPDATE {table_name} SET v = v WHERE v = ?", + [(Decimal("12345.6789"),), (Decimal("300000.00"),)], + ) + cursor.execute(f"SELECT COUNT(*) FROM {table_name}") + assert cursor.fetchone()[0] == 1 + + cursor.executemany( + f"UPDATE {table_name} SET v = v WHERE v = ?", + [(Decimal("12.34"),)], + ) + cursor.execute(f"SELECT COUNT(*) FROM {table_name} WHERE v = ?", [Decimal("12.34")]) + assert cursor.fetchone()[0] == 1 + finally: + drop_table_if_exists(cursor, table_name) + db_connection.commit() + + +def test_gh745_executemany_mixed_sign_money_range_batch(cursor, db_connection): + """Mixed-sign money-range Decimals still insert through executemany (GH-557).""" + table_name = "#pytest_gh745_sign" + try: + drop_table_if_exists(cursor, table_name) + cursor.execute(f"CREATE TABLE {table_name} (v DECIMAL(28, 14))") + data = [ + (Decimal("1.0"),), + (Decimal("-0.1"),), + (Decimal("100.5"),), + (Decimal("-999.99"),), + ] + cursor.executemany(f"INSERT INTO {table_name} VALUES (?)", data) + db_connection.commit() + cursor.execute(f"SELECT COUNT(*) FROM {table_name}") + assert cursor.fetchone()[0] == 4 + finally: + drop_table_if_exists(cursor, table_name) + db_connection.commit() + + +def test_gh745_executemany_tiny_scale38_roundtrip(cursor, db_connection): + """executemany must accept Decimal("1E-38") into numeric(38,38). + + SQL precision stays 38; the SQL_C_CHAR array buffer must be wider than + precision because format(Decimal("1E-38"), "f") is 40 characters. + """ + table_name = "#pytest_gh745_tiny" + value = Decimal("1E-38") + try: + drop_table_if_exists(cursor, table_name) + cursor.execute(f"CREATE TABLE {table_name} (v numeric(38,38))") + cursor.executemany(f"INSERT INTO {table_name} VALUES (?)", [(value,), (Decimal("-1E-38"),)]) + db_connection.commit() + + cursor.execute(f"SELECT v FROM {table_name} ORDER BY v") + rows = [r[0] for r in cursor.fetchall()] + assert rows[0].as_tuple() == Decimal("-1E-38").as_tuple() + assert rows[1].as_tuple() == value.as_tuple() + finally: + drop_table_if_exists(cursor, table_name) + db_connection.commit()