FIX: bind Decimal as SQL_NUMERIC regardless of value - #742
Gaurav 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 <[email protected]>
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.
Clarify that the always-SQL_NUMERIC binding applies to execute(); executemany still string-binds Decimals (GH-503). (GH-740) Co-authored-by: Copilot <[email protected]>
📊 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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
…//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 <[email protected]>
…//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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
…#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.
The full Build-Release-Package-Pipeline run 173176 (main, commit b426da4e) produced Linux wheels as well, so extend the wheel validation to the Linux supported matrix: - Refresh ci/mssql-python-wheels/ with the Windows and Linux x86_64 wheels from a single build (173176). Both still carry microsoft/mssql-python#741 (Binary(memoryview)) and microsoft/mssql-python#742 (Decimal SQL_NUMERIC). - azure-pipelines-steps-linux.yml: install the per-Python manylinux wheel from ci/mssql-python-wheels/ via MSSQL_PYTHON_WHEEL, mirroring the Windows steps. - azure-pipelines.yml: re-enable Linux_Core and raise its timeout to 180 min to match Windows_Core (mssql-python runs the suite ~1.5-2x slower). Windows_Legacy and Linux_Legacy stay gated off (no wheels for EOL py3.8/3.9). Temporary: drop once mssql-python 1.15.0 ships to PyPI with both fixes. Co-authored-by: Copilot <[email protected]>
[AB#48121](https://sqlclientdrivers.visualstudio.com/c6d89619-62de-46a0-8b46-70b92a84d85e/_workitems/edit/48121) ### Summary #### Enhancements - Add selectable native ODBC providers (#730). - Route `setinputsizes()` through the native C++ execution pipeline (#736). - Accept `memoryview` values in `Binary()` (#741). - Expose SQL Server type constants at module level (#764). #### Bug Fixes - Prevent concurrent logging deadlocks (#678). - Vendor the correct `mssql_py_core` architecture in Windows ARM64 wheels (#737). - Resolve bundled Windows driver and authentication DLLs from package-local directories (#735). - Bind `Decimal` parameters consistently as `SQL_NUMERIC` (#742). - Use ODBC 3.x parameter types (#758). - Decode `SQL_DATABASE_NAME` metadata (#771). - Prevent shutdown crashes during mixed cursor cleanup (#772). Bumps the package version from 1.14.0 to 1.15.0 and refreshes the PyPI release summary. --------- Co-authored-by: Gaurav Sharma <[email protected]> Co-authored-by: Copilot Autofix powered by AI <[email protected]>
* FEAT: [mssql-python] Integrate mssql-python into packaging and CI Wire the opt-in mssql-python driver into packaging and the test matrix: - setup.py: add an optional 'mssql-python' extra pinned to mssql-python>=1.15.0. pyodbc stays in install_requires, so the default install is unchanged and mssql-python is pulled in only via 'pip install mssql-django[mssql-python]'. - testapp/settings.py: source the python_driver and extra_params OPTIONS from the MSSQL_PYTHON_DRIVER / MSSQL_EXTRA_PARAMS environment variables (defaulting to empty), so the test app selects the driver without code changes. - tox.ini: on Python 3.10+ envs (mssql-python's supported floor) install mssql-python>=1.15.0 and set MSSQL_PYTHON_DRIVER=mssql_python plus MSSQL_EXTRA_PARAMS=TrustServerCertificate=yes so those legs exercise the mssql-python path end to end. The legacy (py36-py39) envs are untouched and continue to run under pyodbc. This is the final integration task of the mssql-django 2.0 opt-in mssql-python work. It intentionally depends on mssql-python 1.15.0, which publishes on 11 September 2026; until then the Python 3.10+ legs are expected to be red, turning green once 1.15.0 is available. The end-to-end path was validated green across the supported matrix using the fixes shipping in 1.15.0 (microsoft/mssql-python#741, microsoft/mssql-python#742). AB# Co-authored-by: Copilot <[email protected]> * CHORE: Modernize coverage for supported Python versions AB#48003 Co-authored-by: Copilot <[email protected]> * FIX: Validate selected CI driver identities AB#48003 Co-authored-by: Copilot <[email protected]> * FIX: Scope native driver validation to CI Co-authored-by: Copilot <[email protected]> * FEAT: Require mssql-python for mssql-django 2.0 Co-authored-by: Copilot <[email protected]> --------- Co-authored-by: Copilot <[email protected]>
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.