Skip to content

tds/odbc: unified execute + statement-wise result navigation API (msodbcsql parity) - #143

Open
David-Engel wants to merge 19 commits into
mainfrom
david/odbc-multi-statement-results
Open

tds/odbc: unified execute + statement-wise result navigation API (msodbcsql parity)#143
David-Engel wants to merge 19 commits into
mainfrom
david/odbc-multi-statement-results

Conversation

@David-Engel

@David-Engel David-Engel commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Replaces the split execute/result-navigation surface in mssql-tds with a single unified API, and makes statement-wise result navigation (msodbcsql parity) the default across every consumer.

This started (superseding #141) as additive statement-wise SQLMoreResults navigation for mssql-odbc. In review we decided that, since these APIs have no external consumers to preserve, a clean breaking redesign is better than carrying two parallel navigation paths forever. This PR now delivers that unified API and migrates all consumers plus the full test suite.

New unified API (mssql-tds)

  • execute(sql, options) -> StatementResult, where options: impl Into<ExecuteOptions> so () selects defaults.
  • StatementResult: Rows / NoRows { rows_affected } / End.
  • advance() -> StatementResult — drain the current statement and move to the next statement boundary.
  • advance_to_rows() -> bool — collapse forward to the next row-returning statement (preserves the old collapsing behavior where a caller wants it).
  • on_rows() and has_open_batch() accessors.
  • ExecuteOptions { timeout, cancel, column_encryption } (with Default + builder methods) replaces the trailing timeout, cancel (and encryption-setting) parameters on every execute_* method.

msodbcsql rule (verified by probing the reference driver): a statement is a navigable result iff it returns rows, carries a row count (DONE COUNT flag), or produced messages (INFO). Pure no-op statements (e.g. a bare CREATE TABLE) collapse. Predicate: has_count || saw_message.

Breaking changes

Removed in favor of the unified surface:

  • execute_multi_statement, move_to_next_statement
  • the ResultSetClient trait (get_current_resultset / move_to_next)
  • execute_sp_executesql_with_encryption_setting / _core
  • the expose_norow bool on the result-boundary loop — statement-wise navigation is now unconditional.

Every execute_* method now takes impl Into<ExecuteOptions> instead of positional timeout / cancel args.

Consumers migrated

mssql-odbc, mssql-js, mssql-py-core, mssql-tds-cli, and mssql-tds-bench, plus the full mssql-tds unit + integration test suite (~40 files).

Production fix

get_next_row_into's error arm now clears has_open_batch / result-set state after draining, so close_query / advance no longer hang after a mid-row server error.

New test scaffolding: mssql-tds/src/test_client_support.rs

This PR adds a small test-util-gated module that builds a TdsClient on top of a mock transport which replays a scripted sequence of TDS tokens (COLMETADATA, DONE/DONEPROC with specific flags, INFO) instead of talking to a live server. It is not shipped API and is excluded from coverage reporting.

Why it's needed. The unified statement-wise navigation pushes real logic into the seam between the TDS layer and the ODBC layer — no-row results, message-only statements, end-of-batch, and the defensive error guards. Several of those paths are impossible or flaky to reach through a real server:

  • Internal error guards can't be produced by a real server — e.g. an ODBC statement left cursor-open while its connection has no active client, or a transport that dies mid-drain.
  • Some token shapes are awkward to coax out of SQL Server — e.g. a zero-column COLMETADATA, or an exact DONE flag combination such as MORE|COUNT.
  • These are unit tests: they run in the Build stage with no SQL host, and they assert on driver-internal state (STMT/DBC handle state, SQLSTATEs, cursor open/closed) that the black-box ODBC C API does not expose.

Benefits.

  • Deterministic, hermetic coverage of the ODBC result-navigation state machine and its error branches — no server, no network flakiness.
  • Fast feedback in the Build stage, complementing the live integration tests and the e2e --compare-with-msodbcsql parity run, which cover the happy paths end-to-end.

Why it lives in mssql-tds behind a feature. Constructing a positioned TdsClient requires crate-internal transport traits (TdsTransport, TdsTokenStreamReader, …) and TdsClient::new. Exposing these helpers through the test-util feature — opted into by mssql-odbc's dev-dependency — is the idiomatic Rust pattern; a separate testkit crate would instead force widening many internals to pub.

Tests & parity

  • Whole workspace builds; clippy + fmt clean.
  • mssql-tds lib + integration suites, mssql-odbc lib, and the mock / cursor / rpc / tvp / reset-connection / bulk-copy suites are green (local SQL Server 2022; VECTOR and a timing-flaky table-lock case aside, which are environment/flake, not regressions).
  • e2e run_e2e.sh --compare-with-msodbcsql: 14 parity / 0 bugs. The comparison caught one real regression — prepared DML left a 0-column cursor open (SQLSTATE 24000 on reuse) — fixed by draining non-Rows prepared results to idle in the SQLExecute path.

@David-Engel
David-Engel requested a review from a team as a code owner July 21, 2026 20:55
Copilot AI review requested due to automatic review settings July 21, 2026 20:55

Copilot AI 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.

Pull request overview

Adds statement-wise result navigation for ODBC parity while preserving existing result-set behavior for other consumers.

Changes:

  • Adds StatementResult-based TDS navigation.
  • Maps no-row results through ODBC execution, fetch, and SQLMoreResults.
  • Updates parity-focused end-to-end tests.

Reviewed changes

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

Show a summary per file
File Description
mssql-tds/src/connection/tds_client.rs Implements statement-boundary navigation and tests.
mssql-odbc/src/api/exec_direct.rs Enables navigation for plain batches.
mssql-odbc/src/api/exec_common.rs Preserves pending no-row results.
mssql-odbc/src/api/more_results.rs Maps statement results to ODBC returns.
mssql-odbc/src/api/fetch.rs Rejects fetches from zero-column results.
mssql-odbc/tests/e2e/tests/exec_direct_test.cpp Tests separate informational statements.
mssql-odbc/tests/e2e/tests/more_results_test.cpp Tests navigation through PRINT results.

Comment thread mssql-tds/src/connection/tds_client.rs Outdated
Comment thread mssql-tds/src/connection/tds_client.rs Outdated
Comment thread mssql-tds/src/connection/tds_client.rs Outdated
Comment thread mssql-tds/src/connection/tds_client.rs
David-Engel added a commit that referenced this pull request Jul 21, 2026
…t; clarify docs

Address PR #143 review feedback and CI test failures:

- fetch: the no-row (0-column) guard returned SQLSTATE 24000 too early,
  before the busy-with-other-statement (HY000) and already-drained
  (SQL_NO_DATA) checks, breaking two mssql-odbc unit tests. Move the guard
  into fetch_rows_next after those checks, where active_stmt == self is
  established; restore the client so the connection stays busy. Fixes
  fetch_busy_with_other_statement_returns_hy000 and
  fetch_after_cursor_drained_returns_no_data.
- tds_client: add move_to_next_statement_end_after_draining_final_rowset,
  a timeout-bounded regression test for the post-drain has_open_batch
  re-check (the reported hang). Enhance the mock transport's
  receive_row_into to replay queued tokens so drain paths are testable.
- docs: correct advance_to_result_boundary / execute_multi_statement /
  StatementResult doc comments to state the count-or-message predicate;
  pure no-op statements (e.g. bare CREATE TABLE) are collapsed and never
  surface as NoRows.

All mssql-tds statement tests, mssql-odbc fetch tests, and e2e (14/14,
parity) pass; clippy + fmt clean.
@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown

📊 Code Coverage Report

🔥 Diff Coverage

83%

🎯 Overall Coverage

90.3%

📦 Project: mssql-tds + mssql-odbc + mssql-py-core
ℹ️ Note: diff coverage is reported, not enforced.


Diff Coverage

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

  • mssql-odbc/src/api/exec_common.rs (70.6%): Missing lines 177,272-274,287
  • mssql-odbc/src/api/exec_direct.rs (95.8%): Missing lines 132-133
  • mssql-odbc/src/api/execute.rs (0.0%): Missing lines 70,75-80,91-96,99-103,114-115,117-118
  • mssql-odbc/src/api/fetch.rs (85.7%): Missing lines 112-116,130
  • mssql-odbc/src/api/free_handle.rs (0.0%): Missing lines 238
  • mssql-odbc/src/api/more_results.rs (93.9%): Missing lines 131-135,150
  • mssql-py-core/src/cursor.rs (88.0%): Missing lines 88-89,93
  • mssql-tds/src/connection/cursor_ops.rs (100%)
  • mssql-tds/src/connection/tds_client.rs (91.7%): Missing lines 1919,1921,2973,3474-3475,3479-3482,3485-3488,3491-3494
  • mssql-tds/src/test_client_support.rs (70.4%): Missing lines 98,100,108,110,115,117-118,120,131-136,144,147-150,159-162,164-165,167-170

Summary

  • Total: 557 lines
  • Missing: 91 lines
  • Coverage: 83%

mssql-odbc/src/api/exec_common.rs

  173         return;
  174     };
  175     if let Err(e) = dbc
  176         .runtime
! 177         .block_on(client.execute_sp_unprepare(handle, ()))
  178     {
  179         error!(%e, handle, "{op}: sp_unprepare failed — handle leaked until disconnect");
  180     }
  181 }

  268         // 24000). Do NOT drain the wire — that would collapse the rest of the
  269         // batch. Matches msodbcsql.
  270         let info_messages = client.take_info_messages();
  271         let Ok(mut stmt_state) = stmt.inner.lock() else {
! 272             error!("{op}: stmt mutex poisoned on no-row result");
! 273             return_client_busy(dbc, client);
! 274             return SQL_ERROR;
  275         };
  276         stmt_state.column_metadata = metadata; // empty (0 columns)
  277         stmt_state.set_state(STMT_STATE_EXEC_CONTEXT | STMT_STATE_CURSOR_OPEN);
  278         stmt_state.clear_state(STMT_STATE_EXEC_STARTED);

  283             SQL_SUCCESS_WITH_INFO
  284         } else {
  285             SQL_SUCCESS
  286         };
! 287     }
  288 
  289     if !has_result_set {
  290         // DDL / DML (last / only statement): drain trailing DONE tokens and
  291         // return to idle so the statement can re-execute without an explicit

mssql-odbc/src/api/exec_direct.rs

  128     // handle); unparameterized text runs as a plain SQL batch. Neither DBC nor
  129     // STMT lock is held during I/O.
  130     let exec_result: Result<(), mssql_tds::error::Error> = if marker_count > 0 {
  131         dbc.runtime
! 132             .block_on(client.execute_sp_executesql(rewritten_sql, named_params, ()))
! 133             .map(|_| ())
  134     } else {
  135         // Statement-wise navigation: position on the batch's first statement
  136         // (msodbcsql parity) so no-row statements (PRINT / RAISERROR / DML) are
  137         // individually navigable via SQLMoreResults. finish_execute inspects the

mssql-odbc/src/api/execute.rs

  66         Ok(client) => client,
  67         Err(rc) => return rc,
  68     };
  69 
! 70     let exec_result = match exec.handle {
  71         // Already prepared: reuse the cached server handle (msodbcsql
  72         // `cmdp.hPrepCurrent`) via sp_execute. No pending prepared handle drop can
  73         // exist here. (StmtState invariant: `pending_unprepare` is set only when
  74         // `prepared_handle` is None), so nothing to release.
! 75         Some(handle) => dbc.runtime.block_on(client.execute_sp_execute(
! 76             handle,
! 77             None,
! 78             Some(exec.named_params),
! 79             (),
! 80         )),
  81         // First execute / re-prepare: prepare and run in one round trip via
  82         // sp_prepexec (deferred-prepare path). A prepared handle superseded
  83         // by a prior rebind / re-prepare is dropped in the same RPC by passing
  84         // it as sp_prepexec's `@handle` input (`drop_handle`). This avoids

   87         // NOTE: msodbcsql falls back to sp_prepare + sp_execute for statements
   88         // with data-at-execution (DAE) parameters, which sp_prepexec can't
   89         // carry. Phase 1 rejects DAE params at bind time, so that case can't
   90         // occur here yet - add the sp_prepare branch when DAE support lands.
!  91         None => dbc.runtime.block_on(client.execute_sp_prepexec(
!  92             exec.rewritten_sql,
!  93             exec.named_params,
!  94             exec.drop_handle,
!  95             (),
!  96         )),
   97     };
   98 
!  99     let stmt_result = match exec_result {
! 100         Ok(result) => result,
! 101         Err(e) => {
! 102             error!(%e, "SQLExecute: prepared execution failed");
! 103             return fail_with_tds(dbc, stmt, statement_handle, client, &e);
  104         }
  105     };
  106 
  107     // A prepared statement runs a single SQL statement. If it produced no result

  110     // 0-column cursor open — matching how the pre-statement-wise path collapsed
  111     // no-row results. A row-returning statement keeps its cursor open for
  112     // SQLFetch; its `@handle` RETURNVALUE (sp_prepexec) is captured later at
  113     // drain time (SQLCloseCursor / the DDL finish path).
! 114     if !matches!(stmt_result, StatementResult::Rows)
! 115         && let Err(e) = dbc.runtime.block_on(client.advance_to_rows())
  116     {
! 117         error!(%e, "SQLExecute: draining no-row prepared result failed");
! 118         return fail_with_tds(dbc, stmt, statement_handle, client, &e);
  119     }
  120 
  121     finish_execute(dbc, stmt, statement_handle, client, "SQLExecute")
  122 }

mssql-odbc/src/api/fetch.rs

  108     {
  109         let no_columns = match stmt.inner.lock() {
  110             Ok(ss) => ss.column_metadata.is_empty(),
  111             Err(_) => {
! 112                 error!("SQLFetch: stmt mutex poisoned checking no-row result");
! 113                 if let Ok(mut ds) = dbc.inner.lock() {
! 114                     ds.client = Some(client);
! 115                 }
! 116                 return SQL_ERROR;
  117             }
  118         };
  119         if no_columns {
  120             error!("SQLFetch: current result has no columns (no-row statement)");

  126             if let Ok(mut ss) = stmt.inner.lock() {
  127                 post_diag(&mut ss, ERR_INVALID_CURSOR_STATE);
  128             }
  129             return SQL_ERROR;
! 130         }
  131     }
  132 
  133     let fetch_result = dbc.runtime.block_on(client.next_row());

mssql-odbc/src/api/free_handle.rs

  234 
  235     for handle in handles {
  236         if let Err(e) = dbc
  237             .runtime
! 238             .block_on(client.execute_sp_unprepare(handle, ()))
  239         {
  240             error!(%e, handle, "SQLFreeHandle(STMT): sp_unprepare failed — handle leaked until disconnect");
  241         }
  242     }

mssql-odbc/src/api/more_results.rs

  127             // (SQLFetch returns 24000), but it is a navigable result and may
  128             // carry diagnostic messages. The connection stays busy so a further
  129             // SQLMoreResults can advance past it. Matches msodbcsql.
  130             let Ok(mut stmt_state) = stmt.inner.lock() else {
! 131                 error!("SQLMoreResults: stmt mutex poisoned on no-row result");
! 132                 if let Ok(mut ds) = dbc.inner.lock() {
! 133                     ds.client = Some(client);
! 134                 }
! 135                 return SQL_ERROR;
  136             };
  137             stmt_state.column_metadata.clear();
  138             stmt_state.current_row = None;
  139             let info_messages = client.take_info_messages();

  146             debug!("SQLMoreResults: advanced to a no-row statement result");
  147             if has_server_info {
  148                 SQL_SUCCESS_WITH_INFO
  149             } else {
! 150                 SQL_SUCCESS
  151             }
  152         }
  153         Ok(StatementResult::End) => {
  154             // Batch exhausted. Close cursor state and release the connection.

mssql-py-core/src/cursor.rs

  84                 // row-returning result set, so collapse forward to it — matching
  85                 // the pre-statement-wise behavior fetchone/fetchall expect.
  86                 if !matches!(first, StatementResult::Rows) {
  87                     client.advance_to_rows().await.map_err(|e| {
! 88                         error!("execute: Failed to advance to first result set: {}", e);
! 89                         pyo3::exceptions::PyRuntimeError::new_err(format!(
  90                             "Query execution failed: {}",
  91                             e
  92                         ))
! 93                     })?;
  94                 }
  95 
  96                 info!("execute: Query executed successfully");
  97                 Ok::<_, PyErr>(())

mssql-tds/src/connection/tds_client.rs

  1915                 Tokens::TabName | Tokens::ColInfo => {
  1916                     continue;
  1917                 }
  1918                 _ => {
! 1919                     info!("advance_to_result_boundary: {:?}", token);
  1920                     return Err(UsageError(format!(
! 1921                         "Unexpected token while moving to next result boundary: {token:?}"
  1922                     )));
  1923                 }
  1924             }
  1925         }

  2969             }
  2970             Tokens::ColMetadata(_) => Err(crate::error::Error::UsageError(
  2971                 "Unexpected ColMetadata token encountered while reading rows. \
  2972                      This typically indicates the API was not used correctly - \
! 2973                      you may need to call advance_to_rows() to advance to the next result set."
  2974                     .to_string(),
  2975             )),
  2976             Tokens::Info(info_token) => {
  2977                 info!(?info_token);

  3470 }
  3471 
  3472 impl<'a> ExecuteOptions<'a> {
  3473     /// Creates default options (inherit all connection behavior).
! 3474     pub fn new() -> Self {
! 3475         Self::default()
  3476     }
  3477 
  3478     /// Sets a per-request timeout, in seconds.
! 3479     pub fn timeout_secs(mut self, seconds: u32) -> Self {
! 3480         self.timeout = Some(seconds);
! 3481         self
! 3482     }
  3483 
  3484     /// Attaches a cancellation handle.
! 3485     pub fn cancel(mut self, handle: &'a CancelHandle) -> Self {
! 3486         self.cancel = Some(handle);
! 3487         self
! 3488     }
  3489 
  3490     /// Overrides the Always Encrypted behavior for this command only.
! 3491     pub fn column_encryption(mut self, setting: ExecutionColumnEncryptionSetting) -> Self {
! 3492         self.column_encryption = setting;
! 3493         self
! 3494     }
  3495 }
  3496 
  3497 impl From<()> for ExecuteOptions<'_> {
  3498     fn from(_: ()) -> Self {

mssql-tds/src/test_client_support.rs

   94         _pause_state: RowPauseState,
   95         _remaining_request_timeout: Option<Duration>,
   96         _cancel_handle: Option<&CancelHandle>,
   97         _writer: &mut (dyn RowWriter + Send),
!  98     ) -> TdsResult<RowReadResult> {
   99         Err(crate::error::Error::ConnectionClosed("test".to_string()))
! 100     }
  101 
  102     async fn read_active_plp_bytes(
  103         &mut self,
  104         _plp_state: &mut PlpPauseState,

  104         _plp_state: &mut PlpPauseState,
  105         _remaining_request_timeout: Option<Duration>,
  106         _cancel_handle: Option<&CancelHandle>,
  107         _out: &mut [u8],
! 108     ) -> TdsResult<usize> {
  109         Err(crate::error::Error::ConnectionClosed("test".to_string()))
! 110     }
  111 }
  112 
  113 #[async_trait]
  114 impl TransportSslHandler for TokenReplayTransport {
! 115     async fn enable_ssl(&mut self) -> TdsResult<()> {
  116         Ok(())
! 117     }
! 118     async fn disable_ssl(&mut self) -> TdsResult<()> {
  119         Ok(())
! 120     }
  121 }
  122 
  123 #[async_trait]
  124 impl NetworkWriter for TokenReplayTransport {

  127     }
  128     fn packet_size(&self) -> u32 {
  129         4096
  130     }
! 131     fn get_encryption_setting(&self) -> NegotiatedEncryptionSetting {
! 132         NegotiatedEncryptionSetting::NoEncryption
! 133     }
! 134     fn set_reset_mode(&mut self, mode: ResetConnectionMode) {
! 135         self.reset_mode = mode;
! 136     }
  137     fn take_reset_mode(&mut self) -> ResetConnectionMode {
  138         std::mem::replace(&mut self.reset_mode, ResetConnectionMode::None)
  139     }
  140 }

  140 }
  141 
  142 #[async_trait]
  143 impl NetworkReader for TokenReplayTransport {
! 144     async fn receive(&mut self, buffer: &mut [u8]) -> TdsResult<usize> {
  145         buffer.fill(0);
  146         Ok(0)
! 147     }
! 148     fn packet_size(&self) -> u32 {
! 149         4096
! 150     }
  151 }
  152 
  153 #[async_trait]
  154 impl TdsTransport for TokenReplayTransport {

  155     fn as_writer(&mut self) -> &mut dyn NetworkWriter {
  156         self
  157     }
  158     fn reset_reader(&mut self) {}
! 159     fn packet_size(&self) -> u32 {
! 160         4096
! 161     }
! 162     async fn close_transport(&mut self) -> TdsResult<()> {
  163         Ok(())
! 164     }
! 165     async fn send_attention_with_timeout(&mut self, _timeout: Duration) -> TdsResult<bool> {
  166         Ok(false)
! 167     }
! 168     fn is_connection_dead(&self) -> bool {
! 169         true
! 170     }
  171 }
  172 
  173 /// Builds a [`TdsClient`] whose transport replays `tokens`. Combine with the
  174 /// public statement-wise navigation API


🔗 Quick Links

View Azure DevOps Build · Coverage Report

@David-Engel
David-Engel marked this pull request as draft July 21, 2026 23:22
Adds an msodbcsql-aligned navigation model where each statement in a batch
that returns rows, carries a row count (DONE COUNT flag), or produces
messages (PRINT / low-severity RAISERROR) is individually navigable, instead
of collapsing all no-row statements into the next row-returning result set
the way execute()/move_to_next() do. Pure no-op statements (e.g. CREATE with
no count and no messages) are still collapsed, matching msodbcsql — verified
directly: `CREATE; INSERT; SELECT` exposes the INSERT's row count and the
SELECT, not the bare CREATE.

New public API on TdsClient:
  - enum StatementResult { RowSet, NoRows { rows_affected }, End }
  - execute_multi_statement(): runs a batch, positions on its first result.
  - move_to_next_statement(): advances to the next statement's result.

Internals: move_to_column_metadata() is refactored onto a shared
advance_to_result_boundary(expose_norow) helper; with expose=false it keeps
the exact existing (collapsing) behavior used by execute()/move_to_next()
and the JS/Python consumers. The batch-send prologue is factored into
send_query_batch(). No existing caller changes.

Foundation for aligning mssql-odbc SQLMoreResults with msodbcsql. Unit tests
cover PRINT/RAISERROR (each surfaced), a single no-row statement, the
no-op-collapse + row-count-surface rule, and the no-open-batch case.
Wire the ODBC layer to mssql-tds's additive statement-wise navigation so
each no-row statement (PRINT, low-severity RAISERROR, DML with rowcount)
is individually navigable via SQLMoreResults instead of collapsing into
the next row-returning result set.

- tds_client: has_open_batch() accessor; move_to_next_statement re-checks
  has_open_batch after draining so a batch whose final DONE was consumed
  by the row reader returns End instead of blocking on an empty wire.
- exec_direct: plain batch uses execute_multi_statement.
- exec_common::finish_execute: expose no-row statement result, keep
  connection busy/cursor positioned when the batch has more statements.
- more_results: map StatementResult (RowSet/NoRows/End).
- fetch: 0-column no-row result returns SQLSTATE 24000.
- e2e: exec_direct/more_results tests updated for statement-wise behavior.

Parity: 14 parity, 0 bugs vs msodbcsql.
…t; clarify docs

Address PR #143 review feedback and CI test failures:

- fetch: the no-row (0-column) guard returned SQLSTATE 24000 too early,
  before the busy-with-other-statement (HY000) and already-drained
  (SQL_NO_DATA) checks, breaking two mssql-odbc unit tests. Move the guard
  into fetch_rows_next after those checks, where active_stmt == self is
  established; restore the client so the connection stays busy. Fixes
  fetch_busy_with_other_statement_returns_hy000 and
  fetch_after_cursor_drained_returns_no_data.
- tds_client: add move_to_next_statement_end_after_draining_final_rowset,
  a timeout-bounded regression test for the post-drain has_open_batch
  re-check (the reported hang). Enhance the mock transport's
  receive_row_into to replay queued tokens so drain paths are testable.
- docs: correct advance_to_result_boundary / execute_multi_statement /
  StatementResult doc comments to state the count-or-message predicate;
  pure no-op statements (e.g. bare CREATE TABLE) are collapsed and never
  surface as NoRows.

All mssql-tds statement tests, mssql-odbc fetch tests, and e2e (14/14,
parity) pass; clippy + fmt clean.
Raise diff coverage of the statement-wise navigation changes by adding
Rust unit tests for the client-driven ODBC paths that were previously
only exercised by the C++ e2e suite (which does not feed llvm-cov).

- mssql-tds: add `test_client_support` (gated behind the existing
  `test-util` feature) exposing `tds_client_from_tokens` plus opaque
  token constructors. A token-replay transport drives a real TdsClient
  through statement-wise navigation without a live server.
- mssql-odbc: add `test-util` dev-dependency and unit tests injecting a
  scripted client into DbcState to cover SQLMoreResults (RowSet / NoRows
  / End / no-active-client), SQLFetch's no-row 24000 guard, and
  SQLExecDirect's finish_execute no-row branch.

No production behavior change.
@David-Engel
David-Engel force-pushed the david/odbc-multi-statement-results branch from 71786a3 to e3a5ecc Compare July 22, 2026 17:49
…; exclude test scaffolding from coverage

- exec_direct: add a no-row-with-message test covering the finish_execute
  SQL_SUCCESS_WITH_INFO branch.
- more_results: add a transport-failure test covering the SQLMoreResults
  error arm (cursor reset + connection released).
- CI: exclude test_client_support.rs (test-only, test-util-gated
  scaffolding) from the lcov/cobertura coverage reports so diff-cover
  measures product code only.

Diff coverage of the statement-wise changes is ~94%.
@David-Engel
David-Engel marked this pull request as ready for review July 22, 2026 21:19
@David-Engel
David-Engel marked this pull request as draft July 23, 2026 20:42
Redesign the mssql-tds result-navigation and execute* surface into one
lossless, statement-wise primitive, per the agreed design:

- StatementResult { Rows, NoRows { rows_affected: Option<u64> }, End } and a
  single `execute(sql, opts) -> StatementResult` + `advance() -> StatementResult`,
  with `advance_to_rows() -> bool` as the collapse convenience and `on_rows()`.
- ExecuteOptions { timeout, cancel, column_encryption } (Default/builder/From<()>)
  threaded through every execute* method; deletes the
  execute_sp_executesql_with_encryption_setting twin.
- Row-producing execute* (execute, sp_executesql, stored_procedure, sp_prepexec,
  sp_execute) now return StatementResult; sp_prepare -> i32, sp_unprepare -> ().
- Removes ResultSetClient (get_current_resultset/move_to_next),
  execute_multi_statement/move_to_next_statement, move_to_column_metadata.
- Migrates all consumers: mssql-odbc, mssql-js (query_raw/fetch_chunk restructured,
  DML-only close wart gone), mssql-py-core, mssql-tds-cli, benches, and the
  integration test suite.

Robustness fix (surfaced by the migration): get_next_row_into's error path now
clears has_open_batch so close_query/advance no longer block on a drained stream
after a mid-row server error.

Passing: mssql-tds lib 1550, mssql-odbc lib 333, mock 13, query_results,
info_messages, cursor_ops, tvp; clippy + fmt clean. A few behavioral integration
tests (rpc_results, and SQL-setup-gated suites) still need updates for the
statement-wise semantics.
…ntics

Statement-wise execute() leaves the batch positioned on a DML row count or a
stored proc's no-row result (has_open_batch stays true); callers that then run
another operation or read output params must drain to End. Update rpc_results
tests (sp_executesql seed insert; stored-proc output params) to advance_to_rows
after the DML/proc, matching how the ODBC/JS/py-core layers already drain.
The unified statement-wise execute made execute_sp_execute/execute_sp_prepexec
return the DML's row count as a NoRows result with the batch still positioned
(has_open_batch true), so finish_execute left a 0-column cursor open. A prepared
statement runs a single statement, so SQLExecute of a DML must leave it idle and
immediately re-executable (msodbcsql parity) — re-executing hit SQLSTATE 24000.

Drain non-Rows results to End in the SQLExecute path before finish_execute,
restoring the pre-statement-wise collapse behavior for prepared statements.
The row-returning path is unchanged (cursor kept for SQLFetch; sp_prepexec
@handle RETURNVALUE captured at drain time).

e2e --compare-with-msodbcsql: 14 parity, 0 bugs (was 13 parity / 1 bug:
PrepareExecuteLiveTest.PreparedParamDmlReusesHandle).
@David-Engel David-Engel changed the title odbc: statement-wise SQLMoreResults navigation (msodbcsql parity) tds/odbc: unified execute + statement-wise result navigation API (msodbcsql parity) Jul 23, 2026
@David-Engel
David-Engel requested a review from Copilot July 23, 2026 22:46

Copilot AI 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.

Pull request overview

Copilot reviewed 66 out of 66 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (1)

mssql-js/src/connection.rs:157

  • A procedure that first emits a row count is now left on NoRows. The unchanged createResultFast() stops when its first fetchChunk() returns None, so it neither reaches later row sets nor drains the stream before get_return_values() is called; result sets and output parameters can both be missing. Collapse to the first row set before returning to the TypeScript consumer.
        let result = client
            .execute_stored_procedure(stored_proc_name, None, Some(named_params), ())
            .await;

Comment thread mssql-py-core/src/cursor.rs Outdated
Comment thread mssql-js/src/connection.rs Outdated
Comment thread mssql-tds/src/test_client_support.rs
… doc links

Address PR #143 review feedback:
- mssql-py-core cursor.execute and mssql-js execute/executeWithParams/executeProc
  now advance to the first row-returning result set after the unified (statement-
  wise) execute, matching query_raw. Without this, a leading no-row statement
  (e.g. UPDATE ...; SELECT ...) left the client off on_rows() and the consumer's
  fetch path returned no rows, dropping the SELECT.
- Repoint doc links that referenced the removed execute_multi_statement /
  move_to_next_statement / ResultSetClient APIs to the unified execute / advance /
  ResultSet surface, including the mssql-tds crate-level quick-start doctest
  (now compiles) and the newly test-util-exported test_client_support module.
@Theekshna

Copy link
Copy Markdown
Contributor

Looks good. Would be good to make sure we're covering most edge cases etc in out e2e/unit tests

The cargo-fuzz crate (mssql-tds/fuzz) is built by CI's Build stage via
'cargo +nightly fuzz build' but is outside the default workspace, so the unified
execute-API migration and local 'cargo build/clippy' missed it. Its execute /
execute_sp_executesql / execute_stored_procedure calls still passed the removed
trailing timeout/cancel args, failing the Build stage with cargo exit 101.

Drop the trailing None, None args and pass () (ExecuteOptions default).
Verified: RUSTFLAGS="--cfg fuzzing" cargo +nightly fuzz build fuzz_tds_client.
Point the 'unexpected ColMetadata while reading rows' usage error at the unified
advance_to_rows() API instead of the removed move_to_next(). Found during a full
codebase scan for callers of the changed execute/navigation APIs — no remaining
old-signature call sites exist (verified across all 9 crates including the
workspace-excluded fuzz / mssql-py-core / mssql-mock-tds-py targets).
…row() in tests

The get_current_resultset() -> Option<&mut Self> method existed only as a
compatibility shim (cfg(test-util)) so the migrated tests could keep the old
'resultset handle' call shape. It re-introduced exactly the result-set-object
model the unified execute/advance API removed, so drop it and rewrite the 156
call sites across 32 test files to the real API:

  if let Some(rs) = client.get_current_resultset() { rs.next_row().await? ... }
    ->  if client.on_rows() { client.next_row().await? ... }

Behavior is identical (the shim returned Some(self) iff on_rows()). The test-util
feature stays (still gates crypto key-gen helpers for AE tests). No shipped-API
change. Verified: cargo test --no-run (all-features + default), clippy -D warnings,
and the rewritten suites (query_results/info_messages/cursor_ops/rpc_results/
client_read_apis/tvp/bulk_copy) all green against SQL 2022.
Resolve the unified-execute refactor against main's typed/columnar fetch work
(#144) and PLP/sparse row-column read APIs (#109):

- tds_client.rs: main extracted the inline row-read token match into
  handle_row_read_token(); re-applied this branch's two edits there — the
  Tokens::Error batch-state clear (has_open_batch/current_metadata) that fixes
  the post-error close_query/advance hang, and the Tokens::ColMetadata usage
  message (move_to_next -> advance_to_rows).
- test_client_support.rs: implement the new TdsTokenStreamReader methods
  resume_row_into / read_active_plp_bytes on the scripted mock (unreachable for
  it — it never pauses rows — so they error like the drained-stream case).
- Migrate main's newly-added tests (sparse row-column reads in
  test_client_read_apis.rs, paused-AE-PLP in test_always_encrypted.rs) to the
  unified API: execute(.., ()) and on_rows()/next_row_into() instead of
  execute(.., None, None) / get_current_resultset().

Verified: workspace build + clippy -D warnings clean, fuzz build, mssql-odbc lib
354/354, tds client_read_apis 19/19 (incl. sparse-column) and rpc/info suites green.

Copilot AI 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.

Pull request overview

Copilot reviewed 68 out of 68 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (1)

mssql-tds/src/connection/tds_client.rs:1755

  • This documentation still describes the removed expose_norow_statements false/true modes. The function now has no such parameter and always applies the count-or-message predicate, so this guidance misstates the unified API's behavior.
    /// With `expose_norow_statements = false` (result-set navigation used by
    /// batch execution and the JS/Python consumers), a no-row statement's DONE
    /// token carrying the MORE flag is skipped so the method advances to the
    /// next COLMETADATA — consecutive no-row statements collapse into the

Comment thread mssql-tds/tests/common/mod.rs Outdated
Comment thread mssql-tds/src/connection/tds_client.rs
Comment thread mssql-tds/src/connection/tds_client.rs
First full CI run (earlier builds died at fuzz/clippy) surfaced 5 test_vector_type
failures — all from the unified statement-wise navigation:

- 4 output-parameter tests: retrieve_output_params() returned None because the
  proc's trailing RETURNVALUE tokens are only read once the batch is drained past
  the proc body. Add client.advance_to_rows() after execute_stored_procedure
  before reading output params (same fix already used in test_rpc_results).
- test_vector_parameter_in_where_clause: the CREATE TABLE + INSERTs setup is a
  no-row multi-statement batch; 'while client.on_rows() { advance_to_rows() }'
  never entered the loop (on_rows is false on a no-row boundary), leaving the
  batch open -> the next execute_sp_executesql hit 'open batch must be closed or
  fully consumed'. Replace all such setup drains with
  'while client.advance_to_rows().await.unwrap() {}', which drains no-row
  statements to end.

Can't run VECTOR tests locally (needs SQL 2025); verified compile + clippy and
the analogous test_rpc_results output-param tests pass on SQL 2022.
Address PR #143 review comments:
- tds_client: begin_command() now also clears return_values (output params), so a
  new plain batch or sp_executesql after a fully-navigated prior RPC no longer
  reports stale get_return_values()/retrieve_output_params(). Removes the four
  now-redundant per-path clears.
- tests/common validate_results: rewrite to be statement-wise — thread the
  StatementResult returned by execute, iterate with advance(), validate
  NoRows.rows_affected against Update(n) and row counts against Result(n), and
  assert the batch is fully consumed. Pure no-op statements (DDL/SET/ROLLBACK)
  collapse and are expressed as Update(0) (consume no boundary). Previously the
  helper collapsed every no-row boundary and skipped Update validation entirely.

Verified locally (SQL 2022): query_results/transaction/reset_connection/
timeout_and_cancel 39/39, rpc_results 11/11, tds lib return-value units, clippy -D
warnings clean.
… build 163010; Linux+MacOS full suite green)
The Windows-only LocalDB test used `while client.on_rows() { .. }` as the
outer drain loop. `on_rows()` reflects current_metadata.is_some(), which is
only cleared by advance()/advance_to_rows() — draining rows via next_row()
leaves it true, so the outer loop spun forever after the single row of
SELECT @@Version was consumed. This hung the Windows test job to the 60-min
cap (builds 163010, 163021). Linux/macOS never caught it: the test is
#[cfg(windows)] (LocalDB is Windows-only).

Use `if on_rows()` to drain the first rowset, then
`while advance_to_rows()` to drain any additional rowsets, matching the
correct pattern in test_simple_query in this same file.
…sql-py-core test_tracing_end_to_end async-flush race)
@David-Engel
David-Engel marked this pull request as ready for review July 28, 2026 17:06
@David-Engel

Copy link
Copy Markdown
Contributor Author

@saurabh500 - This is a breaking API change. But I think it cleans things up nicely and is worth it. Let me know what you think.

@David-Engel
David-Engel enabled auto-merge (squash) July 28, 2026 17:08
@David-Engel
David-Engel disabled auto-merge July 28, 2026 17:09
@saurabh500

Copy link
Copy Markdown
Contributor

LGTM

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.

4 participants