diff --git a/mssql_python/cursor.py b/mssql_python/cursor.py index 1cb12cb48..693274ef6 100644 --- a/mssql_python/cursor.py +++ b/mssql_python/cursor.py @@ -1032,13 +1032,13 @@ def _reset_cursor(self) -> None: self.is_stmt_prepared = [False] def _soft_reset_cursor(self) -> None: - """Lightweight reset: close cursor and unbind params without freeing the HSTMT. + """Close results without freeing the HSTMT or compatible cached bindings. Preserves the prepared statement plan on the server so repeated executions of the same SQL skip SQLPrepare entirely. """ if self.hstmt: - ret = ddbc_bindings.DDBCSQLResetStmt(self.hstmt) + ret = ddbc_bindings.DDBCSQLResetStmt(self.hstmt, preserve_bindings=True) try: check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, ret) except Exception: diff --git a/mssql_python/pybind/README.md b/mssql_python/pybind/README.md index 977087f11..d6f3ea8ec 100644 --- a/mssql_python/pybind/README.md +++ b/mssql_python/pybind/README.md @@ -2,6 +2,27 @@ This README provides instructions to build the DDBC Bindings for your system and documents the platform-specific dependencies. +## Repeated execute bindings + +Each statement owns at most one reusable generation of native input buffers. +The existing detector and binder still validate and convert every execution. +Bindings are reused only for the same prepared SQL, parameter count, C/SQL +types, column size, scale, direction, encoding, and actual buffer byte lengths. +Inline text/binary and integer, boolean, and floating-point buffers are supported, +up to 2,100 parameters and 8,000 bytes per text/binary buffer. NULL, DAE, and +complex C types use the uncached path; decimal overrides converted to text +can reuse only with matching precision and scale. + +Soft cursor resets preserve successful cached bindings. New SQL, incompatible +metadata or byte lengths, explicit resets, direct/catalog/array execution, +statement-attribute changes, and errors invalidate reuse. Native storage remains +owned until ODBC resets the bindings or frees the statement, including error +paths and parent connection teardown. No Python references are retained in the +cache, and the existing DB-API `threadsafety=1` contract is unchanged. + +`tests/test_037_cached_bindings.py` checks live round trips and actual native +allocation/bind events through the existing debug logger, without a test-only API. + ## **Key Architecture Handling** 1. **Architecture Normalization** (from `mssql_python/ddbc_bindings.py`): diff --git a/mssql_python/pybind/connection/connection.cpp b/mssql_python/pybind/connection/connection.cpp index 8ccee11a7..ac50c1de9 100644 --- a/mssql_python/pybind/connection/connection.cpp +++ b/mssql_python/pybind/connection/connection.cpp @@ -119,14 +119,13 @@ void Connection::disconnect() { LOG("Disconnecting from database"); } - // CRITICAL FIX: Mark all child statement handles as implicitly freed - // When we free the DBC handle below, the ODBC driver will automatically free - // all child STMT handles. We need to tell the SqlHandle objects about this - // so they don't try to free the handles again during their destruction. - + // Retain child owners and bound buffers through SQLDisconnect. With the + // GIL held, checkError throws on failure before retiring any handles. + // THREAD-SAFETY: Lock mutex to safely access _childStatementHandles // This protects against concurrent allocStatementHandle() calls or GC finalizers size_t originalSize = 0, afterCompactSize = 0, badHandleCount = 0; + std::vector childHandles; { std::lock_guard lock(_childHandlesMutex); @@ -147,11 +146,9 @@ void Connection::disconnect() { ++badHandleCount; continue; // Skip marking to prevent leak } - handle->markImplicitlyFreed(); + childHandles.push_back(std::move(handle)); } } - _childStatementHandles.clear(); - _allocationsSinceCompaction = 0; } // Release lock before potentially slow SQLDisconnect call // Log after releasing _childHandlesMutex (#671): LOG()/LOG_ERROR() acquire @@ -178,8 +175,8 @@ void Connection::disconnect() { // Destructor / shutdown path — GIL is not held, call directly. ret = SQLDisconnect_ptr(_dbcHandle->get()); } - // In destructor/shutdown paths, suppress errors to avoid - // std::terminate() if this throws during stack unwinding. + // Surface errors with the GIL held. GIL-less teardown cannot safely + // translate errors through Python, so it continues retiring the handles. if (hasGil) { checkError(ret); } else if (!SQL_SUCCEEDED(ret)) { @@ -187,6 +184,18 @@ void Connection::disconnect() { // via py::gil_scoped_acquire, which is unsafe during interpreter // shutdown or stack unwinding (can deadlock or call std::terminate). } + // Successful SQLDisconnect has already freed its child statements. + // GIL-less failure also retires these wrappers as the parent is abandoned; + // neither that failure nor dropping the DBC owner proves native deallocation. + for (const auto& handle : childHandles) { + handle->markImplicitlyFreed(); + handle->releaseAfterFree(); + } + { + std::lock_guard lock(_childHandlesMutex); + _childStatementHandles.clear(); + _allocationsSinceCompaction = 0; + } // triggers SQLFreeHandle via destructor, if last owner _dbcHandle.reset(); } else if (hasGil) { diff --git a/mssql_python/pybind/ddbc_bindings.cpp b/mssql_python/pybind/ddbc_bindings.cpp index 26948ee88..23d4390d4 100644 --- a/mssql_python/pybind/ddbc_bindings.cpp +++ b/mssql_python/pybind/ddbc_bindings.cpp @@ -10,6 +10,7 @@ #include "logger_bridge.hpp" #include "performance_counter.hpp" #include "param_detect.hpp" +#include "param_bind_cache.hpp" #include "py_ref.hpp" #include "py_type_cache.hpp" #include "utf_utils.h" @@ -306,17 +307,11 @@ std::string MakeParamMismatchErrorStr(const SQLSMALLINT cType, const int paramIn return errorString; } -// This function allocates a buffer of ParamType, stores it as a void* in -// paramBuffers for book-keeping and then returns a ParamType* to the allocated -// memory. ctorArgs are the arguments to ParamType's constructor used while -// creating/allocating ParamType -template -ParamType* AllocateParamBuffer(std::vector>& paramBuffers, - CtorArgs&&... ctorArgs) { - paramBuffers.emplace_back(new ParamType(std::forward(ctorArgs)...), - std::default_delete()); - return static_cast(paramBuffers.back().get()); -} +// The single-buffer AllocateParamBuffer template, its reuse overload, the +// ParameterBinding / ExecuteBindingCache / ExecuteParamBuffers types, +// UpdateParamBuffer, SameParameterShape, and CanCacheParameters live in +// param_bind_cache.hpp. AllocateParamBufferArray below serves the executemany +// array-binding path and is unrelated to the reuse cache, so it stays here. template ParamType* AllocateParamBufferArray(std::vector>& paramBuffers, @@ -452,15 +447,36 @@ static void PreResolveUnknownNullTypes(SqlHandle& handle, SQLHANDLE hStmt, // each of them with appropriate arguments SQLRETURN BindParameters(SqlHandle& handle, SQLHANDLE hStmt, const py::list& params, std::vector& paramInfos, - std::vector>& paramBuffers, - const std::string& charEncoding = "utf-8") { + std::vector>& ownedBuffers, + const std::string& charEncoding = "utf-8", bool cacheForExecute = false) { PERF_TIMER("BindParameters"); LOG("BindParameters: Starting parameter binding for statement handle %p " "with %zu parameters", (void*)hStmt, params.size()); + bool eligible = cacheForExecute && CanCacheParameters(paramInfos); + if (cacheForExecute && !eligible) { + SQLRETURN rc = handle.resetParameterBindings(); + if (!SQL_SUCCEEDED(rc)) + return rc; + } // GH-627: resolve unknown NULL param SQL types before binding any param. PreResolveUnknownNullTypes(handle, hStmt, paramInfos, ¶ms); + auto* previous = handle.executeBindings.get(); + bool reuse = eligible && previous && previous->reusable && previous->encoding == charEncoding && + previous->bindings.size() == paramInfos.size(); + if (reuse) { + for (size_t i = 0; i < paramInfos.size(); ++i) { + if (!SameParameterShape(previous->bindings[i], paramInfos[i])) { + reuse = false; + break; + } + } + } + ExecuteParamBuffers paramBuffers{ownedBuffers, reuse ? &previous->buffers : nullptr}; + ownedBuffers.reserve(params.size() * 2); + std::vector bindings; + bindings.reserve(params.size()); for (int paramIndex = 0; paramIndex < params.size(); paramIndex++) { const auto& param = params[paramIndex]; ParamInfo& paramInfo = paramInfos[paramIndex]; @@ -859,7 +875,49 @@ SQLRETURN BindParameters(SqlHandle& handle, SQLHANDLE hStmt, const py::list& par ThrowStdException(errorString.str()); } } + bindings.push_back({paramInfo.inputOutputType, paramInfo.paramCType, paramInfo.paramSQLType, + paramInfo.columnSize, paramInfo.decimalDigits, dataPtr, bufferLength, + strLenOrIndPtr}); + if (bufferLength > MAX_INLINE_BINARY) + eligible = false; + } + + if (reuse) { + for (size_t i = 0; i < bindings.size(); ++i) { + const auto& old = previous->bindings[i]; + const auto& current = bindings[i]; + if (old.data != current.data || old.length != current.length || + old.indicator != current.indicator) { + reuse = false; + break; + } + } + } + if (reuse) { + LOG("BindParameters: Reusing %zu bound parameters", bindings.size()); + return SQL_SUCCESS; + } + if (cacheForExecute) { + // Reset only after conversion succeeded; old addresses remain owned until + // ODBC releases them. New buffers are handle-owned before the first bind, + // including partial-bind failures where diagnostics must not be erased. + SQLRETURN rc = handle.resetParameterBindings(); + if (!SQL_SUCCEEDED(rc)) + return rc; + auto cache = std::make_unique(); + cache->bindings = bindings; + cache->buffers = ownedBuffers; + cache->encoding = charEncoding; + handle.executeBindings = std::move(cache); + } + for (int paramIndex = 0; paramIndex < bindings.size(); ++paramIndex) { + const ParamInfo& paramInfo = paramInfos[paramIndex]; + const auto& binding = bindings[paramIndex]; + void* dataPtr = binding.data; + SQLLEN bufferLength = binding.length; + SQLLEN* strLenOrIndPtr = binding.indicator; assert(SQLBindParameter_ptr && SQLGetStmtAttr_ptr && SQLSetDescField_ptr); + LOG("BindParameters: SQLBindParameter param[%d]", paramIndex); RETCODE rc; { PERF_TIMER("BindParameters::SQLBindParameter_call"); @@ -936,6 +994,8 @@ SQLRETURN BindParameters(SqlHandle& handle, SQLHANDLE hStmt, const py::list& par LOG("BindParameters: Completed parameter binding for statement handle %p - " "%zu parameters bound successfully", (void*)hStmt, params.size()); + if (cacheForExecute) + handle.executeBindings->reusable = eligible; return SQL_SUCCESS; } @@ -1546,6 +1606,11 @@ SqlHandle::~SqlHandle() { if (_handle) { free(); } + // If the driver refused to free the handle, it can still reference these + // addresses. Leak only this failed teardown's native storage, not dangling + // pointers into freed memory. Explicit free() failures retain ownership. + if (_handle && !_implicitly_freed) + executeBindings.release(); } SQLHANDLE SqlHandle::get() const { @@ -1557,10 +1622,8 @@ SQLSMALLINT SqlHandle::type() const { } void SqlHandle::markImplicitlyFreed() { - // SAFETY: Only STMT handles should be marked as implicitly freed. - // When a DBC handle is freed, the ODBC driver automatically frees all child STMT handles. - // Other handle types (ENV, DBC, DESC) are NOT automatically freed by parents. - // Calling this on wrong handle types will cause silent handle leaks. + // Only tracked STMT wrappers participate in Connection::disconnect() cleanup. + // This flag suppresses later ODBC calls; it does not free the native handle. if (_type != SQL_HANDLE_STMT) { // Log error but don't throw - we're likely in cleanup/destructor path LOG_ERROR("SAFETY VIOLATION: Attempted to mark non-STMT handle as implicitly freed. " @@ -1570,6 +1633,27 @@ void SqlHandle::markImplicitlyFreed() { return; // Refuse to mark - let normal free() handle it } _implicitly_freed = true; + if (executeBindings) + executeBindings->reusable = false; +} + +SQLRETURN SqlHandle::resetParameterBindings() { + if (!executeBindings) + return SQL_SUCCESS; + executeBindings->reusable = false; + if (!_handle || _implicitly_freed) + return SQL_INVALID_HANDLE; + SQLRETURN rc = SQLFreeStmt_ptr(_handle, SQL_RESET_PARAMS); + if (SQL_SUCCEEDED(rc)) + executeBindings.reset(); + return rc; +} + +void SqlHandle::releaseAfterFree() { + _handle = nullptr; + executeBindings.reset(); + preparedQuery.clear(); + describeCache.clear(); } /* @@ -1597,17 +1681,15 @@ void SqlHandle::free() { // 3. This tradeoff prioritizes crash prevention over resource cleanup, which // is appropriate since we're already in shutdown sequence if (pythonShuttingDown && (_type == SQL_HANDLE_STMT || _type == SQL_HANDLE_DBC)) { + executeBindings.release(); _handle = nullptr; // Mark as freed to prevent double-free attempts return; } - // CRITICAL FIX: Check if handle was already implicitly freed by parent handle - // When Connection::disconnect() frees the DBC handle, the ODBC driver automatically - // frees all child STMT handles. We track this state to avoid double-free attempts. - // This approach avoids calling ODBC functions on potentially-freed handles, which - // would cause use-after-free errors. + // Connection::disconnect() has retired this wrapper after disconnect or + // terminal GIL-less cleanup. Do not call ODBC again on its former handle. if (_implicitly_freed) { - _handle = nullptr; // Just clear the pointer, don't call ODBC functions + releaseAfterFree(); return; } @@ -1620,13 +1702,18 @@ void SqlHandle::free() { // (issue #565). Only release the GIL if it is actually held AND the // interpreter is not finalizing - gil_scoped_release is unsafe during // shutdown even if PyGILState_Check() reports the GIL as held. + SQLRETURN rc; if (!pythonShuttingDown && PyGILState_Check()) { py::gil_scoped_release release; - SQLFreeHandle_ptr(_type, _handle); + rc = SQLFreeHandle_ptr(_type, _handle); } else { - SQLFreeHandle_ptr(_type, _handle); + rc = SQLFreeHandle_ptr(_type, _handle); + } + if (SQL_SUCCEEDED(rc)) { + releaseAfterFree(); + } else if (executeBindings) { + executeBindings->reusable = false; } - _handle = nullptr; } } @@ -1652,6 +1739,8 @@ void SqlHandle::close_cursor() { ret = SQLFreeStmt_ptr(_handle, SQL_CLOSE); } if (ret != SQL_SUCCESS && ret != SQL_SUCCESS_WITH_INFO) { + if (executeBindings) + executeBindings->reusable = false; ThrowStdException("SQLFreeStmt(SQL_CLOSE) failed"); } } @@ -1703,7 +1792,7 @@ void SqlHandle::cancel() { } } -SQLRETURN SQLResetStmt_wrap(SqlHandlePtr statementHandle) { +SQLRETURN SQLResetStmt_wrap(SqlHandlePtr statementHandle, bool preserveBindings = false) { if (!statementHandle || !statementHandle->get()) { return SQL_INVALID_HANDLE; } @@ -1719,18 +1808,31 @@ SQLRETURN SQLResetStmt_wrap(SqlHandlePtr statementHandle) { { py::gil_scoped_release release; rc = SQLFreeStmt_ptr(hStmt, SQL_CLOSE); - if (SQL_SUCCEEDED(rc)) { - rc = SQLFreeStmt_ptr(hStmt, SQL_RESET_PARAMS); + if (SQL_SUCCEEDED(rc) && !(preserveBindings && statementHandle->executeBindings && + statementHandle->executeBindings->reusable)) { + if (statementHandle->executeBindings) { + rc = statementHandle->resetParameterBindings(); + } else { + rc = SQLFreeStmt_ptr(hStmt, SQL_RESET_PARAMS); + } } if (SQL_SUCCEEDED(rc) && SQLSetStmtAttr_ptr) { rc = SQLSetStmtAttr_ptr(hStmt, SQL_ATTR_PARAMSET_SIZE, (SQLPOINTER)1, 0); } } + if (!SQL_SUCCEEDED(rc) && statementHandle->executeBindings) { + statementHandle->executeBindings->reusable = false; + } return rc; } SQLRETURN SQLGetTypeInfo_Wrapper(SqlHandlePtr StatementHandle, SQLSMALLINT DataType) { PERF_TIMER("SQLGetTypeInfo_Wrapper"); + SQLRETURN reset = StatementHandle->resetParameterBindings(); + if (!SQL_SUCCEEDED(reset)) + return reset; + StatementHandle->preparedQuery.clear(); + StatementHandle->clearDescribeCache(); if (!SQLGetTypeInfo_ptr) { ThrowStdException("SQLGetTypeInfo function not loaded"); } @@ -1743,6 +1845,11 @@ SQLRETURN SQLGetTypeInfo_Wrapper(SqlHandlePtr StatementHandle, SQLSMALLINT DataT SQLRETURN SQLProcedures_wrap(SqlHandlePtr StatementHandle, const py::object& catalogObj, const py::object& schemaObj, const py::object& procedureObj) { PERF_TIMER("SQLProcedures_wrap"); + SQLRETURN reset = StatementHandle->resetParameterBindings(); + if (!SQL_SUCCEEDED(reset)) + return reset; + StatementHandle->preparedQuery.clear(); + StatementHandle->clearDescribeCache(); if (!SQLProcedures_ptr) { ThrowStdException("SQLProcedures function not loaded"); } @@ -1767,6 +1874,11 @@ SQLRETURN SQLForeignKeys_wrap(SqlHandlePtr StatementHandle, const py::object& pk const py::object& fkCatalogObj, const py::object& fkSchemaObj, const py::object& fkTableObj) { PERF_TIMER("SQLForeignKeys_wrap"); + SQLRETURN reset = StatementHandle->resetParameterBindings(); + if (!SQL_SUCCEEDED(reset)) + return reset; + StatementHandle->preparedQuery.clear(); + StatementHandle->clearDescribeCache(); if (!SQLForeignKeys_ptr) { ThrowStdException("SQLForeignKeys function not loaded"); } @@ -1799,6 +1911,11 @@ SQLRETURN SQLForeignKeys_wrap(SqlHandlePtr StatementHandle, const py::object& pk SQLRETURN SQLPrimaryKeys_wrap(SqlHandlePtr StatementHandle, const py::object& catalogObj, const py::object& schemaObj, const std::u16string& table) { PERF_TIMER("SQLPrimaryKeys_wrap"); + SQLRETURN reset = StatementHandle->resetParameterBindings(); + if (!SQL_SUCCEEDED(reset)) + return reset; + StatementHandle->preparedQuery.clear(); + StatementHandle->clearDescribeCache(); if (!SQLPrimaryKeys_ptr) { ThrowStdException("SQLPrimaryKeys function not loaded"); } @@ -1821,6 +1938,11 @@ SQLRETURN SQLStatistics_wrap(SqlHandlePtr StatementHandle, const py::object& cat const py::object& schemaObj, const std::u16string& table, SQLUSMALLINT unique, SQLUSMALLINT reserved) { PERF_TIMER("SQLStatistics_wrap"); + SQLRETURN reset = StatementHandle->resetParameterBindings(); + if (!SQL_SUCCEEDED(reset)) + return reset; + StatementHandle->preparedQuery.clear(); + StatementHandle->clearDescribeCache(); if (!SQLStatistics_ptr) { ThrowStdException("SQLStatistics function not loaded"); } @@ -1843,6 +1965,11 @@ SQLRETURN SQLColumns_wrap(SqlHandlePtr StatementHandle, const py::object& catalo const py::object& schemaObj, const py::object& tableObj, const py::object& columnObj) { PERF_TIMER("SQLColumns_wrap"); + SQLRETURN reset = StatementHandle->resetParameterBindings(); + if (!SQL_SUCCEEDED(reset)) + return reset; + StatementHandle->preparedQuery.clear(); + StatementHandle->clearDescribeCache(); if (!SQLColumns_ptr) { ThrowStdException("SQLColumns function not loaded"); } @@ -1870,6 +1997,10 @@ ErrorInfo SQLCheckError_Wrap(SQLSMALLINT handleType, SqlHandlePtr handle, SQLRET PERF_TIMER("SQLCheckError_Wrap"); LOG("SQLCheckError: Checking ODBC errors - handleType=%d, retcode=%d", handleType, retcode); ErrorInfo errorInfo; + if ((retcode == SQL_ERROR || retcode == SQL_INVALID_HANDLE) && handle && + handle->executeBindings) { + handle->executeBindings->reusable = false; + } if (retcode == SQL_INVALID_HANDLE) { LOG("SQLCheckError: SQL_INVALID_HANDLE detected - handle is invalid"); errorInfo.ddbcErrorMsg = "Invalid handle!"; @@ -1955,6 +2086,14 @@ py::list SQLGetAllDiagRecords(SqlHandlePtr handle) { // Wrap SQLExecDirect SQLRETURN SQLExecDirect_wrap(SqlHandlePtr StatementHandle, const std::u16string& Query) { PERF_TIMER("SQLExecDirect_wrap"); + if (!StatementHandle || !StatementHandle->get() || StatementHandle->isImplicitlyFreed()) { + return SQL_INVALID_HANDLE; + } + SQLRETURN reset = StatementHandle->resetParameterBindings(); + if (!SQL_SUCCEEDED(reset)) + return reset; + StatementHandle->preparedQuery.clear(); + StatementHandle->clearDescribeCache(); LOG("SQLExecDirect: Executing query directly - statement_handle=%p, " "query_length=%zu chars", (void*)StatementHandle->get(), Query.length()); @@ -1991,6 +2130,11 @@ SQLRETURN SQLTables_wrap(SqlHandlePtr StatementHandle, const std::u16string& cat const std::u16string& schema, const std::u16string& table, const std::u16string& tableType) { PERF_TIMER("SQLTables_wrap"); + SQLRETURN reset = StatementHandle->resetParameterBindings(); + if (!SQL_SUCCEEDED(reset)) + return reset; + StatementHandle->preparedQuery.clear(); + StatementHandle->clearDescribeCache(); if (!SQLTables_ptr) { LOG("SQLTables: Function pointer not initialized, loading driver"); DriverLoader::getInstance().loadDriver(); @@ -2033,14 +2177,31 @@ SQLRETURN SQLExecute_wrap(const SqlHandlePtr statementHandle, bool use_prepare, const py::dict& encoding_settings) { PERF_TIMER("SQLExecute_wrap"); - if (!statementHandle || !statementHandle->get()) { + if (!statementHandle || !statementHandle->get() || statementHandle->isImplicitlyFreed()) { return SQL_INVALID_HANDLE; } + struct ExecutionAttempt { + SqlHandle& handle; + bool succeeded = false; + ~ExecutionAttempt() { + if (!succeeded && handle.executeBindings) + handle.executeBindings->reusable = false; + } + } attempt{*statementHandle}; SQLHANDLE hStmt = statementHandle->get(); + if (statementHandle->executeBindings && + (!statementHandle->executeBindings->reusable || statementHandle->preparedQuery != query)) { + SQLRETURN reset = statementHandle->resetParameterBindings(); + if (!SQL_SUCCEEDED(reset)) + return reset; + } // Configure forward-only / read-only cursor (matches slow path semantics). if (SQLSetStmtAttr_ptr) { + SQLRETURN attrRc = SQLSetStmtAttr_ptr(hStmt, SQL_ATTR_PARAMSET_SIZE, (SQLPOINTER)1, 0); + if (!SQL_SUCCEEDED(attrRc)) + return attrRc; SQLSetStmtAttr_ptr(hStmt, SQL_ATTR_CURSOR_TYPE, (SQLPOINTER)SQL_CURSOR_FORWARD_ONLY, 0); SQLSetStmtAttr_ptr(hStmt, SQL_ATTR_CONCURRENCY, @@ -2074,7 +2235,8 @@ SQLRETURN SQLExecute_wrap(const SqlHandlePtr statementHandle, std::vector paramInfos = DetectParamTypes(params.ptr(), input_sizes.ptr()); RETCODE rc; - bool already_prepared = is_stmt_prepared[0].cast(); + bool already_prepared = + is_stmt_prepared[0].cast() && statementHandle->preparedQuery == query; // Honor use_prepare flag (matching slow path behavior): // - use_prepare=true: prepare now (or reuse if same SQL already prepared) @@ -2082,6 +2244,10 @@ SQLRETURN SQLExecute_wrap(const SqlHandlePtr statementHandle, // - use_prepare=false + not prepared: error (cannot execute unprepared) if (!already_prepared) { if (use_prepare) { + rc = statementHandle->resetParameterBindings(); + if (!SQL_SUCCEEDED(rc)) + return rc; + statementHandle->preparedQuery.clear(); SQLWCHAR* queryPtr = reinterpretU16stringAsSqlWChar(query); { py::gil_scoped_release release; @@ -2089,6 +2255,7 @@ SQLRETURN SQLExecute_wrap(const SqlHandlePtr statementHandle, } if (!SQL_SUCCEEDED(rc)) return rc; statementHandle->clearDescribeCache(); + statementHandle->preparedQuery = query; is_stmt_prepared[0] = py::bool_(true); } else { ThrowStdException("Cannot execute unprepared statement"); @@ -2096,7 +2263,8 @@ SQLRETURN SQLExecute_wrap(const SqlHandlePtr statementHandle, } std::vector> paramBuffers; - rc = BindParameters(*statementHandle, hStmt, params, paramInfos, paramBuffers, charEncoding); + rc = BindParameters(*statementHandle, hStmt, params, paramInfos, paramBuffers, charEncoding, + true); if (!SQL_SUCCEEDED(rc)) return rc; { @@ -2190,10 +2358,15 @@ SQLRETURN SQLExecute_wrap(const SqlHandlePtr statementHandle, if (!SQL_SUCCEEDED(rc) && rc != SQL_NO_DATA) return rc; - // Unbind parameter buffers before they go out of scope. - // Not called on error paths — diagnostics must remain readable. + // Unsupported shapes are not retained for reuse. On errors native ownership + // stays with the handle until reset/free, without destroying diagnostics. SQLRETURN exec_rc = rc; - SQLFreeStmt_ptr(hStmt, SQL_RESET_PARAMS); + if (!statementHandle->executeBindings->reusable) { + rc = statementHandle->resetParameterBindings(); + if (!SQL_SUCCEEDED(rc)) + return rc; + } + attempt.succeeded = true; return exec_rc; } @@ -2837,6 +3010,13 @@ SQLRETURN SQLExecuteMany_wrap(const SqlHandlePtr statementHandle, const std::u16 std::vector& paramInfos, size_t paramSetSize, const py::dict& encodingSettings) { PERF_TIMER("SQLExecuteMany_wrap"); + if (!statementHandle || !statementHandle->get() || statementHandle->isImplicitlyFreed()) { + return SQL_INVALID_HANDLE; + } + SQLRETURN reset = statementHandle->resetParameterBindings(); + if (!SQL_SUCCEEDED(reset)) + return reset; + statementHandle->preparedQuery.clear(); LOG("SQLExecuteMany: Starting batch execution - param_count=%zu, " "param_set_size=%zu", columnwise_params.size(), paramSetSize); @@ -2855,6 +3035,7 @@ SQLRETURN SQLExecuteMany_wrap(const SqlHandlePtr statementHandle, const std::u16 } // GH-610: Clear per-handle describe cache (new prepare = new param types) statementHandle->clearDescribeCache(); + statementHandle->preparedQuery = query; LOG("SQLExecuteMany: Query prepared successfully"); bool hasDAE = false; @@ -3066,6 +3247,11 @@ SQLRETURN SQLSpecialColumns_wrap(SqlHandlePtr StatementHandle, SQLSMALLINT ident const std::u16string& table, SQLSMALLINT scope, SQLSMALLINT nullable) { PERF_TIMER("SQLSpecialColumns_wrap"); + SQLRETURN reset = StatementHandle->resetParameterBindings(); + if (!SQL_SUCCEEDED(reset)) + return reset; + StatementHandle->preparedQuery.clear(); + StatementHandle->clearDescribeCache(); if (!SQLSpecialColumns_ptr) { ThrowStdException("SQLSpecialColumns function not loaded"); } @@ -5977,8 +6163,12 @@ SQLRETURN SQLFreeHandle_wrap(SQLSMALLINT HandleType, SqlHandlePtr Handle) { } if (!SQL_SUCCEEDED(ret)) { LOG("SQLFreeHandle_wrap: SQLFreeHandle failed with error code - %d", ret); + if (Handle->executeBindings) { + Handle->executeBindings->reusable = false; + } return ret; } + Handle->releaseAfterFree(); return ret; } @@ -6147,7 +6337,8 @@ PYBIND11_MODULE(ddbc_bindings, m) { "Fetch an arrow batch of given length from the result set"); m.def("DDBCSQLFreeHandle", &SQLFreeHandle_wrap, "Free a handle"); m.def("DDBCSQLResetStmt", &SQLResetStmt_wrap, - "Close cursor and unbind params without freeing HSTMT"); + "Close cursor, optionally retaining compatible execute bindings", + py::arg("statementHandle"), py::arg("preserve_bindings") = false); m.def("DDBCSQLCheckError", &SQLCheckError_Wrap, "Check for driver errors"); m.def("DDBCSQLGetAllDiagRecords", &SQLGetAllDiagRecords, "Get all diagnostic records for a handle", py::arg("handle")); @@ -6163,6 +6354,9 @@ PYBIND11_MODULE(ddbc_bindings, m) { m.def( "DDBCSQLSetStmtAttr", [](SqlHandlePtr stmt, SQLINTEGER attr, py::object value) { + SQLRETURN reset = stmt->resetParameterBindings(); + if (!SQL_SUCCEEDED(reset)) + return reset; SQLPOINTER ptr_value; if (py::isinstance(value)) { // For integer attributes like SQL_ATTR_QUERY_TIMEOUT diff --git a/mssql_python/pybind/ddbc_bindings.h b/mssql_python/pybind/ddbc_bindings.h index 00f04aa09..601a7d4a6 100644 --- a/mssql_python/pybind/ddbc_bindings.h +++ b/mssql_python/pybind/ddbc_bindings.h @@ -280,6 +280,12 @@ struct DescribedParamInfo { SQLSMALLINT decimalDigits; }; +// Handle-owned cache of native parameter bindings, defined in +// param_bind_cache.hpp. SqlHandle only holds a unique_ptr to it and defines +// every method that touches it out of line, so a forward declaration is enough +// here and avoids pulling param_detect.hpp into this header (it includes back). +struct ExecuteBindingCache; + class SqlHandle { public: SqlHandle(SQLSMALLINT type, SQLHANDLE rawHandle); @@ -297,19 +303,18 @@ class SqlHandle { void cancel(); bool isImplicitlyFreed() const { return _implicitly_freed; } - // Mark this handle as implicitly freed (freed by parent handle) - // This prevents double-free attempts when the ODBC driver automatically - // frees child handles (e.g., STMT handles when DBC handle is freed) - // - // SAFETY CONSTRAINTS: - // - ONLY call this on SQL_HANDLE_STMT handles - // - ONLY call this when the parent DBC handle is about to be freed - // - Calling on other handle types (ENV, DBC, DESC) will cause HANDLE LEAKS - // - The ODBC spec only guarantees automatic freeing of STMT handles by DBC parents - // - // Current usage: Connection::disconnect() marks all tracked STMT handles - // before freeing the DBC handle. + // Suppress later statement cleanup after parent disconnect. Only use on + // SQL_HANDLE_STMT after successful SQLDisconnect, or when abandoning the + // parent during GIL-less teardown. Do not use on a recoverable disconnect error. + // This marker neither calls ODBC nor proves native deallocation succeeded. + // Connection::disconnect() retains statement buffers through SQLDisconnect + // and handles their release separately. void markImplicitlyFreed(); + SQLRETURN resetParameterBindings(); + void releaseAfterFree(); + + std::unique_ptr executeBindings; + std::u16string preparedQuery; // GH-610: Per-handle SQLDescribeParam result cache. // Keyed by 0-based parameter index. Populated on first NULL param diff --git a/mssql_python/pybind/param_bind_cache.hpp b/mssql_python/pybind/param_bind_cache.hpp new file mode 100644 index 000000000..f01542853 --- /dev/null +++ b/mssql_python/pybind/param_bind_cache.hpp @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// param_bind_cache.hpp — handle-owned reuse of native parameter bindings. +// +// Owns the data model and helpers that let a re-executed prepared statement skip +// the SQLBindParameter loop when its parameter shape has not changed: +// +// DetectParamTypes -> BindParameters -> SQLExecute +// (param_detect.hpp) (ddbc_bindings.cpp, uses this) +// +// A SqlHandle keeps one ExecuteBindingCache: its single generation of native +// input buffers plus the exact SQLBindParameter arguments ODBC currently holds. +// On the next execute, if every parameter presents identical binding metadata +// and the freshly rebuilt buffers land at the same addresses and byte lengths, +// the bind loop is skipped. The reuse is native-only: the cache holds C++ +// storage (std::string / numeric buffers), never a Python object, so teardown +// is safe even on the GIL-less connection-destruction path. The reuse decision +// and the byte-exact verification that gates the skip live in BindParameters in +// ddbc_bindings.cpp; this header is only the data model and the small predicates +// and buffer helpers it depends on. +// +// Header-only, like param_detect.hpp: the helpers run once per parameter per +// execute, and the build compiles with -O3 but without LTO, so keeping them +// inline in the using translation unit avoids turning inlined code into real +// calls across a .cpp boundary on the hot path. + +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "logger_bridge.hpp" +#include "param_detect.hpp" // ParamInfo, MAX_INLINE_BINARY, ODBC types and constants + +// One entry per bound parameter: the exact SQLBindParameter arguments ODBC holds. +struct ParameterBinding { + SQLSMALLINT direction; + SQLSMALLINT cType; + SQLSMALLINT sqlType; + SQLULEN columnSize; + SQLSMALLINT scale; + SQLPOINTER data; + SQLLEN length; + SQLLEN* indicator; +}; + +// Native-only ownership: cleanup is safe even on GIL-less connection teardown. +// One generation per statement, never keyed by a recycled raw ODBC handle. +struct ExecuteBindingCache { + std::vector bindings; + std::vector> buffers; + std::string encoding; + bool reusable = false; +}; + +// Allocate a ParamType buffer, own it as a void* in paramBuffers for book-keeping, +// and return a typed pointer to it. ctorArgs forward to ParamType's constructor. +// The reuse overload below extends this; keep both together so the header is +// self-contained and the reuse overload sees the base definition, not just a +// declaration, at instantiation. +template +ParamType* AllocateParamBuffer(std::vector>& paramBuffers, + CtorArgs&&... ctorArgs) { + paramBuffers.emplace_back(new ParamType(std::forward(ctorArgs)...), + std::default_delete()); + LOG("AllocateParamBuffer: New owned buffer"); + return static_cast(paramBuffers.back().get()); +} + +// Current-generation buffers being built, plus the previous generation to reuse +// in place when a buffer's byte size is unchanged. +struct ExecuteParamBuffers { + std::vector>& current; + const std::vector>* previous; +}; + +template +static bool UpdateParamBuffer(T& target, T&& value) { + target = std::move(value); + return true; +} + +template +static bool UpdateParamBuffer(std::basic_string& target, std::basic_string&& value) { + // Equal byte lengths keep both the address and ODBC BufferLength unchanged. + // Do not assign a string: even a capacity-preserving assignment may move it. + if (target.size() != value.size()) + return false; + std::copy(value.begin(), value.end(), target.begin()); + return true; +} + +template +ParamType* AllocateParamBuffer(ExecuteParamBuffers& buffers, CtorArgs&&... ctorArgs) { + ParamType value(std::forward(ctorArgs)...); + const size_t index = buffers.current.size(); + if (buffers.previous && index < buffers.previous->size()) { + const auto& previous = (*buffers.previous)[index]; + auto* target = static_cast(previous.get()); + if (UpdateParamBuffer(*target, std::move(value))) { + buffers.current.push_back(previous); + return target; + } + } + return AllocateParamBuffer(buffers.current, std::move(value)); +} + +static bool SameParameterShape(const ParameterBinding& binding, const ParamInfo& info) { + return binding.direction == info.inputOutputType && binding.cType == info.paramCType && + binding.sqlType == info.paramSQLType && binding.columnSize == info.columnSize && + binding.scale == info.decimalDigits; +} + +static bool CanCacheParameters(const std::vector& infos) { + // Bound retained storage to SQL Server's scalar parameter limit. NULL/DAE and + // descriptor-based/complex types deliberately use the existing uncached path. + if (infos.empty() || infos.size() > 2100) + return false; + for (const auto& info : infos) { + if (info.isDAE || info.inputOutputType != SQL_PARAM_INPUT) + return false; + switch (info.paramCType) { + case SQL_C_CHAR: + case SQL_C_WCHAR: + case SQL_C_BINARY: + if (info.columnSize > MAX_INLINE_BINARY) + return false; + break; + case SQL_C_BIT: + case SQL_C_STINYINT: + case SQL_C_TINYINT: + case SQL_C_SSHORT: + case SQL_C_SHORT: + case SQL_C_UTINYINT: + case SQL_C_USHORT: + case SQL_C_SBIGINT: + case SQL_C_SLONG: + case SQL_C_LONG: + case SQL_C_UBIGINT: + case SQL_C_ULONG: + case SQL_C_FLOAT: + case SQL_C_DOUBLE: + break; + default: + return false; + } + } + return true; +} diff --git a/tests/test_037_cached_bindings.py b/tests/test_037_cached_bindings.py new file mode 100644 index 000000000..c7a307a8f --- /dev/null +++ b/tests/test_037_cached_bindings.py @@ -0,0 +1,421 @@ +"""Repeated execute reuses native bindings, not Python values or raw handle keys. + +The existing native logger observes the actual allocation/bind call sites. These +tests therefore check the optimization's contract as well as returned values, +without a test-only API or a special build. +""" + +import datetime +import decimal +import gc +import logging +import os +import subprocess +import sys +import textwrap +import uuid +import weakref +from concurrent.futures import ThreadPoolExecutor + +import pytest + +import mssql_python +from mssql_python import ddbc_bindings +from mssql_python.constants import ConstantsDDBC as SQL +from mssql_python.logging import logger + + +@pytest.fixture +def cursor(db_connection): + current = db_connection.cursor() + try: + yield current + finally: + current.close() + + +@pytest.fixture +def binding_events(caplog): + old_level = logger.level + caplog.set_level(logging.DEBUG, logger="mssql_python") + native_logger = logging.getLogger("mssql_python") + native_logger.addHandler(caplog.handler) + ddbc_bindings.update_log_level(logging.DEBUG) + try: + yield caplog + finally: + ddbc_bindings.update_log_level(old_level) + native_logger.removeHandler(caplog.handler) + + +def counts(events): + messages = [record.getMessage() for record in events.records] + return ( + sum("BindParameters: SQLBindParameter param[" in message for message in messages), + sum("BindParameters: Reusing " in message for message in messages), + sum("AllocateParamBuffer: New owned buffer" in message for message in messages), + ) + + +@pytest.mark.parametrize("reset_cursor", [True, False]) +@pytest.mark.parametrize( + "first,second", + [ + ([12], [34]), + ([32768], [32769]), + ([2**40], [2**40 + 1]), + ([True], [False]), + ([1.25], [-2.5]), + (["abc"], ["def"]), + (["a" * 1000], ["b" * 1000]), + (["你好"], ["世界"]), + (["你" * 1000], ["界" * 1000]), + (["😀x"], ["🚀y"]), + (["a\0b"], ["c\0d"]), + ([""], [""]), + ([b""], [b""]), + ([b"\x00\x01"], [b"\xff\x00"]), + ([b"a" * 1000], [b"b" * 1000]), + ([bytearray(b"ab")], [bytearray(b"cd")]), + ([12, "abc", 1.25, True], [34, "def", -2.5, False]), + ], +) +def test_same_shape_reuses_bound_buffers(cursor, binding_events, reset_cursor, first, second): + query = "SELECT " + ", ".join("?" for _ in first) + cursor.execute(query, first) + assert tuple(cursor.fetchone()) == tuple(first) + handle = cursor.hstmt + assert counts(binding_events)[0] == len(first) + for values in (second, first, second): + binding_events.clear() + cursor.execute(query, values, reset_cursor=reset_cursor) + assert tuple(cursor.fetchone()) == tuple(values) + assert cursor.hstmt is handle + assert counts(binding_events) == (0, 1, 0) + + +@pytest.mark.parametrize( + "first,second", + [ + (12, 32768), + (12, True), + (12, 1.25), + ("abc", "longer string"), + ("abc", "x"), + ("abc", "世界"), + ("abc", b"abc"), + ("", "x"), + (b"", b"x"), + ("abc", None), + (None, "abc"), + ], +) +def test_shape_changes_rebind(cursor, binding_events, first, second): + cursor.execute("SELECT ?", [first]).fetchone() + binding_events.clear() + cursor.execute("SELECT ?", [second]) + assert cursor.fetchone()[0] == second + assert counts(binding_events)[0:2] == (1, 0) + + +def test_input_sizes_and_actual_encoded_length(cursor, binding_events): + for value, reused in [("😀", False), ("🚀", True), ("ab", True), ("中", False), ("文", True)]: + cursor.setinputsizes([(SQL.SQL_WVARCHAR.value, 100, 0)]) + binding_events.clear() + cursor.execute("SELECT ?", [value]) + assert cursor.fetchone()[0] == value + assert counts(binding_events)[0:2] == ((0, 1) if reused else (1, 0)) + cursor.setinputsizes([(SQL.SQL_WVARCHAR.value, 200, 0)]) + binding_events.clear() + assert cursor.execute("SELECT ?", ["字"]).fetchone()[0] == "字" + assert counts(binding_events)[0:2] == (1, 0) + cursor.setinputsizes(None) + + +def test_encoding_changes_rebind(cursor, db_connection, binding_events): + try: + for encoding, value, reuse in [ + ("utf-8", "abc", False), + ("utf-8", "def", True), + ("ascii", "ghi", False), + ("ascii", "jkl", True), + ]: + cursor.setinputsizes([(SQL.SQL_VARCHAR.value, 100, 0)]) + db_connection.setencoding(encoding, ctype=mssql_python.SQL_CHAR) + binding_events.clear() + assert cursor.execute("SELECT ?", [value]).fetchone()[0] == value + assert counts(binding_events)[0:2] == ((0, 1) if reuse else (1, 0)) + finally: + db_connection.setencoding() + cursor.setinputsizes(None) + + +@pytest.mark.parametrize( + "value", + [ + None, + decimal.Decimal("123.45"), + datetime.date(2024, 1, 2), + datetime.datetime(2024, 1, 2, 3, 4, 5), + uuid.UUID("12345678-1234-5678-1234-567812345678"), + "x" * 9000, + "😀" * 4500, + b"\0" * 9000, + bytearray(b"x" * 9000), + ], + ids=[ + "none", + "decimal", + "date", + "datetime", + "uuid", + "long-ascii", + "long-emoji", + "long-null-bytes", + "long-bytearray", + ], +) +def test_uncached_shapes_fall_back_and_recover(cursor, binding_events, value): + cursor.execute("SELECT ?", [12]).fetchone() + for _ in range(2): + binding_events.clear() + cursor.execute("SELECT ?", [value]) + result = cursor.fetchone()[0] + if isinstance(value, uuid.UUID): + assert str(result).lower() == str(value) + else: + assert result == value + assert counts(binding_events)[0:2] == (1, 0) + cursor.execute("SELECT ?", [34]).fetchone() + binding_events.clear() + assert cursor.execute("SELECT ?", [56]).fetchone()[0] == 56 + assert counts(binding_events) == (0, 1, 0) + + +def test_changed_sql_direct_and_parameter_count(cursor, binding_events): + for sql, values in [ + ("SELECT ?", [12]), + ("SELECT ? + 1", [12]), + ("SELECT ?, ?", [12, 13]), + ("SELECT 12", []), + ("SELECT ?", [14]), + ]: + binding_events.clear() + cursor.execute(sql, values).fetchone() + assert counts(binding_events)[0:2] == (len(values), 0) + + +@pytest.mark.parametrize("fast", [False, True]) +def test_executemany_invalidates(cursor, binding_events, fast): + cursor.execute("DROP TABLE IF EXISTS #cached_bindings") + cursor.execute("CREATE TABLE #cached_bindings (value int)") + sql = "INSERT INTO #cached_bindings VALUES (?)" + cursor.execute(sql, [12]) + cursor.execute(sql, [13]) + cursor.fast_executemany = fast + cursor.executemany(sql, [[14], [15]]) + binding_events.clear() + cursor.execute(sql, [16]) + assert counts(binding_events)[0:2] == (1, 0) + cursor.execute("SELECT value FROM #cached_bindings ORDER BY value") + assert [row[0] for row in cursor.fetchall()] == [12, 13, 14, 15, 16] + cursor.execute("DROP TABLE #cached_bindings") + + +def test_execution_failure_diagnostics_and_recovery(cursor, binding_events): + sql = "SELECT 10 / ?" + assert cursor.execute(sql, [2]).fetchone()[0] == 5 + with pytest.raises(mssql_python.DatabaseError, match="(?i)divide by zero"): + cursor.execute(sql, [0]).fetchone() + binding_events.clear() + assert cursor.execute(sql, [5]).fetchone()[0] == 2 + assert counts(binding_events)[0:2] == (1, 0) + + +def test_validation_failure_invalidates_without_stale_values(cursor, binding_events): + cursor.execute("SELECT ?, ?", [12, "abc"]).fetchone() + with pytest.raises((TypeError, RuntimeError, mssql_python.DatabaseError)): + cursor.execute("SELECT ?, ?", [13, object()]) + binding_events.clear() + assert tuple(cursor.execute("SELECT ?, ?", [14, "def"]).fetchone()) == (14, "def") + assert counts(binding_events)[0:2] == (2, 0) + + +def test_conversion_failure_keeps_bound_storage_alive(cursor, binding_events): + sizes = [(SQL.SQL_INTEGER.value, 10, 0), (SQL.SQL_SMALLINT.value, 5, 0)] + cursor.setinputsizes(sizes) + cursor.execute("SELECT ?, ?", [12, 13]).fetchone() + cursor.setinputsizes(sizes) + with pytest.raises((RuntimeError, OverflowError, mssql_python.DatabaseError)): + cursor.execute("SELECT ?, ?", [14, 2**40]) + binding_events.clear() + assert tuple(cursor.execute("SELECT ?, ?", [15, 16]).fetchone()) == (15, 16) + assert counts(binding_events)[0:2] == (2, 0) + cursor.setinputsizes(None) + + +def test_explicit_reset_and_close_lifetimes(db_connection, binding_events): + cursor = db_connection.cursor() + cursor.execute("SELECT ?", [12]).fetchone() + handle = cursor.hstmt + assert ddbc_bindings.DDBCSQLResetStmt(handle) == SQL.SQL_SUCCESS.value + binding_events.clear() + assert cursor.execute("SELECT ?", [13]).fetchone()[0] == 13 + assert counts(binding_events)[0:2] == (1, 0) + cursor.close() + assert ddbc_bindings.DDBCSQLResetStmt(handle) == SQL.SQL_INVALID_HANDLE.value + other = db_connection.cursor() + try: + binding_events.clear() + assert other.execute("SELECT ?", [14]).fetchone()[0] == 14 + assert counts(binding_events)[0:2] == (1, 0) + finally: + other.close() + + +def test_multiple_cursors_and_sequential_thread_handoff(db_connection, binding_events): + first, second = db_connection.cursor(), db_connection.cursor() + try: + assert first.execute("SELECT ?", [12]).fetchall()[0][0] == 12 + assert second.execute("SELECT ?", [34]).fetchall()[0][0] == 34 + binding_events.clear() + # No simultaneous connection/cursor use: this only checks that storage + # belongs to the handle, rather than a thread-local raw-handle map. + with ThreadPoolExecutor(max_workers=1) as executor: + result = executor.submit(lambda: first.execute("SELECT ?", [56]).fetchall()[0][0]) + assert result.result() == 56 + assert second.execute("SELECT ?", [78]).fetchall()[0][0] == 78 + assert counts(binding_events) == (0, 2, 0) + finally: + first.close() + second.close() + + +def test_connection_close_with_retained_statement(conn_str): + connection = mssql_python.connect(conn_str) + cursor = connection.cursor() + cursor.execute("SELECT ?", ["owned"]).fetchone() + handle = cursor.hstmt + connection.close() + assert ddbc_bindings.DDBCSQLResetStmt(handle) == SQL.SQL_INVALID_HANDLE.value + handle.free() + cursor.close() + + +def test_native_char_encoding_and_codec_failure(cursor, binding_events): + # SQL_C_CHAR in the Python constants is historically -8. Use the actual + # native C type (1) through the existing normalized input-size representation. + sizes = [(SQL.SQL_VARCHAR.value, 1, 100, 0)] + for value, reused in [("abc", False), ("def", True), ("longer", False), ("short!", True)]: + cursor._inputsizes = sizes + binding_events.clear() + assert cursor.execute("SELECT ?", [value]).fetchone()[0] == value + assert counts(binding_events)[0:2] == ((0, 1) if reused else (1, 0)) + + class FailingString(str): + def encode(self, *args, **kwargs): + raise UnicodeError("test codec failure") + + cursor._inputsizes = sizes + with pytest.raises(RuntimeError, match="test codec failure"): + cursor.execute("SELECT ?", [FailingString("failed")]) + cursor._inputsizes = sizes + binding_events.clear() + assert cursor.execute("SELECT ?", ["latest"]).fetchone()[0] == "latest" + assert counts(binding_events)[0:2] == (1, 0) + + +def test_numeric_text_precision_and_scale_changes(cursor, binding_events): + for precision, scale, reused in [(10, 2, False), (10, 2, True), (12, 3, False), (12, 3, True)]: + cursor.setinputsizes([(SQL.SQL_DECIMAL.value, precision, scale)]) + binding_events.clear() + result = cursor.execute("SELECT ?", [decimal.Decimal("12.34")]).fetchone()[0] + assert result == decimal.Decimal("12.34") + assert counts(binding_events)[0:2] == ((0, 1) if reused else (1, 0)) + + +@pytest.mark.parametrize("operation", ["catalog", "direct", "attribute"]) +def test_native_invalidation_surfaces(cursor, binding_events, operation): + cursor.execute("SELECT ?", [12]).fetchall() + handle = cursor.hstmt + handle._close_cursor() + if operation == "catalog": + rc = ddbc_bindings.DDBCSQLGetTypeInfo(handle, SQL.SQL_INTEGER.value) + elif operation == "direct": + rc = ddbc_bindings.DDBCSQLExecDirect(handle, "SELECT 99") + else: + rc = ddbc_bindings.DDBCSQLSetStmtAttr(handle, SQL.SQL_ATTR_QUERY_TIMEOUT.value, 0) + assert rc in (SQL.SQL_SUCCESS.value, SQL.SQL_SUCCESS_WITH_INFO.value) + handle._close_cursor() + cursor.is_stmt_prepared = [False] + binding_events.clear() + assert cursor.execute("SELECT ?", [34]).fetchone()[0] == 34 + assert counts(binding_events)[0:2] == (1, 0) + + +def test_raw_free_and_shutdown_with_cached_bindings(conn_str): + code = textwrap.dedent(""" + import os + import mssql_python + from mssql_python import ddbc_bindings as native + from mssql_python.constants import ConstantsDDBC as SQL + + connection = mssql_python.connect(os.environ["DB_CONNECTION_STRING"]) + cursor = connection.cursor() + cursor.execute("SELECT ?", [12]).fetchall() + handle = cursor.hstmt + assert native.DDBCSQLFreeHandle(SQL.SQL_HANDLE_STMT.value, handle) == 0 + cursor.close() + other = connection.cursor() + for value in [34, 56, 78]: + assert other.execute("SELECT ?", [value]).fetchall()[0][0] == value + # Exercise atexit cleanup while native bindings and Python handles live. + """) + result = subprocess.run( + [sys.executable, "-c", code], + env=os.environ.copy(), + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, result.stderr + + +def test_cache_owns_no_python_values(cursor, binding_events): + class Text(str): + pass + + for value in ("abc", "def"): + text = Text(value) + reference = weakref.ref(text) + binding_events.clear() + cursor.execute("SELECT ?", [text]) + del text + gc.collect() + assert reference() is None + assert cursor.fetchone()[0] == value + if value == "def": + assert counts(binding_events) == (0, 1, 0) + + +def test_native_array_execution_invalidates_same_handle(cursor, binding_events): + cursor.execute("CREATE TABLE #cached_native_array (value int)") + sql = "INSERT INTO #cached_native_array VALUES (?)" + cursor.execute(sql, [12]) + handle = cursor.hstmt + handle._close_cursor() + info = ddbc_bindings.ParamInfo() + info.inputOutputType = 1 + info.paramCType = 4 # SQL_C_LONG + info.paramSQLType = 4 # SQL_INTEGER + info.columnSize = 10 + info.decimalDigits = 0 + rc = ddbc_bindings.SQLExecuteMany(handle, sql, [[34, 56]], [info], 2, {}) + assert rc in (SQL.SQL_SUCCESS.value, SQL.SQL_SUCCESS_WITH_INFO.value) + binding_events.clear() + cursor.execute(sql, [78]) + assert cursor.hstmt is handle + assert counts(binding_events)[0:2] == (1, 0) + cursor.execute("SELECT value FROM #cached_native_array ORDER BY value") + assert [row[0] for row in cursor.fetchall()] == [12, 34, 56, 78] + cursor.execute("DROP TABLE #cached_native_array")