|
| 1 | +# GitHub issue [microsoft/mssql-rs#38](https://github.com/microsoft/mssql-rs/issues/38): sp_prepare fails when statement uses a non-int named parameter |
| 2 | + |
| 3 | +## Summary |
| 4 | + |
| 5 | +`TdsClient::execute_sp_prepare` was forwarding the user's named parameter |
| 6 | +values as RPC parameters on the `sp_prepare` call. `sp_prepare`'s signature |
| 7 | +is fixed (`@handle int OUTPUT, @params ntext, @stmt ntext, @options int`) |
| 8 | +and accepts no caller-supplied values; those belong on `sp_execute`, |
| 9 | +`sp_prepexec`, or `sp_executesql`. When the extra named arguments did not |
| 10 | +coincidentally fit the types `sp_prepare` expects, the server returned |
| 11 | + |
| 12 | + Msg 214: Procedure expects parameter '@options' of type 'int'. |
| 13 | + |
| 14 | +`drain_stream()` collected the ERROR token but the call site dropped the |
| 15 | +returned `Vec<SqlErrorInfo>`. The post-drain shape check then saw |
| 16 | +`ColumnValues::Null` for the `@handle` output and raised the generic |
| 17 | +`ProtocolError("Expected an integer value")` reported in the issue. |
| 18 | + |
| 19 | +## Root cause |
| 20 | + |
| 21 | +In `mssql-tds/src/connection/tds_client.rs`, `execute_sp_prepare` built the |
| 22 | +RPC as: |
| 23 | + |
| 24 | +```rust |
| 25 | +let rpc = SqlRpc::new( |
| 26 | + RpcType::ProcId(RpcProcs::Prepare), |
| 27 | + positional_parameters, |
| 28 | + Some(named_params), // <-- bug: user values smuggled onto sp_prepare |
| 29 | + &database_collation, |
| 30 | + &self.execution_context, |
| 31 | +); |
| 32 | +``` |
| 33 | + |
| 34 | +`SqlRpc::write_named_parameters` (`mssql-tds/src/message/rpc.rs`) iterates |
| 35 | +the named slice and serializes every entry on the wire. Because the wire |
| 36 | +contract for `sp_prepare` does not include user values, the server tried to |
| 37 | +bind those as `@options` and rejected anything that wasn't an int. |
| 38 | + |
| 39 | +The same call site also discarded `drain_stream`'s returned errors: |
| 40 | + |
| 41 | +```rust |
| 42 | +self.drain_stream().await?; // returned Vec<SqlErrorInfo> dropped |
| 43 | +// ... shape check then raised a generic ProtocolError |
| 44 | +``` |
| 45 | + |
| 46 | +This collapsed the real diagnostic into an unrelated message and is the |
| 47 | +reason the issue was originally misread as a parameter-declaration bug. |
| 48 | + |
| 49 | +## Fix |
| 50 | + |
| 51 | +`mssql-tds/src/connection/tds_client.rs::execute_sp_prepare`: |
| 52 | + |
| 53 | +- Pass `None` (not `Some(named_params)`) as the RPC named-parameter list. |
| 54 | + `named_params` is still consumed locally to build the `@params` text |
| 55 | + string serialized as the second positional argument; only the wire-level |
| 56 | + RPC named arguments are dropped. |
| 57 | +- Capture the `Vec<SqlErrorInfo>` returned by `drain_stream()` and, when |
| 58 | + non-empty, return `Error::SqlServerError { errors }` so callers see the |
| 59 | + actual server diagnostic. |
| 60 | + |
| 61 | +The other RPC paths (`execute_sp_executesql`, `execute_sp_prepexec`) are |
| 62 | +correct as-is: those stored procedures' contracts include the user's |
| 63 | +parameter values after the fixed positional arguments, and the server |
| 64 | +expects them on the wire. |
| 65 | + |
| 66 | +## Tests |
| 67 | + |
| 68 | +Added in `mssql-tds/tests/test_rpc_results.rs` (the file already contains |
| 69 | +the other `sp_prepare` / `sp_prepexec` integration tests): |
| 70 | + |
| 71 | +- `test_sp_prepare_with_named_nvarchar_param_succeeds`: regression test |
| 72 | + for [microsoft/mssql-rs#38](https://github.com/microsoft/mssql-rs/issues/38); |
| 73 | + prepares a statement that references |
| 74 | + `@db_name nvarchar(6)` and asserts a positive handle is returned, then |
| 75 | + unprepares it. |
| 76 | +- `test_sp_prepare_surfaces_server_error_on_invalid_sql`: verifies the |
| 77 | + diagnostics fix; preparing an undeclared-variable statement returns |
| 78 | + `Error::SqlServerError` carrying the populated server message instead |
| 79 | + of a generic `ProtocolError`. |
| 80 | + |
| 81 | +Both are integration tests; like the rest of the file they require the |
| 82 | +DB environment variables (`DB_HOST`, `DB_USERNAME`, `SQL_PASSWORD`). |
| 83 | + |
| 84 | +## Why the existing tests didn't catch this |
| 85 | + |
| 86 | +- The integration tests in `mssql-tds/tests/test_rpc_results.rs` for |
| 87 | + `sp_prepare` (`test_sp_prepare_and_unprepare_multi_param`) only use |
| 88 | + `Int` user parameters. SQL Server tolerates extra named values on |
| 89 | + `sp_prepare` when their types happen to match what the optional |
| 90 | + `@options` parameter accepts (int), so all-int tests pass against a |
| 91 | + live server even though the code is wrong. |
| 92 | +- No existing test exercises `sp_prepare` with `NVarchar`, `NChar`, |
| 93 | + `Decimal`, `DateTime2`, `Uuid`, `Vector`, or any other non-int user |
| 94 | + parameter type. |
| 95 | +- No negative test asserts what happens when `sp_prepare` fails. With |
| 96 | + the drained errors discarded, even a CI run that happened to surface |
| 97 | + the bug would have reported the misleading |
| 98 | + `ProtocolError("Expected an integer value")`. |
| 99 | +- No mock-server test exercises the wire-level RPC structure. |
| 100 | + `mssql-mock-tds` exists but is not used to assert that `sp_prepare` is |
| 101 | + sent without user named parameters or that ERROR tokens propagate as |
| 102 | + `SqlServerError`. |
| 103 | +- The integration tests panic when `DB_HOST`/`DB_USERNAME`/`SQL_PASSWORD` |
| 104 | + are not set (`mssql-tds/tests/common/mod.rs`). They cannot run as |
| 105 | + unit-only checks, so the suite often runs in environments where the |
| 106 | + prepare paths simply do not execute. |
| 107 | + |
| 108 | +## Test-coverage follow-ups |
| 109 | + |
| 110 | +These are scoped to the `sp_prepare` regression and would have caught |
| 111 | +this specific class of bug. They are not part of this fix. |
| 112 | + |
| 113 | +- Add per-type integration tests for `sp_prepare`, `sp_prepexec`, and |
| 114 | + `sp_executesql` that cover at least: `Char`, `NChar`, `Varchar`, |
| 115 | + `NVarchar`, `VarcharMax`, `NVarcharMax`, `Decimal` with explicit |
| 116 | + precision and scale, `DateTime2`, `DateTimeOffset`, `Time`, `Uuid`, |
| 117 | + `VarBinary`, `Vector`. This would have caught the |
| 118 | + [microsoft/mssql-rs#38](https://github.com/microsoft/mssql-rs/issues/38) |
| 119 | + regression on the first non-int type added. |
| 120 | +- Add a mock-TDS test that injects an ERROR token into the response |
| 121 | + stream for an `sp_prepare` RPC and asserts the call surfaces it as |
| 122 | + `SqlServerError`. This covers the diagnostics path without a live |
| 123 | + server. |
| 124 | +- Make the integration helpers in `mssql-tds/tests/common/mod.rs` skip |
| 125 | + cleanly (instead of panicking) when the database environment variables |
| 126 | + are absent, so CI without a SQL Server can still run the unit-style |
| 127 | + tests. |
0 commit comments