Skip to content

Commit 6ab9252

Browse files
committed
FIX: cursor.execute() with reset_cursor=False raises Invalid cursor state #506
Add SqlHandle::close_cursor() which calls SQLFreeStmt(SQL_CLOSE) to close the ODBC cursor without destroying the prepared statement handle. When reset_cursor=False, execute() now calls close_cursor() instead of skipping cleanup entirely, allowing the prepared plan to be reused. Added 5 tests covering reset_cursor=False scenarios.
1 parent a4ab587 commit 6ab9252

File tree

4 files changed

+100
-1
lines changed

4 files changed

+100
-1
lines changed

mssql_python/cursor.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1351,6 +1351,16 @@ def execute( # pylint: disable=too-many-locals,too-many-branches,too-many-state
13511351
if reset_cursor:
13521352
logger.debug("execute: Resetting cursor state")
13531353
self._reset_cursor()
1354+
else:
1355+
# Close just the ODBC cursor (not the statement handle) so the
1356+
# prepared plan can be reused. SQLFreeStmt(SQL_CLOSE) releases
1357+
# the cursor associated with hstmt without destroying the
1358+
# prepared statement, which is the standard ODBC pattern for
1359+
# re-executing a prepared query.
1360+
if self.hstmt:
1361+
logger.debug("execute: Closing cursor for re-execution (reset_cursor=False)")
1362+
self.hstmt.close_cursor()
1363+
self._clear_rownumber()
13541364

13551365
# Clear any previous messages
13561366
self.messages = []

mssql_python/pybind/ddbc_bindings.cpp

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1362,6 +1362,12 @@ void SqlHandle::free() {
13621362
}
13631363
}
13641364

1365+
void SqlHandle::close_cursor() {
1366+
if (_handle && SQLFreeStmt_ptr && _type == SQL_HANDLE_STMT) {
1367+
SQLFreeStmt_ptr(_handle, SQL_CLOSE);
1368+
}
1369+
}
1370+
13651371
SQLRETURN SQLGetTypeInfo_Wrapper(SqlHandlePtr StatementHandle, SQLSMALLINT DataType) {
13661372
if (!SQLGetTypeInfo_ptr) {
13671373
ThrowStdException("SQLGetTypeInfo function not loaded");
@@ -5740,7 +5746,8 @@ PYBIND11_MODULE(ddbc_bindings, m) {
57405746
.def_readwrite("ddbcErrorMsg", &ErrorInfo::ddbcErrorMsg);
57415747

57425748
py::class_<SqlHandle, SqlHandlePtr>(m, "SqlHandle")
5743-
.def("free", &SqlHandle::free, "Free the handle");
5749+
.def("free", &SqlHandle::free, "Free the handle")
5750+
.def("close_cursor", &SqlHandle::close_cursor, "Close the cursor on the statement handle without freeing the prepared statement");
57445751

57455752
py::class_<ConnectionHandle>(m, "Connection")
57465753
.def(py::init<const std::string&, bool, const py::dict&>(), py::arg("conn_str"),

mssql_python/pybind/ddbc_bindings.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,7 @@ class SqlHandle {
378378
SQLHANDLE get() const;
379379
SQLSMALLINT type() const;
380380
void free();
381+
void close_cursor();
381382

382383
// Mark this handle as implicitly freed (freed by parent handle)
383384
// This prevents double-free attempts when the ODBC driver automatically

tests/test_004_cursor.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15961,3 +15961,84 @@ def reader(reader_id):
1596115961
finally:
1596215962
stop_event.set()
1596315963
mssql_python.native_uuid = original
15964+
15965+
15966+
def test_execute_reset_cursor_false_reuses_prepared_plan(db_connection):
15967+
"""Test that reset_cursor=False reuses the prepared statement handle
15968+
and successfully re-executes after consuming the previous result set."""
15969+
cursor = db_connection.cursor()
15970+
try:
15971+
cursor.execute("SELECT 1 AS val WHERE 1 = ?", (1,))
15972+
row = cursor.fetchone()
15973+
assert row[0] == 1
15974+
_ = cursor.fetchall() # consume remaining
15975+
15976+
# Re-execute with reset_cursor=False — this was raising
15977+
# ProgrammingError: Invalid cursor state before the fix.
15978+
cursor.execute("SELECT 1 AS val WHERE 1 = ?", (2,), reset_cursor=False)
15979+
row = cursor.fetchone()
15980+
assert row is None # No match for WHERE 1 = 2
15981+
finally:
15982+
cursor.close()
15983+
15984+
15985+
def test_execute_reset_cursor_false_returns_new_results(db_connection):
15986+
"""Test that reset_cursor=False correctly returns results from the
15987+
second execution with different parameter values."""
15988+
cursor = db_connection.cursor()
15989+
try:
15990+
cursor.execute("SELECT ? AS val", (42,))
15991+
row = cursor.fetchone()
15992+
assert row[0] == 42
15993+
_ = cursor.fetchall()
15994+
15995+
cursor.execute("SELECT ? AS val", (99,), reset_cursor=False)
15996+
row = cursor.fetchone()
15997+
assert row[0] == 99
15998+
finally:
15999+
cursor.close()
16000+
16001+
16002+
def test_execute_reset_cursor_false_multiple_iterations(db_connection):
16003+
"""Test that reset_cursor=False works across several consecutive
16004+
re-executions on the same cursor."""
16005+
cursor = db_connection.cursor()
16006+
try:
16007+
for i in range(5):
16008+
kwargs = {"reset_cursor": False} if i > 0 else {}
16009+
cursor.execute("SELECT ? AS iter", (i,), **kwargs)
16010+
row = cursor.fetchone()
16011+
assert row[0] == i, f"Expected {i}, got {row[0]}"
16012+
_ = cursor.fetchall()
16013+
finally:
16014+
cursor.close()
16015+
16016+
16017+
def test_execute_reset_cursor_false_no_params(db_connection):
16018+
"""Test that reset_cursor=False works for queries without parameters."""
16019+
cursor = db_connection.cursor()
16020+
try:
16021+
cursor.execute("SELECT 1 AS a")
16022+
_ = cursor.fetchall()
16023+
16024+
cursor.execute("SELECT 2 AS a", reset_cursor=False)
16025+
row = cursor.fetchone()
16026+
assert row[0] == 2
16027+
finally:
16028+
cursor.close()
16029+
16030+
16031+
def test_execute_reset_cursor_false_after_fetchone_only(db_connection):
16032+
"""Test reset_cursor=False when only fetchone() was called (result set
16033+
not fully consumed via fetchall)."""
16034+
cursor = db_connection.cursor()
16035+
try:
16036+
cursor.execute("SELECT ? AS val", (1,))
16037+
row = cursor.fetchone()
16038+
assert row[0] == 1
16039+
# Do NOT call fetchall — go straight to re-execute
16040+
cursor.execute("SELECT ? AS val", (2,), reset_cursor=False)
16041+
row = cursor.fetchone()
16042+
assert row[0] == 2
16043+
finally:
16044+
cursor.close()

0 commit comments

Comments
 (0)