Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,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.
Comment thread
Copilot marked this conversation as resolved.
Outdated
- **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
Expand Down
95 changes: 84 additions & 11 deletions mssql_python/cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking (Low): this non-finite guard is reached too late on the executemany path. _compute_column_type runs first (called at line 2527) and does v_tuple.exponent < 0 / sample_tuple.exponent < 0 (lines 2391/2397) on the Decimal, which raises TypeError: '<' not supported between instances of 'str' and 'int' for a NaN/Infinity (string exponent) before this ValueError is hit. So a NaN in a multi-value Decimal batch surfaces a confusing TypeError (order-dependent; a single-value column does reach this ValueError). test_gh745_batch_decimal_rejects_non_finite calls _batch_decimal_precision_scale directly, so it does not catch this. Consider a finiteness guard in _compute_column_type (not v.is_finite() raising the same ValueError) plus an end-to-end executemany NaN test for both row orders. The underlying comparison is pre-existing.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 763a8dd.

_compute_column_type now rejects non-finite Decimals with is_finite() before any exponent < 0 comparison, raising the same ValueError as _decimal_sql_precision_scale. Added executemany coverage for both row orders (test_gh745_executemany_rejects_non_finite_both_orders / test_gh745_executemany_rejects_nan_both_orders).

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)
Comment thread
Copilot marked this conversation as resolved.
if not found:
return 0, 0
return max(max_int_digits + max_scale, 1), max_scale
Comment thread
Copilot marked this conversation as resolved.

def _compute_column_type(self, column):
"""
Determine representative value and integer min/max for a column.
Expand Down Expand Up @@ -2551,6 +2599,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,
Expand All @@ -2559,6 +2619,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++,
Expand All @@ -2576,9 +2637,21 @@ 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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this uses sql numeric precision as the array's character-buffer width too. precision only counts digits, but the fixed-point string also needs space for the sign, decimal point, leading zero, and terminator. Decimal("1E-38") gets (38, 38) here but formats to 40 characters, so PyStringArrayDataBuffer rejects it before sql server sees it.

I ran the same cases on this head and the exact merge base. Decimal("1E-38") and [-12.34, 56.78] both complete on the base; this head raises RuntimeError: Input string exceeds allowed column size at parameter index 0. the linux and windows jobs show the same 13 decimal failures too.

can we keep sql precision and scale separate from the SQL_C_CHAR buffer stride, sizing the buffer from the longest encoded fixed-point value plus its terminator?

batch_precision, batch_scale = self._batch_decimal_precision_scale(column)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

batch_precision is applied to columnSize without a <=38 check. The sample-level guard in _map_sql_type doesn't cover the whole batch, so a mixed batch (many integer digits in one row, many fractional in another) can produce batch_precision > 38 even when each value is individually storable, and the driver then fails with an opaque message. Consider raising the existing "precision too high" ValueError when batch_precision > 38. Don't clamp, since that would silently truncate.

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

# Correct column size for Decimal columns sent as SQL_VARCHAR (GH-557).
# The sample value's formatted string may be shorter than another
Expand Down
172 changes: 172 additions & 0 deletions tests/test_004_cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -17234,6 +17234,178 @@ 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
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
Expand Down
Loading
Loading