FIX: bind Decimal as SQL_NUMERIC regardless of value - #742
FIX: bind Decimal as SQL_NUMERIC regardless of value#742Gaurav Sharma (bewithgaurav) merged 10 commits into
Conversation
The standard execute path chose a Decimal's bind type from its value: anything in the MONEY/SMALLMONEY range was sent as a formatted VARCHAR. Comparing such a value against a smaller numeric column made SQL Server convert varchar to numeric and overflow, so 'WHERE v = ?' raised an arithmetic overflow instead of just not matching. Bind every finite Decimal as SQL_NUMERIC with its own precision and scale, matching pyodbc. Removing the shortcut surfaced a second bug: the numeric parameter's APD record number in SQLSetDescField was hardcoded to 1, so a numeric parameter in any position other than the first wrote its precision/scale onto the wrong record and the driver raised 'Numeric value out of range'. Use the parameter's own 1-based position. Scoped to the single execute() path; executemany still string-binds decimals (GH-503) and is a separate follow-up. (GH-740) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🔵 Needs a closer look
It changes native ODBC parameter binding behavior (descriptor manipulation and Decimal typing), which warrants final human review despite good regression coverage.
Pull request overview
This PR adjusts the driver’s execute() fast path parameter detection/binding so Python Decimal values are always bound as SQL_NUMERIC (with derived precision/scale), avoiding SQL Server’s server-side varchar→numeric conversion overflow behavior seen when Decimals were previously string-bound in MONEY/SMALLMONEY ranges, and fixes descriptor-record selection for non-first numeric parameters.
Changes:
- Bind all finite
Decimalparameters asSQL_NUMERICinDetectParamTypes(removing the MONEY/SMALLMONEY VARCHAR shortcut). - Fix numeric APD descriptor record selection to use the parameter’s own 1-based position instead of hardcoding record 1.
- Add regression tests covering GH-740 scenarios (overflow avoidance, non-first-position numeric params, multiple numerics, boundary round-trips, re-exec with changing precision/scale).
File summaries
| File | Description |
|---|---|
| tests/test_020_money_smallmoney.py | Updates module-level behavior description and adds GH-740 regression tests for Decimal numeric binding and descriptor record handling. |
| mssql_python/pybind/py_type_cache.hpp | Removes cached MONEY/SMALLMONEY boundary Decimal objects no longer needed after eliminating range-based binding. |
| mssql_python/pybind/param_detect.hpp | Removes MONEY/SMALLMONEY range detection/string-binding; always constructs NumericData and sets SQL_NUMERIC binding for finite Decimals. |
| mssql_python/pybind/ddbc_bindings.cpp | Fixes numeric APD descriptor record number to match the actual 1-based parameter index when setting precision/scale/data ptr. |
Review details
- Files reviewed: 4/4 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
📊 Code Coverage Report
Diff CoverageDiff: main...HEAD, staged and unstaged changes
Summary
mssql_python/pybind/ddbc_bindings.cppLines 875-885 875 // The APD record number is the 1-based parameter position, matching the
876 // SQLBindParameter call above. It was previously hardcoded to 1, so a
877 // SQL_C_NUMERIC parameter in any position other than the first had its
878 // precision/scale/data pointer written onto record 1 instead of its own.
! 879 // The driver then read the numeric struct with the wrong descriptor and
! 880 // raised "Numeric value out of range" (GH-740).
! 881 const SQLSMALLINT descRecNum = static_cast<SQLSMALLINT>(paramIndex + 1);
882 SQLHDESC hDesc = nullptr;
883 rc = SQLGetStmtAttr_ptr(hStmt, SQL_ATTR_APP_PARAM_DESC, &hDesc, 0, NULL);
884 if (!SQL_SUCCEEDED(rc)) {
885 LOG("BindParameters: SQLGetStmtAttr(SQL_ATTR_APP_PARAM_DESC) "Lines 915-923 915 paramIndex, rc);
916 return rc;
917 }
918
! 919 rc = SQLSetDescField_ptr(hDesc, descRecNum, SQL_DESC_DATA_PTR,
920 reinterpret_cast<SQLPOINTER>(numericPtr), 0);
921 if (!SQL_SUCCEEDED(rc)) {
922 LOG("BindParameters: SQLSetDescField(SQL_DESC_DATA_PTR) failed "
923 "for param[%d] - SQLRETURN=%d",📋 Files Needing Attention📉 Files with overall lowest coverage (click to expand)mssql_python.pybind.logger_bridge.cpp: 58.9%
mssql_python.pybind.ddbc_bindings.h: 61.5%
mssql_python.pybind.logger_bridge.hpp: 70.8%
mssql_python.pybind.ddbc_bindings.cpp: 75.6%
mssql_python.__init__.py: 77.6%
mssql_python.row.py: 77.6%
mssql_python.pybind.connection.connection_pool.cpp: 81.6%
mssql_python.pybind.connection.connection.cpp: 84.4%
mssql_python.logging.py: 85.5%
mssql_python.connection.py: 85.9%🔗 Quick Links
|
test_money_range_decimal_binds_wide only round-tripped the value, so it stayed green after the native C type changed to NUMERIC. Assert the declared base type via sql_variant, and note that _map_sql_type still text-binds money-range Decimals to protect executemany's string binding. (GH-740) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The position test used a NULL first param, which masked the old record-1 bug (record 1 held no data). Use a non-null value first and assert it round-trips intact, so the test pins the collateral corruption of the earlier parameter, not just the numeric's own misplacement. Verified it fails against the pre-fix binder. (GH-740) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The native execute() path was fixed for GH-740, but a real execute() still reaches the Python _map_sql_type when setinputsizes() covers fewer positions than parameters: the uncovered money-range Decimal took the VARCHAR shortcut and overflowed a numeric comparison. Thread a decimal_as_numeric flag so the legacy execute path binds every finite Decimal as SQL_NUMERIC, while executemany keeps its batch VARCHAR string binding (GH-503) unchanged. Adds a partial-setinputsizes regression test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…//github.com/microsoft/mssql-python into bewithgaurav/fix-740-decimal-numeric-binding
|
This is a behavioral change on the wire (money-range Decimals now bind NUMERIC, not VARCHAR) - bare |
Sumit Sarabhai (sumitmsft)
left a comment
There was a problem hiding this comment.
Reviewed the PR for correctness, security, reliability, performance, test coverage, repository conventions, and applicable architecture and design specifications. No actionable issues were identified. The implementation is consistent with repository standards and the applicable approved design requirements.
The earlier finding on the legacy execute path (an uncovered money-range Decimal binding as VARCHAR when setinputsizes is shorter than the parameter list) is fully addressed here via decimal_as_numeric=True, with a dedicated regression test in tests/test_023_execute_path_parity.py. The executemany residual is disclosed in the description and tracked as a separate follow-up (#745).
Recommendation: Approve
Document the money-range Decimal binding change (now numeric, not varchar) in CHANGELOG, including the on-the-wire behavioral effects (SELECT ? returns Decimal, sql_variant base type, and numeric type-precedence causing CONVERT_IMPLICIT on the column side). Add two edge-case tests: an exact numeric(38,38) round-trip of 1E-38 asserted via as_tuple() (loose-tolerance coverage elsewhere would miss a silent zero), and signed-zero normalization. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…//github.com/microsoft/mssql-python into bewithgaurav/fix-740-decimal-numeric-binding
Reconcile #742 (bind Decimal as SQL_NUMERIC regardless of value, GH-740) with the native setinputsizes migration: - param_detect.hpp: drop the automatic MONEY/SMALLMONEY VARCHAR shortcut; every finite Decimal binds SQL_NUMERIC natively. FormatDecimalParam stays for the setinputsizes DECIMAL override only. - cursor.py: _create_parameter_types_list forwards decimal_as_numeric to _map_sql_type; the parameterless else-branch keeps DDBCSQLExecDirect and drops the deleted DDBCSQLExecuteLegacy block (GH-740 fix now happens in native detection). - test_023: keep both suites; narrow test_decimal_format_must_return_string to the setinputsizes override, the only path that still formats Decimals after GH-740. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Point the Windows PR-validation legs at the mssql-python wheel from Build-Release-Package-Pipeline dev build 172271, which already carries the two pyodbc-parity fixes (microsoft/mssql-python#741 Binary(memoryview) and microsoft/mssql-python#742 Decimal SQL_NUMERIC) with the native core rebuilt. The published PyPI 1.14.0 wheel does not have these yet, so the two Decimal and BinaryField gaps would otherwise still fail. - tox.ini: allow the mssql-python requirement to be overridden by MSSQL_PYTHON_WHEEL, defaulting to the PyPI requirement when unset. - azure-pipelines-steps-windows.yml: download the matching per-Python wheel from build 172271 and hand its path to tox. - azure-pipelines.yml: gate Linux_Core, Linux_Legacy and Windows_Legacy off for this run. Build 172271 produced no Linux wheels, and the EOL/py3.8-3.9 legs have no wheel, so only the supported Windows matrix is validated here. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Cross-project artifact download from the mssql-python project is blocked for the public project's build identity (VS800075), so commit the Windows wheels directly under ci/mssql-python-wheels/ and install the per-Python wheel from there. Wheels are from Build-Release-Package-Pipeline dev build 172271 and carry microsoft/mssql-python#741 (Binary(memoryview)) and microsoft/mssql-python#742 (Decimal SQL_NUMERIC). Temporary: drop once mssql-python 1.15.0 ships to PyPI with both fixes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…#742) on merge #720 branched before #742 landed the money-range Decimal->SQL_NUMERIC fix (the decimal_as_numeric path), so a careless 'take ours' merge would have silently DELETED 29 lines of that shipped correctness logic -- invisible in the PR's own diff. Restored main's cursor.py (which has #742) and re-applied ONLY #720's intended change: the bulkcopy ImportError reword (GH-619, names the Windows-ARM64 / not-shipped-on-every-platform case). git diff origin/main HEAD -- mssql_python/cursor.py now shows only that one hunk. Black + compile clean.
…n/main Bulk copy IS now available on Windows ARM64 (the mssql_py_core arm64 core was built + shipped and validated in production pipelines), so the reword naming win-arm64 as an 'not shipped on every platform' example is factually stale. cursor.py is also out of scope for this conda-pipeline PR. Reverted it to origin/main exactly -- which KEEPS the #742 (GH-740) money-range Decimal->SQL_NUMERIC fix (decimal_as_numeric, 9 refs present) and removes the stale message. git diff origin/main HEAD -- mssql_python/cursor.py is now EMPTY, so #720 no longer touches cursor.py (no merge-revert risk, no scope creep). Any improved bulkcopy-unavailable message belongs in a separate GH-619 product PR with accurate current platform coverage.
Work Item / Issue Reference
Summary
The standard execute path chose a
Decimal's bind type from its value, sendinganything in the MONEY/SMALLMONEY range as a formatted VARCHAR. Comparing such a
value against a smaller numeric column made SQL Server convert varchar to numeric
and overflow, so
WHERE v = ?raised an arithmetic overflow instead of simply notmatching. Bind every finite
DecimalasSQL_NUMERICwith its own precision andscale, matching pyodbc.
Removing the shortcut surfaced a second bug: the numeric parameter's descriptor
record number was hardcoded to 1, so a numeric parameter in any position other than
the first wrote its precision/scale onto the wrong record and the driver raised
"Numeric value out of range". Use the parameter's own 1-based position.
The fix covers both
execute()routes: the native C++ detection path, and the Pythonlegacy path reached when
setinputsizes()covers fewer positions than parameters (anuncovered money-range
Decimalthere previously fell back to the VARCHAR shortcut andoverflowed).
executemanyis intentionally left on its batch VARCHAR string binding(GH-503), so a money-range
Decimalcompared throughexecutemanycan still overflow;that is a tangential finding filed separately as #745.