Skip to content
Merged
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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,23 @@ 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-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
`setinputsizes()` covers fewer positions than parameters). Previously it was
bound as a formatted `VARCHAR` based on the value alone, so comparing it against
a smaller `numeric`/`decimal` column (`WHERE v = ?`) made SQL Server convert
`varchar`→`numeric` and raise an arithmetic overflow instead of simply not
matching. Also fixes a latent binder bug the shortcut was masking: the numeric
APD descriptor record number was hardcoded to `1`, so a numeric parameter in any
position other than the first corrupted the parameter bound at position 1.
**Behavioral change on the wire:** money-range Decimals now arrive as `numeric`
rather than `varchar` — a bare `SELECT ?` returns `Decimal` instead of `str`,
`sql_variant` stores them as `numeric`, and because `numeric` outranks
`money`/`varchar` in data-type precedence, `WHERE money_or_varchar_col = ?` can
add a `CONVERT_IMPLICIT` on the column side that turns an index seek into a scan.
`executemany` intentionally keeps its batch `VARCHAR` string binding (GH-503);
the remaining money-range case there is tracked in #745.
- **GH-725:** The `timeout` parameter of `connect()` / `Connection(...)` now
correctly sets the **login (connection-attempt) timeout**
(`SQL_ATTR_LOGIN_TIMEOUT`), matching pyodbc and its own docstring. Previously
Expand Down
32 changes: 27 additions & 5 deletions mssql_python/cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,7 @@ def _map_sql_type( # pylint: disable=too-many-arguments,too-many-positional-arg
i: int,
min_val: Optional[Any] = None,
max_val: Optional[Any] = None,
decimal_as_numeric: bool = False,
) -> Tuple[int, int, int, int, bool]:
"""
Map a Python data type to the corresponding SQL type,
Expand All @@ -658,6 +659,11 @@ def _map_sql_type( # pylint: disable=too-many-arguments,too-many-positional-arg
- param: The parameter to map.
- parameters_list: The list of parameters to bind.
- 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).
Returns:
- A tuple containing the SQL type, C type, column size, and decimal digits.
"""
Expand Down Expand Up @@ -793,8 +799,12 @@ 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
if SMALLMONEY_MIN <= param <= SMALLMONEY_MAX:
# 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).
if not decimal_as_numeric and SMALLMONEY_MIN <= param <= SMALLMONEY_MAX:
logger.debug("_map_sql_type: DECIMAL -> SMALLMONEY - index=%d", i)
# smallmoney
parameters_list[i] = format(param, "f")
Expand All @@ -805,7 +815,7 @@ def _map_sql_type( # pylint: disable=too-many-arguments,too-many-positional-arg
0,
False,
)
if MONEY_MIN <= param <= MONEY_MAX:
if not decimal_as_numeric and MONEY_MIN <= param <= MONEY_MAX:
logger.debug("_map_sql_type: DECIMAL -> MONEY - index=%d", i)
# money
parameters_list[i] = format(param, "f")
Expand Down Expand Up @@ -1247,6 +1257,7 @@ def _create_parameter_types_list( # pylint: disable=too-many-arguments,too-many
i: int,
min_val: Optional[Any] = None,
max_val: Optional[Any] = None,
decimal_as_numeric: bool = False,
) -> Tuple[int, int, int, int, bool]:
"""
Maps parameter types for the given parameter.
Expand Down Expand Up @@ -1313,7 +1324,12 @@ def _create_parameter_types_list( # pylint: disable=too-many-arguments,too-many
else:
# Fall back to automatic type inference
sql_type, c_type, column_size, decimal_digits, is_dae = self._map_sql_type(
parameter, parameters_list, i, min_val=min_val, max_val=max_val
parameter,
parameters_list,
i,
min_val=min_val,
max_val=max_val,
decimal_as_numeric=decimal_as_numeric,
)

# If TIME values are being bound via text C-types, normalize them to a
Expand Down Expand Up @@ -1827,7 +1843,13 @@ def execute( # pylint: disable=too-many-locals,too-many-branches,too-many-state
if parameters:
param_info = ddbc_bindings.ParamInfo
for i, param in enumerate(parameters):
paraminfo = self._create_parameter_types_list(param, param_info, parameters, i)
# 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).
paraminfo = self._create_parameter_types_list(
param, param_info, parameters, i, decimal_as_numeric=True
)
parameters_type.append(paraminfo)

if logger.isEnabledFor(logging.DEBUG):
Expand Down
16 changes: 12 additions & 4 deletions mssql_python/pybind/ddbc_bindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -872,6 +872,13 @@ SQLRETURN BindParameters(SqlHandle& handle, SQLHANDLE hStmt, const py::list& par
// Special handling for Numeric type -
// https://learn.microsoft.com/en-us/sql/odbc/reference/appendixes/retrieve-numeric-data-sql-numeric-struct-kb222831?view=sql-server-ver16#sql_c_numeric-overview
if (paramInfo.paramCType == SQL_C_NUMERIC) {
// The APD record number is the 1-based parameter position, matching the
// SQLBindParameter call above. It was previously hardcoded to 1, so a
// SQL_C_NUMERIC parameter in any position other than the first had its
// precision/scale/data pointer written onto record 1 instead of its own.
// The driver then read the numeric struct with the wrong descriptor and
// raised "Numeric value out of range" (GH-740).
const SQLSMALLINT descRecNum = static_cast<SQLSMALLINT>(paramIndex + 1);
SQLHDESC hDesc = nullptr;
rc = SQLGetStmtAttr_ptr(hStmt, SQL_ATTR_APP_PARAM_DESC, &hDesc, 0, NULL);
if (!SQL_SUCCEEDED(rc)) {
Expand All @@ -880,7 +887,8 @@ SQLRETURN BindParameters(SqlHandle& handle, SQLHANDLE hStmt, const py::list& par
paramIndex, rc);
return rc;
}
rc = SQLSetDescField_ptr(hDesc, 1, SQL_DESC_TYPE, (SQLPOINTER)SQL_C_NUMERIC, 0);
rc = SQLSetDescField_ptr(hDesc, descRecNum, SQL_DESC_TYPE,
(SQLPOINTER)SQL_C_NUMERIC, 0);
if (!SQL_SUCCEEDED(rc)) {
LOG("BindParameters: SQLSetDescField(SQL_DESC_TYPE) failed for "
"param[%d] - SQLRETURN=%d",
Expand All @@ -889,7 +897,7 @@ SQLRETURN BindParameters(SqlHandle& handle, SQLHANDLE hStmt, const py::list& par
}
SQL_NUMERIC_STRUCT* numericPtr = reinterpret_cast<SQL_NUMERIC_STRUCT*>(dataPtr);
rc = SQLSetDescField_ptr(
hDesc, 1, SQL_DESC_PRECISION,
hDesc, descRecNum, SQL_DESC_PRECISION,
reinterpret_cast<SQLPOINTER>(static_cast<uintptr_t>(numericPtr->precision)), 0);
if (!SQL_SUCCEEDED(rc)) {
LOG("BindParameters: SQLSetDescField(SQL_DESC_PRECISION) "
Expand All @@ -899,7 +907,7 @@ SQLRETURN BindParameters(SqlHandle& handle, SQLHANDLE hStmt, const py::list& par
}

rc = SQLSetDescField_ptr(
hDesc, 1, SQL_DESC_SCALE,
hDesc, descRecNum, SQL_DESC_SCALE,
reinterpret_cast<SQLPOINTER>(static_cast<intptr_t>(numericPtr->scale)), 0);
if (!SQL_SUCCEEDED(rc)) {
LOG("BindParameters: SQLSetDescField(SQL_DESC_SCALE) failed "
Expand All @@ -908,7 +916,7 @@ SQLRETURN BindParameters(SqlHandle& handle, SQLHANDLE hStmt, const py::list& par
return rc;
}

rc = SQLSetDescField_ptr(hDesc, 1, SQL_DESC_DATA_PTR,
rc = SQLSetDescField_ptr(hDesc, descRecNum, SQL_DESC_DATA_PTR,
reinterpret_cast<SQLPOINTER>(numericPtr), 0);
if (!SQL_SUCCEEDED(rc)) {
LOG("BindParameters: SQLSetDescField(SQL_DESC_DATA_PTR) failed "
Expand Down
49 changes: 8 additions & 41 deletions mssql_python/pybind/param_detect.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -194,8 +194,9 @@ inline bool StartsWithAscii(unsigned int kind, const void* data, Py_ssize_t leng
// storage engine's range exactly (TINYINT: 0-255, SMALLINT: -32768..32767, etc.)
// 4. String handling inspects UCS kind directly for O(1) ASCII detection rather than
// scanning content — critical for bulk insert scenarios with thousands of params.
// 5. MONEY/SMALLMONEY uses exact Decimal comparison (PyObject_RichCompareBool) to avoid
// double-precision boundary errors (e.g., 214748.3647 would round incorrectly as double).
// 5. Every finite Decimal binds as SQL_NUMERIC with its own precision/scale; the value's
// magnitude does not change the bind type, so a comparison against a smaller numeric
// column returns no match instead of a server-side varchar->numeric overflow (GH-740).
// ---------------------------------------------------------------------------
//
// ORDERING MATTERS:
Expand Down Expand Up @@ -511,45 +512,11 @@ inline std::vector<ParamInfo> DetectParamTypes(PyObject* params) {
std::to_string(precision) + ".");
}

// Check SMALLMONEY first, then widen to MONEY, so common small values keep the narrowest
// exact range while still accepting larger fixed-point values supported by SQL Server.
// MONEY/SMALLMONEY: SQL Server stores these as fixed-point integers internally.
// We bind as formatted VARCHAR (e.g., "214748.3647") because SQL_C_NUMERIC can't
// represent the exact money range without precision loss on certain ODBC drivers.
// Use exact Decimal comparison (not double) to avoid boundary misclassification.
bool in_money_range = false;
int cmp_ge = PyObject_RichCompareBool(obj, PyTypeCache::smallmoney_min, Py_GE);
int cmp_le = PyObject_RichCompareBool(obj, PyTypeCache::smallmoney_max, Py_LE);
if (cmp_ge == -1 || cmp_le == -1) throw py::error_already_set();
if (cmp_ge == 1 && cmp_le == 1) {
in_money_range = true;
} else {
cmp_ge = PyObject_RichCompareBool(obj, PyTypeCache::money_min, Py_GE);
cmp_le = PyObject_RichCompareBool(obj, PyTypeCache::money_max, Py_LE);
if (cmp_ge == -1 || cmp_le == -1) throw py::error_already_set();
if (cmp_ge == 1 && cmp_le == 1) {
in_money_range = true;
}
}

if (in_money_range) {
py::object formatted = steal(PyObject_CallMethod(obj, "__format__", "s", "f"));
if (!formatted) throw py::error_already_set();
info.paramSQLType = SQL_VARCHAR;
info.paramCType = PARAM_C_TYPE_TEXT;
info.columnSize = PyUnicode_GET_LENGTH(formatted.ptr());
info.decimalDigits = 0;
PyObject* raw = formatted.release().ptr();
if (PyList_SetItem(params, i, raw) != 0) {
// PyList_SetItem steals (decrefs) the item even on failure,
// so raw is already freed — do NOT Py_DECREF here.
throw py::error_already_set();
}
continue;
}

// Build SQL_NUMERIC_STRUCT from the Decimal object. Store as a pybind11-castable
// object in the param list so BindParameters can extract it as NumericData.
// Bind every finite Decimal as SQL_NUMERIC using its own precision and scale,
Comment thread
bewithgaurav marked this conversation as resolved.
// regardless of value. The previous MONEY/SMALLMONEY VARCHAR shortcut chose the
// bind type from the value alone, ignoring the target column, so an in-range value
// compared against a smaller numeric column triggered a server-side varchar->numeric
// overflow instead of simply not matching (GH-740).
info.paramSQLType = SQL_NUMERIC;
Comment thread
sumitmsft marked this conversation as resolved.
info.paramCType = SQL_C_NUMERIC;
NumericData nd = build_numeric_data(as_tuple_ptr.ptr(), digits_obj.ptr(), exponent);
Expand Down
18 changes: 1 addition & 17 deletions mssql_python/pybind/py_type_cache.hpp
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// py_type_cache.hpp — One-time cache of Python type objects and MONEY boundary constants.
// py_type_cache.hpp — One-time cache of Python type objects.
//
// Called on first execute(). Uses raw CPython API (not pybind11) because
// these cached PyObject* are compared via PyObject_IsInstance in the
Expand All @@ -24,10 +24,6 @@ inline PyObject* date_class = nullptr;
inline PyObject* time_class = nullptr;
inline PyObject* decimal_class = nullptr;
inline PyObject* uuid_class = nullptr;
inline PyObject* money_min = nullptr;
inline PyObject* money_max = nullptr;
inline PyObject* smallmoney_min = nullptr;
inline PyObject* smallmoney_max = nullptr;
inline bool cache_initialized = false;

// Import a module and extract an attribute. Returns a new reference.
Expand Down Expand Up @@ -69,24 +65,12 @@ inline void initialize() {
py::object dec_cls = steal(import_attr("decimal", "Decimal"));
py::object uuid_cls = steal(import_attr("uuid", "UUID"));

// Pre-compute MONEY/SMALLMONEY boundary Decimals for exact comparison
// in DetectParamTypes (avoids double-precision boundary errors).
py::object sm_min = steal(PyObject_CallFunction(dec_cls.ptr(), "s", "-214748.3648"));
py::object sm_max = steal(PyObject_CallFunction(dec_cls.ptr(), "s", "214748.3647"));
py::object m_min = steal(PyObject_CallFunction(dec_cls.ptr(), "s", "-922337203685477.5808"));
py::object m_max = steal(PyObject_CallFunction(dec_cls.ptr(), "s", "922337203685477.5807"));
if (!sm_min || !sm_max || !m_min || !m_max) throw py::error_already_set();

// Commit to globals — all acquisitions succeeded.
datetime_class = dt_cls.release().ptr();
date_class = date_cls.release().ptr();
time_class = time_cls.release().ptr();
decimal_class = dec_cls.release().ptr();
uuid_class = uuid_cls.release().ptr();
smallmoney_min = sm_min.release().ptr();
smallmoney_max = sm_max.release().ptr();
money_min = m_min.release().ptr();
money_max = m_max.release().ptr();
cache_initialized = true;
}

Expand Down
Loading
Loading