Skip to content

Commit ab57bb8

Browse files
committed
Optimize execute() hot path for repeated parameterized inserts
- Add _soft_reset_cursor: SQL_CLOSE + SQL_RESET_PARAMS instead of full HSTMT free/realloc on each execute() call - Add DDBCSQLResetStmt C++ wrapper exposing lightweight reset via pybind11 - Skip SQLPrepare when re-executing the same SQL (prepare caching) - Skip detect_and_convert_parameters on repeated same-SQL calls - Guard DDBCSQLGetAllDiagRecords behind SQL_SUCCESS_WITH_INFO check - Guard per-parameter debug logging behind logger.isEnabledFor(DEBUG) - Fix benchmark script to strip Driver= from mssql-python connection string
1 parent f419d4c commit ab57bb8

File tree

3 files changed

+80
-31
lines changed

3 files changed

+80
-31
lines changed

benchmarks/perf-benchmarking.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ def _init_conn_strings():
4949
CONN_STR_PYODBC = f"Driver={{ODBC Driver 18 for SQL Server}};{CONN_STR}"
5050
else:
5151
CONN_STR_PYODBC = CONN_STR
52+
# mssql-python manages its own driver — strip Driver= from its connection string
53+
import re
54+
CONN_STR = re.sub(r"Driver=[^;]*;?", "", CONN_STR, flags=re.IGNORECASE).strip(";")
55+
5256

5357

5458
class BenchmarkResult:

mssql_python/cursor.py

Lines changed: 56 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# pylint: disable=too-many-lines # Large file due to comprehensive DB-API 2.0 implementation
1313

1414
import decimal
15+
import logging
1516
import uuid
1617
import datetime
1718
import warnings
@@ -140,6 +141,7 @@ def __init__(self, connection: "Connection", timeout: int = 0) -> None:
140141
False
141142
] # Indicates if last_executed_stmt was prepared by ddbc shim.
142143
# Is a list instead of a bool coz bools in Python are immutable.
144+
self._cached_param_types = None # Cached ParamInfo list for prepare-cache reuse
143145

144146
# Initialize attributes that may be defined later to avoid pylint warnings
145147
# Note: _original_fetch* methods are not initialized here as they need to be
@@ -747,6 +749,18 @@ def _reset_cursor(self) -> None:
747749

748750
# Reinitialize the statement handle
749751
self._initialize_cursor()
752+
self.is_stmt_prepared = [False]
753+
self._cached_param_types = None
754+
755+
def _soft_reset_cursor(self) -> None:
756+
"""Lightweight reset: close cursor and unbind params without freeing the HSTMT.
757+
758+
Preserves the prepared statement plan on the server so repeated
759+
executions of the same SQL skip SQLPrepare entirely.
760+
"""
761+
if self.hstmt:
762+
ddbc_bindings.DDBCSQLResetStmt(self.hstmt)
763+
self._clear_rownumber()
750764

751765
def close(self) -> None:
752766
"""
@@ -1349,8 +1363,10 @@ def execute( # pylint: disable=too-many-locals,too-many-branches,too-many-state
13491363

13501364
self._check_closed() # Check if the cursor is closed
13511365
if reset_cursor:
1352-
logger.debug("execute: Resetting cursor state")
1353-
self._reset_cursor()
1366+
if self.hstmt:
1367+
self._soft_reset_cursor()
1368+
else:
1369+
self._reset_cursor()
13541370

13551371
# Clear any previous messages
13561372
self.messages = []
@@ -1385,20 +1401,28 @@ def execute( # pylint: disable=too-many-locals,too-many-branches,too-many-state
13851401
# Check if single parameter is a nested container that should be unwrapped
13861402
# e.g., execute("SELECT ?", (value,)) vs execute("SELECT ?, ?", ((1, 2),))
13871403
if isinstance(parameters, tuple) and len(parameters) == 1:
1388-
# Could be either (value,) for single param or ((tuple),) for nested
1389-
# Check if it's a nested container
13901404
if isinstance(parameters[0], (tuple, list, dict)):
13911405
actual_params = parameters[0]
13921406
else:
13931407
actual_params = parameters
13941408
else:
13951409
actual_params = parameters
13961410

1397-
# Convert parameters based on detected style
1398-
operation, converted_params = detect_and_convert_parameters(operation, actual_params)
1399-
1400-
# Convert back to list format expected by the binding code
1401-
parameters = list(converted_params)
1411+
# Skip detect_and_convert_parameters when re-executing the same SQL —
1412+
# the parameter style (qmark vs pyformat) won't change between calls.
1413+
if operation == self.last_executed_stmt and isinstance(
1414+
actual_params, (tuple, list)
1415+
):
1416+
parameters = (
1417+
list(actual_params)
1418+
if not isinstance(actual_params, list)
1419+
else actual_params
1420+
)
1421+
else:
1422+
operation, converted_params = detect_and_convert_parameters(
1423+
operation, actual_params
1424+
)
1425+
parameters = list(converted_params)
14021426
else:
14031427
parameters = []
14041428

@@ -1426,35 +1450,36 @@ def execute( # pylint: disable=too-many-locals,too-many-branches,too-many-state
14261450
paraminfo = self._create_parameter_types_list(param, param_info, parameters, i)
14271451
parameters_type.append(paraminfo)
14281452

1429-
# TODO: Use a more sophisticated string compare that handles redundant spaces etc.
1430-
# Also consider storing last query's hash instead of full query string. This will help
1431-
# in low-memory conditions
1432-
# (Ex: huge number of parallel queries with huge query string sizes)
1433-
if operation != self.last_executed_stmt:
1434-
# Executing a new statement. Reset is_stmt_prepared to false
1453+
# Prepare caching: skip SQLPrepare when re-executing the same SQL
1454+
# with parameters. The HSTMT is reused via _soft_reset_cursor, so the
1455+
# server-side plan from the previous SQLPrepare is still valid.
1456+
same_sql = parameters and operation == self.last_executed_stmt and self.is_stmt_prepared[0]
1457+
if not same_sql:
14351458
self.is_stmt_prepared = [False]
1459+
effective_use_prepare = use_prepare and not same_sql
14361460

1437-
for i, param in enumerate(parameters):
1438-
logger.debug(
1439-
"""Parameter number: %s, Parameter: %s,
1440-
Param Python Type: %s, ParamInfo: %s, %s, %s, %s, %s""",
1441-
i + 1,
1442-
param,
1443-
str(type(param)),
1444-
parameters_type[i].paramSQLType,
1445-
parameters_type[i].paramCType,
1446-
parameters_type[i].columnSize,
1447-
parameters_type[i].decimalDigits,
1448-
parameters_type[i].inputOutputType,
1449-
)
1461+
if logger.isEnabledFor(logging.DEBUG):
1462+
for i, param in enumerate(parameters):
1463+
logger.debug(
1464+
"""Parameter number: %s, Parameter: %s,
1465+
Param Python Type: %s, ParamInfo: %s, %s, %s, %s, %s""",
1466+
i + 1,
1467+
param,
1468+
str(type(param)),
1469+
parameters_type[i].paramSQLType,
1470+
parameters_type[i].paramCType,
1471+
parameters_type[i].columnSize,
1472+
parameters_type[i].decimalDigits,
1473+
parameters_type[i].inputOutputType,
1474+
)
14501475

14511476
ret = ddbc_bindings.DDBCSQLExecute(
14521477
self.hstmt,
14531478
operation,
14541479
parameters,
14551480
parameters_type,
14561481
self.is_stmt_prepared,
1457-
use_prepare,
1482+
effective_use_prepare,
14581483
encoding_settings,
14591484
)
14601485
# Check return code
@@ -1467,8 +1492,8 @@ def execute( # pylint: disable=too-many-locals,too-many-branches,too-many-state
14671492
self._reset_cursor()
14681493
raise
14691494

1470-
# Capture any diagnostic messages (SQL_SUCCESS_WITH_INFO, etc.)
1471-
if self.hstmt:
1495+
# Capture diagnostic messages only when the driver signalled info.
1496+
if ret == ddbc_sql_const.SQL_SUCCESS_WITH_INFO.value and self.hstmt:
14721497
self.messages.extend(ddbc_bindings.DDBCSQLGetAllDiagRecords(self.hstmt))
14731498

14741499
self.last_executed_stmt = operation

mssql_python/pybind/ddbc_bindings.cpp

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1362,6 +1362,25 @@ void SqlHandle::free() {
13621362
}
13631363
}
13641364

1365+
SQLRETURN SQLResetStmt_wrap(SqlHandlePtr statementHandle) {
1366+
if (!SQLFreeStmt_ptr) {
1367+
DriverLoader::getInstance().loadDriver();
1368+
}
1369+
SQLHANDLE hStmt = statementHandle->get();
1370+
if (!hStmt) {
1371+
return SQL_INVALID_HANDLE;
1372+
}
1373+
1374+
SQLRETURN rc = SQLFreeStmt_ptr(hStmt, SQL_CLOSE);
1375+
if (SQL_SUCCEEDED(rc)) {
1376+
rc = SQLFreeStmt_ptr(hStmt, SQL_RESET_PARAMS);
1377+
}
1378+
if (SQL_SUCCEEDED(rc) && SQLSetStmtAttr_ptr) {
1379+
SQLSetStmtAttr_ptr(hStmt, SQL_ATTR_PARAMSET_SIZE, (SQLPOINTER)1, 0);
1380+
}
1381+
return rc;
1382+
}
1383+
13651384
SQLRETURN SQLGetTypeInfo_Wrapper(SqlHandlePtr StatementHandle, SQLSMALLINT DataType) {
13661385
if (!SQLGetTypeInfo_ptr) {
13671386
ThrowStdException("SQLGetTypeInfo function not loaded");
@@ -5783,6 +5802,7 @@ PYBIND11_MODULE(ddbc_bindings, m) {
57835802
py::arg("wcharEncoding") = "utf-16le");
57845803
m.def("DDBCSQLFetchArrowBatch", &FetchArrowBatch_wrap, "Fetch an arrow batch of given length from the result set");
57855804
m.def("DDBCSQLFreeHandle", &SQLFreeHandle_wrap, "Free a handle");
5805+
m.def("DDBCSQLResetStmt", &SQLResetStmt_wrap, "Close cursor and unbind params without freeing HSTMT");
57865806
m.def("DDBCSQLCheckError", &SQLCheckError_Wrap, "Check for driver errors");
57875807
m.def("DDBCSQLGetAllDiagRecords", &SQLGetAllDiagRecords,
57885808
"Get all diagnostic records for a handle", py::arg("handle"));

0 commit comments

Comments
 (0)