-
Notifications
You must be signed in to change notification settings - Fork 55
FIX: bind Decimal as SQL_NUMERIC regardless of value #742
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
f333534
bb77e1e
c2e1e36
90d4304
2a93752
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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: | ||
|
|
@@ -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, | ||
| // 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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This scopes the fix to the native execute path. When Example: cur.setinputsizes([(SQL_INTEGER, 0, 0)]) # 1 entry
cur.execute("SELECT ... WHERE v = ?", [5, Decimal("12345.6789")]) # 2 params
# the Decimal at index 1 is uncovered -> _map_sql_type -> bound as VARCHAR -> overflowCan we close this so the fix holds for every |
||
| info.paramCType = SQL_C_NUMERIC; | ||
| NumericData nd = build_numeric_data(as_tuple_ptr.ptr(), digits_obj.ptr(), exponent); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,9 +4,12 @@ | |
| Validates that Python Decimal values are correctly bound and round-tripped | ||
| through MONEY, SMALLMONEY, and DECIMAL columns with proper precision handling. | ||
|
|
||
| Key implementation detail: MONEY-range Decimals use string binding (SQL_VARCHAR) | ||
| because SQL_NUMERIC binding fails with ODBC "Numeric value out of range" error. | ||
| String binding preserves full precision and SQL Server handles conversion. | ||
| Key implementation detail: on the execute() path every finite Decimal binds as | ||
| SQL_NUMERIC using its own precision and scale, regardless of value. Binding no longer | ||
| depends on whether the value falls in the MONEY/SMALLMONEY range, so an in-range value | ||
| compared against a smaller numeric column returns no match instead of a varchar->numeric | ||
| overflow (GH-740). executemany still string-binds Decimals (SQL_VARCHAR) to preserve | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since native binding is now NUMERIC, |
||
| scale-38 precision (GH-503), so that path is unchanged here. | ||
| """ | ||
|
|
||
| import pytest | ||
|
|
@@ -640,3 +643,125 @@ def test_both_null(cursor, db_connection): | |
| finally: | ||
| drop_table_if_exists(cursor, table_name) | ||
| db_connection.commit() | ||
|
|
||
|
|
||
| # ============================================================================= | ||
| # GH-740: in-range Decimal must bind as SQL_NUMERIC, not VARCHAR | ||
| # ============================================================================= | ||
|
|
||
|
|
||
| def test_gh740_in_range_decimal_numeric_comparison_no_overflow(cursor, db_connection): | ||
| """A money-range Decimal compared against a smaller numeric column must not raise. | ||
|
|
||
| Before the fix the value was bound as VARCHAR, so SQL Server did a | ||
| varchar->numeric conversion that overflowed instead of simply not matching. | ||
| """ | ||
| table_name = "#pytest_gh740_cmp" | ||
| try: | ||
| drop_table_if_exists(cursor, table_name) | ||
| cursor.execute(f"CREATE TABLE {table_name} (v numeric(5,2))") # max 999.99 | ||
| cursor.execute(f"INSERT INTO {table_name} VALUES (?)", [Decimal("12.34")]) | ||
| db_connection.commit() | ||
|
|
||
| # Both probes sit inside the MONEY range but exceed numeric(5,2); they must | ||
| # return no rows rather than overflow. | ||
| cursor.execute(f"SELECT COUNT(*) FROM {table_name} WHERE v = ?", [Decimal("12345.6789")]) | ||
| assert cursor.fetchone()[0] == 0 | ||
| cursor.execute(f"SELECT COUNT(*) FROM {table_name} WHERE v = ?", [Decimal("300000.00")]) | ||
| assert cursor.fetchone()[0] == 0 | ||
| # The matching value still matches. | ||
| cursor.execute(f"SELECT COUNT(*) FROM {table_name} WHERE v = ?", [Decimal("12.34")]) | ||
| assert cursor.fetchone()[0] == 1 | ||
| finally: | ||
| drop_table_if_exists(cursor, table_name) | ||
| db_connection.commit() | ||
|
|
||
|
|
||
| def test_gh740_numeric_param_not_in_first_position(cursor, db_connection): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These use a |
||
| """A numeric param at position 2+ must not corrupt the parameter bound before it. | ||
|
|
||
| Guards the descriptor-record fix: the APD record number was hardcoded to 1, so a | ||
| numeric param at any later position wrote its type/precision/scale/data-ptr onto | ||
| record 1, clobbering the FIRST parameter's binding as collateral. Putting a non-null | ||
| value first pins that collateral corruption - the first value must round-trip intact, | ||
| not just the numeric's own value. A NULL first would mask it (record 1 held no data). | ||
| """ | ||
| table_name = "#pytest_gh740_pos" | ||
| try: | ||
| drop_table_if_exists(cursor, table_name) | ||
| cursor.execute(f"CREATE TABLE {table_name} (a int, b varchar(10), c numeric(6,4))") | ||
| cursor.execute( | ||
| f"INSERT INTO {table_name} VALUES (?, ?, ?)", | ||
| [12345, "keep", Decimal("67.8900")], | ||
| ) | ||
| db_connection.commit() | ||
|
|
||
| cursor.execute(f"SELECT a, b, c FROM {table_name}") | ||
| row = cursor.fetchone() | ||
| assert row[0] == 12345 # first param intact despite the later numeric | ||
| assert row[1] == "keep" | ||
| assert row[2] == Decimal("67.8900") | ||
| finally: | ||
| drop_table_if_exists(cursor, table_name) | ||
| db_connection.commit() | ||
|
|
||
|
|
||
| def test_gh740_multiple_numerics_with_null_between(cursor, db_connection): | ||
| """Multiple SQL_NUMERIC params with differing scales and a NULL between them.""" | ||
| table_name = "#pytest_gh740_multi" | ||
| try: | ||
| drop_table_if_exists(cursor, table_name) | ||
| cursor.execute(f"CREATE TABLE {table_name} (a numeric(10,4), b int, c numeric(8,2))") | ||
| cursor.execute( | ||
| f"INSERT INTO {table_name} VALUES (?, ?, ?)", | ||
| [Decimal("1.2300"), None, Decimal("999999.99")], | ||
| ) | ||
| db_connection.commit() | ||
|
|
||
| cursor.execute(f"SELECT a, b, c FROM {table_name}") | ||
| row = cursor.fetchone() | ||
| assert row[0] == Decimal("1.2300") | ||
| assert row[1] is None | ||
| assert row[2] == Decimal("999999.99") | ||
| finally: | ||
| drop_table_if_exists(cursor, table_name) | ||
| db_connection.commit() | ||
|
|
||
|
|
||
| def test_gh740_money_boundary_still_round_trips(cursor, db_connection): | ||
| """MONEY/SMALLMONEY boundary values still insert exactly after the binding change.""" | ||
| for coltype, value in [ | ||
| ("MONEY", Decimal("922337203685477.5807")), | ||
| ("MONEY", Decimal("-922337203685477.5808")), | ||
| ("SMALLMONEY", Decimal("214748.3647")), | ||
| ("SMALLMONEY", Decimal("-214748.3648")), | ||
| ]: | ||
| table_name = "#pytest_gh740_bound" | ||
| try: | ||
| drop_table_if_exists(cursor, table_name) | ||
| cursor.execute(f"CREATE TABLE {table_name} (v {coltype})") | ||
| cursor.execute(f"INSERT INTO {table_name} VALUES (?)", [value]) | ||
| db_connection.commit() | ||
| cursor.execute(f"SELECT v FROM {table_name}") | ||
| assert cursor.fetchone()[0] == value | ||
| finally: | ||
| drop_table_if_exists(cursor, table_name) | ||
| db_connection.commit() | ||
|
|
||
|
|
||
| def test_gh740_same_statement_changing_precision(cursor, db_connection): | ||
| """Re-executing the same statement with Decimals of different precision/scale works.""" | ||
| table_name = "#pytest_gh740_reexec" | ||
| try: | ||
| drop_table_if_exists(cursor, table_name) | ||
| cursor.execute(f"CREATE TABLE {table_name} (v numeric(20,6))") | ||
| for value in [Decimal("1.5"), Decimal("123456.789012"), Decimal("0.000001")]: | ||
| cursor.execute(f"INSERT INTO {table_name} VALUES (?)", [value]) | ||
| db_connection.commit() | ||
|
|
||
| cursor.execute(f"SELECT v FROM {table_name} ORDER BY v") | ||
| rows = [r[0] for r in cursor.fetchall()] | ||
| assert rows == [Decimal("0.000001"), Decimal("1.500000"), Decimal("123456.789012")] | ||
| finally: | ||
| drop_table_if_exists(cursor, table_name) | ||
| db_connection.commit() | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The identical money→VARCHAR shortcut still lives in the Python
_map_sql_type, which this PR doesn't touch, and that path is still reachable from a realexecute(). If a caller usessetinputsizes()for only some positions, the un-sized params fall back to_map_sql_typeand GH-740 reproduces again.executemany()has the same problem forWHERE numeric_col = ?.Can we either mirror this change in
_map_sql_typeor explicitly scope/track the remaining path?