Skip to content

FIX: Multi-Statement SQL Enhancement for mssql-python - #229

Open
Arvis Šmats (arvis108) wants to merge 5 commits into
microsoft:mainfrom
arvis108:arvis/multi_statement_feature
Open

FIX: Multi-Statement SQL Enhancement for mssql-python#229
Arvis Šmats (arvis108) wants to merge 5 commits into
microsoft:mainfrom
arvis108:arvis/multi_statement_feature

Conversation

@arvis108

Copy link
Copy Markdown
Contributor

Work Item / Issue Reference

GitHub Issue: #228


Summary

FEAT: Multi-statement SQL support with temp tables

This PR fixes an issue where multi-statement SQL queries (especially those using temporary tables) executed successfully but returned empty result sets in mssql-python, unlike SSMS and pyodbc.

The solution follows pyodbc’s proven approach by automatically applying SET NOCOUNT ON to multi-statement queries. This prevents DONE_IN_PROC interference, ensuring correct results without breaking existing functionality.


Implementation Highlights

  • Core: Added multi-statement detection and automatic SET NOCOUNT ON injection in cursor.py
  • Tests: 14 new test cases validating temp table usage, multi-statement detection, and real-world production scenarios

Benefits

  • Correct results for temp table queries
  • Zero breaking changes — transparent to existing code
  • Broader SQL Server compatibility (temp tables, stored procs, complex batches)
  • Performance improvement by reducing extra network messages

@arvis108

Copy link
Copy Markdown
Contributor Author

@microsoft-github-policy-service agree

@gargsaumya gargsaumya changed the title Multi-Statement SQL Enhancement for mssql-python FIX: Multi-Statement SQL Enhancement for mssql-python Sep 9, 2025
@bewithgaurav

Copy link
Copy Markdown
Collaborator

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines successfully started running 1 pipeline(s).

@bewithgaurav

Copy link
Copy Markdown
Collaborator

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines successfully started running 1 pipeline(s).

@gargsaumya

Copy link
Copy Markdown
Contributor

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines successfully started running 1 pipeline(s).

@gargsaumya gargsaumya left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hi Arvis Šmats (@arvis108) ,
Thank you for your contribution and for working on this enhancement! We appreciate the effort you’ve put into addressing this.

I noticed that the PR includes the file PR_SUMMARY.md, which isn’t required for this change. While the PR is currently passing all pipeline checks and everything else looks good, it would be great if we could remove this extra file to keep the PR clean and aligned with our project guidelines.

Let me know if you’d like any help or have questions, we’d be happy to assist!
Thanks again for your work on this!

Comment thread PR_SUMMARY.md Outdated
@@ -0,0 +1,299 @@
# PR Summary: PyODBC-Style Multi-Statement SQL Enhancement for mssql-python

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can we please remove this file.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed

@bewithgaurav Gaurav Sharma (bewithgaurav) left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Arvis Šmats (@arvis108) - thanks a lot for your contribution to the project, this is indeed a detailed implementation of the fix.
I have added some comments requesting a few more testcases and a minor logger change, also I have identified a expected behaviour deviation of pyodbc with this.
Please fel free to add your inputs in the discussion

Comment thread mssql_python/cursor.py Outdated
Comment thread tests/test_004_cursor.py Outdated
drop_table_if_exists(cursor, "dbo.money_test")
db_connection.commit()

def test_multi_statement_query(cursor, db_connection):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

great testcase, can we add a few more tests targetting the below areas

  • multiple result sets with multiple select statements on temp tables with nextset()
  • semicolons in b/w string literals to ensure no false positives in buffering logic
  • multi-statement batch where the final statement is not a SELECT

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added the tests you mentioned, feel free to add more

Comment thread tests/test_004_cursor.py Outdated
def test_multi_statement_query(cursor, db_connection):
"""Test multi-statement query with temp tables"""
try:
# Single SQL with multiple statements - tests pyODBC-style buffering

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

actually - pyodbc strangely gives out below error with this testcase (technically it shouldn't)

pyodbc.ProgrammingError: No results.  Previous SQL was not a query.

just curious for discussion, gargsaumya - if possible, could you please take a look at what pyodbc internally might be doing for this case?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

the simplest repro would be

cursor.execute("""
    SELECT 1 as col1, 'test' as col2 INTO #temp1;
    SELECT * FROM #temp1;
""")
rows = cursor.fetchall()
print(rows)

pyodbc gives out the error as mentioned in above comment
whereas mssql-python in this PR branch gives out
[(1, 'test')]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

pymssql also gives out [(1, 'test')] with your query

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You are right about pyodbc, I am getting same error now. Looks like when I was testing, I had "SET NOCOUNT ON" set for the session. I am sorry.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No worries! Since we have a few higher-priority items in progress, we’ll circle back to testing and reviewing this fix a bit later so we can get it merged. Please let us know if this PR is blocking you in any way. Really appreciate your continued efforts to improve the driver.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

PR is not blocking me, no worries

Axell Padilla (axellpadilla) added a commit to dbt-msft/dbt-sqlserver that referenced this pull request Aug 6, 2026
…uccessful batch

(cherry picked in spirit from 9161fce on fix/814-mssql-python-trailing-drain-transaction-error;
not a literal cherry-pick since v1.11's execute() predates _try_drain_nextset
existing here at all -- it drains with a raw `while cursor.nextset(): pass`
loop instead, which propagates a failure exactly the same way.)

Reported against dbt-sqlserver 1.12.0rc2 / mssql-python 1.12.0: an
incremental delete+insert model failed with

  Driver Error: Syntax error or access violation; DDBC Error:
  [Microsoft][SQL Server]New transaction is not allowed because there are
  other threads running in the session.

with dbt_sqlserver_use_dbt_transactions: false and threads > 1 on the
mssql-python backend. The batch itself succeeded -- the failure traced to
execute()'s trailing nextset() drain, i.e. after execute() had already read
the batch's response via get_response(cursor). Forcing threads=1 avoided it.
The same delete+insert strategy and the same connection-manager shape exist
unchanged on this branch, so the bug applies here too.

Could not reproduce the exact driver-side condition locally (see the fuller
investigation notes on the master-branch fix); microsoft/mssql-python#229
(open upstream) describes the mechanism: a multi-statement batch that turns
SET NOCOUNT back off before its final statement -- exactly what
sqlserver__get_delete_insert_merge_sql does, so that statement's rowcount is
reported -- leaves a DONE_IN_PROC token the driver's nextset() handling does
not always walk cleanly.

execute() already read cursor.rowcount into its response before any of this
runs, so nothing about a build's outcome depends on successfully walking the
rest of the DONE chain. _drain_trailing_results wraps that walk the same way
_discard_pending_results already treats a probe cursor's own trailing
results -- logged and swallowed, not propagated. Narrower than that: it only
wraps the walk that runs after the response has been captured, not the
fetch=True loop still looking for the first real result set.

Verified with a fake cursor reproducing the exact reported message --
test_execute_survives_a_spurious_mssql_python_error_during_trailing_drain.
Axell Padilla (axellpadilla) added a commit to dbt-msft/dbt-sqlserver that referenced this pull request Aug 6, 2026
…l-python trailing-drain error

Reported: on the mssql-python backend, with dbt_sqlserver_use_dbt_transactions:
false and threads > 1, a successful multi-statement batch (e.g. the
delete+insert incremental strategy, or a table_refresh_method: dml swap)
could fail the run with:

  Driver Error: Syntax error or access violation; DDBC Error:
  [Microsoft][SQL Server]New transaction is not allowed because there are
  other threads running in the session.

The failure traced to execute()'s trailing nextset() drain, which runs
after get_response(cursor) has already read the batch's own response. This
matches an open upstream mssql-python defect (microsoft/mssql-python#229):
a multi-statement batch that turns SET NOCOUNT back off before its last
statement -- which sqlserver__get_delete_insert_merge_sql does deliberately,
so that statement's rowcount is reported -- leaves a DONE_IN_PROC token the
driver's nextset() handling does not always walk cleanly.

_drain_trailing_results swallows only that exact error (duck-typed on
exception class and message, the same way _try_drain_nextset already
duck-types ADBC's NotSupportedError). nextset() can also carry a genuine,
deferred error from a later statement in the same batch -- SQL Server's
deferred name resolution lets `CREATE VIEW ... AS SELECT bad_column FROM t`
succeed at create time, so a later statement querying that view (e.g.
sqlserver__create_table_as's SELECT * INTO) only fails once nextset() walks
to it -- so every other error still fails the build exactly as before.

Verified with a fake mssql-python cursor: the known-spurious message no
longer fails execute(), while a different error (mirroring
test_concurrency.py's deliberately-broken model) still raises.
Axell Padilla (axellpadilla) added a commit to dbt-msft/dbt-sqlserver that referenced this pull request Aug 6, 2026
…l-python trailing-drain error

Reported: on the mssql-python backend, with dbt_sqlserver_use_dbt_transactions:
false and threads > 1, a successful multi-statement batch (e.g. the
delete+insert incremental strategy, or a table_refresh_method: dml swap)
could fail the run with:

  Driver Error: Syntax error or access violation; DDBC Error:
  [Microsoft][SQL Server]New transaction is not allowed because there are
  other threads running in the session.

The failure traced to execute()'s trailing nextset() drain, which runs
after get_response(cursor) has already read the batch's own response. This
matches an open upstream mssql-python defect (microsoft/mssql-python#229):
a multi-statement batch that turns SET NOCOUNT back off before its last
statement -- which sqlserver__get_delete_insert_merge_sql does deliberately,
so that statement's rowcount is reported -- leaves a DONE_IN_PROC token the
driver's nextset() handling does not always walk cleanly.

_drain_trailing_results swallows only that exact error (duck-typed on
exception class and message, the same way _try_drain_nextset already
duck-types ADBC's NotSupportedError). nextset() can also carry a genuine,
deferred error from a later statement in the same batch -- SQL Server's
deferred name resolution lets `CREATE VIEW ... AS SELECT bad_column FROM t`
succeed at create time, so a later statement querying that view (e.g.
sqlserver__create_table_as's SELECT * INTO) only fails once nextset() walks
to it -- so every other error still fails the build exactly as before.

Verified with a fake mssql-python cursor: the known-spurious message no
longer fails execute(), while a different error (mirroring
test_concurrency.py's deliberately-broken model) still raises.

(cherry picked from commit ee30264)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants