Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 @@ -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
Expand Down
83 changes: 72 additions & 11 deletions mssql_python/cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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
Comment on lines +2402 to +2417

def _compute_column_type(self, column):
"""
Determine representative value and integer min/max for a column.
Expand Down Expand Up @@ -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,
Expand All @@ -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++,
Expand All @@ -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

Expand Down
57 changes: 57 additions & 0 deletions tests/test_004_cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 63 additions & 2 deletions tests/test_020_money_smallmoney.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()