From 13147b226c3ecba48c577fd9630295f51a41dfe6 Mon Sep 17 00:00:00 2001 From: vyrnsynx <153433026+vyrnsynx@users.noreply.github.com> Date: Fri, 4 Sep 2026 04:42:56 +0000 Subject: [PATCH 1/4] FIX: bind executemany money-range Decimals as SQL_NUMERIC executemany auto-detect skipped the money-range VARCHAR shortcut by deriving a batch-wide NUMERIC precision/scale, matching execute() so comparisons against smaller numeric columns no longer overflow. setinputsizes DECIMAL/NUMERIC string binding is unchanged. Fixes #745 --- CHANGELOG.md | 1 + mssql_python/cursor.py | 83 ++++++++++++++++++++++++++---- tests/test_004_cursor.py | 57 ++++++++++++++++++++ tests/test_020_money_smallmoney.py | 65 ++++++++++++++++++++++- 4 files changed, 193 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28e2469db..e19c99926 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ 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. The `setinputsizes` DECIMAL/NUMERIC string path is unchanged. - **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 02915a952..cb91bafd4 100644 --- a/mssql_python/cursor.py +++ b/mssql_python/cursor.py @@ -661,9 +661,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. """ @@ -799,11 +799,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 @@ -1845,8 +1845,7 @@ def execute( # pylint: disable=too-many-locals,too-many-branches,too-many-state for i, param in enumerate(parameters): # decimal_as_numeric=True so an uncovered money-range Decimal here # (setinputsizes shorter than the parameter list) binds as SQL_NUMERIC - # like the native path, not VARCHAR (GH-740). executemany keeps the - # VARCHAR shortcut for its batch string binding (GH-503). + # like the native path, not VARCHAR (GH-740). paraminfo = self._create_parameter_types_list( param, param_info, parameters, i, decimal_as_numeric=True ) @@ -2371,6 +2370,52 @@ 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. + """ + max_scale = 0 + max_int_digits = 0 + found = False + for value in column: + if not isinstance(value, decimal.Decimal): + continue + try: + precision, scale = self._decimal_sql_precision_scale(value) + except ValueError: + continue + 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. @@ -2638,6 +2683,13 @@ 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. + decimal_as_numeric = isinstance(sample_value, decimal.Decimal) + dummy_row = list(sample_row) paraminfo = self._create_parameter_types_list( sample_value, @@ -2646,6 +2698,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++, @@ -2663,7 +2716,15 @@ 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 + # One NUMERIC(precision, scale) must fit every Decimal in the + # batch (GH-745). Sample-only precision/scale is not enough. + batch_precision, batch_scale = self._batch_decimal_precision_scale(column) + if batch_precision > paraminfo.columnSize: + paraminfo.columnSize = batch_precision + if batch_scale > paraminfo.decimalDigits: + paraminfo.decimalDigits = batch_scale + # Ensure columnSize also accommodates the longest string form + # (mixed-sign batches, GH-557). if max_decimal_len > paraminfo.columnSize: paraminfo.columnSize = max_decimal_len diff --git a/tests/test_004_cursor.py b/tests/test_004_cursor.py index a3ddaaa1f..2fa714b74 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -17182,6 +17182,63 @@ 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 + assert pt[0].columnSize >= len("-0.1") + assert pt[0].decimalDigits >= 4 + for val in captured["columnwise_params"][0]: + assert isinstance(val, str) + + 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 f60d37e00..848add1c9 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,63 @@ 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() From 04a6ab75ed4fa3f6c4a12072e721cb2b09c3dff6 Mon Sep 17 00:00:00 2001 From: vyrnsynx <153433026+vyrnsynx@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:08:29 +0000 Subject: [PATCH 2/4] FIX: drop NUMERIC max_decimal_len override and harden batch Decimal binding Address review feedback on the executemany SQL_NUMERIC path: keep columnSize as numeric precision (not string length), raise when batch precision exceeds 38, force NUMERIC only when every non-NULL value is Decimal, and cover the cases with unit tests. --- mssql_python/cursor.py | 28 +++++++--- tests/test_004_cursor.py | 117 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 135 insertions(+), 10 deletions(-) diff --git a/mssql_python/cursor.py b/mssql_python/cursor.py index cb91bafd4..0e50f8751 100644 --- a/mssql_python/cursor.py +++ b/mssql_python/cursor.py @@ -2398,6 +2398,9 @@ def _batch_decimal_precision_scale(self, column) -> Tuple[int, int]: 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 @@ -2405,10 +2408,8 @@ def _batch_decimal_precision_scale(self, column) -> Tuple[int, int]: for value in column: if not isinstance(value, decimal.Decimal): continue - try: - precision, scale = self._decimal_sql_precision_scale(value) - except ValueError: - 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) @@ -2688,7 +2689,12 @@ def executemany( # pylint: disable=too-many-locals,too-many-branches,too-many-s # 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. - decimal_as_numeric = isinstance(sample_value, decimal.Decimal) + # 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( @@ -2718,15 +2724,19 @@ def executemany( # pylint: disable=too-many-locals,too-many-branches,too-many-s paraminfo.paramCType = ddbc_sql_const.SQL_C_CHAR.value # 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 - # Ensure columnSize also accommodates the longest string form - # (mixed-sign batches, GH-557). - if max_decimal_len > paraminfo.columnSize: - paraminfo.columnSize = max_decimal_len # Correct column size for Decimal columns sent as SQL_VARCHAR (GH-557). # The sample value's formatted string may be shorter than another diff --git a/tests/test_004_cursor.py b/tests/test_004_cursor.py index 2fa714b74..3b616a79d 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -17233,12 +17233,127 @@ def fake_sql_execute_many(hstmt, op, col_params, param_types, row_count, enc): assert len(pt) == 1 assert pt[0].paramSQLType == _C.SQL_NUMERIC.value assert pt[0].paramCType == _C.SQL_C_CHAR.value - assert pt[0].columnSize >= len("-0.1") + # columnSize is NUMERIC precision (not formatted-string length). + assert pt[0].columnSize == 9 + assert pt[0].columnSize <= 38 assert pt[0].decimalDigits >= 4 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 must stay <= 38 for near-max Decimals (no max_decimal_len).""" + 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: 1) + # Decimal('1E-38') needs precision=38, scale=38. Formatted string length is > 38, + # so the old max_decimal_len override would wrongly push precision past 38. + data = [(decimal.Decimal("1E-38"),)] + 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 + + +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 From 977654fec2af8233e3bddc11251388f7e94936c8 Mon Sep 17 00:00:00 2001 From: vyrnsynx <153433026+vyrnsynx@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:52:05 +0000 Subject: [PATCH 3/4] FIX: size SQL_C_CHAR buffer separately from NUMERIC precision Keep SQLBindParameter columnSize as digit precision/scale, and size the executemany CHAR array stride from the longest fixed-point encoding (bufferSize) so values like Decimal("1E-38") and mixed-sign short batches are not rejected by the array buffer. --- CHANGELOG.md | 10 ++++- mssql_python/cursor.py | 25 ++++++++++++ mssql_python/pybind/ddbc_bindings.cpp | 58 +++++++++++++++++++-------- mssql_python/pybind/param_detect.hpp | 7 ++++ tests/test_004_cursor.py | 57 ++++++++++++++++++++++++-- tests/test_020_money_smallmoney.py | 23 +++++++++++ 6 files changed, 159 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25c5e46f3..c4a801131 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,7 +69,15 @@ 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. The `setinputsizes` DECIMAL/NUMERIC string path is unchanged. +- **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 is unchanged aside from + the same buffer-width split. - **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 6122ae87b..1e5fde7af 100644 --- a/mssql_python/cursor.py +++ b/mssql_python/cursor.py @@ -2548,12 +2548,30 @@ 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) + # Same SQL_C_CHAR stride vs precision split as auto-detect. + max_encoded = 0 + for row in seq_of_parameters: + value = row[col_index] + if value is None: + continue + try: + if isinstance(value, decimal.Decimal): + encoded = format(value, "f") + else: + encoded = format(decimal.Decimal(str(value)), "f") + max_encoded = max(max_encoded, len(encoded)) + except (decimal.DecimalException, ValueError, TypeError): + # Conversion failures surface later in the Decimal + # conversion loop; keep a safe precision-based floor. + pass + 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 ( @@ -2584,6 +2602,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: @@ -2652,6 +2672,11 @@ def executemany( # pylint: disable=too-many-locals,too-many-branches,too-many-s 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 diff --git a/mssql_python/pybind/ddbc_bindings.cpp b/mssql_python/pybind/ddbc_bindings.cpp index 9908d8422..23439c8aa 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 172a98b51..cb779d716 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 d14ec4e73..f3874e121 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -17289,6 +17289,8 @@ def fake_sql_execute_many(hstmt, op, col_params, param_types, row_count, enc): 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) @@ -17302,7 +17304,7 @@ def test_gh745_batch_decimal_rejects_non_finite(): def test_gh745_executemany_near_max_precision_stays_within_38(monkeypatch): - """NUMERIC columnSize must stay <= 38 for near-max Decimals (no max_decimal_len).""" + """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 @@ -17321,6 +17323,7 @@ def test_gh745_executemany_near_max_precision_stays_within_38(monkeypatch): 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) @@ -17329,13 +17332,61 @@ def fake_sql_execute_many(hstmt, op, col_params, param_types, row_count, enc): 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 the old max_decimal_len override would wrongly push precision past 38. - data = [(decimal.Decimal("1E-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_gh745_executemany_batch_precision_over_38_raises(monkeypatch): diff --git a/tests/test_020_money_smallmoney.py b/tests/test_020_money_smallmoney.py index 848add1c9..89b4d5764 100644 --- a/tests/test_020_money_smallmoney.py +++ b/tests/test_020_money_smallmoney.py @@ -873,3 +873,26 @@ def test_gh745_executemany_mixed_sign_money_range_batch(cursor, db_connection): 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() From ef13ce4a73d748c50ea41bf74be415017dd50d61 Mon Sep 17 00:00:00 2001 From: vyrnsynx <153433026+vyrnsynx@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:43:11 +0000 Subject: [PATCH 4/4] FIX: size setinputsizes DECIMAL buffer via protected conversion Provisional CHAR buffer sizing for setinputsizes DECIMAL/NUMERIC now only measures already-Decimal values. Non-Decimal conversion stays in the protected loop so MemoryError/RuntimeError become sanitized ValueError with row/column details; bufferSize is then widened from that converted fixed-point text. --- CHANGELOG.md | 6 +- mssql_python/cursor.py | 40 +++++++---- tests/test_004_cursor.py | 145 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 172 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4a801131..f4381a384 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,8 +76,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), `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 is unchanged aside from - the same buffer-width split. + 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 1e5fde7af..9ab43f19d 100644 --- a/mssql_python/cursor.py +++ b/mssql_python/cursor.py @@ -2555,22 +2555,17 @@ def executemany( # pylint: disable=too-many-locals,too-many-branches,too-many-s ): column_size = max(1, min(int(column_size) if column_size > 0 else 18, 38)) decimal_digits = min(max(0, decimal_digits), column_size) - # Same SQL_C_CHAR stride vs precision split as auto-detect. + # 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 value is None: - continue - try: - if isinstance(value, decimal.Decimal): - encoded = format(value, "f") - else: - encoded = format(decimal.Decimal(str(value)), "f") - max_encoded = max(max_encoded, len(encoded)) - except (decimal.DecimalException, ValueError, TypeError): - # Conversion failures surface later in the Decimal - # conversion loop; keep a safe precision-based floor. - pass + 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 @@ -2776,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/tests/test_004_cursor.py b/tests/test_004_cursor.py index f3874e121..ffa26c942 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: @@ -17389,6 +17390,142 @@ def fake_sql_execute_many(hstmt, op, col_params, param_types, row_count, enc): 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