Skip to content

Commit 70be653

Browse files
authored
FIX: Capture PRINT messages in nextset() via SQL_SUCCESS_WITH_INFO (#618)
### Work Item / Issue Reference <!-- IMPORTANT: Please follow the PR template guidelines below. For mssql-python maintainers: Insert your ADO Work Item ID below For external contributors: Insert Github Issue number below Only one reference is required - either GitHub issue OR ADO Work Item. --> <!-- mssql-python maintainers: ADO Work Item --> > [AB#45395](https://sqlclientdrivers.visualstudio.com/c6d89619-62de-46a0-8b46-70b92a84d85e/_workitems/edit/45395) <!-- External contributors: GitHub Issue --> > GitHub Issue: #612 ------------------------------------------------------------------- ### Summary This pull request addresses an issue where diagnostic messages (such as SQL Server PRINT output) from subsequent result sets were not properly captured when using the `nextset()` method. The fix ensures that all messages are collected, even across multiple result sets, and adds comprehensive tests to verify this behavior. **Bug Fix: Diagnostic Message Handling** - Updated the `nextset()` method in `cursor.py` to capture diagnostic messages (e.g., PRINT output) when SQL returns `SQL_SUCCESS_WITH_INFO`, ensuring messages from all result sets are preserved. **Testing Improvements** - Added several new tests in `test_004_cursor.py` to verify that PRINT messages are correctly captured across multiple result sets and that messages from previous result sets are cleared appropriately: - Test for multiple PRINT statements across result sets via `nextset()` - Test for PRINT messages interleaved with SELECT statements - Test for three consecutive PRINT messages across `nextset()` calls - Test to ensure `nextset()` clears messages from the previous result set
1 parent 4f12cee commit 70be653

2 files changed

Lines changed: 165 additions & 10 deletions

File tree

‎mssql_python/cursor.py‎

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ def __init__(self, connection: "Connection", timeout: int = 0) -> None:
161161
self._skip_increment_for_next_fetch = (
162162
False # Track if we need to skip incrementing the row index
163163
)
164-
self.messages = [] # Store diagnostic messages
164+
self.messages: List[Tuple[str, str]] = [] # Store diagnostic messages
165165

166166
def _is_unicode_string(self, param: str) -> bool:
167167
"""
@@ -810,6 +810,25 @@ def _check_closed(self) -> None:
810810
ddbc_error="",
811811
)
812812

813+
def _capture_diagnostics(self, ret: int) -> None:
814+
"""Append diagnostic messages to self.messages when the return code
815+
indicates records may be present.
816+
817+
Captures on SQL_SUCCESS_WITH_INFO (info/warning messages) and
818+
SQL_NO_DATA (trailing diagnostics attached by SQLMoreResults,
819+
e.g. a PRINT after the final result set).
820+
821+
Skips SQL_SUCCESS to avoid the ~10 ms overhead of scanning the
822+
driver's internal state when no records exist. SQL_ERROR is
823+
handled separately by check_error() which extracts diagnostics
824+
and raises.
825+
"""
826+
if self.hstmt and ret in (
827+
ddbc_sql_const.SQL_SUCCESS_WITH_INFO.value,
828+
ddbc_sql_const.SQL_NO_DATA.value,
829+
):
830+
self.messages.extend(ddbc_bindings.DDBCSQLGetAllDiagRecords(self.hstmt))
831+
813832
def _ensure_pyarrow(self) -> Any:
814833
"""
815834
Import and return pyarrow or raise ImportError accordingly.
@@ -1518,13 +1537,7 @@ def execute( # pylint: disable=too-many-locals,too-many-branches,too-many-state
15181537
self._reset_cursor()
15191538
raise
15201539

1521-
# Capture diagnostic messages only on SQL_SUCCESS_WITH_INFO.
1522-
# SQL_SUCCESS has no records — calling DDBCSQLGetAllDiagRecords on it
1523-
# costs ~10ms/call (driver scans internal state to find nothing).
1524-
# SQL_ERROR is already handled by check_error() above which extracts
1525-
# diagnostics and raises.
1526-
if ret == ddbc_sql_const.SQL_SUCCESS_WITH_INFO.value and self.hstmt:
1527-
self.messages.extend(ddbc_bindings.DDBCSQLGetAllDiagRecords(self.hstmt))
1540+
self._capture_diagnostics(ret)
15281541

15291542
self.last_executed_stmt = operation
15301543

@@ -2773,12 +2786,16 @@ def batch_generator():
27732786

27742787
return pyarrow.RecordBatchReader.from_batches(schema, batch_generator())
27752788

2776-
def nextset(self) -> Union[bool, None]:
2789+
def nextset(self) -> Optional[bool]:
27772790
"""
27782791
Skip to the next available result set.
27792792
27802793
Returns:
2781-
True if there is another result set, None otherwise.
2794+
True if there is another result set, False otherwise.
2795+
Note: PEP 249 specifies True/None; we return True/False
2796+
for backward compatibility with existing callers and pyodbc
2797+
parity. The signature is Optional[bool] to keep the door
2798+
open for a future migration to True/None semantics.
27822799
27832800
Raises:
27842801
Error: If the previous call to execute did not produce any result set.
@@ -2799,6 +2816,12 @@ def nextset(self) -> Union[bool, None]:
27992816
ret = ddbc_bindings.DDBCSQLMoreResults(self.hstmt)
28002817
check_error(ddbc_sql_const.SQL_HANDLE_STMT.value, self.hstmt, ret)
28012818

2819+
# Capture diagnostic messages (e.g. PRINT output) — handles both
2820+
# SQL_SUCCESS_WITH_INFO and SQL_NO_DATA (trailing PRINT after the
2821+
# final result set). Without this, messages from subsequent result
2822+
# sets are silently lost (GH-612).
2823+
self._capture_diagnostics(ret)
2824+
28022825
if ret == ddbc_sql_const.SQL_NO_DATA.value:
28032826
logger.debug("nextset: No more result sets available")
28042827
self._clear_rownumber()

‎tests/test_004_cursor.py‎

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6452,6 +6452,138 @@ def test_cursor_messages_with_error(cursor):
64526452
assert "After error" in cursor.messages[0][1], "Message should be from after the error"
64536453

64546454

6455+
def test_cursor_messages_nextset_multiple_prints(cursor):
6456+
"""Test that PRINT messages from subsequent result sets are captured via nextset().
6457+
6458+
Regression test for GH-612: PRINT messages after the first one were lost
6459+
because nextset() did not capture SQL_SUCCESS_WITH_INFO diagnostics.
6460+
"""
6461+
cursor.execute("""
6462+
PRINT 'hi';
6463+
PRINT 'ih';
6464+
""")
6465+
6466+
# First PRINT is captured by execute()
6467+
assert len(cursor.messages) == 1, "execute() should capture the first PRINT message"
6468+
assert "hi" in cursor.messages[0][1]
6469+
6470+
# Advance to the next result set — should capture the second PRINT
6471+
assert cursor.nextset()
6472+
assert len(cursor.messages) == 1, "nextset() should capture the second PRINT message"
6473+
assert "ih" in cursor.messages[0][1]
6474+
6475+
# No more result sets
6476+
assert not cursor.nextset()
6477+
6478+
6479+
def test_cursor_messages_nextset_print_with_select(cursor):
6480+
"""Test PRINT messages interleaved with SELECT result sets via nextset().
6481+
6482+
Ensures messages are captured correctly when PRINT and SELECT are mixed.
6483+
Only messages collected from nextset() itself are checked so the test
6484+
fails if nextset() drops messages (even if fetchall() would mask it).
6485+
"""
6486+
cursor.execute("""
6487+
PRINT 'before select';
6488+
SELECT 1 AS val;
6489+
PRINT 'after select';
6490+
""")
6491+
6492+
# First PRINT captured by execute()
6493+
assert len(cursor.messages) >= 1
6494+
assert "before select" in cursor.messages[0][1]
6495+
6496+
nextset_messages = []
6497+
all_rows = []
6498+
6499+
while cursor.nextset():
6500+
# Collect only messages produced by nextset() — not by fetchall()
6501+
nextset_messages.extend(cursor.messages)
6502+
if cursor.description:
6503+
all_rows.extend(cursor.fetchall())
6504+
6505+
# Also collect messages from the final nextset() that returned False
6506+
# (trailing PRINT can attach to SQL_NO_DATA)
6507+
nextset_messages.extend(cursor.messages)
6508+
6509+
# Verify the "after select" PRINT was captured by nextset(), not fetchall()
6510+
combined_text = " ".join(m[1] for m in nextset_messages)
6511+
assert "after select" in combined_text, "nextset() should capture the trailing PRINT message"
6512+
6513+
# Verify the SELECT result was returned
6514+
assert len(all_rows) == 1
6515+
assert all_rows[0][0] == 1
6516+
6517+
6518+
def test_cursor_messages_nextset_three_prints(cursor):
6519+
"""Test that three consecutive PRINT messages are all captured across nextset() calls."""
6520+
cursor.execute("""
6521+
PRINT 'msg1';
6522+
PRINT 'msg2';
6523+
PRINT 'msg3';
6524+
""")
6525+
6526+
# First PRINT captured by execute()
6527+
assert len(cursor.messages) == 1
6528+
assert "msg1" in cursor.messages[0][1]
6529+
6530+
# Second PRINT via nextset()
6531+
assert cursor.nextset()
6532+
assert len(cursor.messages) == 1
6533+
assert "msg2" in cursor.messages[0][1]
6534+
6535+
# Third PRINT via nextset()
6536+
assert cursor.nextset()
6537+
assert len(cursor.messages) == 1
6538+
assert "msg3" in cursor.messages[0][1]
6539+
6540+
# No more result sets
6541+
assert not cursor.nextset()
6542+
6543+
6544+
def test_cursor_messages_nextset_clears_previous(cursor):
6545+
"""Test that nextset() clears messages from the previous result set."""
6546+
cursor.execute("""
6547+
PRINT 'first';
6548+
PRINT 'second';
6549+
""")
6550+
6551+
assert len(cursor.messages) == 1
6552+
assert "first" in cursor.messages[0][1]
6553+
6554+
# After nextset(), messages should only contain the new message
6555+
assert cursor.nextset()
6556+
assert len(cursor.messages) == 1, "Previous messages should have been cleared"
6557+
assert "second" in cursor.messages[0][1]
6558+
assert not any("first" in m[1] for m in cursor.messages), "Old message should not persist"
6559+
6560+
6561+
def test_cursor_messages_nextset_trailing_print(cursor):
6562+
"""Test that a trailing PRINT after the final SELECT is captured.
6563+
6564+
The ODBC driver delivers the trailing PRINT as a separate result set
6565+
(SQL_SUCCESS_WITH_INFO), so nextset() returns True and captures the
6566+
message. A second nextset() then returns False (SQL_NO_DATA).
6567+
This is the most common customer pain point (GH-612).
6568+
"""
6569+
cursor.execute("""
6570+
SELECT 1 AS val;
6571+
PRINT 'trailing';
6572+
""")
6573+
6574+
rows = cursor.fetchall()
6575+
assert len(rows) == 1
6576+
assert rows[0][0] == 1
6577+
6578+
# The trailing PRINT is delivered as a separate result set
6579+
assert cursor.nextset()
6580+
assert len(cursor.messages) >= 1, "Trailing PRINT after final SELECT should be captured"
6581+
assert "trailing" in cursor.messages[0][1]
6582+
6583+
# No more result sets
6584+
assert not cursor.nextset()
6585+
6586+
64556587
def test_tables_setup(cursor, db_connection):
64566588
"""Create test objects for tables method testing"""
64576589
try:

0 commit comments

Comments
 (0)