Skip to content

PERF: Native C++ parameter detection and execute pipeline#549

Open
bewithgaurav wants to merge 22 commits into
mainfrom
bewithgaurav/insertmany-perf-detect-types
Open

PERF: Native C++ parameter detection and execute pipeline#549
bewithgaurav wants to merge 22 commits into
mainfrom
bewithgaurav/insertmany-perf-detect-types

Conversation

@bewithgaurav

@bewithgaurav bewithgaurav commented Apr 29, 2026

Copy link
Copy Markdown
Collaborator

Work Item / Issue Reference

AB#44979
AB#49196

GitHub Issue: #500


Summary

Moves parameter type detection and binding from Python into a native C++ pipeline using raw CPython API calls. The new DDBCSQLExecute handles type detection → parameter binding → SQLExecute in a single FFI crossing, eliminating per-parameter Python overhead entirely.

What changed:

  • DetectParamTypes — C++ type detection using raw CPython API (PyLong_Check, PyDateTime_Check, PyObject_RichCompareBool, etc.) replacing the Python-side _create_parameter_types_list loop for the primary execute path.
  • DDBCSQLExecute (formerly DDBCSQLExecuteFast) — single C++ pipeline: detect → bind → execute. ParamInfo never crosses the pybind11 boundary.
  • DDBCSQLExecuteLegacy (formerly DDBCSQLExecute) — retained for setinputsizes users only.
  • MONEY/SMALLMONEY bounds cached once at init using exact Decimal comparison (not lossy double).
  • PythonObjectCache stores all type objects as raw PyObject* (not py::object), eliminating pybind11 wrapper overhead on every cache hit.
  • Proper Py_DECREF cleanup and PyObject_IsInstance error handling on all paths.

Routing (cursor.py):

# Primary path (99% of calls): no setinputsizes → all in C++
# Legacy path: setinputsizes active → Python type detection + DDBCSQLExecuteLegacy

Performance Results 🚀

The Python-side type detection cost was ~2.0–2.3µs per parameter — an isinstance check, ParamInfo object construction, and a pybind11 FFI boundary crossing per parameter, per execute call. The C++ path replaces this with ~35ns/param (raw PyLong_Check + struct field write) — a ~60x faster per-parameter detection.

macOS arm64 (Apple Silicon M-series), Python 3.13

Scenario After (C++) Before Improvement Python overhead eliminated
3 params 435µs 444µs 2% faster 10µs saved
10 params 321µs 344µs 7% faster 22µs saved
50 params 394µs 502µs 21% faster 107µs saved
100 params 558µs 770µs 28% faster 212µs saved
200 params 644µs 1.08ms 40% faster 432µs saved
500 params 708µs 1.82ms 61% faster 1.12ms saved
1024 params 1.07ms 3.32ms 68% faster 2.25ms saved

Linux aarch64 (Docker container), Python 3.13

Scenario After (C++) Before Improvement Python overhead eliminated
3 params 158µs 164µs 4% faster 6µs saved
10 params 159µs 178µs 11% faster 19µs saved
50 params 170µs 271µs 37% faster 101µs saved
100 params 209µs 411µs 49% faster 202µs saved
200 params 280µs 651µs 57% faster 371µs saved
500 params 396µs 1.35ms 71% faster 956µs saved
1024 params 746µs 2.75ms 73% faster 2.0ms saved

vs pyodbc (post-PR, macOS)

Params mssql-python pyodbc Gap
3 399µs 388µs 3% gap (near parity)
50 458µs 452µs 1% gap (parity)
200 604µs 550µs 10% gap (down from ~14x pre-PR)
1024 1.31ms 979µs 33% gap (binding overhead, addressable separately)

Bottom line

Metric Value
Avg improvement (50+ params) ~50% faster execute()
Worst-case improvement (1024 params) 73% faster (2ms saved per call)
Per-param overhead reduction ~60x (2.3µs → 35ns)
pyodbc gap closed From 14x slower (GH-500) to <10% gap at 200 params
Real-world impact Bulk inserts with 200+ params see 2–3x throughput gain

Checklist

  • Tested locally (macOS arm64 + Linux aarch64)
  • Verified perf gain with micro-benchmarks on both platforms
  • CI passing (CodeQL, DevSkim, Black, C++ lint)
  • No breaking changes to public API
  • setinputsizes users unaffected (routed to legacy path)

Move parameter type detection from Python into C++ using raw CPython
type checks (PyLong_CheckExact, PyFloat_CheckExact, etc.). Merge the
DetectParamTypes → BindParameters → SQLExecute pipeline into a single
DDBCSQLExecuteFast call so ParamInfo never crosses the pybind11 boundary.

- DetectParamTypes: handles int (range-detected), float, bool, str
  (unicode + geometry sniffing), bytes, datetime/date/time, Decimal
  (MONEY range + generic numeric), UUID, None, with fallback to string
- SQLExecuteFast_wrap: single pipeline with GIL release, always uses
  SQLPrepare for parameterized queries
- cursor.py: fast path routing when no setinputsizes overrides present;
  old DDBCSQLExecute path preserved for setinputsizes callers
- Named constants: MAX_INLINE_CHAR, MAX_INLINE_BINARY, MAX_NUMERIC_PRECISION,
  MONEY/SMALLMONEY ranges, PARAM_C_TYPE_TEXT platform macro
Comment thread mssql_python/pybind/ddbc_bindings.cpp Fixed
- Add complete DAE (Data-At-Execution) loop to SQLExecuteFast_wrap:
  SQL_NEED_DATA → SQLParamData/SQLPutData for large str/bytes/binary,
  matching the existing SQLExecute_wrap logic exactly
- Fix DAE type assignment: non-unicode DAE strings use SQL_C_CHAR
  (not PARAM_C_TYPE_TEXT which maps to SQL_C_WCHAR on macOS/Linux)
- Fix MONEY range lower bound: use MONEY_MIN not SMALLMONEY_MIN so
  negative decimals in MONEY range bind as VARCHAR (matches Python path)
- Raise TypeError for unknown param types instead of silent str conversion
- Add SQLFreeStmt(SQL_RESET_PARAMS) to unbind after execute
@github-actions

github-actions Bot commented Apr 29, 2026

Copy link
Copy Markdown

📊 Code Coverage Report

🔥 Diff Coverage

70%


🎯 Overall Coverage

79%


📈 Total Lines Covered: 7182 out of 9066
📁 Project: mssql-python


Diff Coverage

Diff: main...HEAD, staged and unstaged changes

  • mssql_python/cursor.py (100%)
  • mssql_python/pybind/ddbc_bindings.cpp (70.4%): Missing lines 140-141,153,178-187,208-212,225-229,242-246,259-263,276-280,326-341,343-364,898-899,912-914,926-929,935-938,966-969,976-979,988-991,999-1000,1017-1019,1034-1036,1058-1060,1063-1066,1069-1073,1078-1082,1088-1092,1111-1116,1123-1128,1133-1137,1141-1146,1152-1156,1162-1167,1172-1177,1184-1188,1206-1208,1473-1474,1615,2698-2699,2703,2705-2706,2737,2828-2829,2832-2835,2854-2872,2903-2904,2934-2936,2946-2950,2954,2960,2980,2996-2999,3009,3436,3551,4640

Summary

  • Total: 802 lines
  • Missing: 234 lines
  • Coverage: 70%

mssql_python/pybind/ddbc_bindings.cpp

Lines 136-145

  136 // unnecessary ref-count traffic on every parameter.
  137 void initialize() {
  138     if (!cache_initialized) {
  139         PyDateTime_IMPORT;
! 140         if (PyDateTimeAPI == nullptr) {
! 141             throw py::error_already_set();
  142         }
  143 
  144         // Cache datetime.datetime, datetime.date, datetime.time for isinstance
  145         // checks in DetectParamTypes. Single import, then borrowed refs held

Lines 149-157

  149         datetime_class = PyObject_GetAttrString(datetime_module, "datetime");
  150         date_class = PyObject_GetAttrString(datetime_module, "date");
  151         time_class = PyObject_GetAttrString(datetime_module, "time");
  152         Py_DECREF(datetime_module);
! 153         if (!datetime_class || !date_class || !time_class)
  154             throw py::error_already_set();
  155 
  156         PyObject* decimal_module = PyImport_ImportModule("decimal");
  157         if (!decimal_module) throw py::error_already_set();

Lines 174-191

  174         smallmoney_max = PyObject_CallFunction(Decimal, "s", "214748.3647");
  175         money_min = PyObject_CallFunction(Decimal, "s", "-922337203685477.5808");
  176         money_max = PyObject_CallFunction(Decimal, "s", "922337203685477.5807");
  177         if (!smallmoney_min || !smallmoney_max || !money_min || !money_max) {
! 178             // Partial creation — clean up whichever succeeded before throwing.
! 179             Py_XDECREF(smallmoney_min);
! 180             Py_XDECREF(smallmoney_max);
! 181             Py_XDECREF(money_min);
! 182             Py_XDECREF(money_max);
! 183             smallmoney_min = nullptr;
! 184             smallmoney_max = nullptr;
! 185             money_min = nullptr;
! 186             money_max = nullptr;
! 187             throw py::error_already_set();
  188         }
  189 
  190         cache_initialized = true;
  191     }

Lines 204-216

  204 PyObject* get_datetime_class() {
  205     if (cache_initialized && datetime_class) {
  206         return datetime_class;
  207     }
! 208     PyObject* mod = PyImport_ImportModule("datetime");
! 209     if (!mod) return nullptr;
! 210     PyObject* cls = PyObject_GetAttrString(mod, "datetime");
! 211     Py_DECREF(mod);
! 212     return cls;  // caller must check for NULL
  213 }
  214 
  215 py::object get_datetime_class_obj() {
  216     return wrap_cached_or_imported(get_datetime_class());

Lines 221-233

  221 PyObject* get_date_class() {
  222     if (cache_initialized && date_class) {
  223         return date_class;
  224     }
! 225     PyObject* mod = PyImport_ImportModule("datetime");
! 226     if (!mod) return nullptr;
! 227     PyObject* cls = PyObject_GetAttrString(mod, "date");
! 228     Py_DECREF(mod);
! 229     return cls;  // caller must check for NULL
  230 }
  231 
  232 py::object get_date_class_obj() {
  233     return wrap_cached_or_imported(get_date_class());

Lines 238-250

  238 PyObject* get_time_class() {
  239     if (cache_initialized && time_class) {
  240         return time_class;
  241     }
! 242     PyObject* mod = PyImport_ImportModule("datetime");
! 243     if (!mod) return nullptr;
! 244     PyObject* cls = PyObject_GetAttrString(mod, "time");
! 245     Py_DECREF(mod);
! 246     return cls;  // caller must check for NULL
  247 }
  248 
  249 py::object get_time_class_obj() {
  250     return wrap_cached_or_imported(get_time_class());

Lines 255-267

  255 PyObject* get_decimal_class() {
  256     if (cache_initialized && decimal_class) {
  257         return decimal_class;
  258     }
! 259     PyObject* mod = PyImport_ImportModule("decimal");
! 260     if (!mod) return nullptr;
! 261     PyObject* cls = PyObject_GetAttrString(mod, "Decimal");
! 262     Py_DECREF(mod);
! 263     return cls;  // caller must check for NULL
  264 }
  265 
  266 py::object get_decimal_class_obj() {
  267     return wrap_cached_or_imported(get_decimal_class());

Lines 272-284

  272 PyObject* get_uuid_class() {
  273     if (cache_initialized && uuid_class) {
  274         return uuid_class;
  275     }
! 276     PyObject* mod = PyImport_ImportModule("uuid");
! 277     if (!mod) return nullptr;
! 278     PyObject* cls = PyObject_GetAttrString(mod, "UUID");
! 279     Py_DECREF(mod);
! 280     return cls;  // caller must check for NULL
  281 }
  282 
  283 py::object get_uuid_class_obj() {
  284     return wrap_cached_or_imported(get_uuid_class());

Lines 322-368

  322         Py_XINCREF(dataPtr);
  323     }
  324     // Copy/move assignment and move constructor below are required for
  325     // std::vector<ParamInfo> resize/reallocation and pybind11's type_caster.
! 326     // They manually manage dataPtr refcounts since ParamInfo owns a strong ref.
! 327     ParamInfo& operator=(const ParamInfo& other) {
! 328         if (this != &other) {
! 329             Py_XDECREF(dataPtr);
! 330             inputOutputType = other.inputOutputType;
! 331             paramCType = other.paramCType;
! 332             paramSQLType = other.paramSQLType;
! 333             columnSize = other.columnSize;
! 334             decimalDigits = other.decimalDigits;
! 335             strLenOrInd = other.strLenOrInd;
! 336             isDAE = other.isDAE;
! 337             dataPtr = other.dataPtr;
! 338             utf16Len = other.utf16Len;
! 339             Py_XINCREF(dataPtr);
! 340         }
! 341         return *this;
  342     }
! 343     ParamInfo(ParamInfo&& other) noexcept
! 344         : inputOutputType(other.inputOutputType), paramCType(other.paramCType),
! 345           paramSQLType(other.paramSQLType), columnSize(other.columnSize),
! 346           decimalDigits(other.decimalDigits), strLenOrInd(other.strLenOrInd),
! 347           isDAE(other.isDAE), dataPtr(other.dataPtr), utf16Len(other.utf16Len) {
! 348         other.dataPtr = nullptr;
! 349     }
! 350     ParamInfo& operator=(ParamInfo&& other) noexcept {
! 351         if (this != &other) {
! 352             Py_XDECREF(dataPtr);
! 353             inputOutputType = other.inputOutputType;
! 354             paramCType = other.paramCType;
! 355             paramSQLType = other.paramSQLType;
! 356             columnSize = other.columnSize;
! 357             decimalDigits = other.decimalDigits;
! 358             strLenOrInd = other.strLenOrInd;
! 359             isDAE = other.isDAE;
! 360             dataPtr = other.dataPtr;
! 361             utf16Len = other.utf16Len;
! 362             other.dataPtr = nullptr;
! 363         }
! 364         return *this;
  365     }
  366 };
  367 #ifdef __GNUC__
  368 #pragma GCC diagnostic pop

Lines 894-903

  894             Py_ssize_t time_len = PyUnicode_GET_LENGTH(time_str);
  895             info.columnSize = std::max<SQLULEN>(info.columnSize, time_len);
  896             // PyList_SetItem (lowercase) decrefs the old slot before stealing the new
  897             // reference; safe here because cursor.py already passed a fresh list copy.
! 898             if (PyList_SetItem(params, i, time_str) != 0) {
! 899                 throw py::error_already_set();
  900             }
  901             continue;
  902         }

Lines 908-918

  908             PyObject* as_tuple = PyObject_CallMethod(obj, "as_tuple", NULL);
  909             if (!as_tuple) throw py::error_already_set();
  910 
  911             PyObject* exponent_obj = PyObject_GetAttrString(as_tuple, "exponent");
! 912             if (!exponent_obj) {
! 913                 Py_DECREF(as_tuple);
! 914                 throw py::error_already_set();
  915             }
  916 
  917             // NaN / Infinity / sNaN: refuse rather than silently writing 0.
  918             if (PyUnicode_Check(exponent_obj)) {

Lines 922-933

  922                     "Cannot bind non-finite Decimal (NaN/Infinity) as SQL NUMERIC");
  923             }
  924 
  925             PyObject* digits_obj = PyObject_GetAttrString(as_tuple, "digits");
! 926             if (!digits_obj) {
! 927                 Py_DECREF(exponent_obj);
! 928                 Py_DECREF(as_tuple);
! 929                 throw py::error_already_set();
  930             }
  931 
  932             Py_ssize_t num_digits = PyTuple_GET_SIZE(digits_obj);
  933             int exponent = static_cast<int>(PyLong_AsLong(exponent_obj));

Lines 931-942

  931 
  932             Py_ssize_t num_digits = PyTuple_GET_SIZE(digits_obj);
  933             int exponent = static_cast<int>(PyLong_AsLong(exponent_obj));
  934             Py_DECREF(exponent_obj);
! 935             if (exponent == -1 && PyErr_Occurred()) {
! 936                 Py_DECREF(digits_obj);
! 937                 Py_DECREF(as_tuple);
! 938                 throw py::error_already_set();
  939             }
  940 
  941             int precision;
  942             if (exponent >= 0)

Lines 962-973

  962             // Use exact Decimal comparison (not double) to avoid boundary misclassification.
  963             bool in_money_range = false;
  964             int cmp_ge = PyObject_RichCompareBool(obj, PythonObjectCache::smallmoney_min, Py_GE);
  965             int cmp_le = PyObject_RichCompareBool(obj, PythonObjectCache::smallmoney_max, Py_LE);
! 966             if (cmp_ge == -1 || cmp_le == -1) {
! 967                 Py_DECREF(digits_obj);
! 968                 Py_DECREF(as_tuple);
! 969                 throw py::error_already_set();
  970             }
  971             if (cmp_ge == 1 && cmp_le == 1) {
  972                 in_money_range = true;
  973             } else {

Lines 972-983

  972                 in_money_range = true;
  973             } else {
  974                 cmp_ge = PyObject_RichCompareBool(obj, PythonObjectCache::money_min, Py_GE);
  975                 cmp_le = PyObject_RichCompareBool(obj, PythonObjectCache::money_max, Py_LE);
! 976                 if (cmp_ge == -1 || cmp_le == -1) {
! 977                     Py_DECREF(digits_obj);
! 978                     Py_DECREF(as_tuple);
! 979                     throw py::error_already_set();
  980                 }
  981                 if (cmp_ge == 1 && cmp_le == 1) {
  982                     in_money_range = true;
  983                 }

Lines 984-995

  984             }
  985 
  986             if (in_money_range) {
  987                 PyObject* formatted = PyObject_CallMethod(obj, "__format__", "s", "f");
! 988                 if (!formatted) {
! 989                     Py_DECREF(digits_obj);
! 990                     Py_DECREF(as_tuple);
! 991                     throw py::error_already_set();
  992                 }
  993                 info.paramSQLType = SQL_VARCHAR;
  994                 info.paramCType = PARAM_C_TYPE_TEXT;
  995                 info.columnSize = PyUnicode_GET_LENGTH(formatted);

Lines 995-1004

   995                 info.columnSize = PyUnicode_GET_LENGTH(formatted);
   996                 info.decimalDigits = 0;
   997                 Py_DECREF(digits_obj);
   998                 Py_DECREF(as_tuple);
!  999                 if (PyList_SetItem(params, i, formatted) != 0) {
! 1000                     throw py::error_already_set();
  1001                 }
  1002                 continue;
  1003             }

Lines 1013-1023

  1013             info.decimalDigits = nd.scale;
  1014             // Store NumericData as a Python object in the param list for the binder.
  1015             py::object numeric_obj = py::cast(nd);
  1016             PyObject* raw = numeric_obj.release().ptr();
! 1017             if (PyList_SetItem(params, i, raw) != 0) {
! 1018                 Py_DECREF(raw);
! 1019                 throw py::error_already_set();
  1020             }
  1021             continue;
  1022         }

Lines 1030-1040

  1030             info.paramSQLType = SQL_GUID;
  1031             info.paramCType = SQL_C_GUID;
  1032             info.columnSize = 16;
  1033             info.decimalDigits = 0;
! 1034             if (PyList_SetItem(params, i, bytes_le) != 0) {
! 1035                 Py_DECREF(bytes_le);
! 1036                 throw py::error_already_set();
  1037             }
  1038             continue;
  1039         }

Lines 1054-1086

  1054     PyObject* as_tuple = PyObject_CallMethod(decimal_param, "as_tuple", NULL);
  1055     if (!as_tuple) throw py::error_already_set();
  1056 
  1057     PyObject* digits_obj = PyObject_GetAttrString(as_tuple, "digits");
! 1058     if (!digits_obj) {
! 1059         Py_DECREF(as_tuple);
! 1060         throw py::error_already_set();
  1061     }
  1062     PyObject* sign_obj = PyObject_GetAttrString(as_tuple, "sign");
! 1063     if (!sign_obj) {
! 1064         Py_DECREF(digits_obj);
! 1065         Py_DECREF(as_tuple);
! 1066         throw py::error_already_set();
  1067     }
  1068     PyObject* exponent_obj = PyObject_GetAttrString(as_tuple, "exponent");
! 1069     if (!exponent_obj) {
! 1070         Py_DECREF(sign_obj);
! 1071         Py_DECREF(digits_obj);
! 1072         Py_DECREF(as_tuple);
! 1073         throw py::error_already_set();
  1074     }
  1075 
  1076     int sign_val = static_cast<int>(PyLong_AsLong(sign_obj));
  1077     Py_DECREF(sign_obj);
! 1078     if (sign_val == -1 && PyErr_Occurred()) {
! 1079         Py_DECREF(exponent_obj);
! 1080         Py_DECREF(digits_obj);
! 1081         Py_DECREF(as_tuple);
! 1082         throw py::error_already_set();
  1083     }
  1084 
  1085     int exponent = 0;
  1086     if (PyLong_Check(exponent_obj)) {

Lines 1084-1096

  1084 
  1085     int exponent = 0;
  1086     if (PyLong_Check(exponent_obj)) {
  1087         exponent = static_cast<int>(PyLong_AsLong(exponent_obj));
! 1088         if (exponent == -1 && PyErr_Occurred()) {
! 1089             Py_DECREF(exponent_obj);
! 1090             Py_DECREF(digits_obj);
! 1091             Py_DECREF(as_tuple);
! 1092             throw py::error_already_set();
  1093         }
  1094     }
  1095     Py_DECREF(exponent_obj);

Lines 1107-1120

  1107     scale = std::min(scale, precision);
  1108 
  1109     PyObject* py_ten = PyLong_FromLong(10);
  1110     PyObject* int_val = PyLong_FromLong(0);
! 1111     if (!py_ten || !int_val) {
! 1112         Py_XDECREF(int_val);
! 1113         Py_XDECREF(py_ten);
! 1114         Py_DECREF(digits_obj);
! 1115         Py_DECREF(as_tuple);
! 1116         throw py::error_already_set();
  1117     }
  1118 
  1119     const Py_ssize_t digit_count = PyTuple_GET_SIZE(digits_obj);
  1120     for (Py_ssize_t i = 0; i < digit_count; ++i) {

Lines 1119-1150

  1119     const Py_ssize_t digit_count = PyTuple_GET_SIZE(digits_obj);
  1120     for (Py_ssize_t i = 0; i < digit_count; ++i) {
  1121         PyObject* digit_obj = PyTuple_GET_ITEM(digits_obj, i);
  1122         long digit = PyLong_AsLong(digit_obj);
! 1123         if (digit == -1 && PyErr_Occurred()) {
! 1124             Py_DECREF(int_val);
! 1125             Py_DECREF(py_ten);
! 1126             Py_DECREF(digits_obj);
! 1127             Py_DECREF(as_tuple);
! 1128             throw py::error_already_set();
  1129         }
  1130 
  1131         PyObject* multiplied = PyNumber_Multiply(int_val, py_ten);
  1132         Py_DECREF(int_val);
! 1133         if (!multiplied) {
! 1134             Py_DECREF(py_ten);
! 1135             Py_DECREF(digits_obj);
! 1136             Py_DECREF(as_tuple);
! 1137             throw py::error_already_set();
  1138         }
  1139 
  1140         PyObject* py_digit = PyLong_FromLong(digit);
! 1141         if (!py_digit) {
! 1142             Py_DECREF(multiplied);
! 1143             Py_DECREF(py_ten);
! 1144             Py_DECREF(digits_obj);
! 1145             Py_DECREF(as_tuple);
! 1146             throw py::error_already_set();
  1147         }
  1148 
  1149         int_val = PyNumber_Add(multiplied, py_digit);
  1150         Py_DECREF(multiplied);

Lines 1148-1160

  1148 
  1149         int_val = PyNumber_Add(multiplied, py_digit);
  1150         Py_DECREF(multiplied);
  1151         Py_DECREF(py_digit);
! 1152         if (!int_val) {
! 1153             Py_DECREF(py_ten);
! 1154             Py_DECREF(digits_obj);
! 1155             Py_DECREF(as_tuple);
! 1156             throw py::error_already_set();
  1157         }
  1158     }
  1159 
  1160     if (exponent > 0) {

Lines 1158-1181

  1158     }
  1159 
  1160     if (exponent > 0) {
  1161         PyObject* multiplier = PyLong_FromLong(1);
! 1162         if (!multiplier) {
! 1163             Py_DECREF(int_val);
! 1164             Py_DECREF(py_ten);
! 1165             Py_DECREF(digits_obj);
! 1166             Py_DECREF(as_tuple);
! 1167             throw py::error_already_set();
  1168         }
  1169         for (int j = 0; j < exponent; ++j) {
  1170             PyObject* next_multiplier = PyNumber_Multiply(multiplier, py_ten);
  1171             Py_DECREF(multiplier);
! 1172             if (!next_multiplier) {
! 1173                 Py_DECREF(int_val);
! 1174                 Py_DECREF(py_ten);
! 1175                 Py_DECREF(digits_obj);
! 1176                 Py_DECREF(as_tuple);
! 1177                 throw py::error_already_set();
  1178             }
  1179             multiplier = next_multiplier;
  1180         }
  1181         PyObject* scaled_val = PyNumber_Multiply(int_val, multiplier);

Lines 1180-1192

  1180         }
  1181         PyObject* scaled_val = PyNumber_Multiply(int_val, multiplier);
  1182         Py_DECREF(multiplier);
  1183         Py_DECREF(int_val);
! 1184         if (!scaled_val) {
! 1185             Py_DECREF(py_ten);
! 1186             Py_DECREF(digits_obj);
! 1187             Py_DECREF(as_tuple);
! 1188             throw py::error_already_set();
  1189         }
  1190         int_val = scaled_val;
  1191     }

Lines 1202-1212

  1202     if (!val_bytes) throw py::error_already_set();
  1203 
  1204     char* val_buf = nullptr;
  1205     Py_ssize_t val_size = 0;
! 1206     if (PyBytes_AsStringAndSize(val_bytes, &val_buf, &val_size) == -1) {
! 1207         Py_DECREF(val_bytes);
! 1208         throw py::error_already_set();
  1209     }
  1210 
  1211     NumericData nd;
  1212     nd.precision = static_cast<SQLCHAR>(precision);

Lines 1469-1478

  1469                     dataPtr = sqlwcharBuffer->data();
  1470                     bufferLength = sqlwcharBuffer->size() * sizeof(SQLWCHAR);
  1471                     strLenOrIndPtr = AllocateParamBuffer<SQLLEN>(paramBuffers);
  1472                     // Use explicit byte length instead of SQL_NTS so embedded NUL chars
! 1473                     // aren't treated as string terminators.
! 1474                     *strLenOrIndPtr = static_cast<SQLLEN>(sqlwcharBuffer->size() * sizeof(SQLWCHAR));
  1475                 }
  1476                 break;
  1477             }
  1478             case SQL_C_BIT: {

Lines 1611-1619

  1611                 dataPtr = static_cast<void*>(sqlTimePtr);
  1612                 break;
  1613             }
  1614             case SQL_C_SS_TIMESTAMPOFFSET: {
! 1615                 py::object datetimeType = PythonObjectCache::get_datetime_class_obj();
  1616                 if (!py::isinstance(param, datetimeType)) {
  1617                     ThrowStdException(MakeParamMismatchErrorStr(paramInfo.paramCType, paramIndex));
  1618                 }
  1619                 // Checking if the object has a timezone

Lines 2694-2710

  2694                 }
  2695                 if (!matchedInfo) {
  2696                     ThrowStdException("Unrecognized paramToken returned by SQLParamData");
  2697                 }
! 2698                 PyObject* pyObj = matchedInfo->dataPtr;
! 2699                 if (!pyObj || pyObj == Py_None) {
  2700                     putData(nullptr, 0);
  2701                     continue;
  2702                 }
! 2703                 if (PyUnicode_Check(pyObj)) {
  2704                     if (matchedInfo->paramCType == SQL_C_WCHAR) {
! 2705                         std::u16string utf16 =
! 2706                             py::reinterpret_borrow<py::str>(py::handle(pyObj)).cast<std::u16string>();
  2707                         size_t totalChars = utf16.size();
  2708                         const SQLWCHAR* dataPtr = reinterpretU16stringAsSqlWChar(utf16);
  2709                         size_t offset = 0;
  2710                         size_t chunkChars = DAE_CHUNK_SIZE / sizeof(SQLWCHAR);

Lines 2733-2741

  2733                             py::object encoded = py::reinterpret_borrow<py::object>(py::handle(pyObj))
  2734                                                      .attr("encode")(charEncoding, "strict");
  2735                             encodedStr = encoded.cast<std::string>();
  2736                             LOG("SQLExecute: DAE SQL_C_CHAR - Encoded with '%s', %zu bytes",
! 2737                                 charEncoding.c_str(), encodedStr.size());
  2738                         } catch (const py::error_already_set& e) {
  2739                             LOG_ERROR("SQLExecute: DAE SQL_C_CHAR - Failed to encode with '%s': %s",
  2740                                       charEncoding.c_str(), e.what());
  2741                             throw;

Lines 2824-2839

  2824 SQLRETURN SQLExecute_wrap(const SqlHandlePtr statementHandle,
  2825                               const std::u16string& query,
  2826                               py::list params,
  2827                               py::list is_stmt_prepared,
! 2828                               bool use_prepare,
! 2829                               const py::dict& encoding_settings) {
  2830     if (!statementHandle || !statementHandle->get()) {
  2831         return SQL_INVALID_HANDLE;
! 2832     }
! 2833 
! 2834     SQLHANDLE hStmt = statementHandle->get();
! 2835 
  2836     // Configure forward-only / read-only cursor (matches slow path semantics).
  2837     if (SQLSetStmtAttr_ptr) {
  2838         SQLSetStmtAttr_ptr(hStmt, SQL_ATTR_CURSOR_TYPE,
  2839                            (SQLPOINTER)SQL_CURSOR_FORWARD_ONLY, 0);

Lines 2850-2876

  2850     // encoding when ctype == 1 (real ODBC SQL_CHAR). Otherwise the user's
  2851     // "encoding" value is meant for the wide-char path and we leave it alone.
  2852     std::string charEncoding = "utf-8";
  2853     if (encoding_settings.contains("ctype") && encoding_settings.contains("encoding")) {
! 2854         int ctype = encoding_settings["ctype"].cast<int>();
! 2855         if (ctype == SQL_C_CHAR /* real ODBC value: 1 */) {
! 2856             charEncoding = encoding_settings["encoding"].cast<std::string>();
! 2857         }
! 2858     }
! 2859 
! 2860     // The cursor.py caller always passes a fresh `list(actual_params)` so this
! 2861     // function is free to mutate slots in place. Even so, every site below uses
! 2862     // PyList_SetItem (which decrefs the old slot before stealing the new ref),
! 2863     // so the function is safe regardless of who owns the list.
! 2864 
! 2865     // Run DetectParamTypes BEFORE SQLPrepare so that type-detection errors
! 2866     // (unsupported type, NaN Decimal, precision overflow) don't leave the
! 2867     // cursor in a half-prepared state.
! 2868     std::vector<ParamInfo> paramInfos = DetectParamTypes(params.ptr());
! 2869 
! 2870     RETCODE rc;
! 2871     bool already_prepared = is_stmt_prepared[0].cast<bool>();
! 2872 
  2873     // Honor use_prepare flag (matching slow path behavior):
  2874     // - use_prepare=true: prepare now (or reuse if same SQL already prepared)
  2875     // - use_prepare=false + already prepared: reuse existing plan
  2876     // - use_prepare=false + not prepared: error (cannot execute unprepared)

Lines 2899-2908

  2899     }
  2900 
  2901     // DAE (Data-At-Execution) loop: when BindParameters marks a param as DAE
  2902     // (large str/bytes/binary), SQLExecute returns SQL_NEED_DATA. We must
! 2903     // stream the data via SQLParamData/SQLPutData before execution completes.
! 2904     // GIL is released around each ODBC call to match slow-path concurrency.
  2905     if (rc == SQL_NEED_DATA) {
  2906         SQLPOINTER paramToken = nullptr;
  2907         while (true) {
  2908             {

Lines 2930-2940

  2930 
  2931             if (PyUnicode_Check(pyObj)) {
  2932                 if (matchedInfo->paramCType == SQL_C_WCHAR) {
  2933                     std::u16string u16 =
! 2934                         py::reinterpret_borrow<py::str>(py::handle(pyObj)).cast<std::u16string>();
! 2935                     const SQLWCHAR* dataPtr = reinterpretU16stringAsSqlWChar(u16);
! 2936                     size_t totalChars = u16.size();
  2937                     size_t chunkChars = DAE_CHUNK_SIZE / sizeof(SQLWCHAR);
  2938                     for (size_t offset = 0; offset < totalChars; offset += chunkChars) {
  2939                         size_t len = std::min(chunkChars, totalChars - offset);
  2940                         {

Lines 2942-2958

  2942                             rc = SQLPutData_ptr(hStmt, (SQLPOINTER)(dataPtr + offset),
  2943                                                 static_cast<SQLLEN>(len * sizeof(SQLWCHAR)));
  2944                         }
  2945                         if (!SQL_SUCCEEDED(rc)) return rc;
! 2946                     }
! 2947                 } else if (matchedInfo->paramCType == SQL_C_CHAR) {
! 2948                     std::string encodedStr;
! 2949                     py::object encoded = py::reinterpret_borrow<py::object>(py::handle(pyObj))
! 2950                                              .attr("encode")(charEncoding, "strict");
  2951                     encodedStr = encoded.cast<std::string>();
  2952                     const char* dataPtr = encodedStr.data();
  2953                     size_t totalBytes = encodedStr.size();
! 2954                     for (size_t offset = 0; offset < totalBytes; offset += DAE_CHUNK_SIZE) {
  2955                         size_t len = std::min(static_cast<size_t>(DAE_CHUNK_SIZE),
  2956                                               totalBytes - offset);
  2957                         {
  2958                             py::gil_scoped_release release;

Lines 2956-2964

  2956                                               totalBytes - offset);
  2957                         {
  2958                             py::gil_scoped_release release;
  2959                             rc = SQLPutData_ptr(hStmt, (SQLPOINTER)(dataPtr + offset),
! 2960                                                 static_cast<SQLLEN>(len));
  2961                         }
  2962                         if (!SQL_SUCCEEDED(rc)) return rc;
  2963                     }
  2964                 } else {

Lines 2976-2984

  2976                     dataPtr = bytesStorage.data();
  2977                     totalBytes = bytesStorage.size();
  2978                 } else {
  2979                     // bytearray is mutable — copy to stable buffer before streaming
! 2980                     bytesStorage.assign(PyByteArray_AS_STRING(pyObj),
  2981                                         static_cast<size_t>(PyByteArray_GET_SIZE(pyObj)));
  2982                     dataPtr = bytesStorage.data();
  2983                     totalBytes = bytesStorage.size();
  2984                 }

Lines 2992-3003

  2992                                             static_cast<SQLLEN>(len));
  2993                     }
  2994                     if (!SQL_SUCCEEDED(rc)) return rc;
  2995                 }
! 2996             } else {
! 2997                 ThrowStdException("SQLExecuteFast: DAE only supported for str or bytes");
! 2998             }
! 2999         }
  3000         if (!SQL_SUCCEEDED(rc) && rc != SQL_NO_DATA) return rc;
  3001     }
  3002 
  3003     if (!SQL_SUCCEEDED(rc) && rc != SQL_NO_DATA) return rc;

Lines 3005-3013

  3005     // Preserve the execute return code (e.g. SQL_SUCCESS_WITH_INFO) — don't
  3006     // let the SQLFreeStmt return value clobber what the caller needs to see.
  3007     SQLRETURN exec_rc = rc;
  3008     SQLFreeStmt_ptr(hStmt, SQL_RESET_PARAMS);
! 3009     return exec_rc;
  3010 }
  3011 
  3012 SQLRETURN BindParameterArray(SqlHandle& handle, SQLHANDLE hStmt, const py::list& columnwise_params,
  3013                              std::vector<ParamInfo>& paramInfos, size_t paramSetSize,

Lines 3432-3440

  3432                     DateTimeOffset* dtoArray =
  3433                         AllocateParamBufferArray<DateTimeOffset>(tempBuffers, paramSetSize);
  3434                     strLenOrIndArray = AllocateParamBufferArray<SQLLEN>(tempBuffers, paramSetSize);
  3435 
! 3436                     py::object datetimeType = PythonObjectCache::get_datetime_class_obj();
  3437 
  3438                     for (size_t i = 0; i < paramSetSize; ++i) {
  3439                         const py::handle& param = columnValues[i];

Lines 3547-3555

  3547 
  3548                     // Get cached UUID class from module-level helper
  3549                     // This avoids static object destruction issues during
  3550                     // Python finalization
! 3551                     py::object uuid_class = PythonObjectCache::get_uuid_class_obj();
  3552                     // Get cached UUID class
  3553 
  3554                     for (size_t i = 0; i < paramSetSize; ++i) {
  3555                         const py::handle& element = columnValues[i];

Lines 4636-4644

  4636                     int microseconds = dtoValue.fraction / 1000;
  4637                     py::object datetime_module = py::module_::import("datetime");
  4638                     py::object tzinfo = datetime_module.attr("timezone")(
  4639                         datetime_module.attr("timedelta")(py::arg("minutes") = totalMinutes));
! 4640                     py::object py_dt = PythonObjectCache::get_datetime_class_obj()(
  4641                         dtoValue.year, dtoValue.month, dtoValue.day, dtoValue.hour, dtoValue.minute,
  4642                         dtoValue.second, microseconds, tzinfo);
  4643                     row.append(py_dt);
  4644                 } else {


📋 Files Needing Attention

📉 Files with overall lowest coverage (click to expand)
mssql_python.pybind.logger_bridge.cpp: 59.2%
mssql_python.pybind.ddbc_bindings.h: 59.9%
mssql_python.pybind.logger_bridge.hpp: 70.8%
mssql_python.pybind.ddbc_bindings.cpp: 74.1%
mssql_python.pybind.connection.connection.cpp: 76.2%
mssql_python.__init__.py: 77.3%
mssql_python.row.py: 77.6%
mssql_python.ddbc_bindings.py: 79.6%
mssql_python.connection.py: 83.6%
mssql_python.logging.py: 85.5%

🔗 Quick Links

⚙️ Build Summary 📋 Coverage Details

View Azure DevOps Build

Browse Full Coverage Report

- Comment out use_prepare parameter name (C4100: unreferenced parameter)
- Remove unused catch variable name (C4101: unreferenced local variable)
Add explicit null pointer and zero-length guards before memcpy in
build_numeric_data to satisfy DevSkim code scanning rule DS121708.
Comment thread mssql_python/pybind/ddbc_bindings.cpp Fixed
@github-actions github-actions Bot added the pr-size: large Substantial code update label May 7, 2026
…or attrs, parity test

Six review fixes for SQLExecuteFast_wrap and DetectParamTypes:

1. Encoding key: read 'encoding' from settings dict (was 'charEncoding'
   which never matched). Only honor when ctype==SQL_C_CHAR so the default
   utf-16le doesn't corrupt SQL_C_CHAR DAE/inline byte paths.
2. Subclass support: PyLong_Check/PyFloat_Check/PyUnicode_Check/PyBytes_Check
   instead of *_CheckExact. Fixes user-defined int/str/bytes/float
   subclasses that were silently rejected with TypeError. Switched
   PyBytes_GET_SIZE to PyBytes_Size for subclass-safe length.
3. GIL release in DAE loop: SQLParamData and SQLPutData now release the
   GIL during each ODBC call, matching slow-path concurrency for large
   blobs/strings.
4. Preserve exec_rc: stash the SQLExecute return code before SQLFreeStmt
   so SUCCESS_WITH_INFO and other non-success-non-error codes are not
   clobbered by the unbind call.
5. Shallow-copy params: params = py::list(params) at function entry so
   DetectParamTypes' in-place PyList_SET_ITEM cannot mutate the caller's
   list under any future code path that might pass it directly.
6. Cursor attrs: SQLSetStmtAttr(SQL_ATTR_CURSOR_TYPE/CONCURRENCY) at
   entry to match slow-path semantics regardless of prior hstmt state.

Also adds tests/test_023_fast_path_parity.py covering int/str/bytes/float
subclasses, caller-list non-mutation, and unsupported-type TypeError.
Comment thread tests/test_023_fast_path_parity.py Fixed
bewithgaurav and others added 2 commits May 7, 2026 14:46
Eight follow-up fixes after review feedback on c5a827f.

1. Refcount leak (BLOCKER): replace PyList_SET_ITEM (uppercase, no decref of
   old slot) with PyList_SetItem (decrefs old slot before stealing the new
   reference) in DetectParamTypes time/Decimal/UUID branches. The previous
   shallow-copy defense via py::list(params) was a no-op because pybind11s
   list constructor only inc_refs an already-list argument.
2. Geometry + DAE conflict: gate the geometry-prefix override on the not-DAE
   branch so a long POLYGON/POINT/LINESTRING string does not end up with
   isDAE=true, dataPtr set, AND a non-zero columnSize.
3. Decimal NaN/Infinity: throw ValueError instead of silently binding 0 via
   build_numeric_data on an empty digits tuple.
4. Time format: always emit microseconds (HH:MM:SS.ffffff), matching slow
   path isoformat(timespec=microseconds).
5. PyObject_IsInstance: explicit equality check so a custom __instancecheck__
   that raises (returns -1) does not fall through with a Python error set.
6. Dead code: removed unused SMALLMONEY_MIN/SMALLMONEY_MAX constants and the
   unused utf16Len assignments in DetectParamTypes.
7. Encoding-key contract: only honor encoding_settings encoding when the
   user explicitly opted in via setencoding(..., ctype=SQL_C_CHAR=1). The
   Python layer SQL_C_CHAR constant is numerically -8 (real ODBC SQL_C_WCHAR),
   so by default the wide-char path is taken and encoding is irrelevant.
8. Parity test rewrite: drop the dead _force_slow_path_roundtrip helper, use
   the project cursor fixture instead of a hard-coded conn string, and add
   (a) a real fast-vs-slow parity check via setinputsizes-forced slow path,
   (b) a refcount-leak regression test using a Decimal subclass + weakref,
   (c) explicit NaN-rejection coverage.
Resolve conflicts in ddbc_bindings.cpp from main's GH-610 work:
- Keep both build_numeric_data (this PR) and ResolveNullParamType (main)
- Adopt main's BindParameters/BindParameterArray signatures that take
  SqlHandle& handle; update the SQLExecuteFast_wrap call site to pass
  *statementHandle so the fast path uses the per-handle NULL describe cache
- Migrate SQLExecuteFast_wrap from std::wstring + WStringToSQLWCHAR to
  std::u16string + reinterpretU16stringAsSqlWChar (main's uniform 16-bit
  query/param representation), dropping the platform #ifdef in both the
  prepare path and the DAE wide-char put-data loop

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Comment thread mssql_python/pybind/ddbc_bindings.cpp Fixed
bewithgaurav and others added 5 commits July 6, 2026 14:23
- Honor use_prepare flag (was silently ignored, always preparing)
- Move DetectParamTypes before SQLPrepare to prevent half-prepared state
- Fix bytearray DAE crash (pybind11 bytes caster doesn't handle bytearray)
- Replace lossy double MONEY comparison with exact Decimal arithmetic
- Add SMALLMONEY range detection (was missing from fast path)
- Handle PyObject_IsInstance error return (-1) with proper exception propagation
- Clear describe cache on prepare (matching slow path)
- Add edge case tests: large bytearray/bytes/string DAE, MONEY boundaries,
  Infinity rejection, embedded nulls

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Replace pybind11 .attr()/.cast<>() with raw CPython calls throughout
  DetectParamTypes and build_numeric_data
- datetime/date/time: use PyDateTime_Check/PyDate_Check/PyTime_Check macros
  and PyDateTime_TIME_GET_* accessors (requires PyDateTime_IMPORT)
- Decimal: PyObject_CallMethod/GetAttrString/RichCompareBool instead of
  py::module_::import + py::object .attr() chains
- UUID: PyObject_GetAttrString("bytes_le") instead of py::handle .attr()
- Cache MONEY/SMALLMONEY Decimal bounds in PythonObjectCache (constructed
  once at init, not per-call) using cached Python-side constants
- Replace magic int range numbers with UINT8_MAX/INT16_MIN/MAX/INT32_MIN/MAX
- Proper Py_DECREF cleanup on all error paths in build_numeric_data

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Comment thread mssql_python/pybind/ddbc_bindings.cpp Fixed
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
memcpy_s(&nd.val[0], SQL_MAX_NUMERIC_LEN, val_buf, copy_len);
#else
// copy_len is bounded to SQL_MAX_NUMERIC_LEN above — safe by construction
std::memcpy(&nd.val[0], val_buf, copy_len); // NOLINT(clang-analyzer-security.insecureAPI.DeprecatedOrUnsafeBufferHandling)
…ecuteLegacy

The new C++ pipeline is the primary path (99% of calls). The old function
is the legacy fallback for setinputsizes users only. Naming should reflect this.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@bewithgaurav bewithgaurav changed the title PERF: Add C++ DetectParamTypes + SQLExecuteFast pipeline PERF: Native C++ parameter detection and execute pipeline Jul 14, 2026
bewithgaurav and others added 4 commits July 14, 2026 11:45
Removes unnecessary pybind11 ↔ CPython round-trips in the hot path:
- PythonObjectCache types stored as PyObject* (not py::object)
- ParamInfo::dataPtr is raw PyObject* with explicit refcount management
- DetectParamTypes takes PyObject* directly (not py::list&)
- build_numeric_data returns NumericData struct (not py::object)
- Added contextual comments explaining non-obvious design decisions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ility

pybind11's type_caster needs copy semantics for std::vector<ParamInfo>&
in the legacy path. Provide a copy ctor that Py_XINCREFs dataPtr.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Strings with embedded NUL characters (e.g., 'hello\x00world') were
truncated at the first NUL because BindParameters used SQL_NTS
(null-terminated string indicator). Now passes the actual byte/char
length so ODBC sees the full string.

Fixes test_string_with_embedded_nulls on all platforms.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add tests: integer overflow (2**63), Decimal NaN/sNaN, precision > 38
- Add LCOV_EXCL markers on CPython import-failure and cache-fallback paths
- Add contextual comments on PythonObjectCache and ParamInfo operators

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Comment thread tests/test_023_fast_path_parity.py Dismissed
@bewithgaurav bewithgaurav marked this pull request as ready for review July 14, 2026 14:17
Copilot AI review requested due to automatic review settings July 14, 2026 14:17

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.

Pull request overview

This PR shifts the primary cursor.execute() pipeline from Python into native C++ by adding a raw-CPython DetectParamTypes fast path and routing calls to a single DDBCSQLExecute FFI entrypoint when setinputsizes isn’t active, targeting large parameter-count performance regressions (GH-500).

Changes:

  • Added a native DetectParamTypes → BindParameters → SQLExecute pipeline (DDBCSQLExecute) to avoid per-parameter Python/pybind11 overhead.
  • Preserved a legacy path (DDBCSQLExecuteLegacy) for setinputsizes users and updated Python routing accordingly.
  • Added parity and regression tests covering fast/slow path equivalence, subclass handling, DAE streaming, and refcount safety.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 6 comments.

File Description
tests/test_023_fast_path_parity.py Adds fast-vs-legacy parity tests and regression coverage for the new native execute pipeline.
tests/test_010_pybind_functions.py Updates exposed-function expectations to include DDBCSQLExecuteLegacy.
mssql_python/pybind/ddbc_bindings.cpp Implements native type detection, new execute entrypoints, and various binding/DAE handling updates.
mssql_python/cursor.py Routes execute() to DDBCSQLExecute for the primary path and to DDBCSQLExecuteLegacy when setinputsizes overrides are present.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +2772 to +2775
} else {
dataPtr = PyByteArray_AS_STRING(pyObj);
totalBytes = static_cast<size_t>(PyByteArray_GET_SIZE(pyObj));
}
Comment on lines +2976 to +2980
} else {
// bytearray: use raw buffer access
dataPtr = PyByteArray_AS_STRING(pyObj);
totalBytes = static_cast<size_t>(PyByteArray_GET_SIZE(pyObj));
}
Comment thread mssql_python/pybind/ddbc_bindings.cpp Outdated
info.columnSize = std::max<SQLULEN>(info.columnSize, time_len);
// PyList_SetItem (lowercase) decrefs the old slot before stealing the new
// reference; safe here because cursor.py already passed a fresh list copy.
PyList_SetItem(params, i, time_str);
Comment thread mssql_python/pybind/ddbc_bindings.cpp Outdated
info.decimalDigits = 0;
Py_DECREF(digits_obj);
Py_DECREF(as_tuple);
PyList_SetItem(params, i, formatted);
Comment thread mssql_python/pybind/ddbc_bindings.cpp Outdated
Comment on lines +1022 to +1024
// Store NumericData as a Python object in the param list for the binder.
py::object numeric_obj = py::cast(nd);
PyList_SetItem(params, i, numeric_obj.release().ptr());
Comment thread mssql_python/pybind/ddbc_bindings.cpp Outdated
info.paramCType = SQL_C_GUID;
info.columnSize = 16;
info.decimalDigits = 0;
PyList_SetItem(params, i, bytes_le);
bewithgaurav and others added 2 commits July 14, 2026 20:06
…E safety

- Check PyList_SetItem return value at all 4 call sites in DetectParamTypes
- Copy mutable bytearray into std::string before DAE streaming (both paths)
- Revert LCOV_EXCL markers (not processed by llvm-cov pipeline)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add PyTuple_Check guard before PyTuple_GET_SIZE/GET_ITEM on
  Decimal.as_tuple().digits in DetectParamTypes and build_numeric_data
- Add ParamResetGuard RAII struct to ensure SQLFreeStmt(SQL_RESET_PARAMS)
  fires on all exit paths after BindParameters succeeds
- Wrap PythonObjectCache::initialize() in try/catch to clean up partial
  refs on any import failure

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr-size: large Substantial code update

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants