Skip to content

FIX: cursor.execute() with reset_cursor=False raises Invalid cursor state #521

Open
jahnvi480 wants to merge 1 commit intomainfrom
jahnvi/reset_cursor_error
Open

FIX: cursor.execute() with reset_cursor=False raises Invalid cursor state #521
jahnvi480 wants to merge 1 commit intomainfrom
jahnvi/reset_cursor_error

Conversation

@jahnvi480
Copy link
Copy Markdown
Contributor

@jahnvi480 jahnvi480 commented Apr 14, 2026

Work Item / Issue Reference

AB#44009

GitHub Issue: #506


Summary

This pull request improves the handling of cursor state when executing prepared statements with reset_cursor=False, ensuring that the prepared plan is correctly reused and the cursor state is properly managed between executions. It introduces a new close_cursor method at the ODBC statement handle level, updates the Python bindings, and adds comprehensive tests to verify the new behavior.

Enhancements to cursor state management:

  • Added logic in cursor.py to call hstmt.close_cursor() (which issues an ODBC SQLFreeStmt(SQL_CLOSE)) when reset_cursor=False, allowing prepared statements to be reused safely without resetting the entire cursor state. This resolves issues with invalid cursor state on re-execution.

ODBC handle and Python bindings updates:

  • Implemented the close_cursor method in the SqlHandle C++ class, which closes only the cursor on the statement handle without freeing the prepared statement.
  • Exposed the new close_cursor method to Python via the ddbc_bindings Pybind11 module, allowing it to be called from Python code.

Testing improvements:

  • Added multiple test cases in test_004_cursor.py to verify that reset_cursor=False correctly reuses prepared plans, returns new results, works across multiple iterations, handles queries without parameters, and functions correctly when the previous result set was not fully consumed.

…tate #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.
Copilot AI review requested due to automatic review settings April 14, 2026 09:22
@github-actions github-actions bot added the pr-size: medium Moderate update size label Apr 14, 2026
@github-actions
Copy link
Copy Markdown

📊 Code Coverage Report

🔥 Diff Coverage

100%


🎯 Overall Coverage

79%


📈 Total Lines Covered: 6648 out of 8412
📁 Project: mssql-python


Diff Coverage

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

  • mssql_python/cursor.py (100%)
  • mssql_python/pybind/ddbc_bindings.cpp (100%)

Summary

  • Total: 10 lines
  • Missing: 0 lines
  • Coverage: 100%

📋 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: 67.8%
mssql_python.row.py: 70.5%
mssql_python.pybind.logger_bridge.hpp: 70.8%
mssql_python.pybind.ddbc_bindings.cpp: 74.2%
mssql_python.pybind.connection.connection.cpp: 75.3%
mssql_python.__init__.py: 77.3%
mssql_python.ddbc_bindings.py: 79.6%
mssql_python.pybind.connection.connection_pool.cpp: 79.6%
mssql_python.connection.py: 85.2%

🔗 Quick Links

⚙️ Build Summary 📋 Coverage Details

View Azure DevOps Build

Browse Full Coverage Report

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Fixes Invalid cursor state errors when re-executing statements with reset_cursor=False by closing only the ODBC cursor (not the prepared statement), enabling safe prepared-plan reuse across executions.

Changes:

  • Add SqlHandle::close_cursor() (ODBC SQLFreeStmt(SQL_CLOSE)) and expose it via Pybind11.
  • Update cursor.execute() to close the ODBC cursor when reset_cursor=False.
  • Add regression tests covering re-execution, multi-iteration reuse, no-params execution, and re-execution without fully consuming prior results.

Reviewed changes

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

File Description
tests/test_004_cursor.py Adds regression tests validating reset_cursor=False prepared-plan reuse and cursor state handling.
mssql_python/pybind/ddbc_bindings.h Declares SqlHandle::close_cursor() API.
mssql_python/pybind/ddbc_bindings.cpp Implements close_cursor() and exposes it to Python via Pybind11.
mssql_python/cursor.py Calls hstmt.close_cursor() when reset_cursor=False to prevent invalid cursor state on re-exec.

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

Comment on lines +1366 to +1368
if (_handle && SQLFreeStmt_ptr && _type == SQL_HANDLE_STMT) {
SQLFreeStmt_ptr(_handle, SQL_CLOSE);
}
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

close_cursor() silently no-ops when SQLFreeStmt_ptr is not loaded and also ignores the SQLRETURN from SQLFreeStmt. This can mask failures and leave the cursor open, potentially preserving the original 'Invalid cursor state' behavior without surfacing an actionable error. Consider aligning behavior with other wrappers: (1) throw when SQLFreeStmt_ptr is not loaded, and (2) check the return code and raise via the existing error/diagnostics path when the call fails (treat SQL_SUCCESS_WITH_INFO appropriately).

Suggested change
if (_handle && SQLFreeStmt_ptr && _type == SQL_HANDLE_STMT) {
SQLFreeStmt_ptr(_handle, SQL_CLOSE);
}
if (_type != SQL_HANDLE_STMT || !_handle) {
return;
}
if (!SQLFreeStmt_ptr) {
ThrowStdException("SQLFreeStmt function not loaded");
}
SQLRETURN ret = SQLFreeStmt_ptr(_handle, SQL_CLOSE);
if (ret != SQL_SUCCESS && ret != SQL_SUCCESS_WITH_INFO) {
ThrowStdException("SQLFreeStmt(SQL_CLOSE) failed");
}

Copilot uses AI. Check for mistakes.
Comment thread tests/test_004_cursor.py
kwargs = {"reset_cursor": False} if i > 0 else {}
cursor.execute("SELECT ? AS iter", (i,), **kwargs)
row = cursor.fetchone()
assert row[0] == i, f"Expected {i}, got {row[0]}"
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The assertion message indexes row[0] even in the failure path; if fetchone() unexpectedly returns None, this will raise a TypeError and obscure the real failure. Prefer asserting the whole row (e.g., assert row == (i,)) or add a separate assert row is not None before indexing so test failures remain clear and diagnostic.

Suggested change
assert row[0] == i, f"Expected {i}, got {row[0]}"
assert row == (i,), f"Expected {(i,)}, got {row}"

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr-size: medium Moderate update size

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants