Skip to content

Commit 93ef1bd

Browse files
committed
GH-745: bind money-range Decimal batches as SQL_NUMERIC in executemany
1 parent f4b8f37 commit 93ef1bd

3 files changed

Lines changed: 117 additions & 3 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
8585
`money`/`varchar` in data-type precedence, `WHERE money_or_varchar_col = ?` can
8686
add a `CONVERT_IMPLICIT` on the column side that turns an index seek into a scan.
8787
`executemany` intentionally keeps its batch `VARCHAR` string binding (GH-503);
88-
the remaining money-range case there is tracked in #745.
88+
the remaining money-range case there is tracked in #745 (fixed below).
89+
- **GH-745:** A `Decimal` batch in the MONEY / SMALLMONEY range passed to
90+
`executemany()` is now re-declared as `SQL_NUMERIC` (with the batch-wide maximum
91+
scale) instead of keeping the sample row's `VARCHAR` money shortcut, mirroring
92+
the GH-740 fix on the `execute()` paths. Previously such a batch compared
93+
against a smaller `numeric`/`decimal` column (`WHERE v = ?`) made SQL Server
94+
convert each row's `varchar`→`numeric` and raise an arithmetic overflow. The
95+
batch still string-binds (GH-503), and `decimalDigits` uses the max scale across
96+
the whole batch so mixed-scale rows all fit the declared type (a sample-only
97+
switch would regress the GH-557 mixed-sign sizing). Only pure-Decimal columns
98+
fully inside the money range are affected; wider values, mixed-type columns, and
99+
`setinputsizes()`-typed columns are untouched.
89100
- **GH-725:** The `timeout` parameter of `connect()` / `Connection(...)` now
90101
correctly sets the **login (connection-attempt) timeout**
91102
(`SQL_ATTR_LOGIN_TIMEOUT`), matching pyodbc and its own docstring. Previously

‎mssql_python/cursor.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2371,6 +2371,38 @@ def _compute_column_type(self, column):
23712371

23722372
return sample_value, None, None, max_decimal_formatted_len
23732373

2374+
def _apply_money_batch_declared_type(self, paraminfo, column):
2375+
"""GH-745: re-declare a money-range Decimal column as SQL_NUMERIC.
2376+
2377+
The auto-detection sample path takes the VARCHAR money shortcut
2378+
(see _map_sql_type), but a money-range Decimal compared against a
2379+
smaller numeric column overflows on the server side when bound as
2380+
VARCHAR - the same bug GH-740 fixed on the execute() paths. Declare
2381+
SQL_NUMERIC instead; the existing override below keeps the
2382+
SQL_C_CHAR string binding (GH-503), and decimalDigits uses the
2383+
batch-wide max scale so every row fits the declared type (a naive
2384+
sample-only switch regresses mixed-scale batches, GH-557 shape).
2385+
2386+
Returns True if the declaration was changed.
2387+
"""
2388+
if paraminfo.paramSQLType != ddbc_sql_const.SQL_VARCHAR.value:
2389+
return False
2390+
non_nulls = [v for v in column if v is not None]
2391+
if not non_nulls or not all(isinstance(v, decimal.Decimal) for v in non_nulls):
2392+
return False
2393+
if not all(SMALLMONEY_MIN <= v <= MONEY_MAX for v in non_nulls):
2394+
return False
2395+
paraminfo.paramSQLType = ddbc_sql_const.SQL_NUMERIC.value
2396+
paraminfo.decimalDigits = max(
2397+
(-v.as_tuple().exponent) if v.as_tuple().exponent < 0 else 0 for v in non_nulls
2398+
)
2399+
logger.debug(
2400+
"executemany: money-range batch re-declared as SQL_NUMERIC, "
2401+
"decimalDigits=%d (GH-745)",
2402+
paraminfo.decimalDigits,
2403+
)
2404+
return True
2405+
23742406
def executemany( # pylint: disable=too-many-locals,too-many-branches,too-many-statements
23752407
self,
23762408
operation: str,
@@ -2561,6 +2593,13 @@ def executemany( # pylint: disable=too-many-locals,too-many-branches,too-many-s
25612593
max_val=max_val,
25622594
)
25632595

2596+
# GH-745: a money-range Decimal batch declared as SQL_VARCHAR
2597+
# overflows on the server when compared against a smaller
2598+
# numeric column (the executemany remnant of GH-740). Re-declare
2599+
# as SQL_NUMERIC with batch-wide scale; string binding (GH-503)
2600+
# is preserved by the override below.
2601+
self._apply_money_batch_declared_type(paraminfo, column)
2602+
25642603
# GH-610: all-NULL columns now pass SQL_UNKNOWN_TYPE to C++,
25652604
# where BindParameterArray resolves the correct type via the
25662605
# SQLDescribeParam cache. The previous SQL_VARCHAR hardcoded

‎tests/test_020_money_smallmoney.py‎

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@
88
SQL_NUMERIC using its own precision and scale, regardless of value. Binding no longer
99
depends on whether the value falls in the MONEY/SMALLMONEY range, so an in-range value
1010
compared against a smaller numeric column returns no match instead of a varchar->numeric
11-
overflow (GH-740). executemany still string-binds Decimals (SQL_VARCHAR) to preserve
12-
scale-38 precision (GH-503), so that path is unchanged here.
11+
overflow (GH-740). executemany string-binds Decimals to preserve scale-38 precision
12+
(GH-503); a money-range batch is additionally re-declared as SQL_NUMERIC with batch-wide
13+
scale so the server does not overflow when coercing the strings (GH-745).
1314
"""
1415

1516
import pytest
@@ -528,6 +529,69 @@ def test_executemany_money_smallmoney(cursor, db_connection):
528529
db_connection.commit()
529530

530531

532+
class _ParamInfo:
533+
"""Duck-typed stand-in for ddbc_bindings.ParamInfo (no C extension needed)."""
534+
535+
def __init__(self, sql_type):
536+
self.paramSQLType = sql_type
537+
self.decimalDigits = 0
538+
self.columnSize = 0
539+
540+
541+
def _bare_cursor():
542+
"""A Cursor instance that skips __init__ (no DB connection required)."""
543+
from mssql_python.cursor import Cursor
544+
545+
return Cursor.__new__(Cursor)
546+
547+
548+
def test_money_batch_redeclared_as_numeric():
549+
"""GH-745: a money-range Decimal batch must declare SQL_NUMERIC (not the
550+
VARCHAR shortcut), with decimalDigits = batch-wide max scale, so the server
551+
does not overflow converting the strings. Mirrors the GH-740 fix on
552+
execute(); the string binding (GH-503) is applied separately downstream."""
553+
from mssql_python.constants import ConstantsDDBC as ddbc
554+
555+
cur = _bare_cursor()
556+
varchar = ddbc.SQL_VARCHAR.value
557+
numeric = ddbc.SQL_NUMERIC.value
558+
559+
# In-range batch, mixed scales -> redeclared NUMERIC with max scale 4
560+
p = _ParamInfo(varchar)
561+
assert cur._apply_money_batch_declared_type(
562+
p, [Decimal("12345.6789"), Decimal("-0.0001"), None]
563+
) is True
564+
assert p.paramSQLType == numeric
565+
assert p.decimalDigits == 4
566+
567+
# Integer-valued money -> scale 0
568+
p = _ParamInfo(varchar)
569+
assert cur._apply_money_batch_declared_type(p, [Decimal("100"), Decimal("-2")]) is True
570+
assert p.paramSQLType == numeric
571+
assert p.decimalDigits == 0
572+
573+
# Above MONEY_MAX -> unchanged (existing NUMERIC path handles it)
574+
p = _ParamInfo(varchar)
575+
assert cur._apply_money_batch_declared_type(p, [Decimal("9999999999999999")]) is False
576+
assert p.paramSQLType == varchar
577+
578+
# Mixed Decimal + str -> unchanged (not a pure Decimal column)
579+
p = _ParamInfo(varchar)
580+
assert cur._apply_money_batch_declared_type(p, [Decimal("1.5"), "2.5"]) is False
581+
assert p.paramSQLType == varchar
582+
583+
# All-NULL column -> unchanged (nothing to type)
584+
p = _ParamInfo(varchar)
585+
assert cur._apply_money_batch_declared_type(p, [None, None]) is False
586+
assert p.paramSQLType == varchar
587+
588+
# Already declared non-VARCHAR -> untouched
589+
p = _ParamInfo(numeric)
590+
assert cur._apply_money_batch_declared_type(p, [Decimal("1.5")]) is False
591+
assert p.paramSQLType == numeric
592+
assert p.decimalDigits == 0
593+
594+
531595
# =============================================================================
532596
# Invalid Input Handling
533597
# =============================================================================

0 commit comments

Comments
 (0)