From 7f5f999e55ccaac099eef7780cdd15ef9cb190ff Mon Sep 17 00:00:00 2001 From: Luiz Phelipe Date: Fri, 23 Oct 2026 21:43:30 -0300 Subject: [PATCH] FIX: Update rowcount in nextset() nextset() moved to the next result with SQLMoreResults and refreshed description and messages, but left rowcount with the value execute() set for the first statement. The affected-row count of later statements in a batch (e.g. each INSERT of a T-SQL WHILE loop) could not be read. nextset() now sets rowcount like execute(): -1 for a result set, the SQLRowCount of the statement otherwise. This matches pyodbc. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++++ mssql_python/cursor.py | 9 +++++ tests/test_004_cursor.py | 72 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cac01d4d..d4c34850e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -104,6 +104,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), before; users should call `cursor.setinputsizes()` to work around this. ### Fixed +- **GH-863:** `Cursor.nextset()` now refreshes `Cursor.rowcount` for + the result it moves to, as `execute()` does: the affected-row count for an + INSERT/UPDATE/DELETE/MERGE result and -1 for a result set. Previously + `rowcount` kept the value of the first statement of a multi-statement batch, + so the rows affected by later statements (for example each INSERT of a + T-SQL `WHILE` loop) could not be read. This matches pyodbc. - **GH-754:** Pooled connections are now rolled back and restored to autocommit mode before being parked. This prevents an empty transaction from remaining visible on an idle SQL Server session after `Connection.close()`. Abandoned diff --git a/mssql_python/cursor.py b/mssql_python/cursor.py index d4b85e56b..8e2e9d4f2 100644 --- a/mssql_python/cursor.py +++ b/mssql_python/cursor.py @@ -3287,6 +3287,15 @@ def nextset(self) -> Optional[bool]: # If describe fails, there might be no results in this result set self.description = None + # Update rowcount for the new result, as execute() does: -1 for a + # result set, the affected-row count for INSERT/UPDATE/DELETE/MERGE. + # Without this, rowcount keeps the value of the first statement of a + # multi-statement batch (pyodbc refreshes it here too). + if self.description: + self.rowcount = -1 + else: + self.rowcount = ddbc_bindings.DDBCSQLRowCount(self.hstmt) + logger.debug( "nextset: Moved to next result set - column_count=%d", len(self.description) if self.description else 0, diff --git a/tests/test_004_cursor.py b/tests/test_004_cursor.py index 001440edf..fdd04eb71 100644 --- a/tests/test_004_cursor.py +++ b/tests/test_004_cursor.py @@ -7691,6 +7691,78 @@ def test_cursor_messages_nextset_trailing_print(cursor): assert not cursor.nextset() +def test_nextset_updates_rowcount(cursor, db_connection): + """Test that nextset() refreshes rowcount for each statement of a batch. + + execute() sets rowcount from the first statement only; each later + INSERT/UPDATE/DELETE result must report its own count after nextset(), + and a result set must report -1, as after execute(). + """ + try: + cursor.execute("CREATE TABLE #nextset_rowcount (id INT)") + db_connection.commit() + + cursor.execute(""" + INSERT INTO #nextset_rowcount VALUES (1); + INSERT INTO #nextset_rowcount VALUES (2), (3); + SELECT id FROM #nextset_rowcount; + UPDATE #nextset_rowcount SET id = id + 10; + DELETE FROM #nextset_rowcount WHERE id = 99; + """) + assert cursor.rowcount == 1, "execute() should report the first INSERT" + + assert cursor.nextset() + assert cursor.rowcount == 2, "nextset() should report the second INSERT" + + assert cursor.nextset() + assert cursor.description is not None + assert cursor.rowcount == -1, "A result set should report -1 before fetching" + assert len(cursor.fetchall()) == 3 + + assert cursor.nextset() + assert cursor.rowcount == 3, "nextset() should report the UPDATE" + + assert cursor.nextset() + assert cursor.rowcount == 0, "nextset() should report a DELETE matching no rows" + + assert not cursor.nextset() + finally: + cursor.execute("DROP TABLE IF EXISTS #nextset_rowcount") + db_connection.commit() + + +def test_nextset_rowcount_in_loop_batch(cursor, db_connection): + """Test that the per-statement counts of a T-SQL loop can be summed. + + A WHILE loop sends one result per INSERT; summing rowcount over + nextset() must give the number of rows the batch inserted. Each + iteration inserts a different number of rows (1, 2, 3), so a stale + rowcount cannot add up to the right total by accident. + """ + try: + cursor.execute("CREATE TABLE #nextset_loop (id INT)") + db_connection.commit() + + cursor.execute(""" + DECLARE @i INT = 1; + WHILE @i <= 3 + BEGIN + INSERT INTO #nextset_loop + SELECT TOP (@i) n FROM (VALUES (1), (2), (3)) AS v(n); + SET @i = @i + 1; + END; + """) + counts = [cursor.rowcount] + while cursor.nextset(): + counts.append(cursor.rowcount) + + assert counts == [1, 2, 3], f"Per-statement rowcounts should be [1, 2, 3], got {counts}" + assert sum(counts) == 6 + finally: + cursor.execute("DROP TABLE IF EXISTS #nextset_loop") + db_connection.commit() + + def test_tables_setup(cursor, db_connection): """Create test objects for tables method testing""" try: