Skip to content

FIX: bind executemany money-range Decimals as SQL_NUMERIC - #752

Open
VyrnSynx (vyrnsynx) wants to merge 5 commits into
microsoft:mainfrom
vyrnsynx:fix-745-executemany-decimal-sql-numeric
Open

FIX: bind executemany money-range Decimals as SQL_NUMERIC#752
VyrnSynx (vyrnsynx) wants to merge 5 commits into
microsoft:mainfrom
vyrnsynx:fix-745-executemany-decimal-sql-numeric

Conversation

@vyrnsynx

Copy link
Copy Markdown

Work Item / Issue Reference

GitHub Issue: #745


Summary

executemany auto-detect still used the MONEY/SMALLMONEY-range VARCHAR shortcut after #742 fixed execute(). A money-range Decimal compared against a smaller numeric column could still overflow on that path.

This change binds auto-detected Decimal columns as SQL_NUMERIC with a batch-wide precision/scale (values still go through the existing SQL_C_CHAR string conversion). The setinputsizes DECIMAL/NUMERIC string path (GH-503) and mixed-sign VARCHAR sizing (GH-557) are left alone.

Fixes #745

executemany auto-detect skipped the money-range VARCHAR shortcut by
deriving a batch-wide NUMERIC precision/scale, matching execute() so
comparisons against smaller numeric columns no longer overflow.

setinputsizes DECIMAL/NUMERIC string binding is unchanged.

Fixes microsoft#745
Copilot AI lite review requested due to automatic review settings September 4, 2026 04:43
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new batch precision/scale path needs stricter validation and the current executemany numeric override still conflates numeric precision with string buffer sizing in a way that can produce invalid SQL Server precision (>38) for some Decimal shapes.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR fixes the remaining executemany() auto-detection path where MONEY/SMALLMONEY-range Decimal values could still be bound as SQL_VARCHAR, causing SQL Server to overflow when comparing against smaller numeric/decimal columns. The change aligns executemany() with the already-fixed execute() behavior by binding auto-detected Decimal columns as SQL_NUMERIC using a batch-wide precision/scale, while still sending values as SQL_C_CHAR strings.

Changes:

  • Update executemany() auto-detect to bind Decimal columns as SQL_NUMERIC (skipping the MONEY-range VARCHAR shortcut) and compute a batch-wide (precision, scale).
  • Add unit/integration tests covering the GH-745 overflow regression and mixed-sign batch behavior.
  • Document the behavior change in the changelog and update explanatory test/module docs.
File summaries
File Description
mssql_python/cursor.py Adds batch-wide Decimal precision/scale derivation and applies it to executemany() auto-detect numeric binding.
tests/test_020_money_smallmoney.py Adds DB integration coverage for GH-745 (no overflow on executemany comparisons; mixed-sign batch still works).
tests/test_004_cursor.py Adds unit tests for batch precision/scale derivation and verifies executemany binds money-range Decimals as SQL_NUMERIC.
CHANGELOG.md Records GH-745 fix and clarifies setinputsizes behavior remains unchanged.
Review details

Suppressed comments (1)

mssql_python/cursor.py:2729

  • executemany’s SQL_NUMERIC/SQL_DECIMAL override reuses ParamInfo.columnSize both as the numeric precision (passed as cbColDef to SQLBindParameter) and as the max SQL_C_CHAR buffer length. Setting columnSize to max_decimal_len (which includes sign and decimal point) can push the declared numeric precision above SQL Server’s max 38 (e.g., scale-38 values format to 40 chars), causing bind failures unrelated to the actual numeric precision/scale. Consider decoupling numeric precision from string buffer sizing (e.g., compute buffer sizes from actual encoded string lengths in BindParameterArray when SQLType is NUMERIC/DECIMAL but keep cbColDef=precision<=38).
                    # Ensure columnSize also accommodates the longest string form
                    # (mixed-sign batches, GH-557).
                    if max_decimal_len > paraminfo.columnSize:
                        paraminfo.columnSize = max_decimal_len
  • 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.

Comment thread mssql_python/cursor.py
Comment on lines +2402 to +2417
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the change that routes executemany auto-detected Decimal columns to SQL_NUMERIC (GH-745). The core approach is sound and the new _decimal_sql_precision_scale / _batch_decimal_precision_scale helpers correctly derive a batch-wide precision/scale (and fix a latent sample-only sizing gap). Verified the precision/scale math matches _get_numeric_data, and black --check passes.

One blocking issue: the retained max_decimal_len override inside the NUMERIC block sets the NUMERIC precision from a formatted string length, which can push precision past 38 and break near-max-precision money-range Decimals that previously worked. Two smaller reliability points below.

Recommendation: Request changes.

Comment thread mssql_python/cursor.py Outdated
if batch_scale > paraminfo.decimalDigits:
paraminfo.decimalDigits = batch_scale
# Ensure columnSize also accommodates the longest string form
# (mixed-sign batches, GH-557).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For a SQL_NUMERIC param, columnSize is the numeric precision passed to SQLBindParameter (ddbc_bindings.cpp, 6th arg), not a string length. The max_decimal_len > columnSize override just below counts the decimal point and sign, so it sets precision to batch_precision + 1/2. Now that money-range Decimals flow into this NUMERIC block, a full-scale value like Decimal("1E-38") or Decimal("0."+"1"*38) becomes NUMERIC(40,38) and the driver rejects it (max precision 38), a regression from the prior VARCHAR path and inconsistent with the execute() fix (GH-740). batch_precision already sizes precision correctly; the string-length need is handled separately in the SQL_VARCHAR block below. Please drop this override in the NUMERIC branch and add an executemany test for Decimal("1E-38") into NUMERIC(38,38).

Comment thread mssql_python/cursor.py
# 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

batch_precision is applied to columnSize without a <=38 check. The sample-level guard in _map_sql_type doesn't cover the whole batch, so a mixed batch (many integer digits in one row, many fractional in another) can produce batch_precision > 38 even when each value is individually storable, and the driver then fails with an opaque message. Consider raising the existing "precision too high" ValueError when batch_precision > 38. Don't clamp, since that would silently truncate.

Comment thread mssql_python/cursor.py Outdated
# 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deriving decimal_as_numeric from just the sample type forces the whole column onto the NUMERIC/string-decimal path. A heterogeneous column (Decimal sample plus a stray non-numeric value) that previously bound as VARCHAR will now raise in the Decimal conversion loop below. Consider gating this on "all non-NULL values are Decimal," or documenting heterogeneous columns as unsupported on this path.

…inding

Address review feedback on the executemany SQL_NUMERIC path: keep columnSize as
numeric precision (not string length), raise when batch precision exceeds 38,
force NUMERIC only when every non-NULL value is Decimal, and cover the cases
with unit tests.
@vyrnsynx

Copy link
Copy Markdown
Author

Sumit Sarabhai (@sumitmsft) Thanks for the review — addressed in the latest commit:

  1. Dropped the max_decimal_len override inside the NUMERIC branch so columnSize stays as numeric precision (not string length).
  2. Raise ValueError when batch precision exceeds 38 (no clamp).
  3. Force decimal_as_numeric only when every non-NULL value in the column is a Decimal.

Also added unit coverage for near-max precision (1E-38), batch precision >38, heterogeneous columns, and non-finite Decimals.

@vyrnsynx

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

VyrnSynx (vyrnsynx) and others added 2 commits September 5, 2026 15:27
Resolve conflict in execute(): keep upstream native DDBCSQLExecute /
DDBCSQLExecDirect path from microsoft#736/microsoft#758; retain microsoftGH-745 executemany
SQL_NUMERIC batch precision/scale changes.
Copilot AI review requested due to automatic review settings September 8, 2026 10:14

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

It changes core executemany() parameter binding semantics in a DB driver, so a final human review is needed to confirm no subtle regressions across ODBC/SQL Server edge cases.

Review details
  • Files reviewed: 4/4 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment thread CHANGELOG.md Outdated
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.
Comment thread mssql_python/cursor.py
Comment on lines +2609 to +2612
non_null_values = [v for v in column if v is not None]
decimal_as_numeric = bool(non_null_values) and all(
isinstance(v, decimal.Decimal) for v in non_null_values
)
@bewithgaurav

Copy link
Copy Markdown
Collaborator

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

@bewithgaurav

Copy link
Copy Markdown
Collaborator

VyrnSynx (@vyrnsynx) - requesting you to please look into the failing tests, let us know if you need any help

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

requesting changes. the issue comparison still errors, while normal decimal batches that work on the merge base now fail before reaching sql server. I reproduced this locally, and the same regression appears in the linux and windows CI jobs.

Comment thread mssql_python/cursor.py
# One NUMERIC(precision, scale) must fit every Decimal in the
# batch (GH-745). Sample-only precision/scale is not enough.
# columnSize is NUMERIC precision for SQLBindParameter, not a
# string buffer length — do not widen it with max_decimal_len.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this uses sql numeric precision as the array's character-buffer width too. precision only counts digits, but the fixed-point string also needs space for the sign, decimal point, leading zero, and terminator. Decimal("1E-38") gets (38, 38) here but formats to 40 characters, so PyStringArrayDataBuffer rejects it before sql server sees it.

I ran the same cases on this head and the exact merge base. Decimal("1E-38") and [-12.34, 56.78] both complete on the base; this head raises RuntimeError: Input string exceeds allowed column size at parameter index 0. the linux and windows jobs show the same 13 decimal failures too.

can we keep sql precision and scale separate from the SQL_C_CHAR buffer stride, sizing the buffer from the longest encoded fixed-point value plus its terminator?

Keep SQLBindParameter columnSize as digit precision/scale, and size the
executemany CHAR array stride from the longest fixed-point encoding
(bufferSize) so values like Decimal("1E-38") and mixed-sign short
batches are not rejected by the array buffer.
Copilot AI review requested due to automatic review settings September 8, 2026 17:52
@vyrnsynx

Copy link
Copy Markdown
Author

Gaurav Sharma (@bewithgaurav) thanks for catching this — you were right that we were treating SQL numeric precision as the CHAR array width.

Pushed a follow-up that keeps columnSize / decimalDigits as SQL precision/scale for SQLBindParameter, and sizes the SQL_C_CHAR array stride separately (bufferSize) from the longest fixed-point encoding in the batch (with a precision+3 fallback in the binder). That covers Decimal("1E-38") and the mixed-sign short-precision case like [-12.34, 56.78].

Added/updated unit coverage for those, plus an executemany numeric(38,38) round-trip test.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

It changes core executemany type inference and native array-binding buffer sizing in C++, which is regression-prone and warrants final human review despite strong new test coverage.

Review details

Suppressed comments (2)

mssql_python/cursor.py:2632

  • executemany() builds a full non_null_values list just to decide whether all non-NULL values are Decimal. For large batches this doubles per-column memory and does extra work; a single-pass check avoids allocating another list.
                non_null_values = [v for v in column if v is not None]
                decimal_as_numeric = bool(non_null_values) and all(
                    isinstance(v, decimal.Decimal) for v in non_null_values
                )

mssql_python/pybind/ddbc_bindings.cpp:2418

  • This bounds check is against dataWidth (array buffer stride), but the thrown message still refers to "column size". Since columnSize can represent NUMERIC precision rather than string length, the message should reference buffer width to match the check.
                            if (encodedStr.size() > dataWidth) {
                                LOG("BindParameterArray: String/binary too "
                                    "long - param_index=%d, row=%zu, size=%zu, "
                                    "max=%zu",
                                    paramIndex, i, encodedStr.size(), dataWidth);
                                ThrowStdException("Input exceeds column size at index " +
                                                  std::to_string(i));
  • Files reviewed: 6/6 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment thread mssql_python/cursor.py
Comment on lines +2560 to +2563
for row in seq_of_parameters:
value = row[col_index]
if value is None:
continue
Comment on lines +2284 to 2288
if (wstr.length() > dataWidth) {
ThrowStdException("Input string exceeds allowed column size "
"at parameter index " +
std::to_string(paramIndex));
}
@bewithgaurav

Copy link
Copy Markdown
Collaborator

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the original decimal buffer issue is fixed. requesting changes for a new regression in how conversion errors are handled, detailed inline.

Comment thread mssql_python/cursor.py
if isinstance(value, decimal.Decimal):
encoded = format(value, "f")
else:
encoded = format(decimal.Decimal(str(value)), "f")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

with explicit numeric input sizes, failed conversions can now escape as raw exceptions and expose value-bearing error messages in the traceback, instead of the existing ValueError with row/column details.

this sizing pass converts non-Decimal inputs before the protected conversion loop. its catch misses MemoryError and RuntimeError.

the existing test_setinputsizes_sql_decimal_non_decimal_exception_no_leak fails here with raw MemoryError for "1e999999999999999999". it passes on the exact merge base.

I also reproduced the message exposure with a synthetic marker:

class ExplodingStr:
    def __str__(self):
        raise RuntimeError("synthetic-private-parameter")

cursor.execute("CREATE TABLE #pr752_error_boundary (v DECIMAL(18,2))")
cursor.setinputsizes([(mssql_python.SQL_DECIMAL, 18, 2)])
cursor.executemany(
    "INSERT INTO #pr752_error_boundary VALUES (?)",
    [(ExplodingStr(),)],
)

head 977654f raises RuntimeError: synthetic-private-parameter. base cb0bd6c raises the sanitized ValueError identifying row 0, column 0, with the marker absent from the formatted traceback.

please derive the buffer width from text produced through the existing protected conversion path, rather than converting independently here, and cover both error cases.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Decimal in money range still string-binds on the executemany path

4 participants