Skip to content

Implement SQLRowCount - #151

Open
gargsaumya wants to merge 8 commits into
mainfrom
dev/saumya/odbc-row-count
Open

Implement SQLRowCount#151
gargsaumya wants to merge 8 commits into
mainfrom
dev/saumya/odbc-row-count

Conversation

@gargsaumya

@gargsaumya gargsaumya commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Description

Implement SQLRowCount for the mssql-odbc driver, replacing the previous no-op stub.

SQLRowCount now reports the number of rows affected by the last INSERT /
UPDATE / DELETE, matching the classic msodbcsql C++ driver:

  • DML → the affected-row count from the TDS DONE token.
  • SELECT (forward-only cursor), DDL, and SET NOCOUNT ON-1
    (SQL_NO_ROWCOUNT_TOTAL).
  • No HY010 sequence check in the driver — the Driver Manager rejects the call
    on an unexecuted statement before the driver is invoked, exactly as it does
    for msodbcsql. Verified live against unixODBC.
  • The keyset/static-cursor GetRowInfo recompute branch is intentionally
    omitted (those cursor types are not yet supported) and documented in code.

How the count is plumbed

  • mssql-tds: new TdsClient::last_rows_affected() accessor backed by a field
    reset at each execute() and populated from the DONE token when the
    DONE_COUNT flag is set.
  • mssql-odbc: StmtState.row_count is set on execute (SQLExecDirectW) and
    refreshed by SQLMoreResults so the count tracks the currently-positioned
    result set. SQLRowCount reads it, following the crate's
    safe-core/unsafe-shell + ffi_entry! conventions.

Tests

  • Unit tests in mssql-odbc/src/api/row_count.rs (null handle, null out-ptr,
    fresh statement → -1 via direct driver call, stored DML count).
  • New C++ e2e suite tests/e2e/tests/row_count_test.cpp14/14 e2e suites
    pass
    against a live SQL Server (INSERT/UPDATE/DELETE counts, SELECT/DDL/
    SET NOCOUNT ON-1, multi-result-set refresh, and the DM-enforced
    HY010 on a fresh statement).

Related Issues

Closes AB#46414

Checklist

  • cargo bfmt passes
  • cargo bclippy passes
  • cargo btest passes
  • New/changed functionality has tests
  • Public API changes are documented

Report rows affected by the last INSERT/UPDATE/DELETE via SQLRowCount, matching msodbcsql: -1 (SQL_NO_ROWCOUNT_TOTAL) for SELECT/DDL/SET NOCOUNT ON and no HY010 check in the driver. Plumb the DONE-token count through TdsClient::last_rows_affected into StmtState.row_count (set on execute and refreshed by SQLMoreResults). Adds unit tests and a live e2e suite.
# Conflicts:
#	mssql-odbc/src/api/exec_direct.rs
#	mssql-odbc/src/handles/stmt.rs
#	mssql-odbc/tests/e2e/CMakeLists.txt
@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown

📊 Code Coverage Report

🔥 Diff Coverage

95%

🎯 Overall Coverage

90.2%

📦 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/close_cursor.rs (0.0%): Missing lines 136
  • mssql-odbc/src/api/exec_common.rs (0.0%): Missing lines 276-277,284-285,306-307
  • mssql-odbc/src/api/exec_direct.rs (100%)
  • mssql-odbc/src/api/execute.rs (100%)
  • mssql-odbc/src/api/exports.rs (0.0%): Missing lines 435,448
  • mssql-odbc/src/api/more_results.rs (96.6%): Missing lines 116
  • mssql-odbc/src/api/row_count.rs (100%)
  • mssql-odbc/src/handles/stmt.rs (100%)
  • mssql-tds/src/connection/tds_client.rs (100%)

Summary

  • Total: 225 lines
  • Missing: 10 lines
  • Coverage: 95%

mssql-odbc/src/api/close_cursor.rs

  132 pub(super) fn reset_cursor_state(stmt_state: &mut crate::handles::stmt::StmtState) {
  133     stmt_state.clear_state(STMT_STATE_CURSOR_OPEN | STMT_STATE_EXEC_CONTEXT);
  134     stmt_state.current_row = None;
  135     stmt_state.column_metadata.clear();
! 136     stmt_state.pending_row_counts.clear();
  137 }
  138 
  139 /// Outcome of draining the TDS stream and releasing the connection on cursor close.
  140 pub(super) enum DrainOutcome {

mssql-odbc/src/api/exec_common.rs

  272         let info_messages = client.take_info_messages();
  273         // A pure-DML batch (UPDATE; DELETE; INSERT) yields one count per
  274         // statement. Report the first here; queue the rest for SQLMoreResults to
  275         // step through, matching msodbcsql's one result set per DML statement.
! 276         let mut dml_counts: VecDeque<i64> = client.take_dml_result_counts().into();
! 277         let first_count = dml_counts.pop_front().unwrap_or(-1);
  278         let Ok(mut stmt_state) = stmt.inner.lock() else {
  279             error!("{op}: stmt mutex poisoned");
  280             return_client_idle(dbc, statement_handle, client);
  281             return SQL_ERROR;

  280             return_client_idle(dbc, statement_handle, client);
  281             return SQL_ERROR;
  282         };
  283         stmt_state.column_metadata = metadata; // empty
! 284         stmt_state.row_count = first_count;
! 285         stmt_state.pending_row_counts = dml_counts;
  286         stmt_state.set_state(STMT_STATE_EXEC_CONTEXT);
  287         stmt_state.clear_state(STMT_STATE_CURSOR_OPEN | STMT_STATE_EXEC_STARTED);
  288         let has_server_info = post_tds_info_messages(&mut stmt_state, &info_messages);
  289         drop(stmt_state);

  302         return_client_busy(dbc, client);
  303         return SQL_ERROR;
  304     };
  305     stmt_state.column_metadata = metadata;
! 306     stmt_state.row_count = client.last_rows_affected();
! 307     stmt_state.pending_row_counts.clear();
  308     stmt_state.set_state(STMT_STATE_EXEC_CONTEXT | STMT_STATE_CURSOR_OPEN);
  309     stmt_state.clear_state(STMT_STATE_EXEC_STARTED);
  310     let has_server_info = post_tds_info_messages(&mut stmt_state, &info_messages);
  311     drop(stmt_state);

mssql-odbc/src/api/exports.rs

  431     crate::init_tracing();
  432     unsafe { super::more_results::sql_more_results(statement_handle) }
  433 }
  434 
! 435 // ---- Result set processing --------------------------------------------------
  436 
  437 /// Returns the row count from the last INSERT, UPDATE, or DELETE statement.
  438 ///
  439 /// # Safety

  444     statement_handle: SqlHandle,
  445     row_count_ptr: *mut SqlLen,
  446 ) -> SqlReturn {
  447     crate::init_tracing();
! 448     unsafe { super::row_count::sql_row_count(statement_handle, row_count_ptr) }
  449 }
  450 
  451 // ---- Attribute management (TO-BE-IMPLEMENTED) --------------------------------

mssql-odbc/src/api/more_results.rs

  112                 return SQL_ERROR;
  113             };
  114             stmt_state.column_metadata = metadata;
  115             // Refresh the count for the newly-positioned result set (-1 for a SELECT).
! 116             stmt_state.row_count = client.last_rows_affected();
  117             stmt_state.current_row = None;
  118             // Drain INFO only after the lock is held.
  119             let info_messages = client.take_info_messages();
  120             let has_server_info = post_tds_info_messages(&mut stmt_state, &info_messages);


🔗 Quick Links

View Azure DevOps Build · Coverage Report

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

Implements ODBC SQLRowCount by plumbing TDS DONE-token counts into statement state.

Changes:

  • Tracks affected rows in TdsClient.
  • Implements the safe ODBC SQLRowCount entry point.
  • Adds unit and live end-to-end coverage.

Reviewed changes

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

Show a summary per file
File Description
mssql-tds/src/connection/tds_client.rs Captures DONE-token row counts.
mssql-odbc/src/handles/stmt.rs Stores per-statement row count.
mssql-odbc/src/api/row_count.rs Implements SQLRowCount.
mssql-odbc/src/api/exec_common.rs Copies counts after execution.
mssql-odbc/src/api/exec_direct.rs Resets counts before direct execution.
mssql-odbc/src/api/more_results.rs Refreshes counts across results.
mssql-odbc/src/api/exports.rs Replaces the exported stub.
mssql-odbc/src/api/mod.rs Registers the API module.
mssql-odbc/tests/e2e/tests/row_count_test.cpp Adds live behavior tests.
mssql-odbc/tests/e2e/CMakeLists.txt Registers the new test suite.

Comment thread mssql-tds/src/connection/tds_client.rs Outdated
Comment thread mssql-odbc/src/api/more_results.rs

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 10 out of 10 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (3)

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

  • This reset only covers plain SQL batches. Parameterized SQLExecDirectW uses execute_sp_executesql, while SQLExecute uses execute_sp_prepexec/execute_sp_execute; none of those entry points reset this field. After a DML sets a count, a later parameterized or prepared SELECT/DDL can therefore copy the stale count in finish_execute. Move the reset into the shared command-start path (or reset it in every execution entry point) so every new execution starts at -1.
        // Reset the affected-row count so a stale value from a prior execute on
        // this connection cannot leak into `SQLRowCount` for the new statement.
        self.last_rows_affected = -1;

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

  • The value is only overwritten by another DONE_COUNT, so it is not scoped to the result currently being exposed. For INSERT ...; SELECT ..., move_to_column_metadata consumes the INSERT's counted DONE and then stops at the SELECT metadata; finish_execute consequently reports the INSERT count while the SELECT is current. Reset the value when positioning a metadata-bearing result and preserve counts as per-result state rather than one connection-global last-count field.
                    // Capture the affected-row count for `SQLRowCount`, but only
                    // when the DONE_COUNT flag is set — otherwise `row_count` is
                    // not meaningful (DDL, SET NOCOUNT ON) and must stay -1.
                    if done.status.contains(DoneStatus::COUNT) {
                        self.last_rows_affected = i64::try_from(done.row_count).unwrap_or(i64::MAX);
                    }

mssql-odbc/src/api/more_results.rs:109

  • This refresh runs only when move_to_next() finds COLMETADATA. A DML result has no metadata, so a batch such as SELECT 1; UPDATE ... causes move_to_next() to consume the UPDATE's counted DONE and return false; SQLMoreResults returns SQL_NO_DATA and the DML count is never exposed. The result iterator must represent no-column DML results so SQLMoreResults can return success and SQLRowCount can read their count.
            // Refresh the count for the newly-positioned result set (-1 for a SELECT).
            stmt_state.row_count = client.last_rows_affected();

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 10 out of 10 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (2)

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

  • This reset only covers plain SQL batches. SQLExecDirectW with markers calls execute_sp_executesql, and SQLExecute calls execute_sp_prepexec/execute_sp_execute; those paths call begin_command but never reset this field. After a counted DML, a parameterized SELECT, DDL, or SET NOCOUNT ON execution can therefore retain the old value, which finish_execute copies into the statement and SQLRowCount reports. Reset the field in the common per-command initialization (or every execution entry point) so RPC executions cannot inherit a prior command's count.
        // Reset the affected-row count so a stale value from a prior execute on
        // this connection cannot leak into `SQLRowCount` for the new statement.
        self.last_rows_affected = -1;

mssql-odbc/src/api/more_results.rs:109

  • This refresh runs only when move_to_next() returns true, but that method returns true only for a result with COLMETADATA; move_to_column_metadata consumes INSERT/UPDATE/DELETE DONE-only results while searching for metadata and returns false at batch end. Consequently, for SELECT ...; UPDATE ..., SQLMoreResults returns SQL_NO_DATA and the UPDATE count is never observable. To make row counts follow the currently positioned result, DONE-only results must be surfaced as results (or otherwise distinguished from true batch exhaustion).
            // Refresh the count for the newly-positioned result set (-1 for a SELECT).
            stmt_state.row_count = client.last_rows_affected();

Comment thread mssql-tds/src/connection/tds_client.rs Outdated

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 10 out of 10 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

mssql-odbc/src/api/exec_common.rs:277

  • The shared completion path also covers SQLExecute, but every new end-to-end row-count assertion executes DML through SQLExecDirect. Prepared execution uses sp_prepexec on the first call and sp_execute thereafter, producing a different DONEINPROC/DONEPROC token sequence, so neither RPC path is currently verified to preserve the DML count. Add a prepared-DML test that calls SQLRowCount after both the initial SQLExecute and a re-execution.
        stmt_state.row_count = client.last_rows_affected();

@gargsaumya
gargsaumya marked this pull request as ready for review July 28, 2026 06:44
@gargsaumya
gargsaumya requested a review from a team as a code owner July 28, 2026 06:44
Comment thread mssql-tds/src/connection/tds_client.rs Outdated
Comment thread mssql-odbc/tests/e2e/tests/row_count_test.cpp
@gargsaumya
gargsaumya force-pushed the dev/saumya/odbc-row-count branch from 52f8390 to d0984fb Compare July 28, 2026 12:44
# Conflicts:
#	mssql-odbc/tests/e2e/CMakeLists.txt
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