diff --git a/mssql-odbc/Cargo.toml b/mssql-odbc/Cargo.toml index 4c3de845..b491a6ec 100644 --- a/mssql-odbc/Cargo.toml +++ b/mssql-odbc/Cargo.toml @@ -26,6 +26,7 @@ azure_core = { version = "1.1", default-features = false, features = [ ] } reqwest = { version = "0.13", default-features = false, features = ["native-tls", "stream"] } url = "2" +uuid = "1.19.0" [dev-dependencies] # Enable the `test-util` feature on mssql-tds so unit tests can build a @@ -34,3 +35,4 @@ url = "2" mssql-tds = { path = "../mssql-tds", features = ["test-util"] } [build-dependencies] + diff --git a/mssql-odbc/docs/mssql-python-gap-analysis.md b/mssql-odbc/docs/mssql-python-gap-analysis.md new file mode 100644 index 00000000..6c7c4f8e --- /dev/null +++ b/mssql-odbc/docs/mssql-python-gap-analysis.md @@ -0,0 +1,337 @@ +# mssql-python parity gap analysis + +`mssql-odbc` builds `msodbcsql18.dll`, a drop-in replacement for the Microsoft +ODBC Driver 18 for SQL Server. [mssql-python][mssql-python] ships that driver +inside its wheel and loads it directly with `LoadLibraryW`, resolving 38 entry +points by name — there is no Driver Manager in the path, so the driver alone is +responsible for the whole ODBC contract. + +That makes mssql-python's integration suite a good conformance test. This +document records what it found. + +[mssql-python]: https://github.com/microsoft/mssql-python + +## Result + +| Driver | Failed | Passed | +| --- | --- | --- | +| `msodbcsql18.dll` (C++, shipped) | 2 | 1930 | +| `msodbcsql18.dll` (this crate), before | 300+, plus two hard crashes | — | +| `msodbcsql18.dll` (this crate), after | 2 | 1929 | + +(The counts differ by one because the runs are collected differently: the +baseline was a single pytest invocation, and the parity runs are per-file so +that a crash in one file does not hide the rest.) + +The two remaining failures are identical under both drivers: + +- `test_015_utf8_path_handling.py::test_very_long_path_component` — exceeds + Windows `MAX_PATH`; no ODBC call involved. +- `test_019_bulkcopy.py::test_bulkcopy_udt_geometry` — goes through + mssql-python's own `mssql_py_core` PyO3 extension, which opens its own TDS + connection and never calls the driver. + +The largest single file, `test_004_cursor.py`, went from an access violation +part-way through the run to 518 passed, 0 failed. + +## Method + +1. Build `mssql-odbc` and copy `msodbcsql18.dll` over the copy in + `mssql_python/libs/windows/x64/`, keeping the original as `.orig`. +2. Run the suite per file so one crash does not hide the rest of the results. +3. Record a baseline with the original driver, and treat only the delta as a + gap. +4. Fix, rebuild, re-run the affected file, and confirm the count moved. + +Both drivers ran against the same local SQL Server and the same database, over +a SQL login, with `Encrypt=no`. + +## Gaps + +Each entry below is a distinct defect found by the suite. "Blast radius" is the +number of tests that changed state when it was fixed, which is a better measure +of severity than the defect itself: a small mistake in a hot path fails +hundreds of tests, and a missing feature usually fails one. + +### A. Data-at-execution parameters were unimplemented + +`SQLParamData` and `SQLPutData` returned "not implemented", so any value the +application chose to stream — which mssql-python does for large text and +binary — could not be sent at all. + +Fixed by implementing the full `SQL_NEED_DATA` loop: `SQLExecute` returns +`SQL_NEED_DATA` for each parameter whose indicator is `SQL_DATA_AT_EXEC`, the +application streams the value in chunks, and the statement is executed once the +last parameter is satisfied. + +### B. Parameter arrays were ignored + +`SQL_ATTR_PARAMSET_SIZE` was accepted and then ignored, so `executemany` sent +only the first row and silently discarded the rest — a data-loss bug, not just +a failure. + +Fixed by executing each row of the array in turn and accumulating the row +counts. + +### C. `sql_variant` collapsed to a string + +Every `sql_variant` value came back as text regardless of the base type stored +in it, so an integer stored in a variant came back as `"1"`. + +Fixed by decoding the variant header and returning the base type. + +### D. UDT and spatial columns returned hex text + +`geometry`, `geography`, and `hierarchyid` were rendered as a hex string rather +than returned as bytes, so they could not be round-tripped. + +Fixed by treating UDTs as binary. + +### E. Non-Unicode character data was transcoded to UTF-8 + +`char` and `varchar` data was handed to the application as UTF-8 while +`SQL_C_CHAR` is defined to be in the client's ANSI code page. Every non-ASCII +character in a CP1252 column was corrupted. + +Fixed by encoding `SQL_C_CHAR` in the client code page. + +### F. Long values reported a negative length + +`SQLGetData` on a LOB returned `-1` for the length instead of the remaining +byte count, so chunked reads could not be driven. + +Fixed by reporting the true remaining length and `SQL_SUCCESS_WITH_INFO` while +data remains. + +### G. Errors used a generic SQLSTATE + +Syntax errors and truncations both surfaced as `HY000`, so applications could +not distinguish them. mssql-python maps SQLSTATE to its exception hierarchy, so +every error arrived as the wrong Python exception type. + +Fixed by mapping known error numbers to their SQLSTATE and, for unknown +numbers, deriving the class from the server's severity. + +**Blast radius: large.** Exception-type assertions appear throughout the suite. + +### H. The connection stayed busy after a result set + +The connection was released only when the statement was freed, so a second +statement on the same connection failed with "connection is busy" — which is +the normal pattern for a cursor that has been fully read but not closed. + +Fixed by releasing the connection once the last result set in a batch reaches +end-of-data. + +### I. DDL rollback — not reproducible + +The driver only sets `IMPLICIT_TRANSACTIONS ON` in manual-commit mode rather +than beginning an explicit transaction, which was expected to leave DDL +outside the transaction. It does not: SQL Server starts an implicit +transaction on `CREATE TABLE` as well, and a rollback discards it. Verified +directly, and the behaviour matches the reference driver. + +### J. The application name was wrong + +The driver reported its own name in the TDS login packet, so `APP_NAME()` +returned the driver rather than the application, and `APP=` in the connection +string was ignored. + +Fixed by parsing `APP=` and using it as the application name. + +### K. `SQL_C_TINYINT` was signed + +ODBC's `SQL_C_TINYINT` is unsigned, matching SQL Server's `tinyint`. Reading it +as signed made every value from 128 to 255 fail to bind. + +### L. Time values were scaled by 100 + +The TDS time fields count 100-nanosecond ticks; the code treated them as +nanoseconds. Every `time`, `datetime2`, and `datetimeoffset` value was wrong by +a factor of 100, which for most values overflowed into an invalid time. + +**Blast radius: large.** All date/time tests, plus anything using a timestamp +column incidentally. + +### M. Not a driver defect + +`test_very_long_path_component` exceeds the Windows path limit and fails under +both drivers. + +### N. Errors after column metadata were deferred + +When a statement produced column metadata and then failed — a constraint +violation on an `INSERT ... OUTPUT`, for example — the error was not raised +until the rows were read, so `execute()` appeared to succeed. + +Fixed by draining the token stream far enough to see the error before +returning. + +### O. `SQL_ATTR_CURRENT_CATALOG` was unsupported + +Reading or setting the current database through the connection attribute +failed, so `conn.setcatalog()` and the reverse did not work. + +### P. `numeric` was reported as `decimal` + +`SQLDescribeCol` reported `SQL_DECIMAL` for a `numeric` column. The two are +distinct ODBC types and mssql-python surfaces the distinction. + +### Q. `SQLGetInfo` returned wrong or missing values + +Several information types were unimplemented or returned placeholder values, +including the identifier quote character, the driver name, and the supported +conformance level. + +### R. Bound columns were strided by the wrong amount + +**This one caused the crashes.** ODBC ignores `BufferLength` for fixed-width C +types, and mssql-python exploits that: when binding a `SQL_C_SS_TIMESTAMPOFFSET` +column it passes the size of the *whole array* as the buffer length. The driver +used that value as the per-row stride and wrote far past the end of the buffer. + +Symptoms were non-deterministic heap corruption and access violations, which is +why the first several runs of `test_004_cursor.py` died at a different test each +time. + +Fixed by deriving the stride from the C type, and only falling back to +`BufferLength` for variable-length types. + +**Blast radius: the entire file.** Everything after the crash point was +unreported. + +### S. ODBC 2.x date and time type codes were rejected + +`SQL_DATE`, `SQL_TIME`, and `SQL_TIMESTAMP` (9, 10, 11) are the ODBC 2.x +spellings of the 3.x codes 91, 92, and 93. Applications still use them, and the +driver rejected them. + +### T. Parameter arrays left a cursor open + +Each row of an `executemany` that produced a result set left its cursor open, +so the next row failed the invalid-cursor check. Intermediate cursors are now +drained — but the last row's cursor stays open, because a statement with an +`OUTPUT` clause is expected to leave its rows available. + +### U. UDT columns reported a size of zero + +`SQLDescribeCol` returned zero for a UDT's column size, so mssql-python treated +every UDT as a LOB and read it through the streaming path. + +### V. NULL failed to bind to a binary buffer + +`SQLGetData` with `SQL_C_BINARY` on a NULL value returned an error instead of +setting the indicator. + +### W. Money lost precision + +`money` and `smallmoney` were converted through `f64`, which cannot represent +every value of a 4-decimal fixed-point type exactly. + +Fixed by keeping the scaled integer. + +### X. `SQLDescribeParam` was a stub + +It returned a fixed `SQL_VARCHAR(1)` guess for every parameter. mssql-python +calls it to decide how to bind, so the guess propagated into every binding +decision. + +Fixed by calling `sp_describe_undeclared_parameters` and caching the result for +the prepared statement. Note that this procedure cannot see temporary tables or +table variables; mssql-python has its own fallback for that case. + +### Y. Catalog functions did not match ODBC 3 + +Four distinct problems in `SQLTables`, `SQLColumns`, `SQLStatistics`, +`SQLSpecialColumns`, `SQLPrimaryKeys`, `SQLForeignKeys`, and `SQLProcedures`: + +1. The underlying system procedures return ODBC 2.x column names + (`TABLE_QUALIFIER`, `TABLE_OWNER`), and ODBC 3 renamed them + (`TABLE_CAT`, `TABLE_SCHEM`). mssql-python builds row attributes from the + driver's column names, so the attribute names were wrong. +2. `sp_statistics` filters on `index_name LIKE @index_name`, which matches + nothing when the argument is NULL. The reference driver always passes `'%'`. +3. `sp_tables` needs each element of a table type list quoted individually, so + `TABLE,VIEW` becomes `'TABLE','VIEW'` — the whole list was being quoted as + one element. +4. A catalog argument naming a database that does not exist must produce an + empty result set, not an error. The batch is now guarded on `DB_ID`. + +**Blast radius: 44 tests.** + +### Z. `PRINT` output never reached the application + +A `SET @v = ...` assignment emits a row-count-flagged `DONEINPROC` with no +column metadata. Statement-wise navigation treated that as a statement boundary +and stopped, leaving the following `PRINT` message unread — so +`cursor.messages` was empty. + +Fixed by recognising an assignment's `DONEINPROC` and continuing past it. DML +carries a different command code, so row counts are unaffected. + +### AA. NULL without an indicator must fail + +**The highest-value finding.** ODBC requires `SQLGetData` to return `SQL_ERROR` +with SQLSTATE `22002` when the value is NULL and the application passed a null +indicator pointer — there is nowhere to report the NULL, so it is an error. + +The driver returned `SQL_SUCCESS` and left the buffer untouched. mssql-python's +`FetchOne_wrap` relies on the documented behaviour for every fixed-width type, +so `fetchone()` on any NULL fixed-width column returned whatever happened to be +on the stack: `0`, `447`, `621`, varying between runs. + +`fetchall()` was unaffected because it uses `SQLBindCol` with real indicator +arrays. That split — same query, same column, correct through one API and +garbage through the other — is what identified it. + +**Blast radius: 10 tests, and silent data corruption in any application using +`fetchone`.** + +### AB. ODBC escape sequences were passed through + +`{CALL proc(?)}`, `{fn ...}`, `{ts '...'}`, and the rest are ODBC syntax that +the driver is required to translate. They were sent to the server verbatim, +which answered `Incorrect syntax near '{'`. + +Fixed with a quote- and comment-aware translator. Note that `EXEC proc(1)` is +also invalid T-SQL, so a call's parenthesised argument list has to become a +bare argument list: `EXEC proc 1`. + +### AC. An error after the first row of a rowset was lost + +`INSERT ... OUTPUT` with a duplicate key emits column metadata, one row, and +then the error. The driver returned the row it had with +`SQL_SUCCESS_WITH_INFO`, but had already closed the cursor, so the caller's +next fetch reported `24000 invalid cursor state` instead of the constraint +violation. + +Discarding the partial rowset would have been simpler but loses genuine rows +when a `SELECT` fails part-way. The diagnostic is instead held and replayed on +the next fetch. + +### AD. NULL decimals were declared too narrow + +A NULL carries no precision, so a NULL `decimal` parameter was declared with +the TDS default of `decimal(18, 10)`. In a parameter array the first row's +declaration is baked into the prepared plan and reused for every later row, so +`executemany` with a NULL in the first row rejected any subsequent value wider +than 18 digits. + +The bindings are byte-identical whether or not the array contains a NULL, which +is what made this hard to see: the failure depends only on the *order* of the +rows. + +Fixed by declaring NULL decimal parameters with the precision and scale the +application bound, falling back to the widest declaration when it bound none. + +## Notes for future work + +- `sp_describe_undeclared_parameters` cannot see temporary tables or table + variables. `SQLDescribeParam` therefore cannot describe a parameter of a + statement that targets one, and applications need their own fallback. +- `SELECT ... INTO #t` reports the same command code as a plain `SELECT` along + with a row count. The `DONEINPROC` navigation change in gap Z assumes an + assignment; this case is untested. +- The suite exercises Windows only. The catalog, escape, and conversion fixes + are platform-independent, but nothing here validates the Linux or macOS + builds. diff --git a/mssql-odbc/src/api/ansi.rs b/mssql-odbc/src/api/ansi.rs new file mode 100644 index 00000000..45904f63 --- /dev/null +++ b/mssql-odbc/src/api/ansi.rs @@ -0,0 +1,178 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Narrow (`SQL_C_CHAR`) character encoding. +//! +//! ODBC's narrow C character type carries text in the driver's *ANSI* encoding, +//! not UTF-8. msodbcsql uses the Windows active code page (CP1252 on a typical +//! US/Western-European install) so that a `VARCHAR(n)` round-trips byte-for-byte +//! through a narrow buffer. On non-Windows platforms msodbcsql uses UTF-8, which +//! is what `String` already holds. + +/// Encodes `text` into the client ANSI code page. +/// +/// Unmappable characters become `?`, matching `WideCharToMultiByte`'s default +/// replacement behaviour. +pub(crate) fn encode(text: &str) -> Vec { + #[cfg(windows)] + { + windows_acp::encode(text) + } + #[cfg(not(windows))] + { + text.as_bytes().to_vec() + } +} + +/// Decodes `bytes` from the client ANSI code page. +/// +/// Invalid sequences are replaced rather than rejected; ODBC has no way to +/// report a decoding failure on the parameter path. +pub(crate) fn decode(bytes: &[u8]) -> String { + #[cfg(windows)] + { + windows_acp::decode(bytes) + } + #[cfg(not(windows))] + { + String::from_utf8_lossy(bytes).into_owned() + } +} + +#[cfg(windows)] +mod windows_acp { + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetACP() -> u32; + fn WideCharToMultiByte( + code_page: u32, + flags: u32, + wide_char_str: *const u16, + wide_char: i32, + multi_byte_str: *mut u8, + multi_byte: i32, + default_char: *const u8, + used_default_char: *mut i32, + ) -> i32; + fn MultiByteToWideChar( + code_page: u32, + flags: u32, + multi_byte_str: *const u8, + multi_byte: i32, + wide_char_str: *mut u16, + wide_char: i32, + ) -> i32; + } + + const CP_UTF8: u32 = 65001; + + fn acp() -> u32 { + unsafe { GetACP() } + } + + pub(super) fn encode(text: &str) -> Vec { + let cp = acp(); + if cp == CP_UTF8 { + return text.as_bytes().to_vec(); + } + let wide: Vec = text.encode_utf16().collect(); + if wide.is_empty() { + return Vec::new(); + } + let needed = unsafe { + WideCharToMultiByte( + cp, + 0, + wide.as_ptr(), + wide.len() as i32, + std::ptr::null_mut(), + 0, + std::ptr::null(), + std::ptr::null_mut(), + ) + }; + if needed <= 0 { + return text.as_bytes().to_vec(); + } + let mut out = vec![0u8; needed as usize]; + let written = unsafe { + WideCharToMultiByte( + cp, + 0, + wide.as_ptr(), + wide.len() as i32, + out.as_mut_ptr(), + needed, + std::ptr::null(), + std::ptr::null_mut(), + ) + }; + if written <= 0 { + return text.as_bytes().to_vec(); + } + out.truncate(written as usize); + out + } + + pub(super) fn decode(bytes: &[u8]) -> String { + let cp = acp(); + if cp == CP_UTF8 { + return String::from_utf8_lossy(bytes).into_owned(); + } + if bytes.is_empty() { + return String::new(); + } + let needed = unsafe { + MultiByteToWideChar( + cp, + 0, + bytes.as_ptr(), + bytes.len() as i32, + std::ptr::null_mut(), + 0, + ) + }; + if needed <= 0 { + return String::from_utf8_lossy(bytes).into_owned(); + } + let mut wide = vec![0u16; needed as usize]; + let written = unsafe { + MultiByteToWideChar( + cp, + 0, + bytes.as_ptr(), + bytes.len() as i32, + wide.as_mut_ptr(), + needed, + ) + }; + if written <= 0 { + return String::from_utf8_lossy(bytes).into_owned(); + } + wide.truncate(written as usize); + String::from_utf16_lossy(&wide) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ascii_round_trips() { + assert_eq!(encode("hello"), b"hello"); + assert_eq!(decode(b"hello"), "hello"); + } + + #[test] + fn latin1_round_trips() { + let round = decode(&encode("café René")); + assert_eq!(round, "café René"); + } + + #[test] + fn empty_is_empty() { + assert!(encode("").is_empty()); + assert_eq!(decode(&[]), ""); + } +} diff --git a/mssql-odbc/src/api/bind_col.rs b/mssql-odbc/src/api/bind_col.rs new file mode 100644 index 00000000..bf4d23f7 --- /dev/null +++ b/mssql-odbc/src/api/bind_col.rs @@ -0,0 +1,207 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Implementation of SQLBindCol — bind a result column to an application buffer. + +use tracing::{debug, error}; + +use super::odbc_types::{ + SQL_ERROR, SQL_INVALID_HANDLE, SQL_SUCCESS, SqlHandle, SqlLen, SqlPointer, SqlReturn, + SqlSmallInt, SqlUSmallInt, +}; +use super::sqlstate::{ERR_INVALID_DESCRIPTOR_INDEX, post_diag}; +use crate::error::free_errors; +use crate::handles::stmt::BoundCol; +use crate::handles::{HandleType, StmtHandle, handle_from_raw}; + +/// Implements `SQLBindCol`. +/// +/// A null `target_value_ptr` unbinds the column, matching the ODBC contract. +/// Bindings are consumed by the block-fetch path in `SQLFetchScroll` and by +/// `SQLFetch`. +/// +/// # Safety +/// `statement_handle` must be a valid `StmtHandle` or null. The application +/// buffers referenced by `target_value_ptr` / `strlen_or_ind_ptr` must remain +/// valid until the column is unbound or the statement is freed. +pub(crate) unsafe fn sql_bind_col( + statement_handle: SqlHandle, + column_number: SqlUSmallInt, + target_type: SqlSmallInt, + target_value_ptr: SqlPointer, + buffer_length: SqlLen, + strlen_or_ind_ptr: *mut SqlLen, +) -> SqlReturn { + debug!( + ?statement_handle, + column_number, target_type, buffer_length, "SQLBindCol called" + ); + crate::ffi_entry!("SQLBindCol", unsafe { + sql_bind_col_impl( + statement_handle, + column_number, + target_type, + target_value_ptr, + buffer_length, + strlen_or_ind_ptr, + ) + }) +} + +unsafe fn sql_bind_col_impl( + statement_handle: SqlHandle, + column_number: SqlUSmallInt, + target_type: SqlSmallInt, + target_value_ptr: SqlPointer, + buffer_length: SqlLen, + strlen_or_ind_ptr: *mut SqlLen, +) -> SqlReturn { + if statement_handle.is_null() { + error!("SQLBindCol: statement_handle is null"); + return SQL_INVALID_HANDLE; + } + let stmt = unsafe { handle_from_raw::(statement_handle) }; + debug_assert_eq!(stmt.object_type, HandleType::Stmt); + + let Ok(mut state) = stmt.inner.lock() else { + error!("SQLBindCol: stmt mutex poisoned"); + return SQL_ERROR; + }; + free_errors(&mut state); + + if column_number == 0 { + // Bookmark column binding is not supported. + post_diag(&mut state, ERR_INVALID_DESCRIPTOR_INDEX); + return SQL_ERROR; + } + + let idx = usize::from(column_number) - 1; + if state.bound_cols.len() <= idx { + state.bound_cols.resize(idx + 1, None); + } + + state.bound_cols[idx] = if target_value_ptr.is_null() && strlen_or_ind_ptr.is_null() { + None + } else { + Some(BoundCol { + target_type, + target_value_ptr, + buffer_length, + strlen_or_ind_ptr, + }) + }; + + SQL_SUCCESS +} + +/// Implements `SQLFreeStmt(SQL_UNBIND)` — releases every column binding. +/// +/// # Safety +/// `statement_handle` must be a valid `StmtHandle` or null. +pub(crate) unsafe fn sql_free_stmt_unbind(statement_handle: SqlHandle) -> SqlReturn { + debug!(?statement_handle, "SQLFreeStmt(SQL_UNBIND) called"); + crate::ffi_entry!("SQLFreeStmt", unsafe { + if statement_handle.is_null() { + error!("SQLFreeStmt(SQL_UNBIND): statement_handle is null"); + return SQL_INVALID_HANDLE; + } + let stmt = handle_from_raw::(statement_handle); + debug_assert_eq!(stmt.object_type, HandleType::Stmt); + let Ok(mut state) = stmt.inner.lock() else { + error!("SQLFreeStmt(SQL_UNBIND): stmt mutex poisoned"); + return SQL_ERROR; + }; + free_errors(&mut state); + state.bound_cols.clear(); + SQL_SUCCESS + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::odbc_types::{SQL_C_SLONG, SQL_NULL_HANDLE}; + use crate::test_support::TestHandles; + + #[test] + fn bind_col_null_handle() { + let ret = unsafe { + sql_bind_col( + SQL_NULL_HANDLE, + 1, + SQL_C_SLONG, + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + ) + }; + assert_eq!(ret, SQL_INVALID_HANDLE); + } + + #[test] + fn bind_col_records_binding() { + let h = TestHandles::with_env_dbc_stmt(); + let mut buf: i32 = 0; + let ret = unsafe { + sql_bind_col( + h.stmt, + 2, + SQL_C_SLONG, + (&mut buf as *mut i32).cast(), + 4, + std::ptr::null_mut(), + ) + }; + assert_eq!(ret, SQL_SUCCESS); + + let stmt = unsafe { handle_from_raw::(h.stmt) }; + let state = stmt.inner.lock().unwrap(); + assert_eq!(state.bound_cols.len(), 2); + assert!(state.bound_cols[0].is_none()); + assert_eq!(state.bound_cols[1].unwrap().target_type, SQL_C_SLONG); + } + + #[test] + fn bind_col_null_pointers_unbind() { + let h = TestHandles::with_env_dbc_stmt(); + let mut buf: i32 = 0; + unsafe { + sql_bind_col( + h.stmt, + 1, + SQL_C_SLONG, + (&mut buf as *mut i32).cast(), + 4, + std::ptr::null_mut(), + ); + sql_bind_col( + h.stmt, + 1, + SQL_C_SLONG, + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + ); + } + + let stmt = unsafe { handle_from_raw::(h.stmt) }; + let state = stmt.inner.lock().unwrap(); + assert!(state.bound_cols[0].is_none()); + } + + #[test] + fn bind_col_zero_column_is_rejected() { + let h = TestHandles::with_env_dbc_stmt(); + let ret = unsafe { + sql_bind_col( + h.stmt, + 0, + SQL_C_SLONG, + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + ) + }; + assert_eq!(ret, SQL_ERROR); + } +} diff --git a/mssql-odbc/src/api/bind_param.rs b/mssql-odbc/src/api/bind_param.rs index dd1f4418..e2c3cdab 100644 --- a/mssql-odbc/src/api/bind_param.rs +++ b/mssql-odbc/src/api/bind_param.rs @@ -234,7 +234,8 @@ fn sql_free_stmt_reset_params_safe(stmt: &StmtHandle) -> SqlReturn { mod tests { use super::*; use crate::api::odbc_types::{ - SQL_C_CHAR, SQL_INTEGER, SQL_NULL_HANDLE, SQL_PARAM_OUTPUT, SQL_VARCHAR, + SQL_C_CHAR, SQL_C_TYPE_TIMESTAMP, SQL_INTEGER, SQL_NULL_HANDLE, SQL_PARAM_OUTPUT, + SQL_VARCHAR, }; use crate::handles::handle_from_raw; use crate::test_support::TestHandles; @@ -399,7 +400,7 @@ mod tests { h.stmt, 1, SQL_PARAM_INPUT, - SQL_INTEGER, + SQL_C_TYPE_TIMESTAMP, SQL_INTEGER, 0, 0, diff --git a/mssql-odbc/src/api/catalog.rs b/mssql-odbc/src/api/catalog.rs new file mode 100644 index 00000000..193999ad --- /dev/null +++ b/mssql-odbc/src/api/catalog.rs @@ -0,0 +1,668 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! ODBC catalog functions. +//! +//! SQL Server ships stored procedures whose result sets already match the ODBC +//! catalog contract (`sp_tables`, `sp_columns_100`, `sp_pkeys`, ...), and +//! msodbcsql dispatches to them rather than hand-rolling `sys.*` queries. +//! Doing the same here keeps column order, types, and NULL semantics identical +//! to the C++ driver for free. + +use tracing::{debug, error}; + +use super::exec_common::{ + claim_connection, fail_with_tds, finish_execute, flush_pending_unprepare, +}; +use super::odbc_types::{ + SQL_ERROR, SQL_INVALID_HANDLE, SqlHandle, SqlReturn, SqlSmallInt, SqlWChar, +}; +use super::sqlstate::{ERR_INVALID_CURSOR_STATE, post_diag}; +use super::util::read_utf16; +use crate::error::free_errors; +use crate::handles::stmt::{ + STMT_STATE_CURSOR_OPEN, STMT_STATE_EXEC_CONTEXT, STMT_STATE_EXEC_STARTED, STMT_STATE_PREPARED, +}; +use crate::handles::{HandleType, StmtHandle, handle_from_raw}; + +/// A catalog argument: absent (`NULL`) or a literal value. +type Arg = Option; + +/// # Safety +/// `ptr` must be null or point to `len` readable UTF-16 code units (or be +/// NUL-terminated when `len` is `SQL_NTS`). +unsafe fn opt_arg(ptr: *const SqlWChar, len: SqlSmallInt) -> Arg { + if ptr.is_null() { + None + } else { + Some(unsafe { read_utf16(ptr, len) }) + } +} + +/// Renders the `SQLTables` table-type list the way `sp_tables` expects it: +/// each element of the comma-separated list individually quoted, so `TABLE,VIEW` +/// becomes `N'''TABLE'',''VIEW'''`. Mirrors `ValidateTableType` in msodbcsql. +fn table_type_literal(arg: &Arg) -> String { + match arg { + None => "NULL".to_string(), + Some(v) if v.trim().is_empty() => "NULL".to_string(), + Some(v) if v.trim() == "%" => "N'%'".to_string(), + Some(v) => { + let quoted = v + .split(',') + .map(|t| format!("''{}''", t.trim().trim_matches('\'').replace('\'', "''"))) + .collect::>() + .join(","); + format!("N'{quoted}'") + } + } +} + +/// Renders a catalog argument as a T-SQL literal, escaping embedded quotes. +fn literal(arg: &Arg) -> String { + match arg { + None => "NULL".to_string(), + Some(v) => format!("N'{}'", v.replace('\'', "''")), + } +} + +/// Builds `EXEC [catalog].sys. `. +/// +/// Catalog scoping matters: `sp_tables` only sees the current database, so a +/// non-empty qualifier has to be turned into a three-part procedure name. +/// +/// A qualifier naming a database that does not exist must produce an empty +/// result set rather than an error, so the batch is guarded by `DB_ID` and +/// falls back to running the same procedure locally with an unmatchable object +/// name. That keeps the column shape identical while returning no rows. +fn build_exec(catalog: &Arg, proc_name: &str, args: &[String]) -> String { + let Some(db) = catalog.as_deref().filter(|db| !db.is_empty()) else { + return format!("EXEC sys.{} {}", proc_name, args.join(", ")); + }; + let qualified = format!("[{}].sys.{}", db.replace(']', "]]"), proc_name); + let mut empty_args = args.to_vec(); + if let Some(first) = empty_args.first_mut() { + let prefix = first + .find('=') + .filter(|_| first.starts_with('@')) + .map(|i| first[..=i].to_string()) + .unwrap_or_default(); + *first = format!("{prefix}N'\u{1}no such object\u{1}'"); + } + format!( + "IF DB_ID(N'{}') IS NULL EXEC sys.{} {} ELSE EXEC {} {}", + db.replace('\'', "''"), + proc_name, + empty_args.join(", "), + qualified, + args.join(", ") + ) +} + +fn build_exec_named(catalog: &Arg, proc_name: &str, args: &[(&str, String)]) -> String { + let named = args + .iter() + .map(|(name, value)| format!("@{name} = {value}")) + .collect::>(); + build_exec(catalog, proc_name, &named) +} + +/// Shared entry: validate the handle, then run the generated catalog batch +/// through the ordinary direct-execution path so cursor/metadata state is +/// managed exactly as for a user query. +/// +/// # Safety +/// `statement_handle` must be a valid `StmtHandle` or null. +unsafe fn run_catalog( + statement_handle: SqlHandle, + name: &str, + sql: String, + renames: &[(usize, &str)], +) -> SqlReturn { + if statement_handle.is_null() { + error!("{name}: statement_handle is null"); + return SQL_INVALID_HANDLE; + } + let stmt = unsafe { handle_from_raw::(statement_handle) }; + debug_assert_eq!(stmt.object_type, HandleType::Stmt); + debug!(%sql, "{name}: executing catalog query"); + let dbc = stmt.parent_dbc(); + + { + let Ok(mut stmt_state) = stmt.inner.lock() else { + error!("{name}: stmt mutex poisoned"); + return SQL_ERROR; + }; + free_errors(&mut stmt_state); + if stmt_state.has_state(STMT_STATE_EXEC_STARTED | STMT_STATE_CURSOR_OPEN) { + error!("{name}: statement has an active execute or open cursor"); + post_diag(&mut stmt_state, ERR_INVALID_CURSOR_STATE); + return SQL_ERROR; + } + stmt_state.clear_state(STMT_STATE_EXEC_CONTEXT); + stmt_state.column_metadata.clear(); + stmt_state.reset_rows(); + stmt_state.row_count = -1; + stmt_state.pending_row_counts.clear(); + stmt_state.prepared_sql = None; + stmt_state.described_params = None; + stmt_state.orphan_prepared_handle(); + stmt_state.clear_state(STMT_STATE_PREPARED); + stmt_state.set_state(STMT_STATE_EXEC_STARTED); + } + + let mut client = match claim_connection(dbc, stmt, statement_handle, name) { + Ok(client) => client, + Err(rc) => return rc, + }; + + flush_pending_unprepare(dbc, stmt, &mut client, name); + + if let Err(e) = dbc.runtime.block_on(client.execute(sql, ())).map(|_| ()) { + error!(%e, "{name}: execution failed"); + return fail_with_tds(dbc, stmt, statement_handle, client, &e); + } + + if !client.on_rows() + && client.has_open_batch() + && let Err(e) = dbc.runtime.block_on(client.advance_to_rows()) + { + error!(%e, "{name}: advancing to catalog rows failed"); + return fail_with_tds(dbc, stmt, statement_handle, client, &e); + } + + let rc = finish_execute(dbc, stmt, statement_handle, client, name); + + // The sp_* procedures still emit the ODBC 2.x column names. msodbcsql + // rewrites them to the ODBC 3.x names the spec mandates, and callers bind + // result attributes straight from SQLDescribeCol, so the rename is what + // makes `row.table_cat` and friends exist. + if let Ok(mut stmt_state) = stmt.inner.lock() { + for (index, new_name) in renames { + if let Some(meta) = stmt_state.column_metadata.get_mut(*index) { + meta.column_name = (*new_name).to_string(); + } + } + } + + rc +} + +/// Implements `SQLTablesW`. +/// +/// # Safety +/// Each name pointer must be null or reference `*_len` readable UTF-16 units. +#[allow(clippy::too_many_arguments)] +pub(crate) unsafe fn sql_tables_w( + statement_handle: SqlHandle, + catalog_name: *const SqlWChar, + name_length_1: SqlSmallInt, + schema_name: *const SqlWChar, + name_length_2: SqlSmallInt, + table_name: *const SqlWChar, + name_length_3: SqlSmallInt, + table_type: *const SqlWChar, + name_length_4: SqlSmallInt, +) -> SqlReturn { + crate::ffi_entry!("SQLTablesW", unsafe { + let catalog = opt_arg(catalog_name, name_length_1); + let schema = opt_arg(schema_name, name_length_2); + let table = opt_arg(table_name, name_length_3); + let types = opt_arg(table_type, name_length_4); + let sql = build_exec( + &catalog, + "sp_tables", + &[ + literal(&table), + literal(&schema), + // The qualifier argument is redundant once the proc is + // three-part qualified, but sp_tables validates it against the + // current database, so pass NULL. + "NULL".to_string(), + table_type_literal(&types), + "1".to_string(), + ], + ); + run_catalog( + statement_handle, + "SQLTablesW", + sql, + &[(0, "TABLE_CAT"), (1, "TABLE_SCHEM")], + ) + }) +} + +/// Implements `SQLColumnsW`. +/// +/// # Safety +/// Each name pointer must be null or reference `*_len` readable UTF-16 units. +#[allow(clippy::too_many_arguments)] +pub(crate) unsafe fn sql_columns_w( + statement_handle: SqlHandle, + catalog_name: *const SqlWChar, + name_length_1: SqlSmallInt, + schema_name: *const SqlWChar, + name_length_2: SqlSmallInt, + table_name: *const SqlWChar, + name_length_3: SqlSmallInt, + column_name: *const SqlWChar, + name_length_4: SqlSmallInt, +) -> SqlReturn { + crate::ffi_entry!("SQLColumnsW", unsafe { + let catalog = opt_arg(catalog_name, name_length_1); + let schema = opt_arg(schema_name, name_length_2); + let table = opt_arg(table_name, name_length_3); + let column = opt_arg(column_name, name_length_4); + let sql = build_exec_named( + &catalog, + "sp_columns_100", + &[ + ("table_name", literal(&table)), + ("table_owner", literal(&schema)), + ("table_qualifier", "NULL".to_string()), + ("column_name", literal(&column)), + ("ODBCVer", "3".to_string()), + ("fUsePattern", "1".to_string()), + ], + ); + run_catalog( + statement_handle, + "SQLColumnsW", + sql, + &[ + (0, "TABLE_CAT"), + (1, "TABLE_SCHEM"), + (6, "COLUMN_SIZE"), + (7, "BUFFER_LENGTH"), + (8, "DECIMAL_DIGITS"), + (9, "NUM_PREC_RADIX"), + ], + ) + }) +} + +/// Implements `SQLPrimaryKeysW`. +/// +/// # Safety +/// Each name pointer must be null or reference `*_len` readable UTF-16 units. +pub(crate) unsafe fn sql_primary_keys_w( + statement_handle: SqlHandle, + catalog_name: *const SqlWChar, + name_length_1: SqlSmallInt, + schema_name: *const SqlWChar, + name_length_2: SqlSmallInt, + table_name: *const SqlWChar, + name_length_3: SqlSmallInt, +) -> SqlReturn { + crate::ffi_entry!("SQLPrimaryKeysW", unsafe { + let catalog = opt_arg(catalog_name, name_length_1); + let schema = opt_arg(schema_name, name_length_2); + let table = opt_arg(table_name, name_length_3); + let sql = build_exec( + &catalog, + "sp_pkeys", + &[literal(&table), literal(&schema), "NULL".to_string()], + ); + run_catalog( + statement_handle, + "SQLPrimaryKeysW", + sql, + &[(0, "TABLE_CAT"), (1, "TABLE_SCHEM")], + ) + }) +} + +/// Implements `SQLForeignKeysW`. +/// +/// # Safety +/// Each name pointer must be null or reference `*_len` readable UTF-16 units. +#[allow(clippy::too_many_arguments)] +pub(crate) unsafe fn sql_foreign_keys_w( + statement_handle: SqlHandle, + pk_catalog_name: *const SqlWChar, + name_length_1: SqlSmallInt, + pk_schema_name: *const SqlWChar, + name_length_2: SqlSmallInt, + pk_table_name: *const SqlWChar, + name_length_3: SqlSmallInt, + fk_catalog_name: *const SqlWChar, + name_length_4: SqlSmallInt, + fk_schema_name: *const SqlWChar, + name_length_5: SqlSmallInt, + fk_table_name: *const SqlWChar, + name_length_6: SqlSmallInt, +) -> SqlReturn { + crate::ffi_entry!("SQLForeignKeysW", unsafe { + let pk_catalog = opt_arg(pk_catalog_name, name_length_1); + let pk_schema = opt_arg(pk_schema_name, name_length_2); + let pk_table = opt_arg(pk_table_name, name_length_3); + let fk_catalog = opt_arg(fk_catalog_name, name_length_4); + let fk_schema = opt_arg(fk_schema_name, name_length_5); + let fk_table = opt_arg(fk_table_name, name_length_6); + // Both sides must live in one database; prefer whichever qualifier the + // caller supplied. + let catalog = match (&pk_catalog, &fk_catalog) { + (Some(c), _) if !c.is_empty() => pk_catalog.clone(), + (_, Some(c)) if !c.is_empty() => fk_catalog.clone(), + _ => None, + }; + let sql = build_exec( + &catalog, + "sp_fkeys", + &[ + literal(&pk_table), + literal(&pk_schema), + "NULL".to_string(), + literal(&fk_table), + literal(&fk_schema), + "NULL".to_string(), + ], + ); + run_catalog( + statement_handle, + "SQLForeignKeysW", + sql, + &[ + (0, "PKTABLE_CAT"), + (1, "PKTABLE_SCHEM"), + (4, "FKTABLE_CAT"), + (5, "FKTABLE_SCHEM"), + ], + ) + }) +} + +/// Implements `SQLStatisticsW`. +/// +/// # Safety +/// Each name pointer must be null or reference `*_len` readable UTF-16 units. +#[allow(clippy::too_many_arguments)] +pub(crate) unsafe fn sql_statistics_w( + statement_handle: SqlHandle, + catalog_name: *const SqlWChar, + name_length_1: SqlSmallInt, + schema_name: *const SqlWChar, + name_length_2: SqlSmallInt, + table_name: *const SqlWChar, + name_length_3: SqlSmallInt, + unique: u16, + reserved: u16, +) -> SqlReturn { + crate::ffi_entry!("SQLStatisticsW", unsafe { + let catalog = opt_arg(catalog_name, name_length_1); + let schema = opt_arg(schema_name, name_length_2); + let table = opt_arg(table_name, name_length_3); + // SQL_INDEX_UNIQUE == 0 selects unique indexes only. + let is_unique = if unique == 0 { "'Y'" } else { "'N'" }; + // SQL_QUICK == 0 permits cached cardinality; SQL_ENSURE == 1 forces a scan. + let accuracy = if reserved == 0 { "'Q'" } else { "'E'" }; + let sql = build_exec( + &catalog, + "sp_statistics", + &[ + literal(&table), + literal(&schema), + "NULL".to_string(), + // msodbcsql always passes '%' here: sp_statistics filters + // `index_name LIKE @index_name`, so NULL returns no index rows. + "N'%'".to_string(), + is_unique.to_string(), + accuracy.to_string(), + ], + ); + run_catalog( + statement_handle, + "SQLStatisticsW", + sql, + &[ + (0, "TABLE_CAT"), + (1, "TABLE_SCHEM"), + (7, "ORDINAL_POSITION"), + (9, "ASC_OR_DESC"), + ], + ) + }) +} + +/// Implements `SQLSpecialColumnsW`. +/// +/// # Safety +/// Each name pointer must be null or reference `*_len` readable UTF-16 units. +#[allow(clippy::too_many_arguments)] +pub(crate) unsafe fn sql_special_columns_w( + statement_handle: SqlHandle, + identifier_type: u16, + catalog_name: *const SqlWChar, + name_length_1: SqlSmallInt, + schema_name: *const SqlWChar, + name_length_2: SqlSmallInt, + table_name: *const SqlWChar, + name_length_3: SqlSmallInt, + scope: u16, + nullable: u16, +) -> SqlReturn { + crate::ffi_entry!("SQLSpecialColumnsW", unsafe { + let catalog = opt_arg(catalog_name, name_length_1); + let schema = opt_arg(schema_name, name_length_2); + let table = opt_arg(table_name, name_length_3); + // SQL_BEST_ROWID == 1 maps to 'R', SQL_ROWVER == 2 to 'V'. + let col_type = if identifier_type == 1 { "'R'" } else { "'V'" }; + // SQL_NULLABLE == 1 permits nullable columns in the result. + let nullable_arg = if nullable == 1 { "'U'" } else { "'O'" }; + let sql = build_exec( + &catalog, + "sp_special_columns_100", + &[ + literal(&table), + literal(&schema), + "NULL".to_string(), + col_type.to_string(), + format!("'{}'", if scope == 0 { "C" } else { "T" }), + nullable_arg.to_string(), + "3".to_string(), + ], + ); + run_catalog( + statement_handle, + "SQLSpecialColumnsW", + sql, + &[ + (4, "COLUMN_SIZE"), + (5, "BUFFER_LENGTH"), + (6, "DECIMAL_DIGITS"), + ], + ) + }) +} + +/// Implements `SQLProceduresW`. +/// +/// # Safety +/// Each name pointer must be null or reference `*_len` readable UTF-16 units. +pub(crate) unsafe fn sql_procedures_w( + statement_handle: SqlHandle, + catalog_name: *const SqlWChar, + name_length_1: SqlSmallInt, + schema_name: *const SqlWChar, + name_length_2: SqlSmallInt, + proc_name: *const SqlWChar, + name_length_3: SqlSmallInt, +) -> SqlReturn { + crate::ffi_entry!("SQLProceduresW", unsafe { + let catalog = opt_arg(catalog_name, name_length_1); + let schema = opt_arg(schema_name, name_length_2); + let proc = opt_arg(proc_name, name_length_3); + let sql = build_exec( + &catalog, + "sp_stored_procedures", + &[ + literal(&proc), + literal(&schema), + "NULL".to_string(), + "1".to_string(), + ], + ); + run_catalog( + statement_handle, + "SQLProceduresW", + sql, + &[(0, "PROCEDURE_CAT"), (1, "PROCEDURE_SCHEM")], + ) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::odbc_types::{SQL_ERROR, SQL_NTS, SQL_NULL_HANDLE}; + use crate::handles::handle_from_raw; + use crate::test_support::TestHandles; + + fn w(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() + } + + #[test] + fn literal_escapes_quotes() { + assert_eq!(literal(&None), "NULL"); + assert_eq!(literal(&Some("O'Brien".into())), "N'O''Brien'"); + } + + #[test] + fn build_exec_qualifies_with_catalog() { + let sql = build_exec(&Some("mydb".into()), "sp_tables", &["NULL".into()]); + assert!(sql.ends_with("ELSE EXEC [mydb].sys.sp_tables NULL")); + } + + #[test] + fn build_exec_without_catalog_uses_current_database() { + let sql = build_exec(&None, "sp_pkeys", &["N't'".into()]); + assert_eq!(sql, "EXEC sys.sp_pkeys N't'"); + } + + #[test] + fn build_exec_escapes_bracket_in_catalog() { + let sql = build_exec(&Some("we]ird".into()), "sp_tables", &[]); + assert!(sql.contains("EXEC [we]]ird].sys.sp_tables")); + } + + #[test] + fn build_exec_named_renders_named_arguments() { + let sql = build_exec_named( + &None, + "sp_columns_100", + &[("table_name", "N't'".into()), ("ODBCVer", "3".into())], + ); + assert_eq!( + sql, + "EXEC sys.sp_columns_100 @table_name = N't', @ODBCVer = 3" + ); + } + + #[test] + fn tables_null_handle_is_invalid_handle() { + let name = w("t"); + let ret = unsafe { + sql_tables_w( + SQL_NULL_HANDLE, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + name.as_ptr(), + SQL_NTS, + std::ptr::null(), + 0, + ) + }; + assert_eq!(ret, SQL_INVALID_HANDLE); + } + + #[test] + fn table_type_quotes_each_element_individually() { + assert_eq!( + table_type_literal(&Some("TABLE,VIEW".to_string())), + "N'''TABLE'',''VIEW'''" + ); + } + + #[test] + fn table_type_passes_wildcard_through() { + assert_eq!(table_type_literal(&Some("%".to_string())), "N'%'"); + } + + #[test] + fn table_type_treats_blank_as_null() { + assert_eq!(table_type_literal(&Some(" ".to_string())), "NULL"); + assert_eq!(table_type_literal(&None), "NULL"); + } + + #[test] + fn build_exec_without_catalog_runs_locally() { + let sql = build_exec(&None, "sp_tables", &["NULL".to_string()]); + assert_eq!(sql, "EXEC sys.sp_tables NULL"); + } + + #[test] + fn build_exec_with_catalog_guards_on_db_id() { + let sql = build_exec( + &Some("other".to_string()), + "sp_tables", + &["@table_name = N'%'".to_string(), "NULL".to_string()], + ); + assert!(sql.starts_with("IF DB_ID(N'other') IS NULL EXEC sys.sp_tables ")); + assert!(sql.contains("ELSE EXEC [other].sys.sp_tables @table_name = N'%', NULL")); + // The fallback keeps the named-argument prefix so the call still binds. + assert!(sql.contains("@table_name =N'\u{1}no such object\u{1}'")); + } + + #[test] + fn build_exec_escapes_bracketed_catalog_name() { + let sql = build_exec(&Some("we]ird".to_string()), "sp_tables", &[]); + assert!(sql.contains("[we]]ird].sys.sp_tables")); + } + + #[test] + fn procedures_null_handle_is_invalid_handle() { + let ret = unsafe { + sql_procedures_w( + SQL_NULL_HANDLE, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + ) + }; + assert_eq!(ret, SQL_INVALID_HANDLE); + } + + #[test] + fn catalog_execution_resets_stale_statement_state_before_claiming_connection() { + let h = TestHandles::with_env_dbc_stmt(); + let stmt = unsafe { handle_from_raw::(h.stmt) }; + { + let mut state = stmt.inner.lock().unwrap(); + state.set_state(STMT_STATE_EXEC_CONTEXT); + state.row_count = 7; + state.pending_row_counts.push_back(3); + } + + let ret = unsafe { + run_catalog( + h.stmt, + "SQLTablesW", + "EXEC sys.sp_tables NULL".to_string(), + &[], + ) + }; + assert_eq!(ret, SQL_ERROR); + + let state = stmt.inner.lock().unwrap(); + assert!(!state.has_state(STMT_STATE_EXEC_STARTED)); + assert!(!state.has_state(STMT_STATE_EXEC_CONTEXT)); + assert_eq!(state.row_count, -1); + assert!(state.pending_row_counts.is_empty()); + } +} diff --git a/mssql-odbc/src/api/cdata.rs b/mssql-odbc/src/api/cdata.rs new file mode 100644 index 00000000..f97e875e --- /dev/null +++ b/mssql-odbc/src/api/cdata.rs @@ -0,0 +1,891 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Conversion of TDS column values into ODBC C buffers. +//! +//! Shared by `SQLGetData` (single value into a caller buffer) and the bound +//! column path used by `SQLFetch` / `SQLFetchScroll`. The two entry points +//! differ only in how the destination pointer is computed, so all of the +//! SQL-type → C-type conversion policy lives here. + +use mssql_tds::datatypes::column_values::ColumnValues; +use mssql_tds::datatypes::decoder::DecimalParts; +use mssql_tds::datatypes::sql_string::EncodingType; + +use super::odbc_types::*; +use super::util::{copy_with_nul, write_if_some}; + +/// Outcome of a successful write. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub(crate) enum WriteOutcome { + /// The whole value was written. + Complete, + /// The value did not fit; a truncated prefix was written (01004). + Truncated, +} + +/// Why a conversion could not be performed. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub(crate) enum WriteError { + /// The requested C type is not a valid ODBC C type (HY003). + InvalidCType, + /// The SQL type cannot be converted to the requested C type (07006). + RestrictedConversion, + /// The value does not fit the target C type (22003). + OutOfRange, +} + +/// Normalized view of a column value, decoupled from the TDS representation. +pub(crate) enum Cell { + Int(i64), + UInt(u64), + Double(f64), + /// `money` / `smallmoney` as its raw scaled integer (4 decimal places). + /// Kept exact so character and `SQL_C_NUMERIC` targets don't inherit `f64` + /// rounding at the edges of the `money` range. + Money(i64), + Bool(bool), + Text(String), + Binary(Vec), + Guid([u8; 16]), + Decimal(DecimalParts), + Date(CivilDate), + Time(CivilTime), + Timestamp(CivilDate, CivilTime), + TimestampOffset(CivilDate, CivilTime, i16), +} + +#[derive(Clone, Copy)] +pub(in crate::api) struct CivilDate { + year: i32, + month: u32, + day: u32, +} + +#[derive(Clone, Copy, Default)] +pub(in crate::api) struct CivilTime { + hour: u32, + minute: u32, + second: u32, + /// Fractional seconds in nanoseconds. + nanos: u32, + /// Fractional-seconds scale (0–7), used when rendering to text. + scale: u8, +} + +/// Days from 0001-01-01 to 1970-01-01. +const DAYS_YEAR_ONE_TO_EPOCH: i64 = 719_162; +/// Days from 1900-01-01 to 1970-01-01. +const DAYS_1900_TO_EPOCH: i64 = 25_567; + +/// Howard Hinnant's `civil_from_days`: converts days since 1970-01-01 into a +/// proleptic Gregorian calendar date. +fn civil_from_days(z: i64) -> CivilDate { + let z = z + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + CivilDate { + year: (y + i64::from(m <= 2)) as i32, + month: m as u32, + day: d as u32, + } +} + +/// TDS `SqlTime::time_nanoseconds` is actually a count of 100ns ticks (the +/// decoder normalizes every scale to that unit), so callers converting a TDS +/// time must go through [`time_from_ticks`], not this helper. +fn time_from_nanos(total_nanos: u64, scale: u8) -> CivilTime { + let secs = total_nanos / 1_000_000_000; + CivilTime { + hour: (secs / 3600) as u32, + minute: ((secs / 60) % 60) as u32, + second: (secs % 60) as u32, + nanos: (total_nanos % 1_000_000_000) as u32, + scale, + } +} + +/// Converts TDS 100ns ticks since midnight into a civil time. +fn time_from_ticks(ticks_100ns: u64, scale: u8) -> CivilTime { + time_from_nanos(ticks_100ns.saturating_mul(100), scale) +} + +impl CivilDate { + fn to_text(self) -> String { + format!("{:04}-{:02}-{:02}", self.year, self.month, self.day) + } +} + +impl CivilTime { + fn to_text(self) -> String { + let base = format!("{:02}:{:02}:{:02}", self.hour, self.minute, self.second); + if self.scale == 0 { + return base; + } + let scale = usize::from(self.scale.min(9)); + let frac = format!("{:09}", self.nanos); + format!("{base}.{}", &frac[..scale]) + } +} + +fn money_scaled(lsb: i32, msb: i32) -> i64 { + ((msb as i64) << 32) | ((lsb as u32) as i64) +} + +/// Renders a scaled `money` integer with its four implied decimal places. +fn money_text(scaled: i64) -> String { + let sign = if scaled < 0 { "-" } else { "" }; + let abs = scaled.unsigned_abs(); + format!("{sign}{}.{:04}", abs / 10_000, abs % 10_000) +} + +/// Projects a TDS column value onto the normalized [`Cell`] model. +pub(crate) fn to_cell(v: &ColumnValues) -> Option { + Some(match v { + ColumnValues::TinyInt(x) => Cell::UInt(u64::from(*x)), + ColumnValues::SmallInt(x) => Cell::Int(i64::from(*x)), + ColumnValues::Int(x) => Cell::Int(i64::from(*x)), + ColumnValues::BigInt(x) => Cell::Int(*x), + ColumnValues::Real(x) => Cell::Double(f64::from(*x)), + ColumnValues::Float(x) => Cell::Double(*x), + ColumnValues::Bit(x) => Cell::Bool(*x), + ColumnValues::Decimal(d) | ColumnValues::Numeric(d) => Cell::Decimal(d.clone()), + ColumnValues::String(s) => Cell::Text(s.to_utf8_string()), + ColumnValues::Xml(x) => Cell::Text(x.as_string()), + ColumnValues::Json(j) => Cell::Text(j.as_string()), + ColumnValues::Bytes(b) => Cell::Binary(b.clone()), + ColumnValues::Uuid(u) => Cell::Guid(*u.as_bytes()), + ColumnValues::SmallMoney(m) => Cell::Money(i64::from(m.int_val)), + ColumnValues::Money(m) => Cell::Money(money_scaled(m.lsb_part, m.msb_part)), + ColumnValues::Date(d) => Cell::Date(civil_from_days( + i64::from(d.get_days()) - DAYS_YEAR_ONE_TO_EPOCH, + )), + ColumnValues::Time(t) => Cell::Time(time_from_ticks(t.time_nanoseconds, t.scale)), + ColumnValues::DateTime2(dt) => Cell::Timestamp( + civil_from_days(i64::from(dt.days) - DAYS_YEAR_ONE_TO_EPOCH), + time_from_ticks(dt.time.time_nanoseconds, dt.time.scale), + ), + ColumnValues::DateTimeOffset(dto) => Cell::TimestampOffset( + civil_from_days(i64::from(dto.datetime2.days) - DAYS_YEAR_ONE_TO_EPOCH), + time_from_ticks( + dto.datetime2.time.time_nanoseconds, + dto.datetime2.time.scale, + ), + dto.offset, + ), + ColumnValues::DateTime(dt) => { + // 1/300 s ticks since midnight; msodbcsql rounds to 3 fractional digits. + let nanos = (u64::from(dt.time) * 1_000_000_000).div_euclid(300); + let millis = (nanos + 500_000) / 1_000_000; + Cell::Timestamp( + civil_from_days(i64::from(dt.days) - DAYS_1900_TO_EPOCH), + time_from_nanos(millis * 1_000_000, 3), + ) + } + ColumnValues::SmallDateTime(dt) => Cell::Timestamp( + civil_from_days(i64::from(dt.days) - DAYS_1900_TO_EPOCH), + time_from_nanos(u64::from(dt.time) * 60 * 1_000_000_000, 0), + ), + ColumnValues::Null | ColumnValues::Vector(_) => return None, + }) +} + +impl Cell { + /// Renders the value the way msodbcsql renders it for character targets. + fn to_text(&self) -> String { + match self { + Cell::Int(x) => x.to_string(), + Cell::UInt(x) => x.to_string(), + Cell::Double(x) => x.to_string(), + Cell::Money(x) => money_text(*x), + Cell::Bool(x) => (if *x { "1" } else { "0" }).to_string(), + Cell::Text(s) => s.clone(), + Cell::Binary(b) => b.iter().map(|byte| format!("{byte:02X}")).collect(), + Cell::Guid(g) => guid_text(g), + Cell::Decimal(d) => d.to_string(), + Cell::Date(d) => d.to_text(), + Cell::Time(t) => t.to_text(), + Cell::Timestamp(d, t) => format!("{} {}", d.to_text(), t.to_text()), + Cell::TimestampOffset(d, t, off) => { + let sign = if *off < 0 { '-' } else { '+' }; + let abs = off.unsigned_abs(); + format!( + "{} {} {sign}{:02}:{:02}", + d.to_text(), + t.to_text(), + abs / 60, + abs % 60 + ) + } + } + } + + pub(crate) fn as_i64(&self) -> Option { + match self { + Cell::Int(x) => Some(*x), + Cell::UInt(x) => i64::try_from(*x).ok(), + Cell::Double(x) => Some(x.round() as i64), + Cell::Money(x) => Some(x / 10_000), + Cell::Bool(x) => Some(i64::from(*x)), + Cell::Decimal(d) => d.to_decimal_string().parse::().ok().map(|f| f as i64), + Cell::Text(s) => s.trim().parse::().ok(), + _ => None, + } + } + + fn as_f64(&self) -> Option { + match self { + Cell::Int(x) => Some(*x as f64), + Cell::UInt(x) => Some(*x as f64), + Cell::Double(x) => Some(*x), + Cell::Money(x) => Some(*x as f64 / 10_000.0), + Cell::Bool(x) => Some(f64::from(u8::from(*x))), + Cell::Decimal(d) => d.to_decimal_string().parse::().ok(), + Cell::Text(s) => s.trim().parse::().ok(), + _ => None, + } + } + + fn as_bytes(&self) -> Option> { + match self { + Cell::Binary(b) => Some(b.clone()), + Cell::Guid(g) => Some(g.to_vec()), + Cell::Text(s) => Some(s.as_bytes().to_vec()), + _ => None, + } + } +} + +fn guid_text(g: &[u8; 16]) -> String { + format!( + "{:02X}{:02X}{:02X}{:02X}-{:02X}{:02X}-{:02X}{:02X}-{:02X}{:02X}-{:02X}{:02X}{:02X}{:02X}{:02X}{:02X}", + g[0], + g[1], + g[2], + g[3], + g[4], + g[5], + g[6], + g[7], + g[8], + g[9], + g[10], + g[11], + g[12], + g[13], + g[14], + g[15] + ) +} + +/// Writes a fixed-size POD value to `dst` and reports its byte size through the +/// indicator. +/// +/// # Safety +/// `dst` must be valid for writes of `size_of::()` bytes (possibly +/// unaligned), or null; `ind` must be null or a valid `SqlLen` pointer. +unsafe fn write_pod(dst: SqlPointer, ind: *mut SqlLen, value: T) -> WriteOutcome { + if !dst.is_null() { + unsafe { std::ptr::write_unaligned(dst as *mut T, value) }; + } + unsafe { write_if_some(ind, std::mem::size_of::() as SqlLen) }; + WriteOutcome::Complete +} + +/// Converts `value` into `target_type` and writes it to the caller's buffer. +/// +/// # Safety +/// `target_value_ptr` must be valid for writes of `buffer_length` bytes (or +/// null), and `strlen_or_ind_ptr` must be null or point to a writable `SqlLen`. +pub(crate) unsafe fn write_c_value( + value: &ColumnValues, + target_type: SqlSmallInt, + target_value_ptr: SqlPointer, + buffer_length: SqlLen, + strlen_or_ind_ptr: *mut SqlLen, +) -> Result { + if matches!(value, ColumnValues::Null) { + unsafe { write_if_some(strlen_or_ind_ptr, SQL_NULL_DATA) }; + // Character/binary targets still get a terminator so naive callers that + // ignore the indicator read an empty value rather than stale memory. + match target_type { + SQL_C_WCHAR => unsafe { + copy_with_nul( + target_value_ptr as *mut SqlWChar, + wchar_capacity(buffer_length), + &[], + ); + }, + SQL_C_CHAR => unsafe { + copy_with_nul( + target_value_ptr as *mut u8, + buffer_length.max(0) as usize, + &[], + ); + }, + _ => {} + } + return Ok(WriteOutcome::Complete); + } + + let Some(cell) = to_cell(value) else { + return Err(WriteError::RestrictedConversion); + }; + + match target_type { + SQL_C_CHAR | SQL_C_DEFAULT => { + let ansi = narrow_bytes(value).ok_or(WriteError::RestrictedConversion)?; + Ok(unsafe { + write_text( + &ansi, + target_value_ptr as *mut u8, + buffer_length.max(0) as usize, + strlen_or_ind_ptr, + ) + }) + } + SQL_C_WCHAR => { + let utf16: Vec = cell.to_text().encode_utf16().collect(); + Ok(unsafe { + write_text( + &utf16, + target_value_ptr as *mut SqlWChar, + wchar_capacity(buffer_length), + strlen_or_ind_ptr, + ) + }) + } + SQL_C_BINARY => { + let bytes = cell.as_bytes().ok_or(WriteError::RestrictedConversion)?; + Ok(unsafe { + write_binary( + &bytes, + target_value_ptr as *mut u8, + buffer_length.max(0) as usize, + strlen_or_ind_ptr, + ) + }) + } + SQL_C_BIT => { + let v = cell.as_i64().ok_or(WriteError::RestrictedConversion)?; + Ok(unsafe { write_pod::(target_value_ptr, strlen_or_ind_ptr, u8::from(v != 0)) }) + } + SQL_C_STINYINT => { + let v = cell.as_i64().ok_or(WriteError::RestrictedConversion)?; + let v = i8::try_from(v).map_err(|_| WriteError::OutOfRange)?; + Ok(unsafe { write_pod(target_value_ptr, strlen_or_ind_ptr, v) }) + } + // SQL Server's `tinyint` is unsigned 0..=255 and msodbcsql treats the + // unqualified `SQL_C_TINYINT` alias as unsigned for it, so 128..=255 + // round-trips instead of overflowing a signed byte. + SQL_C_UTINYINT | SQL_C_TINYINT => { + let v = cell.as_i64().ok_or(WriteError::RestrictedConversion)?; + let v = u8::try_from(v).map_err(|_| WriteError::OutOfRange)?; + Ok(unsafe { write_pod(target_value_ptr, strlen_or_ind_ptr, v) }) + } + SQL_C_SSHORT | SQL_C_SHORT => { + let v = cell.as_i64().ok_or(WriteError::RestrictedConversion)?; + let v = i16::try_from(v).map_err(|_| WriteError::OutOfRange)?; + Ok(unsafe { write_pod(target_value_ptr, strlen_or_ind_ptr, v) }) + } + SQL_C_USHORT => { + let v = cell.as_i64().ok_or(WriteError::RestrictedConversion)?; + let v = u16::try_from(v).map_err(|_| WriteError::OutOfRange)?; + Ok(unsafe { write_pod(target_value_ptr, strlen_or_ind_ptr, v) }) + } + SQL_C_SLONG | SQL_C_LONG => { + let v = cell.as_i64().ok_or(WriteError::RestrictedConversion)?; + let v = i32::try_from(v).map_err(|_| WriteError::OutOfRange)?; + Ok(unsafe { write_pod(target_value_ptr, strlen_or_ind_ptr, v) }) + } + SQL_C_ULONG => { + let v = cell.as_i64().ok_or(WriteError::RestrictedConversion)?; + let v = u32::try_from(v).map_err(|_| WriteError::OutOfRange)?; + Ok(unsafe { write_pod(target_value_ptr, strlen_or_ind_ptr, v) }) + } + SQL_C_SBIGINT => { + let v = cell.as_i64().ok_or(WriteError::RestrictedConversion)?; + Ok(unsafe { write_pod(target_value_ptr, strlen_or_ind_ptr, v) }) + } + SQL_C_UBIGINT => { + let v = cell.as_i64().ok_or(WriteError::RestrictedConversion)?; + let v = u64::try_from(v).map_err(|_| WriteError::OutOfRange)?; + Ok(unsafe { write_pod(target_value_ptr, strlen_or_ind_ptr, v) }) + } + SQL_C_FLOAT => { + let v = cell.as_f64().ok_or(WriteError::RestrictedConversion)?; + Ok(unsafe { write_pod(target_value_ptr, strlen_or_ind_ptr, v as f32) }) + } + SQL_C_DOUBLE => { + let v = cell.as_f64().ok_or(WriteError::RestrictedConversion)?; + Ok(unsafe { write_pod(target_value_ptr, strlen_or_ind_ptr, v) }) + } + SQL_C_NUMERIC => { + let d = match &cell { + Cell::Decimal(d) => d.clone(), + _ => return Err(WriteError::RestrictedConversion), + }; + Ok(unsafe { write_pod(target_value_ptr, strlen_or_ind_ptr, numeric_struct(&d)) }) + } + SQL_C_GUID => { + let Cell::Guid(g) = cell else { + return Err(WriteError::RestrictedConversion); + }; + Ok(unsafe { write_pod(target_value_ptr, strlen_or_ind_ptr, guid_struct(&g)) }) + } + SQL_C_TYPE_DATE | SQL_C_DATE => { + let d = cell_date(&cell).ok_or(WriteError::RestrictedConversion)?; + Ok(unsafe { + write_pod( + target_value_ptr, + strlen_or_ind_ptr, + SqlDateStruct { + year: d.year as SqlSmallInt, + month: d.month as SqlUSmallInt, + day: d.day as SqlUSmallInt, + }, + ) + }) + } + SQL_C_TYPE_TIME | SQL_C_TIME => { + let t = cell_time(&cell).ok_or(WriteError::RestrictedConversion)?; + Ok(unsafe { + write_pod( + target_value_ptr, + strlen_or_ind_ptr, + SqlTimeStruct { + hour: t.hour as SqlUSmallInt, + minute: t.minute as SqlUSmallInt, + second: t.second as SqlUSmallInt, + }, + ) + }) + } + SQL_C_SS_TIME2 => { + let t = cell_time(&cell).ok_or(WriteError::RestrictedConversion)?; + Ok(unsafe { + write_pod( + target_value_ptr, + strlen_or_ind_ptr, + SqlSsTime2Struct { + hour: t.hour as SqlUSmallInt, + minute: t.minute as SqlUSmallInt, + second: t.second as SqlUSmallInt, + fraction: t.nanos, + }, + ) + }) + } + SQL_C_TYPE_TIMESTAMP | SQL_C_TIMESTAMP => { + let (d, t) = cell_timestamp(&cell).ok_or(WriteError::RestrictedConversion)?; + Ok(unsafe { + write_pod( + target_value_ptr, + strlen_or_ind_ptr, + SqlTimestampStruct { + year: d.year as SqlSmallInt, + month: d.month as SqlUSmallInt, + day: d.day as SqlUSmallInt, + hour: t.hour as SqlUSmallInt, + minute: t.minute as SqlUSmallInt, + second: t.second as SqlUSmallInt, + fraction: t.nanos, + }, + ) + }) + } + SQL_C_SS_TIMESTAMPOFFSET => { + let (d, t) = cell_timestamp(&cell).ok_or(WriteError::RestrictedConversion)?; + let offset = match &cell { + Cell::TimestampOffset(_, _, off) => *off, + _ => 0, + }; + Ok(unsafe { + write_pod( + target_value_ptr, + strlen_or_ind_ptr, + SqlSsTimestampoffsetStruct { + year: d.year as SqlSmallInt, + month: d.month as SqlUSmallInt, + day: d.day as SqlUSmallInt, + hour: t.hour as SqlUSmallInt, + minute: t.minute as SqlUSmallInt, + second: t.second as SqlUSmallInt, + fraction: t.nanos, + timezone_hour: offset / 60, + timezone_minute: offset % 60, + }, + ) + }) + } + _ => Err(WriteError::InvalidCType), + } +} + +fn cell_date(cell: &Cell) -> Option { + match cell { + Cell::Date(d) => Some(*d), + Cell::Timestamp(d, _) | Cell::TimestampOffset(d, _, _) => Some(*d), + _ => None, + } +} + +fn cell_time(cell: &Cell) -> Option { + match cell { + Cell::Time(t) => Some(*t), + Cell::Timestamp(_, t) | Cell::TimestampOffset(_, t, _) => Some(*t), + _ => None, + } +} + +fn cell_timestamp(cell: &Cell) -> Option<(CivilDate, CivilTime)> { + match cell { + Cell::Timestamp(d, t) | Cell::TimestampOffset(d, t, _) => Some((*d, *t)), + Cell::Date(d) => Some((*d, CivilTime::default())), + _ => None, + } +} + +fn guid_struct(g: &[u8; 16]) -> SqlGuid { + SqlGuid { + data1: u32::from_be_bytes([g[0], g[1], g[2], g[3]]), + data2: u16::from_be_bytes([g[4], g[5]]), + data3: u16::from_be_bytes([g[6], g[7]]), + data4: [g[8], g[9], g[10], g[11], g[12], g[13], g[14], g[15]], + } +} + +/// Builds a `SQL_NUMERIC_STRUCT` from the decimal's digit string. Going through +/// the rendered digits keeps this independent of the TDS mantissa layout. +fn numeric_struct(d: &DecimalParts) -> SqlNumericStruct { + let text = d.to_decimal_string(); + let digits: String = text.chars().filter(|c| c.is_ascii_digit()).collect(); + let mut mantissa = digits.parse::().unwrap_or(0); + let mut val = [0u8; SQL_MAX_NUMERIC_LEN]; + for slot in val.iter_mut() { + *slot = (mantissa & 0xFF) as u8; + mantissa >>= 8; + } + SqlNumericStruct { + precision: d.precision, + scale: d.scale as i8, + sign: u8::from(d.is_positive), + val, + } +} + +pub(crate) fn wchar_capacity(buffer_length: SqlLen) -> usize { + (buffer_length.max(0) as usize) / std::mem::size_of::() +} + +/// NUL-terminated character write with ODBC truncation semantics: the indicator +/// reports the untruncated length in bytes. +/// +/// # Safety +/// `dst` must be valid for `capacity` elements of `T`, or null. +unsafe fn write_text( + src: &[T], + dst: *mut T, + capacity: usize, + ind: *mut SqlLen, +) -> WriteOutcome { + unsafe { write_if_some(ind, std::mem::size_of_val(src) as SqlLen) }; + if unsafe { copy_with_nul(dst, capacity, src) } { + WriteOutcome::Truncated + } else { + WriteOutcome::Complete + } +} + +/// Binary write: no NUL terminator, indicator reports the untruncated length. +/// +/// # Safety +/// `dst` must be valid for `capacity` bytes, or null. +unsafe fn write_binary( + src: &[u8], + dst: *mut u8, + capacity: usize, + ind: *mut SqlLen, +) -> WriteOutcome { + unsafe { write_if_some(ind, src.len() as SqlLen) }; + if dst.is_null() || capacity == 0 { + return if src.is_empty() { + WriteOutcome::Complete + } else { + WriteOutcome::Truncated + }; + } + let n = src.len().min(capacity); + unsafe { std::ptr::copy_nonoverlapping(src.as_ptr(), dst, n) }; + if n < src.len() { + WriteOutcome::Truncated + } else { + WriteOutcome::Complete + } +} + +/// Character/binary payload for a column, ready to be streamed by `SQLGetData`. +/// +/// `SQLGetData` returns long values in chunks, so the payload has to be +/// materialized once and then sliced at a byte offset across calls. Fixed-width +/// C types are never chunked and are served directly by [`write_c_value`]. +pub(crate) enum StreamPayload { + /// Narrow character bytes, terminated with a single NUL when copied out. + Narrow(Vec), + /// UTF-16LE code units. + Wide(Vec), + /// Raw bytes, copied without a terminator. + Binary(Vec), +} + +/// Materializes the streamable payload for a column value, or `None` when the +/// target C type is fixed-width. +pub(crate) fn stream_payload( + value: &ColumnValues, + target_type: SqlSmallInt, +) -> Option> { + match target_type { + SQL_C_CHAR | SQL_C_DEFAULT => Some(Ok(StreamPayload::Narrow(narrow_bytes(value)?))), + SQL_C_WCHAR => { + let cell = to_cell(value)?; + Some(Ok(StreamPayload::Wide( + cell.to_text().encode_utf16().collect(), + ))) + } + SQL_C_BINARY => { + let Some(cell) = to_cell(value) else { + // Vectors and other values without a Cell projection still have + // a byte form on the wire; treat them as opaque binary. + return Some(Err(WriteError::RestrictedConversion)); + }; + Some(match cell.as_bytes() { + Some(bytes) => Ok(StreamPayload::Binary(bytes)), + None => Err(WriteError::RestrictedConversion), + }) + } + _ => None, + } +} + +/// Produces the bytes msodbcsql hands back for a narrow (`SQL_C_CHAR`) target. +/// +/// Character columns with a collation-derived encoding are passed through in +/// their original code page rather than transcoded to UTF-8: that is what the +/// native driver does on Windows, and clients decode using the column collation. +/// Everything else is rendered as text and encoded into the client ANSI code +/// page, which is what `SQL_C_CHAR` means to an ODBC application. +fn narrow_bytes(value: &ColumnValues) -> Option> { + if let ColumnValues::String(s) = value + && matches!(s.encoding_type(), EncodingType::LcidBased(_)) + { + return Some(s.bytes.clone()); + } + Some(crate::api::ansi::encode(&to_cell(value)?.to_text())) +} + +/// Maps a column value to the `SQL_C_*` code msodbcsql reports through +/// `SQL_CA_SS_VARIANT_TYPE` for a `sql_variant` column. +pub(crate) fn variant_c_type(value: &ColumnValues) -> SqlSmallInt { + match value { + ColumnValues::TinyInt(_) => SQL_C_UTINYINT, + ColumnValues::SmallInt(_) => SQL_C_SSHORT, + ColumnValues::Int(_) => SQL_C_SLONG, + ColumnValues::BigInt(_) => SQL_C_SBIGINT, + ColumnValues::Real(_) => SQL_C_FLOAT, + ColumnValues::Float(_) => SQL_C_DOUBLE, + ColumnValues::Bit(_) => SQL_C_BIT, + ColumnValues::Decimal(_) + | ColumnValues::Numeric(_) + | ColumnValues::Money(_) + | ColumnValues::SmallMoney(_) => SQL_C_NUMERIC, + ColumnValues::Bytes(_) => SQL_C_BINARY, + ColumnValues::Uuid(_) => SQL_C_GUID, + ColumnValues::Date(_) => SQL_C_TYPE_DATE, + ColumnValues::Time(_) => SQL_C_TYPE_TIME, + ColumnValues::DateTime2(_) | ColumnValues::DateTime(_) | ColumnValues::SmallDateTime(_) => { + SQL_C_TYPE_TIMESTAMP + } + ColumnValues::String(s) if s.is_utf16() => SQL_C_WCHAR, + ColumnValues::String(_) => SQL_C_CHAR, + _ => SQL_C_WCHAR, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use mssql_tds::datatypes::column_values::{SqlDate, SqlTime}; + use mssql_tds::datatypes::sql_string::SqlString; + + fn write( + value: &ColumnValues, + ctype: SqlSmallInt, + out: &mut T, + ind: &mut SqlLen, + ) -> WriteOutcome { + unsafe { + write_c_value( + value, + ctype, + (out as *mut T).cast(), + std::mem::size_of::() as SqlLen, + ind, + ) + } + .expect("conversion should succeed") + } + + #[test] + fn int_to_slong() { + let mut out: i32 = 0; + let mut ind: SqlLen = 0; + assert_eq!( + write(&ColumnValues::Int(42), SQL_C_SLONG, &mut out, &mut ind), + WriteOutcome::Complete + ); + assert_eq!(out, 42); + assert_eq!(ind, 4); + } + + #[test] + fn bigint_to_slong_out_of_range() { + let mut out: i32 = 0; + let err = unsafe { + write_c_value( + &ColumnValues::BigInt(i64::MAX), + SQL_C_SLONG, + (&mut out as *mut i32).cast(), + 4, + std::ptr::null_mut(), + ) + }; + assert_eq!(err, Err(WriteError::OutOfRange)); + } + + #[test] + fn bit_to_bit() { + let mut out: u8 = 9; + let mut ind: SqlLen = 0; + write(&ColumnValues::Bit(true), SQL_C_BIT, &mut out, &mut ind); + assert_eq!(out, 1); + } + + #[test] + fn float_to_double() { + let mut out: f64 = 0.0; + let mut ind: SqlLen = 0; + write(&ColumnValues::Float(1.5), SQL_C_DOUBLE, &mut out, &mut ind); + assert!((out - 1.5).abs() < f64::EPSILON); + } + + #[test] + fn date_to_date_struct() { + // 1970-01-01 is day 719162 since 0001-01-01. + let value = ColumnValues::Date(SqlDate::create(719_162).unwrap()); + let mut out = SqlDateStruct::default(); + let mut ind: SqlLen = 0; + write(&value, SQL_C_TYPE_DATE, &mut out, &mut ind); + assert_eq!( + out, + SqlDateStruct { + year: 1970, + month: 1, + day: 1 + } + ); + } + + #[test] + fn time_to_ss_time2() { + // The TDS field counts 100-nanosecond ticks, and SQL_SS_TIME2 reports + // nanoseconds. + let value = ColumnValues::Time(SqlTime { + time_nanoseconds: (13 * 3600 + 45 * 60 + 7) * 10_000_000 + 1_234_567, + scale: 7, + }); + let mut out = SqlSsTime2Struct::default(); + let mut ind: SqlLen = 0; + write(&value, SQL_C_SS_TIME2, &mut out, &mut ind); + assert_eq!(out.hour, 13); + assert_eq!(out.minute, 45); + assert_eq!(out.second, 7); + assert_eq!(out.fraction, 123_456_700); + } + + #[test] + fn string_to_wchar_truncates() { + let value = ColumnValues::String(SqlString::from_utf8_string("abcdef".into())); + let mut buf = [0u16; 3]; + let mut ind: SqlLen = 0; + let outcome = unsafe { + write_c_value( + &value, + SQL_C_WCHAR, + buf.as_mut_ptr().cast(), + (buf.len() * 2) as SqlLen, + &mut ind, + ) + } + .unwrap(); + assert_eq!(outcome, WriteOutcome::Truncated); + assert_eq!(ind, 12); + assert_eq!(String::from_utf16(&buf[..2]).unwrap(), "ab"); + } + + #[test] + fn bytes_to_binary() { + let value = ColumnValues::Bytes(vec![1, 2, 3]); + let mut buf = [0u8; 8]; + let mut ind: SqlLen = 0; + let outcome = unsafe { + write_c_value( + &value, + SQL_C_BINARY, + buf.as_mut_ptr().cast(), + buf.len() as SqlLen, + &mut ind, + ) + } + .unwrap(); + assert_eq!(outcome, WriteOutcome::Complete); + assert_eq!(ind, 3); + assert_eq!(&buf[..3], &[1, 2, 3]); + } + + #[test] + fn null_writes_indicator() { + let mut buf = [0u8; 4]; + let mut ind: SqlLen = 0; + unsafe { + write_c_value( + &ColumnValues::Null, + SQL_C_CHAR, + buf.as_mut_ptr().cast(), + 4, + &mut ind, + ) + } + .unwrap(); + assert_eq!(ind, SQL_NULL_DATA); + } + + #[test] + fn unsupported_ctype_is_rejected() { + let err = unsafe { + write_c_value( + &ColumnValues::Int(1), + 12345, + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + ) + }; + assert_eq!(err, Err(WriteError::InvalidCType)); + } +} diff --git a/mssql-odbc/src/api/close_cursor.rs b/mssql-odbc/src/api/close_cursor.rs index 5ae54825..305eb9f5 100644 --- a/mssql-odbc/src/api/close_cursor.rs +++ b/mssql-odbc/src/api/close_cursor.rs @@ -131,7 +131,7 @@ fn sql_free_stmt_close_safe(statement_handle: SqlHandle, stmt: &StmtHandle) -> S /// Resets cursor state on the statement (cursor is no longer open, metadata cleared). pub(super) fn reset_cursor_state(stmt_state: &mut crate::handles::stmt::StmtState) { stmt_state.clear_state(STMT_STATE_CURSOR_OPEN | STMT_STATE_EXEC_CONTEXT); - stmt_state.current_row = None; + stmt_state.reset_rows(); stmt_state.column_metadata.clear(); stmt_state.pending_row_counts.clear(); } diff --git a/mssql-odbc/src/api/col_attribute.rs b/mssql-odbc/src/api/col_attribute.rs new file mode 100644 index 00000000..e72dccd6 --- /dev/null +++ b/mssql-odbc/src/api/col_attribute.rs @@ -0,0 +1,268 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Implementation of SQLColAttributeW — per-column descriptor field access. + +use tracing::{debug, error}; + +use super::cdata::variant_c_type; +use super::describe_col::{column_size, decimal_digits, odbc_sql_type}; +use super::odbc_types::*; +use super::sqlstate::{ + ERR_FUNCTION_SEQUENCE, ERR_INVALID_DESCRIPTOR_INDEX, ERR_STRING_RIGHT_TRUNCATION, + SQLSTATE_HY091, post_diag, +}; +use super::util::{copy_with_nul, write_if_some}; +use crate::error::{free_errors, post_sql_error}; +use crate::handles::stmt::STMT_STATE_EXEC_CONTEXT; +use crate::handles::{HandleType, StmtHandle, handle_from_raw}; + +/// Implements `SQLColAttributeW`. +/// +/// Numeric attributes are returned through `numeric_attribute_ptr`, character +/// attributes through `character_attribute_ptr`. Unknown identifiers produce +/// SQLSTATE HY091, matching msodbcsql. +/// +/// # Safety +/// `statement_handle` must be a valid `StmtHandle` or null; the output +/// pointers, when non-null, must be writable for the sizes implied by +/// `buffer_length`. +pub(crate) unsafe fn sql_col_attribute_w( + statement_handle: SqlHandle, + column_number: SqlUSmallInt, + field_identifier: SqlUSmallInt, + character_attribute_ptr: SqlPointer, + buffer_length: SqlSmallInt, + string_length_ptr: *mut SqlSmallInt, + numeric_attribute_ptr: *mut SqlLen, +) -> SqlReturn { + debug!( + ?statement_handle, + column_number, field_identifier, "SQLColAttributeW called" + ); + crate::ffi_entry!("SQLColAttributeW", unsafe { + sql_col_attribute_w_impl( + statement_handle, + column_number, + field_identifier, + character_attribute_ptr, + buffer_length, + string_length_ptr, + numeric_attribute_ptr, + ) + }) +} + +unsafe fn sql_col_attribute_w_impl( + statement_handle: SqlHandle, + column_number: SqlUSmallInt, + field_identifier: SqlUSmallInt, + character_attribute_ptr: SqlPointer, + buffer_length: SqlSmallInt, + string_length_ptr: *mut SqlSmallInt, + numeric_attribute_ptr: *mut SqlLen, +) -> SqlReturn { + if statement_handle.is_null() { + error!("SQLColAttributeW: statement_handle is null"); + return SQL_INVALID_HANDLE; + } + let stmt = unsafe { handle_from_raw::(statement_handle) }; + debug_assert_eq!(stmt.object_type, HandleType::Stmt); + + let Ok(mut state) = stmt.inner.lock() else { + error!("SQLColAttributeW: stmt mutex poisoned"); + return SQL_ERROR; + }; + free_errors(&mut state); + + if !state.has_state(STMT_STATE_EXEC_CONTEXT) { + post_diag(&mut state, ERR_FUNCTION_SEQUENCE); + return SQL_ERROR; + } + if field_identifier == SQL_DESC_COUNT { + unsafe { write_if_some(numeric_attribute_ptr, state.column_metadata.len() as SqlLen) }; + return SQL_SUCCESS; + } + + if column_number == 0 || usize::from(column_number) > state.column_metadata.len() { + post_diag(&mut state, ERR_INVALID_DESCRIPTOR_INDEX); + return SQL_ERROR; + } + + let meta = &state.column_metadata[usize::from(column_number) - 1]; + let sql_type = odbc_sql_type(meta); + + // Character-valued fields. + let text: Option = match field_identifier { + SQL_DESC_NAME | SQL_DESC_LABEL | SQL_DESC_BASE_COLUMN_NAME | SQL_COLUMN_NAME => { + Some(meta.column_name.clone()) + } + SQL_DESC_TYPE_NAME => Some(type_name(sql_type).to_string()), + SQL_DESC_TABLE_NAME + | SQL_DESC_SCHEMA_NAME + | SQL_DESC_CATALOG_NAME + | SQL_DESC_BASE_TABLE_NAME + | SQL_DESC_LITERAL_PREFIX + | SQL_DESC_LITERAL_SUFFIX + | SQL_DESC_LOCAL_TYPE_NAME => Some(String::new()), + _ => None, + }; + + if let Some(text) = text { + let utf16: Vec = text.encode_utf16().collect(); + let byte_len = (utf16.len() * std::mem::size_of::()) as SqlSmallInt; + unsafe { write_if_some(string_length_ptr, byte_len) }; + let capacity = (buffer_length.max(0) as usize) / std::mem::size_of::(); + let truncated = + unsafe { copy_with_nul(character_attribute_ptr as *mut SqlWChar, capacity, &utf16) }; + return if truncated { + post_diag(&mut state, ERR_STRING_RIGHT_TRUNCATION); + SQL_SUCCESS_WITH_INFO + } else { + SQL_SUCCESS + }; + } + + let numeric: SqlLen = match field_identifier { + // sql_variant columns report the C type of the value in the current row; + // clients probe this to pick the right SQLGetData target type. + SQL_CA_SS_VARIANT_TYPE => state + .current_row + .as_ref() + .and_then(|row| row.get(usize::from(column_number) - 1)) + .map_or(SqlLen::from(SQL_C_WCHAR), |v| { + SqlLen::from(variant_c_type(v)) + }), + SQL_DESC_TYPE | SQL_DESC_CONCISE_TYPE => SqlLen::from(sql_type), + SQL_DESC_LENGTH | SQL_DESC_DISPLAY_SIZE | SQL_DESC_OCTET_LENGTH | SQL_COLUMN_LENGTH => { + column_size(meta) as SqlLen + } + SQL_DESC_PRECISION | SQL_COLUMN_PRECISION => column_size(meta) as SqlLen, + SQL_DESC_SCALE | SQL_COLUMN_SCALE => SqlLen::from(decimal_digits(meta)), + SQL_DESC_NULLABLE => SqlLen::from(if meta.is_nullable() { + SQL_NULLABLE + } else { + SQL_NO_NULLS + }), + SQL_DESC_UNNAMED => SqlLen::from(meta.column_name.is_empty()), + SQL_DESC_UNSIGNED => SqlLen::from(sql_type == SQL_TINYINT), + SQL_DESC_CASE_SENSITIVE => 0, + SQL_DESC_FIXED_PREC_SCALE => 0, + SQL_DESC_AUTO_UNIQUE_VALUE => 0, + SQL_DESC_UPDATABLE => 1, + SQL_DESC_SEARCHABLE => 3, + SQL_DESC_NUM_PREC_RADIX => { + if matches!(sql_type, SQL_REAL | SQL_DOUBLE | SQL_FLOAT) { + 2 + } else { + 10 + } + } + _ => { + post_sql_error( + &mut state, + SQLSTATE_HY091, + 0, + "Invalid descriptor field identifier", + ); + return SQL_ERROR; + } + }; + + unsafe { write_if_some(numeric_attribute_ptr, numeric) }; + SQL_SUCCESS +} + +fn type_name(sql_type: SqlSmallInt) -> &'static str { + match sql_type { + SQL_TINYINT => "tinyint", + SQL_SMALLINT => "smallint", + SQL_INTEGER => "int", + SQL_BIGINT => "bigint", + SQL_BIT => "bit", + SQL_REAL => "real", + SQL_DOUBLE | SQL_FLOAT => "float", + SQL_DECIMAL => "decimal", + SQL_NUMERIC => "numeric", + SQL_GUID => "uniqueidentifier", + SQL_BINARY => "binary", + SQL_VARBINARY => "varbinary", + SQL_LONGVARBINARY => "image", + SQL_CHAR => "char", + SQL_VARCHAR => "varchar", + SQL_LONGVARCHAR => "text", + SQL_WCHAR => "nchar", + SQL_WVARCHAR => "nvarchar", + SQL_WLONGVARCHAR => "ntext", + SQL_TYPE_DATE => "date", + SQL_TYPE_TIME => "time", + SQL_TYPE_TIMESTAMP => "datetime2", + _ => "unknown", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::odbc_types::SQL_NULL_HANDLE; + use crate::test_support::TestHandles; + + #[test] + fn col_attribute_null_handle() { + let ret = unsafe { + sql_col_attribute_w( + SQL_NULL_HANDLE, + 1, + SQL_DESC_COUNT, + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }; + assert_eq!(ret, SQL_INVALID_HANDLE); + } + + #[test] + fn col_attribute_without_exec_context_is_sequence_error() { + let h = TestHandles::with_env_dbc_stmt(); + let mut num: SqlLen = 0; + let ret = unsafe { + sql_col_attribute_w( + h.stmt, + 1, + SQL_DESC_COUNT, + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + &mut num, + ) + }; + assert_eq!(ret, SQL_ERROR); + } + + #[test] + fn col_attribute_count_allows_header_record() { + let h = TestHandles::with_env_dbc_stmt(); + let stmt = unsafe { handle_from_raw::(h.stmt) }; + stmt.inner + .lock() + .unwrap() + .set_state(STMT_STATE_EXEC_CONTEXT); + + let mut num: SqlLen = -1; + let ret = unsafe { + sql_col_attribute_w( + h.stmt, + 0, + SQL_DESC_COUNT, + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + &mut num, + ) + }; + assert_eq!(ret, SQL_SUCCESS); + assert_eq!(num, 0); + } +} diff --git a/mssql-odbc/src/api/conn_exec.rs b/mssql-odbc/src/api/conn_exec.rs new file mode 100644 index 00000000..b83379c6 --- /dev/null +++ b/mssql-odbc/src/api/conn_exec.rs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Connection-level SQL execution. +//! +//! Transaction control (`SQLEndTran`) and the connection attributes that map +//! onto `SET` statements (`SQL_ATTR_AUTOCOMMIT`, `SQL_ATTR_TXN_ISOLATION`) need +//! to run a batch without an application statement handle. These helpers borrow +//! the connection's TDS client the same way `exec_common` does for a STMT, so +//! the "one active statement per connection" invariant still holds and no lock +//! is held across network I/O. + +use tracing::error; + +use super::sqlstate::*; +use crate::api::odbc_types::{SQL_ERROR, SqlReturn}; +use crate::handles::DbcHandle; +use crate::handles::dbc::ConnectionState; + +/// Runs `sql` as a plain batch on the connection and drains the response. +/// +/// Returns `Err(SQL_ERROR)` with a diagnostic posted on the connection when the +/// connection is not usable or the server rejects the batch. +pub(crate) fn exec_on_connection(dbc: &DbcHandle, sql: &str, op: &str) -> Result<(), SqlReturn> { + // Claim marker for `active_stmt`: the DBC's own address is a stable, + // non-null value that can never collide with a real STMT handle. + let claim = std::ptr::from_ref(dbc) as *mut std::ffi::c_void; + + let mut client = { + let Ok(mut state) = dbc.inner.lock() else { + error!("{op}: dbc mutex poisoned"); + return Err(SQL_ERROR); + }; + if state.connection_state != ConnectionState::Connected { + error!("{op}: connection is not open"); + post_diag(&mut state, ERR_CONNECTION_DOES_NOT_EXIST); + return Err(SQL_ERROR); + } + // A statement holding an open cursor would otherwise block transaction + // control and the `SET`-backed connection attributes forever. Spill its + // remaining rows so the connection goes idle, which is what a + // non-MARS session requires before another batch can run. + if let Some(busy_stmt) = state.active_stmt { + drop(state); + if !crate::api::spill::try_release_connection(dbc, busy_stmt) { + let Ok(mut state) = dbc.inner.lock() else { + error!("{op}: dbc mutex poisoned"); + return Err(SQL_ERROR); + }; + error!("{op}: connection is busy with another statement's results"); + post_diag(&mut state, ERR_CONNECTION_BUSY); + return Err(SQL_ERROR); + } + let Ok(reacquired) = dbc.inner.lock() else { + error!("{op}: dbc mutex poisoned"); + return Err(SQL_ERROR); + }; + state = reacquired; + } + let Some(client) = state.client.take() else { + error!("{op}: no active TDS client"); + post_diag(&mut state, ERR_NO_ACTIVE_TDS_CLIENT); + return Err(SQL_ERROR); + }; + state.active_stmt = Some(claim); + client + }; + + let result = dbc.runtime.block_on(async { + client.execute(sql.to_string(), ()).await?; + client.close_query().await + }); + + let info_messages = client.take_info_messages(); + + let Ok(mut state) = dbc.inner.lock() else { + error!("{op}: dbc mutex poisoned returning client"); + return Err(SQL_ERROR); + }; + state.client = Some(client); + if state.active_stmt == Some(claim) { + state.active_stmt = None; + } + post_tds_info_messages(&mut state, &info_messages); + + match result { + Ok(()) => Ok(()), + Err(e) => { + error!(%e, "{op}: connection-level batch failed"); + post_tds_error(&mut state, &e, SQLSTATE_HY000); + Err(SQL_ERROR) + } + } +} diff --git a/mssql-odbc/src/api/dae.rs b/mssql-odbc/src/api/dae.rs new file mode 100644 index 00000000..712cbb6b --- /dev/null +++ b/mssql-odbc/src/api/dae.rs @@ -0,0 +1,338 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Data-at-execution (DAE) parameter streaming — `SQLParamData` / `SQLPutData`. +//! +//! When an application binds a parameter whose length/indicator buffer holds +//! `SQL_DATA_AT_EXEC` or `SQL_LEN_DATA_AT_EXEC(n)`, the value is not in the +//! bound buffer. `SQLExecute`/`SQLExecDirect` return `SQL_NEED_DATA`, and the +//! application then drives this loop: +//! +//! ```text +//! while SQLParamData(&token) == SQL_NEED_DATA { +//! SQLPutData(chunk, len); // one or more times +//! } +//! ``` +//! +//! The token handed back is the `ParameterValuePtr` supplied at bind time, +//! which the application uses to identify which parameter is being asked for. +//! Once every hungry parameter has been fed, the final `SQLParamData` performs +//! the execution that was deferred at staging time. + +use tracing::{debug, error}; + +use super::execute::{Execution, run_execution}; +use super::sqlstate::*; +use crate::api::exec_common::build_named_params_with_dae; +use crate::api::odbc_types::{ + SQL_ERROR, SQL_INVALID_HANDLE, SQL_NEED_DATA, SQL_NTS, SQL_NULL_DATA, SqlHandle, SqlLen, + SqlPointer, SqlReturn, +}; +use crate::error::{free_errors, post_sql_error}; +use crate::handles::{StmtHandle, handle_from_raw}; + +/// Implements `SQLParamData`. +/// +/// # Safety +/// - `statement_handle` must be a valid `StmtHandle` or null. +/// - `value_ptr_ptr`, if non-null, must point to one writable `SqlPointer`. +pub(crate) unsafe fn sql_param_data( + statement_handle: SqlHandle, + value_ptr_ptr: *mut SqlPointer, +) -> SqlReturn { + crate::ffi_entry!("SQLParamData", unsafe { + sql_param_data_impl(statement_handle, value_ptr_ptr) + }) +} + +unsafe fn sql_param_data_impl( + statement_handle: SqlHandle, + value_ptr_ptr: *mut SqlPointer, +) -> SqlReturn { + if statement_handle.is_null() { + error!("SQLParamData: statement_handle is null"); + return SQL_INVALID_HANDLE; + } + let stmt = unsafe { handle_from_raw::(statement_handle) }; + + // Either hand out the next hungry parameter, or build the deferred + // execution. Both need the STMT lock; the execution itself must not. + let staged = { + let Ok(mut state) = stmt.inner.lock() else { + error!("SQLParamData: stmt mutex poisoned"); + return SQL_ERROR; + }; + free_errors(&mut state); + + let Some(dae) = state.dae.as_mut() else { + error!("SQLParamData: no data-at-execution sequence in progress"); + post_sql_error( + &mut state, + SQLSTATE_HY010, + 0, + "Function sequence error: SQLParamData called outside a data-at-execution sequence", + ); + return SQL_ERROR; + }; + + if dae.next < dae.order.len() { + let index = dae.order[dae.next]; + dae.next += 1; + dae.current = Some(index); + let token = state + .bound_params + .get(index) + .and_then(|p| p.as_ref()) + .map(|p| p.parameter_value_ptr) + .unwrap_or(std::ptr::null_mut()); + if !value_ptr_ptr.is_null() { + unsafe { *value_ptr_ptr = token }; + } + debug!(index, "SQLParamData: requesting data for parameter"); + return SQL_NEED_DATA; + } + + // Every DAE parameter has been fed — materialize the execution. + let Some(dae) = state.dae.take() else { + return SQL_ERROR; + }; + let named_params = match unsafe { + build_named_params_with_dae(&mut state, dae.marker_count, dae.op, Some(&dae.data)) + } { + Ok(params) => params, + Err(rc) => return rc, + }; + Execution { + rewritten_sql: dae.rewritten_sql, + named_params, + handle: dae.handle, + drop_handle: dae.drop_handle, + } + }; + + run_execution(statement_handle, stmt, staged, "SQLParamData") +} + +/// Implements `SQLPutData`. +/// +/// # Safety +/// - `statement_handle` must be a valid `StmtHandle` or null. +/// - `data_ptr` must be readable for `str_len_or_ind` bytes when that length is +/// positive. +pub(crate) unsafe fn sql_put_data( + statement_handle: SqlHandle, + data_ptr: SqlPointer, + str_len_or_ind: SqlLen, +) -> SqlReturn { + crate::ffi_entry!("SQLPutData", unsafe { + sql_put_data_impl(statement_handle, data_ptr, str_len_or_ind) + }) +} + +unsafe fn sql_put_data_impl( + statement_handle: SqlHandle, + data_ptr: SqlPointer, + str_len_or_ind: SqlLen, +) -> SqlReturn { + if statement_handle.is_null() { + error!("SQLPutData: statement_handle is null"); + return SQL_INVALID_HANDLE; + } + let stmt = unsafe { handle_from_raw::(statement_handle) }; + + let Ok(mut state) = stmt.inner.lock() else { + error!("SQLPutData: stmt mutex poisoned"); + return SQL_ERROR; + }; + free_errors(&mut state); + + let Some(dae) = state.dae.as_mut() else { + error!("SQLPutData: no data-at-execution sequence in progress"); + post_sql_error( + &mut state, + SQLSTATE_HY010, + 0, + "Function sequence error: SQLPutData called outside a data-at-execution sequence", + ); + return SQL_ERROR; + }; + + let Some(index) = dae.current else { + error!("SQLPutData: called before SQLParamData named a parameter"); + post_sql_error( + &mut state, + SQLSTATE_HY010, + 0, + "Function sequence error: SQLPutData called before SQLParamData", + ); + return SQL_ERROR; + }; + + if str_len_or_ind == SQL_NULL_DATA { + dae.data[index] = None; + return crate::api::odbc_types::SQL_SUCCESS; + } + + // A null pointer with a non-NULL indicator is how mssql-python signals a + // `None` value mid-stream; treat it as a zero-length contribution. + if data_ptr.is_null() { + dae.data[index].get_or_insert_with(Vec::new); + return crate::api::odbc_types::SQL_SUCCESS; + } + + let len = if str_len_or_ind == SQL_NTS as SqlLen { + // Null-terminated: the caller did not tell us the width, so treat the + // buffer as a byte string. + let mut n = 0usize; + while unsafe { *(data_ptr as *const u8).add(n) } != 0 { + n += 1; + } + n + } else if str_len_or_ind < 0 { + error!(str_len_or_ind, "SQLPutData: invalid length"); + post_sql_error( + &mut state, + SQLSTATE_HY090, + 0, + "Invalid string or buffer length", + ); + return SQL_ERROR; + } else { + str_len_or_ind as usize + }; + + let chunk = unsafe { std::slice::from_raw_parts(data_ptr as *const u8, len) }; + dae.data[index] + .get_or_insert_with(Vec::new) + .extend_from_slice(chunk); + crate::api::odbc_types::SQL_SUCCESS +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::odbc_types::{SQL_NULL_HANDLE, SQL_SUCCESS}; + use crate::handles::stmt::DaeState; + use crate::test_support::TestHandles; + + fn arm_dae(stmt_raw: SqlHandle, markers: usize, order: Vec) { + let stmt = unsafe { handle_from_raw::(stmt_raw) }; + let mut state = stmt.inner.lock().unwrap(); + state.dae = Some(DaeState { + rewritten_sql: "INSERT INTO t VALUES (@P1)".to_string(), + marker_count: markers, + handle: None, + drop_handle: None, + order, + next: 0, + current: None, + data: vec![None; markers], + op: "SQLExecute", + }); + } + + #[test] + fn null_handle_returns_invalid_handle() { + let mut token: SqlPointer = std::ptr::null_mut(); + assert_eq!( + unsafe { sql_param_data(SQL_NULL_HANDLE, &mut token) }, + SQL_INVALID_HANDLE + ); + assert_eq!( + unsafe { sql_put_data(SQL_NULL_HANDLE, std::ptr::null_mut(), 0) }, + SQL_INVALID_HANDLE + ); + } + + #[test] + fn param_data_without_sequence_posts_hy010() { + let h = TestHandles::with_env_dbc_stmt(); + let mut token: SqlPointer = std::ptr::null_mut(); + assert_eq!(unsafe { sql_param_data(h.stmt, &mut token) }, SQL_ERROR); + let stmt = unsafe { handle_from_raw::(h.stmt) }; + let state = stmt.inner.lock().unwrap(); + assert_eq!(state.diag_records[0].sql_state, SQLSTATE_HY010); + } + + #[test] + fn put_data_before_param_data_posts_hy010() { + let h = TestHandles::with_env_dbc_stmt(); + arm_dae(h.stmt, 1, vec![0]); + let mut buf = *b"abc"; + let ret = unsafe { sql_put_data(h.stmt, buf.as_mut_ptr() as SqlPointer, 3) }; + assert_eq!(ret, SQL_ERROR); + let stmt = unsafe { handle_from_raw::(h.stmt) }; + let state = stmt.inner.lock().unwrap(); + assert_eq!(state.diag_records[0].sql_state, SQLSTATE_HY010); + } + + #[test] + fn param_data_hands_out_each_parameter_then_executes() { + let h = TestHandles::with_env_dbc_stmt(); + arm_dae(h.stmt, 1, vec![0]); + let mut token: SqlPointer = std::ptr::null_mut(); + assert_eq!(unsafe { sql_param_data(h.stmt, &mut token) }, SQL_NEED_DATA); + // Second call has no more hungry parameters, so it attempts execution, + // which fails because the DBC is not connected. + let ret = unsafe { sql_param_data(h.stmt, &mut token) }; + assert_eq!(ret, SQL_ERROR); + } + + #[test] + fn put_data_accumulates_chunks() { + let h = TestHandles::with_env_dbc_stmt(); + arm_dae(h.stmt, 1, vec![0]); + let mut token: SqlPointer = std::ptr::null_mut(); + assert_eq!(unsafe { sql_param_data(h.stmt, &mut token) }, SQL_NEED_DATA); + + let mut a = *b"abc"; + let mut b = *b"de"; + assert_eq!( + unsafe { sql_put_data(h.stmt, a.as_mut_ptr() as SqlPointer, 3) }, + SQL_SUCCESS + ); + assert_eq!( + unsafe { sql_put_data(h.stmt, b.as_mut_ptr() as SqlPointer, 2) }, + SQL_SUCCESS + ); + + let stmt = unsafe { handle_from_raw::(h.stmt) }; + let state = stmt.inner.lock().unwrap(); + let dae = state.dae.as_ref().unwrap(); + assert_eq!(dae.data[0].as_deref(), Some(&b"abcde"[..])); + } + + #[test] + fn put_data_null_indicator_marks_null() { + let h = TestHandles::with_env_dbc_stmt(); + arm_dae(h.stmt, 1, vec![0]); + let mut token: SqlPointer = std::ptr::null_mut(); + assert_eq!(unsafe { sql_param_data(h.stmt, &mut token) }, SQL_NEED_DATA); + let mut a = *b"abc"; + assert_eq!( + unsafe { sql_put_data(h.stmt, a.as_mut_ptr() as SqlPointer, 3) }, + SQL_SUCCESS + ); + assert_eq!( + unsafe { sql_put_data(h.stmt, std::ptr::null_mut(), SQL_NULL_DATA) }, + SQL_SUCCESS + ); + let stmt = unsafe { handle_from_raw::(h.stmt) }; + let state = stmt.inner.lock().unwrap(); + assert!(state.dae.as_ref().unwrap().data[0].is_none()); + } + + #[test] + fn put_data_negative_length_posts_hy090() { + let h = TestHandles::with_env_dbc_stmt(); + arm_dae(h.stmt, 1, vec![0]); + let mut token: SqlPointer = std::ptr::null_mut(); + assert_eq!(unsafe { sql_param_data(h.stmt, &mut token) }, SQL_NEED_DATA); + let mut a = *b"abc"; + let ret = unsafe { sql_put_data(h.stmt, a.as_mut_ptr() as SqlPointer, -7) }; + assert_eq!(ret, SQL_ERROR); + let stmt = unsafe { handle_from_raw::(h.stmt) }; + let state = stmt.inner.lock().unwrap(); + assert_eq!(state.diag_records[0].sql_state, SQLSTATE_HY090); + } +} diff --git a/mssql-odbc/src/api/desc.rs b/mssql-odbc/src/api/desc.rs new file mode 100644 index 00000000..cc49ad18 --- /dev/null +++ b/mssql-odbc/src/api/desc.rs @@ -0,0 +1,441 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Descriptor and parameter-metadata entry points. +//! +//! These are the minimum viable implementations required by applications that +//! bind `SQL_C_NUMERIC` parameters (which must set precision/scale on the APD) +//! or probe parameter types before binding. + +use tracing::{debug, error}; + +use super::odbc_types::*; +use super::sqlstate::{SQLSTATE_07009, SQLSTATE_HY091}; +use crate::error::{free_errors, post_sql_error}; +use crate::handles::{HandleType, StmtHandle, handle_from_raw}; + +/// Implements `SQLSetDescFieldW`. +/// +/// The driver exposes implicit descriptors only, and the APD fields an +/// application sets for `SQL_C_NUMERIC` binding (type, precision, scale, data +/// pointer) are already captured by `SQLBindParameter`. Accepting them keeps +/// numeric binding working; anything else is reported as an unknown field so +/// callers are not silently misled. +/// +/// # Safety +/// `descriptor_handle` must be a valid handle produced by +/// `SQLGetStmtAttr(SQL_ATTR_APP_PARAM_DESC)` — which this driver reports as the +/// statement handle itself — or null. +pub(crate) unsafe fn sql_set_desc_field_w( + descriptor_handle: SqlHandle, + record_number: SqlSmallInt, + field_identifier: SqlSmallInt, + _value_ptr: SqlPointer, + _buffer_length: SqlInteger, +) -> SqlReturn { + debug!( + ?descriptor_handle, + record_number, field_identifier, "SQLSetDescFieldW called" + ); + crate::ffi_entry!("SQLSetDescFieldW", unsafe { + if descriptor_handle.is_null() { + error!("SQLSetDescFieldW: descriptor_handle is null"); + return SQL_INVALID_HANDLE; + } + let field = field_identifier as SqlUSmallInt; + if matches!( + field, + SQL_DESC_TYPE + | SQL_DESC_CONCISE_TYPE + | SQL_DESC_PRECISION + | SQL_DESC_SCALE + | SQL_DESC_DATA_PTR + | SQL_DESC_LENGTH + | SQL_DESC_OCTET_LENGTH + ) { + return SQL_SUCCESS; + } + + let stmt = handle_from_raw::(descriptor_handle); + if stmt.object_type != HandleType::Stmt { + return SQL_INVALID_HANDLE; + } + let Ok(mut state) = stmt.inner.lock() else { + error!("SQLSetDescFieldW: stmt mutex poisoned"); + return SQL_ERROR; + }; + free_errors(&mut state); + post_sql_error( + &mut state, + SQLSTATE_HY091, + 0, + "Invalid descriptor field identifier", + ); + SQL_ERROR + }) +} + +/// Server-reported parameter metadata, cached per prepared statement. +#[derive(Debug, Clone, Copy)] +pub(crate) struct DescribedParam { + pub(crate) data_type: SqlSmallInt, + pub(crate) parameter_size: SqlULen, + pub(crate) decimal_digits: SqlSmallInt, + pub(crate) nullable: SqlSmallInt, +} + +/// Maps a SQL Server `system_type_id` (plus max length) onto the ODBC SQL type +/// msodbcsql reports for the same column. +fn odbc_type_for_system_type(system_type_id: i64, max_length: i64) -> SqlSmallInt { + match system_type_id { + 34 => SQL_LONGVARBINARY, + 35 => SQL_LONGVARCHAR, + 36 => SQL_GUID, + 40 => SQL_TYPE_DATE, + 41 => SQL_SS_TIME2, + 42 | 58 | 61 => SQL_TYPE_TIMESTAMP, + 43 => SQL_SS_TIMESTAMPOFFSET, + 48 => SQL_TINYINT, + 52 => SQL_SMALLINT, + 56 => SQL_INTEGER, + 59 => SQL_REAL, + 62 => SQL_DOUBLE, + 98 => SQL_SS_VARIANT, + 99 => SQL_WLONGVARCHAR, + 104 => SQL_BIT, + // money and smallmoney surface as fixed-scale decimals. + 60 | 106 | 122 => SQL_DECIMAL, + 108 => SQL_NUMERIC, + 127 => SQL_BIGINT, + // `varbinary(max)` / `varchar(max)` / `nvarchar(max)` report a max + // length of -1 and are the long variants. + 165 if max_length < 0 => SQL_LONGVARBINARY, + 165 => SQL_VARBINARY, + 167 if max_length < 0 => SQL_LONGVARCHAR, + 167 => SQL_VARCHAR, + 173 => SQL_BINARY, + 175 => SQL_CHAR, + 231 if max_length < 0 => SQL_WLONGVARCHAR, + 231 => SQL_WVARCHAR, + 239 => SQL_WCHAR, + 240 => SQL_SS_UDT, + 241 => SQL_WLONGVARCHAR, + _ => SQL_WVARCHAR, + } +} + +/// Derives the ODBC column size from the server's `max_length`/`precision`. +fn describe_size(data_type: SqlSmallInt, max_length: i64, precision: i64) -> SqlULen { + // `max_length = -1` marks a MAX type, which ODBC reports as size 0. + if max_length < 0 { + return 0; + } + let size = match data_type { + SQL_DECIMAL | SQL_NUMERIC => precision, + // Wide types report bytes; ODBC column size counts characters. + SQL_WCHAR | SQL_WVARCHAR | SQL_WLONGVARCHAR => max_length / 2, + _ => max_length, + }; + SqlULen::try_from(size.max(0)).unwrap_or(0) +} + +/// Runs `sp_describe_undeclared_parameters` for the statement's prepared text +/// and caches the result. +/// +/// Returns `None` when the statement has no prepared text, when the connection +/// is not idle, or when the server cannot describe the batch — all of which are +/// legitimate (temp tables and table variables are not describable), and +/// callers fall back to their own type inference. +fn fetch_described_params(stmt: &StmtHandle) -> Option> { + let sql = { + let state = stmt.inner.lock().ok()?; + state.prepared_sql.clone()? + }; + let (rewritten, marker_count) = crate::api::util::rewrite_param_markers(&sql); + if marker_count == 0 { + return Some(Vec::new()); + } + + let dbc = stmt.parent_dbc(); + let stmt_ptr: SqlHandle = (stmt as *const StmtHandle as *mut StmtHandle).cast(); + let mut client = crate::api::exec_common::try_claim_idle_client(dbc, stmt_ptr)?; + + let probe = format!( + "EXEC sys.sp_describe_undeclared_parameters @tsql = N'{}'", + rewritten.replace('\'', "''") + ); + + let described = describe_with_client(dbc, &mut client, probe, marker_count); + crate::api::exec_common::return_client_idle(dbc, stmt_ptr, client); + described +} + +/// Reads the probe batch to completion so the connection is left in sync. +/// +/// A describe probe fails routinely — temp tables and table variables are not +/// describable — and the server still writes a full response for the batch. +/// Leaving those tokens unread desynchronises the TDS stream, so the next +/// statement on the connection would decode garbage. Returns whether the batch +/// drained cleanly. +fn drain_batch( + dbc: &crate::handles::DbcHandle, + client: &mut mssql_tds::connection::tds_client::TdsClient, +) -> bool { + use mssql_tds::connection::tds_client::{ResultSet, StatementResult}; + + // Consume any rows the current result set still holds before advancing. + loop { + match dbc.runtime.block_on(client.next_row()) { + Ok(Some(_)) => {} + Ok(None) => break, + Err(e) => { + debug!(%e, "SQLDescribeParam: draining probe rows failed"); + return false; + } + } + } + while client.has_open_batch() { + match dbc.runtime.block_on(client.advance()) { + Ok(StatementResult::End) => return true, + Ok(_) => {} + Err(e) => { + debug!(%e, "SQLDescribeParam: draining the probe batch failed"); + return false; + } + } + } + true +} + +/// Executes the probe batch and folds its rows into parameter metadata. +fn describe_with_client( + dbc: &crate::handles::DbcHandle, + client: &mut mssql_tds::connection::tds_client::TdsClient, + probe: String, + marker_count: usize, +) -> Option> { + use crate::api::cdata::{Cell, to_cell}; + use mssql_tds::connection::tds_client::ResultSet; + + if let Err(e) = dbc.runtime.block_on(client.execute(probe, ())) { + debug!(%e, "SQLDescribeParam: server could not describe the batch"); + drain_batch(dbc, client); + return None; + } + if !client.on_rows() + && client.has_open_batch() + && let Err(e) = dbc.runtime.block_on(client.advance_to_rows()) + { + debug!(%e, "SQLDescribeParam: no describe result set"); + drain_batch(dbc, client); + return None; + } + + let mut described = vec![ + DescribedParam { + data_type: SQL_WVARCHAR, + parameter_size: 0, + decimal_digits: 0, + nullable: SQL_NULLABLE, + }; + marker_count + ]; + + let mut failed = false; + loop { + match dbc.runtime.block_on(client.next_row()) { + Ok(Some(row)) => { + let num = |idx: usize| -> i64 { + row.get(idx) + .and_then(to_cell) + .and_then(|c: Cell| c.as_i64()) + .unwrap_or(0) + }; + let ordinal = num(COL_PARAMETER_ORDINAL); + let Ok(index) = usize::try_from(ordinal - 1) else { + continue; + }; + let Some(slot) = described.get_mut(index) else { + continue; + }; + let max_length = num(COL_MAX_LENGTH); + let data_type = odbc_type_for_system_type(num(COL_SYSTEM_TYPE_ID), max_length); + *slot = DescribedParam { + data_type, + parameter_size: describe_size(data_type, max_length, num(COL_PRECISION)), + decimal_digits: SqlSmallInt::try_from(num(COL_SCALE)).unwrap_or(0), + nullable: SQL_NULLABLE, + }; + } + Ok(None) => break, + Err(e) => { + debug!(%e, "SQLDescribeParam: reading describe rows failed"); + failed = true; + break; + } + } + } + + // Drain the rest of the batch so the connection is reusable. + if !failed { + failed = !drain_batch(dbc, client); + } + + if failed { None } else { Some(described) } +} + +/// Column ordinals in the `sp_describe_undeclared_parameters` result set. +const COL_PARAMETER_ORDINAL: usize = 0; +const COL_SYSTEM_TYPE_ID: usize = 2; +const COL_MAX_LENGTH: usize = 4; +const COL_PRECISION: usize = 5; +const COL_SCALE: usize = 6; + +/// Implements `SQLDescribeParam`. +/// +/// Parameter metadata comes from `sp_describe_undeclared_parameters`, the same +/// source msodbcsql uses. The result is cached on the statement because callers +/// describe every parameter in turn and the probe costs a round trip. +/// +/// Batches the server cannot describe — temp tables and table variables, most +/// notably — report `HY000`, which callers treat as "fall back to your own type +/// inference" rather than as a fatal error. +/// +/// # Safety +/// `statement_handle` must be a valid `StmtHandle` or null; the output pointers +/// must each be null or writable for one value of their type. +pub(crate) unsafe fn sql_describe_param( + statement_handle: SqlHandle, + parameter_number: SqlUSmallInt, + data_type_ptr: *mut SqlSmallInt, + parameter_size_ptr: *mut SqlULen, + decimal_digits_ptr: *mut SqlSmallInt, + nullable_ptr: *mut SqlSmallInt, +) -> SqlReturn { + debug!( + ?statement_handle, + parameter_number, "SQLDescribeParam called" + ); + crate::ffi_entry!("SQLDescribeParam", unsafe { + if statement_handle.is_null() { + error!("SQLDescribeParam: statement_handle is null"); + return SQL_INVALID_HANDLE; + } + let stmt = handle_from_raw::(statement_handle); + debug_assert_eq!(stmt.object_type, HandleType::Stmt); + + if let Ok(mut state) = stmt.inner.lock() { + free_errors(&mut state); + } else { + error!("SQLDescribeParam: stmt mutex poisoned"); + return SQL_ERROR; + } + + if parameter_number == 0 { + let Ok(mut state) = stmt.inner.lock() else { + return SQL_ERROR; + }; + post_sql_error(&mut state, SQLSTATE_07009, 0, "Invalid descriptor index"); + return SQL_ERROR; + } + + let cached = match stmt.inner.lock() { + Ok(state) => state.described_params.clone(), + Err(_) => return SQL_ERROR, + }; + let described = match cached { + Some(described) => described, + None => { + let Some(described) = fetch_described_params(stmt) else { + let Ok(mut state) = stmt.inner.lock() else { + return SQL_ERROR; + }; + post_sql_error( + &mut state, + super::sqlstate::SQLSTATE_HY000, + 0, + "The server could not describe the statement's parameters", + ); + return SQL_ERROR; + }; + if let Ok(mut state) = stmt.inner.lock() { + state.described_params = Some(described.clone()); + } + described + } + }; + + let Some(info) = described.get(parameter_number as usize - 1).copied() else { + let Ok(mut state) = stmt.inner.lock() else { + return SQL_ERROR; + }; + post_sql_error(&mut state, SQLSTATE_07009, 0, "Invalid descriptor index"); + return SQL_ERROR; + }; + + if !data_type_ptr.is_null() { + data_type_ptr.write(info.data_type); + } + if !parameter_size_ptr.is_null() { + parameter_size_ptr.write(info.parameter_size); + } + if !decimal_digits_ptr.is_null() { + decimal_digits_ptr.write(info.decimal_digits); + } + if !nullable_ptr.is_null() { + nullable_ptr.write(info.nullable); + } + SQL_SUCCESS + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::TestHandles; + + #[test] + fn set_desc_field_accepts_apd_numeric_fields() { + let h = TestHandles::with_env_dbc_stmt(); + let ret = unsafe { + sql_set_desc_field_w( + h.stmt, + 1, + SQL_DESC_PRECISION as SqlSmallInt, + std::ptr::null_mut(), + 0, + ) + }; + assert_eq!(ret, SQL_SUCCESS); + } + + #[test] + fn set_desc_field_rejects_unknown_field() { + let h = TestHandles::with_env_dbc_stmt(); + let ret = unsafe { sql_set_desc_field_w(h.stmt, 1, 9999, std::ptr::null_mut(), 0) }; + assert_eq!(ret, SQL_ERROR); + } + + #[test] + fn set_desc_field_null_handle() { + let ret = + unsafe { sql_set_desc_field_w(SQL_NULL_HANDLE, 1, 1002, std::ptr::null_mut(), 0) }; + assert_eq!(ret, SQL_INVALID_HANDLE); + } + + #[test] + fn describe_param_reports_unsupported() { + let h = TestHandles::with_env_dbc_stmt(); + let ret = unsafe { + sql_describe_param( + h.stmt, + 1, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }; + assert_eq!(ret, SQL_ERROR); + } +} diff --git a/mssql-odbc/src/api/describe_col.rs b/mssql-odbc/src/api/describe_col.rs index 9f22243d..cac42ded 100644 --- a/mssql-odbc/src/api/describe_col.rs +++ b/mssql-odbc/src/api/describe_col.rs @@ -9,10 +9,10 @@ use tracing::{debug, error}; use crate::api::odbc_types::{ SQL_BIGINT, SQL_BINARY, SQL_BIT, SQL_CHAR, SQL_DECIMAL, SQL_DOUBLE, SQL_ERROR, SQL_GUID, SQL_INTEGER, SQL_INVALID_HANDLE, SQL_LONGVARBINARY, SQL_LONGVARCHAR, SQL_NO_NULLS, - SQL_NULLABLE, SQL_REAL, SQL_SMALLINT, SQL_SS_TIME2, SQL_SS_TIMESTAMPOFFSET, SQL_SUCCESS, - SQL_SUCCESS_WITH_INFO, SQL_TINYINT, SQL_TYPE_DATE, SQL_TYPE_TIMESTAMP, SQL_UNKNOWN_TYPE, - SQL_VARBINARY, SQL_VARCHAR, SQL_WCHAR, SQL_WLONGVARCHAR, SQL_WVARCHAR, SqlHandle, SqlReturn, - SqlSmallInt, SqlUSmallInt, SqlWChar, + SQL_NULLABLE, SQL_NUMERIC, SQL_REAL, SQL_SMALLINT, SQL_SS_TIME2, SQL_SS_TIMESTAMPOFFSET, + SQL_SS_UDT, SQL_SS_VARIANT, SQL_SUCCESS, SQL_SUCCESS_WITH_INFO, SQL_TINYINT, SQL_TYPE_DATE, + SQL_TYPE_TIMESTAMP, SQL_UNKNOWN_TYPE, SQL_VARBINARY, SQL_VARCHAR, SQL_WCHAR, SQL_WLONGVARCHAR, + SQL_WVARCHAR, SqlHandle, SqlReturn, SqlSmallInt, SqlUSmallInt, SqlWChar, }; use crate::api::sqlstate::{ ERR_FUNCTION_SEQUENCE, ERR_INVALID_DESCRIPTOR_INDEX, ERR_STRING_RIGHT_TRUNCATION, post_diag, @@ -161,7 +161,7 @@ fn sql_describe_col_w_safe( } } -fn odbc_sql_type(meta: &mssql_tds::query::metadata::ColumnMetadata) -> SqlSmallInt { +pub(crate) fn odbc_sql_type(meta: &mssql_tds::query::metadata::ColumnMetadata) -> SqlSmallInt { match meta.data_type { TdsDataType::Int1 => SQL_TINYINT, TdsDataType::Int2 => SQL_SMALLINT, @@ -182,10 +182,11 @@ fn odbc_sql_type(meta: &mssql_tds::query::metadata::ColumnMetadata) -> SqlSmallI 8 => SQL_DOUBLE, _ => SQL_UNKNOWN_TYPE, }, - TdsDataType::Decimal - | TdsDataType::DecimalN - | TdsDataType::Numeric - | TdsDataType::NumericN => SQL_DECIMAL, + TdsDataType::Decimal | TdsDataType::DecimalN => SQL_DECIMAL, + // msodbcsql keeps `numeric` and `decimal` distinct so applications can + // register per-type output converters, even though the two share a wire + // representation. + TdsDataType::Numeric | TdsDataType::NumericN => SQL_NUMERIC, TdsDataType::Money | TdsDataType::Money4 | TdsDataType::MoneyN => SQL_DECIMAL, TdsDataType::DateN => SQL_TYPE_DATE, // SQL Server's `time` supports up to 7-digit fractional seconds; SQL_TYPE_TIME @@ -210,12 +211,23 @@ fn odbc_sql_type(meta: &mssql_tds::query::metadata::ColumnMetadata) -> SqlSmallI TdsDataType::Image => SQL_LONGVARBINARY, TdsDataType::Guid => SQL_GUID, TdsDataType::Xml | TdsDataType::Json => SQL_WLONGVARCHAR, - TdsDataType::Vector | TdsDataType::SsVariant | TdsDataType::Udt => SQL_VARCHAR, + TdsDataType::Vector => SQL_VARCHAR, + // sql_variant and UDTs (hierarchyid, geometry, geography) carry their own + // SQL Server-specific type codes. Reporting them as SQL_VARCHAR would make + // clients ask for SQL_C_CHAR and receive a rendered string instead of the + // typed value / raw UDT bytes. + TdsDataType::SsVariant => SQL_SS_VARIANT, + TdsDataType::Udt => SQL_SS_UDT, _ => SQL_UNKNOWN_TYPE, } } -fn column_size(meta: &mssql_tds::query::metadata::ColumnMetadata) -> u64 { +pub(crate) fn column_size(meta: &mssql_tds::query::metadata::ColumnMetadata) -> u64 { + // UDTs are PLP on the wire but msodbcsql reports their declared max length, + // because clients use a non-zero ColumnSize to tell a bounded UDT from a LOB. + if matches!(meta.data_type, TdsDataType::Udt) { + return u64::try_from(meta.type_info.length).unwrap_or(0); + } // PLP / `*(max)` / xml / json: ColumnSize is "unbounded". Report 0 per ODBC spec if meta.is_plp() { return 0; @@ -282,7 +294,7 @@ fn column_size(meta: &mssql_tds::query::metadata::ColumnMetadata) -> u64 { } } -fn decimal_digits(meta: &mssql_tds::query::metadata::ColumnMetadata) -> SqlSmallInt { +pub(crate) fn decimal_digits(meta: &mssql_tds::query::metadata::ColumnMetadata) -> SqlSmallInt { match meta.data_type { // T-SQL `money` and `smallmoney` both have a fixed scale of 4. They are stored // as FixedLen/VarLen variants without a scale field, so `get_scale()` returns diff --git a/mssql-odbc/src/api/disconnect.rs b/mssql-odbc/src/api/disconnect.rs index f1a76f17..5c57b0bf 100644 --- a/mssql-odbc/src/api/disconnect.rs +++ b/mssql-odbc/src/api/disconnect.rs @@ -6,7 +6,7 @@ use tracing::{debug, error}; use crate::api::odbc_types::{SQL_ERROR, SQL_INVALID_HANDLE, SQL_SUCCESS, SqlHandle, SqlReturn}; -use crate::api::sqlstate::{ERR_CONNECTION_DOES_NOT_EXIST, post_diag}; + use crate::error::free_errors; use crate::handles::DbcHandle; use crate::handles::StmtHandle; @@ -47,9 +47,12 @@ fn sql_disconnect_safe(dbc: &DbcHandle) -> SqlReturn { free_errors(&mut state); if state.connection_state != ConnectionState::Connected { - error!("SQLDisconnect: not connected"); - post_diag(&mut state, ERR_CONNECTION_DOES_NOT_EXIST); - return SQL_ERROR; + // msodbcsql returns SQL_SUCCESS here rather than the spec's 08003. + // Applications call SQLDisconnect from destructors that run while a + // failed-connect exception is unwinding, and an error return makes + // them throw a second exception -> std::terminate. Match msodbcsql. + debug!("SQLDisconnect: not connected — no-op"); + return SQL_SUCCESS; } // TODO: check for active local transaction → post SQLSTATE 25000 @@ -113,10 +116,9 @@ mod tests { let ret = unsafe { sql_alloc_handle(SQL_HANDLE_DBC, env, &mut dbc) }; assert_eq!(ret, SQL_SUCCESS); - // Disconnect without connecting — should error + // Disconnecting an unconnected DBC is a no-op, matching msodbcsql. let ret = unsafe { sql_disconnect(dbc) }; - assert_eq!(ret, SQL_ERROR); - // TODO: verify SQLSTATE 08003 via SQLGetDiagRec + assert_eq!(ret, SQL_SUCCESS); unsafe { sql_free_handle(SQL_HANDLE_DBC, dbc); diff --git a/mssql-odbc/src/api/driver_connect.rs b/mssql-odbc/src/api/driver_connect.rs index 7c0b3400..5df8af0c 100644 --- a/mssql-odbc/src/api/driver_connect.rs +++ b/mssql-odbc/src/api/driver_connect.rs @@ -7,7 +7,8 @@ use tracing::{debug, error}; use crate::api::odbc_types::{ SQL_DRIVER_NOPROMPT, SQL_ERROR, SQL_INVALID_HANDLE, SQL_NTS, SQL_SUCCESS, - SQL_SUCCESS_WITH_INFO, SqlHWnd, SqlHandle, SqlReturn, SqlSmallInt, SqlUSmallInt, SqlWChar, + SQL_SUCCESS_WITH_INFO, SQL_TXN_READ_COMMITTED, SqlHWnd, SqlHandle, SqlReturn, SqlSmallInt, + SqlUSmallInt, SqlWChar, }; use crate::api::sqlstate::{ ERR_FUNCTION_SEQUENCE, ERR_INVALID_CONNECTION_STRING_ATTRIBUTE, ERR_INVALID_NULL_POINTER, @@ -26,6 +27,8 @@ use mssql_tds::core::{EncryptionOptions, EncryptionSetting}; use mssql_tds::message::login_options::ApplicationIntent; use std::path::PathBuf; +use super::conn_exec::exec_on_connection; +use super::set_connect_attr::{apply_autocommit, isolation_level_sql}; use super::util::read_utf16; use crate::auth::configure_auth; use crate::connection::odbc_authentication_transformer::transform_auth; @@ -180,6 +183,30 @@ pub(crate) fn sql_driver_connect_w_safe( if result != SQL_SUCCESS && result != SQL_SUCCESS_WITH_INFO { // Reset state on failure state.connection_state = ConnectionState::Disconnected; + return result; + } + + // Connection attributes set before connecting are session settings that + // only exist once the session does; apply them now. The lock must be + // released first because applying them runs a batch on this connection. + let autocommit = state.autocommit; + let txn_isolation = state.txn_isolation; + drop(state); + if !autocommit && apply_autocommit(dbc, false) == SQL_ERROR { + error!("SQLDriverConnectW: failed to apply pending SQL_ATTR_AUTOCOMMIT"); + return SQL_ERROR; + } + if txn_isolation != SQL_TXN_READ_COMMITTED + && let Some(level) = isolation_level_sql(txn_isolation) + && exec_on_connection( + dbc, + &format!("SET TRANSACTION ISOLATION LEVEL {level}"), + "SQLDriverConnectW", + ) + .is_err() + { + error!("SQLDriverConnectW: failed to apply pending SQL_ATTR_TXN_ISOLATION"); + return SQL_ERROR; } result @@ -349,6 +376,9 @@ const MAX_PACKET_SIZE: u32 = 32768; /// (matching msodbcsql). Kept separate from `do_connect` so the mapping is /// unit-testable without a live server. fn apply_connection_params(context: &mut ClientContext, params: &ConnectionParams) { + if let Some(app) = ¶ms.app { + context.application_name = app.clone(); + } context.encryption_options.host_name_in_cert = params.host_name_in_certificate.clone(); context.encryption_options.server_certificate = params.server_certificate.as_deref().map(PathBuf::from); diff --git a/mssql-odbc/src/api/end_tran.rs b/mssql-odbc/src/api/end_tran.rs new file mode 100644 index 00000000..ec8717a9 --- /dev/null +++ b/mssql-odbc/src/api/end_tran.rs @@ -0,0 +1,192 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Implementation of SQLEndTran — commit or roll back a transaction. +//! +//! The driver models manual-commit mode the way msodbcsql does: it turns on +//! `IMPLICIT_TRANSACTIONS` so the server opens a transaction on the first +//! statement, and `SQLEndTran` then issues `COMMIT`/`ROLLBACK` guarded by +//! `@@TRANCOUNT` so ending a transaction that was never started is a no-op +//! rather than a `3903`/`3902` error. + +use tracing::{debug, error}; + +use super::conn_exec::exec_on_connection; +use super::sqlstate::*; +use crate::api::odbc_types::{ + SQL_COMMIT, SQL_ERROR, SQL_HANDLE_DBC, SQL_HANDLE_ENV, SQL_INVALID_HANDLE, SQL_ROLLBACK, + SQL_SUCCESS, SqlHandle, SqlReturn, SqlSmallInt, +}; +use crate::error::free_errors; +use crate::handles::dbc::ConnectionState; +use crate::handles::{DbcHandle, EnvHandle, HandleType, handle_from_raw}; + +/// Commits or rolls back all open transactions on a connection. +/// +/// # Safety +/// - `handle` must be a valid handle of type `handle_type` (`SQL_HANDLE_DBC` or +/// `SQL_HANDLE_ENV`) allocated by `SQLAllocHandle`. +pub(crate) unsafe fn sql_end_tran( + handle_type: SqlSmallInt, + handle: SqlHandle, + completion_type: SqlSmallInt, +) -> SqlReturn { + debug!(handle_type, ?handle, completion_type, "SQLEndTran called"); + + crate::ffi_entry!("SQLEndTran", unsafe { + sql_end_tran_impl(handle_type, handle, completion_type) + }) +} + +unsafe fn sql_end_tran_impl( + handle_type: SqlSmallInt, + handle: SqlHandle, + completion_type: SqlSmallInt, +) -> SqlReturn { + if handle.is_null() { + error!("SQLEndTran: handle is null"); + return SQL_INVALID_HANDLE; + } + + match handle_type { + SQL_HANDLE_DBC => { + let dbc = unsafe { handle_from_raw::(handle) }; + debug_assert_eq!( + dbc.object_type, + HandleType::Dbc, + "SQLEndTran: handle is not a DBC" + ); + end_tran_on_dbc(dbc, completion_type) + } + SQL_HANDLE_ENV => { + // ODBC allows ending transactions for every connection on an + // environment. Each connection reports its own diagnostics; the + // worst return code wins. + let env = unsafe { handle_from_raw::(handle) }; + debug_assert_eq!( + env.object_type, + HandleType::Env, + "SQLEndTran: handle is not an ENV" + ); + let Ok(env_state) = env.inner.lock() else { + error!("SQLEndTran: env mutex poisoned"); + return SQL_ERROR; + }; + let connections = env_state.connections.clone(); + drop(env_state); + let mut ret = SQL_SUCCESS; + for raw in connections { + let dbc = unsafe { handle_from_raw::(raw) }; + if end_tran_on_dbc(dbc, completion_type) == SQL_ERROR { + ret = SQL_ERROR; + } + } + ret + } + _ => { + error!(handle_type, "SQLEndTran: unsupported handle type"); + SQL_INVALID_HANDLE + } + } +} + +fn end_tran_on_dbc(dbc: &DbcHandle, completion_type: SqlSmallInt) -> SqlReturn { + let verb = match completion_type { + SQL_COMMIT => "COMMIT", + SQL_ROLLBACK => "ROLLBACK", + other => { + error!(other, "SQLEndTran: invalid completion type"); + if let Ok(mut state) = dbc.inner.lock() { + free_errors(&mut state); + post_diag(&mut state, ERR_INVALID_ATTRIBUTE_VALUE); + } + return SQL_ERROR; + } + }; + + let (connected, autocommit) = { + let Ok(mut state) = dbc.inner.lock() else { + error!("SQLEndTran: dbc mutex poisoned"); + return SQL_ERROR; + }; + free_errors(&mut state); + ( + state.connection_state == ConnectionState::Connected, + state.autocommit, + ) + }; + + if !connected { + error!("SQLEndTran: connection is not open"); + if let Ok(mut state) = dbc.inner.lock() { + post_diag(&mut state, ERR_CONNECTION_DOES_NOT_EXIST); + } + return SQL_ERROR; + } + + // In autocommit mode every statement is its own transaction, so there is + // nothing to end. msodbcsql returns success without a round trip. + if autocommit { + debug!("SQLEndTran: autocommit is on — nothing to do"); + return SQL_SUCCESS; + } + + // `IMPLICIT_TRANSACTIONS` only opens a transaction once a statement runs, so + // @@TRANCOUNT can legitimately be 0 here (e.g. commit immediately after + // connect). Guard the verb instead of failing with 3902/3903. + let sql = format!("IF @@TRANCOUNT > 0 {verb} TRANSACTION"); + match exec_on_connection(dbc, &sql, "SQLEndTran") { + Ok(()) => SQL_SUCCESS, + Err(rc) => rc, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::odbc_types::{SQL_HANDLE_STMT, SQL_NULL_HANDLE}; + use crate::test_support::TestHandles; + + #[test] + fn null_handle_returns_invalid_handle() { + let ret = unsafe { sql_end_tran(SQL_HANDLE_DBC, SQL_NULL_HANDLE, SQL_COMMIT) }; + assert_eq!(ret, SQL_INVALID_HANDLE); + } + + #[test] + fn wrong_handle_type_returns_invalid_handle() { + let h = TestHandles::with_env_dbc(); + let ret = unsafe { sql_end_tran(SQL_HANDLE_STMT, h.dbc, SQL_COMMIT) }; + assert_eq!(ret, SQL_INVALID_HANDLE); + } + + #[test] + fn disconnected_connection_returns_error() { + let h = TestHandles::with_env_dbc(); + let ret = unsafe { sql_end_tran(SQL_HANDLE_DBC, h.dbc, SQL_COMMIT) }; + assert_eq!(ret, SQL_ERROR); + let dbc = unsafe { handle_from_raw::(h.dbc) }; + let state = dbc.inner.lock().unwrap(); + assert_eq!(state.diag_records[0].sql_state, SQLSTATE_08003); + } + + #[test] + fn invalid_completion_type_returns_error() { + let h = TestHandles::with_env_dbc(); + h.mark_dbc_connected(); + let ret = unsafe { sql_end_tran(SQL_HANDLE_DBC, h.dbc, 42) }; + assert_eq!(ret, SQL_ERROR); + let dbc = unsafe { handle_from_raw::(h.dbc) }; + let state = dbc.inner.lock().unwrap(); + assert_eq!(state.diag_records[0].sql_state, SQLSTATE_HY024); + } + + #[test] + fn autocommit_commit_is_a_noop() { + let h = TestHandles::with_env_dbc(); + h.mark_dbc_connected(); + // Default autocommit is on, so no client round trip is attempted. + let ret = unsafe { sql_end_tran(SQL_HANDLE_DBC, h.dbc, SQL_COMMIT) }; + assert_eq!(ret, SQL_SUCCESS); + } +} diff --git a/mssql-odbc/src/api/exec_common.rs b/mssql-odbc/src/api/exec_common.rs index d5aa7486..ccb94fe5 100644 --- a/mssql-odbc/src/api/exec_common.rs +++ b/mssql-odbc/src/api/exec_common.rs @@ -23,7 +23,10 @@ use crate::handles::stmt::{ STMT_STATE_CURSOR_OPEN, STMT_STATE_EXEC_CONTEXT, STMT_STATE_EXEC_STARTED, StmtState, }; use crate::handles::{DbcHandle, StmtHandle}; -use crate::params::convert::{ParamConvError, bound_param_to_rpc}; +use crate::params::BoundParam; +use crate::params::convert::{ + ParamConvError, bound_param_to_rpc_with_data, c_type_stride, is_data_at_exec, +}; /// Clears the in-flight `EXEC_STARTED` flag on an execution failure so the /// statement is reusable. @@ -60,13 +63,21 @@ pub(super) fn claim_connection( if let Some(busy_stmt) = dbc_state.active_stmt && busy_stmt != statement_handle { - error!("{op}: connection is busy with results for another statement"); drop(dbc_state); - if let Ok(mut stmt_state) = stmt.inner.lock() { - post_diag(&mut stmt_state, ERR_CONNECTION_BUSY); + if !crate::api::spill::try_release_connection(dbc, busy_stmt) { + error!("{op}: connection is busy with results for another statement"); + if let Ok(mut stmt_state) = stmt.inner.lock() { + post_diag(&mut stmt_state, ERR_CONNECTION_BUSY); + } + clear_exec_started(stmt); + return Err(SQL_ERROR); } - clear_exec_started(stmt); - return Err(SQL_ERROR); + let Ok(state) = dbc.inner.lock() else { + error!("{op}: dbc mutex poisoned"); + clear_exec_started(stmt); + return Err(SQL_ERROR); + }; + dbc_state = state; } // Claim the connection before releasing the lock so concurrent threads see @@ -195,6 +206,55 @@ pub(super) unsafe fn build_named_params( stmt_state: &mut StmtState, marker_count: usize, op: &str, +) -> Result, SqlReturn> { + unsafe { build_named_params_row(stmt_state, marker_count, op, None, 0) } +} + +/// Reports the zero-based indexes of bound parameters whose indicator requests +/// data-at-execution, in ODBC order. +/// +/// # Safety +/// Each bound parameter's indicator pointer must still satisfy the +/// `SQLBindParameter` contract. +pub(super) unsafe fn collect_dae_params(stmt_state: &StmtState, marker_count: usize) -> Vec { + (0..marker_count) + .filter(|&i| { + let Some(Some(param)) = stmt_state.bound_params.get(i) else { + return false; + }; + !param.strlen_or_ind_ptr.is_null() + && is_data_at_exec(unsafe { *param.strlen_or_ind_ptr }) + }) + .collect() +} + +/// Builds the RPC parameter list, substituting values streamed through +/// `SQLPutData` for any parameter that was bound as data-at-execution. +/// +/// # Safety +/// See [`build_named_params`]. +pub(super) unsafe fn build_named_params_with_dae( + stmt_state: &mut StmtState, + marker_count: usize, + op: &str, + dae_data: Option<&[Option>]>, +) -> Result, SqlReturn> { + unsafe { build_named_params_row(stmt_state, marker_count, op, dae_data, 0) } +} + +/// Builds the RPC parameter list for one row of a column-wise parameter array. +/// +/// `row` selects the element within each bound parameter's array; it is `0` for +/// ordinary single-row execution. +/// +/// # Safety +/// See [`build_named_params`]. +pub(super) unsafe fn build_named_params_row( + stmt_state: &mut StmtState, + marker_count: usize, + op: &str, + dae_data: Option<&[Option>]>, + row: usize, ) -> Result, SqlReturn> { let mut named_params = Vec::with_capacity(marker_count); for i in 0..marker_count { @@ -203,8 +263,14 @@ pub(super) unsafe fn build_named_params( post_diag(stmt_state, ERR_UNBOUND_PARAMETER); return Err(SQL_ERROR); }; + let bound_param = offset_bound_param(bound_param, row); + let dae = dae_data.and_then(|d| { + let is_dae = !bound_param.strlen_or_ind_ptr.is_null() + && is_data_at_exec(unsafe { *bound_param.strlen_or_ind_ptr }); + is_dae.then(|| d.get(i).and_then(|v| v.as_deref())) + }); let name = format!("@P{}", i + 1); - match unsafe { bound_param_to_rpc(name, bound_param) } { + match unsafe { bound_param_to_rpc_with_data(name, &bound_param, dae) } { Ok(param) => named_params.push(param), Err(ParamConvError::InvalidLength(len)) => { error!("{op}: parameter {} has invalid StrLen_or_Ind {len}", i + 1); @@ -225,6 +291,23 @@ pub(super) unsafe fn build_named_params( Ok(named_params) } +/// Advances a bound parameter's value and indicator pointers to element `row` +/// of a column-wise parameter array. +fn offset_bound_param(param: &BoundParam, row: usize) -> BoundParam { + let mut param = *param; + if row == 0 { + return param; + } + let stride = c_type_stride(param.c_type, param.sql_type, param.buffer_length); + if !param.parameter_value_ptr.is_null() { + param.parameter_value_ptr = param.parameter_value_ptr.wrapping_byte_add(row * stride); + } + if !param.strlen_or_ind_ptr.is_null() { + param.strlen_or_ind_ptr = param.strlen_or_ind_ptr.wrapping_add(row); + } + param +} + /// Captures the server-side prepared-statement handle from `sp_prepexec`'s /// `@handle` RETURNVALUE once the batch has been drained. /// @@ -328,12 +411,29 @@ pub(super) fn finish_execute( } // Result-bearing query: leave the cursor open for SQLFetch. + // + // msodbcsql does not return from execute until the server has produced the + // first row of the rowset. Errors raised *after* COLMETADATA — lock + // timeouts, mid-scan conversion failures, aborted queries — must therefore + // surface from SQLExecute rather than from the first SQLFetch. Pull one row + // eagerly and hand it to the fetch path through the spill buffer. + let (first_row, prefetched_eof) = match dbc.runtime.block_on(client.next_row()) { + Ok(Some(row)) => (Some(row), false), + Ok(None) => (None, true), + Err(e) => return fail_with_tds(dbc, stmt, statement_handle, client, &e), + }; + let info_messages = client.take_info_messages(); let Ok(mut stmt_state) = stmt.inner.lock() else { error!("{op}: stmt mutex poisoned"); return_client_busy(dbc, client); return SQL_ERROR; }; + stmt_state.reset_rows(); + if let Some(row) = first_row { + stmt_state.buffered_rows.push_back(row); + } + stmt_state.buffered_eof = prefetched_eof; stmt_state.column_metadata = metadata; stmt_state.row_count = client.last_rows_affected(); stmt_state.pending_row_counts.clear(); diff --git a/mssql-odbc/src/api/exec_direct.rs b/mssql-odbc/src/api/exec_direct.rs index 19103ef4..8bd3b55e 100644 --- a/mssql-odbc/src/api/exec_direct.rs +++ b/mssql-odbc/src/api/exec_direct.rs @@ -69,10 +69,11 @@ unsafe fn sql_exec_direct_w_impl( ); let sql = unsafe { read_utf16(statement_text, text_length) }; + let sql = super::util::translate_odbc_escapes(&sql); sql_exec_direct_w_safe(statement_handle, stmt, sql) } -fn sql_exec_direct_w_safe( +pub(crate) fn sql_exec_direct_w_safe( statement_handle: SqlHandle, stmt: &StmtHandle, sql: String, @@ -106,10 +107,11 @@ fn sql_exec_direct_w_safe( // later execute failure cannot expose stale SQLNumResultCols/DescribeCol state. stmt_state.clear_state(STMT_STATE_EXEC_CONTEXT); stmt_state.column_metadata.clear(); - stmt_state.current_row = None; + stmt_state.reset_rows(); stmt_state.row_count = -1; stmt_state.pending_row_counts.clear(); stmt_state.prepared_sql = None; + stmt_state.described_params = None; // Superseding a prepared plan orphans its server handle; release it // (deferred) once we hold the client below. stmt_state.orphan_prepared_handle(); diff --git a/mssql-odbc/src/api/execute.rs b/mssql-odbc/src/api/execute.rs index d2a6d61a..926fbe7a 100644 --- a/mssql-odbc/src/api/execute.rs +++ b/mssql-odbc/src/api/execute.rs @@ -9,13 +9,19 @@ use tracing::{debug, error}; use mssql_tds::connection::tds_client::StatementResult; use mssql_tds::message::parameters::rpc_parameters::RpcParameter; -use super::exec_common::{build_named_params, claim_connection, fail_with_tds, finish_execute}; +use super::close_cursor::{DrainOutcome, drain_and_release, reset_cursor_state}; +use super::exec_common::{ + build_named_params_row, claim_connection, collect_dae_params, fail_with_tds, finish_execute, +}; use super::sqlstate::*; use super::util::rewrite_param_markers; -use crate::api::odbc_types::{SQL_ERROR, SQL_INVALID_HANDLE, SqlHandle, SqlReturn}; +use crate::api::odbc_types::{ + SQL_ERROR, SQL_INVALID_HANDLE, SQL_NEED_DATA, SQL_SUCCESS, SQL_SUCCESS_WITH_INFO, SqlHandle, + SqlReturn, +}; use crate::error::free_errors; use crate::handles::stmt::{ - STMT_STATE_CURSOR_OPEN, STMT_STATE_EXEC_CONTEXT, STMT_STATE_EXEC_STARTED, + DaeState, STMT_STATE_CURSOR_OPEN, STMT_STATE_EXEC_CONTEXT, STMT_STATE_EXEC_STARTED, }; use crate::handles::{HandleType, StmtHandle, handle_from_raw}; @@ -45,24 +51,112 @@ unsafe fn sql_execute_impl(statement_handle: SqlHandle) -> SqlReturn { } /// Values gathered under the STMT lock before any network I/O. -struct Execution { - rewritten_sql: String, - named_params: Vec, - handle: Option, +pub(super) struct Execution { + pub(super) rewritten_sql: String, + pub(super) named_params: Vec, + pub(super) handle: Option, /// A superseded prepared handle (from a prior rebind / re-prepare) to be dropped /// on the server - drop_handle: Option, + pub(super) drop_handle: Option, } fn sql_execute_safe(statement_handle: SqlHandle, stmt: &StmtHandle) -> SqlReturn { - let dbc = stmt.parent_dbc(); + let paramset_size = stmt + .inner + .lock() + .map(|state| state.paramset_size) + .unwrap_or(1); + + if paramset_size > 1 { + return execute_param_array(statement_handle, stmt, paramset_size); + } - let exec = match stage_execution(stmt) { - Ok(exec) => exec, + let exec = match stage_execution(stmt, 0) { + Ok(Staged::Ready(exec)) => exec, + Ok(Staged::NeedData) => return SQL_NEED_DATA, Err(rc) => return rc, }; + run_execution(statement_handle, stmt, exec, "SQLExecute") +} + +/// Executes a statement once per element of a column-wise parameter array +/// (`SQL_ATTR_PARAMSET_SIZE > 1`), as used by `executemany`. +/// +/// SQL Server has no wire-level parameter-array form for `sp_execute`, so each +/// row is a separate RPC on the same prepared plan: the first execution +/// prepares, and the cached handle makes the rest single-round-trip. Affected +/// row counts are summed so `SQLRowCount` reports the total, matching msodbcsql. +fn execute_param_array( + statement_handle: SqlHandle, + stmt: &StmtHandle, + paramset_size: usize, +) -> SqlReturn { + let mut total_rows: i64 = 0; + let mut worst = SQL_SUCCESS; + + for row in 0..paramset_size { + let exec = match stage_execution(stmt, row) { + Ok(Staged::Ready(exec)) => exec, + // A DAE parameter inside a parameter array is driven row-by-row by + // the application, which mssql-python does on a separate code path; + // arrays reaching here are always fully materialized. + Ok(Staged::NeedData) => return SQL_NEED_DATA, + Err(rc) => return rc, + }; + let rc = run_execution(statement_handle, stmt, exec, "SQLExecute"); + if rc == SQL_ERROR || rc == SQL_INVALID_HANDLE { + return rc; + } + if rc == SQL_SUCCESS_WITH_INFO { + worst = SQL_SUCCESS_WITH_INFO; + } + if let Ok(state) = stmt.inner.lock() + && state.row_count >= 0 + { + total_rows += state.row_count; + } - let mut client = match claim_connection(dbc, stmt, statement_handle, "SQLExecute") { + // A row that produced a result set leaves the cursor open and the + // connection claimed, which would make the next row fail the + // invalid-cursor guard below. `executemany` never exposes intermediate + // result sets, so each row's cursor is closed before the next starts. + // The final row's cursor stays open: a statement with an `OUTPUT` + // clause is expected to leave its rows and metadata available. + let is_last = row + 1 == paramset_size; + let cursor_open = stmt + .inner + .lock() + .is_ok_and(|state| state.has_state(STMT_STATE_CURSOR_OPEN)); + if cursor_open && !is_last { + if let Ok(mut state) = stmt.inner.lock() { + reset_cursor_state(&mut state); + } + if matches!( + drain_and_release(stmt, statement_handle), + DrainOutcome::Failed + ) { + return SQL_ERROR; + } + } + } + + if let Ok(mut state) = stmt.inner.lock() { + state.row_count = total_rows; + } + worst +} + +/// Runs a staged execution: claims the connection, issues `sp_execute` or +/// `sp_prepexec`, and finalizes statement state. +pub(super) fn run_execution( + statement_handle: SqlHandle, + stmt: &StmtHandle, + exec: Execution, + op: &'static str, +) -> SqlReturn { + let dbc = stmt.parent_dbc(); + + let mut client = match claim_connection(dbc, stmt, statement_handle, op) { Ok(client) => client, Err(rc) => return rc, }; @@ -99,7 +193,7 @@ fn sql_execute_safe(statement_handle: SqlHandle, stmt: &StmtHandle) -> SqlReturn let stmt_result = match exec_result { Ok(result) => result, Err(e) => { - error!(%e, "SQLExecute: prepared execution failed"); + error!(%e, "{op}: prepared execution failed"); return fail_with_tds(dbc, stmt, statement_handle, client, &e); } }; @@ -114,17 +208,24 @@ fn sql_execute_safe(statement_handle: SqlHandle, stmt: &StmtHandle) -> SqlReturn if !matches!(stmt_result, StatementResult::Rows) && let Err(e) = dbc.runtime.block_on(client.advance_to_rows()) { - error!(%e, "SQLExecute: draining no-row prepared result failed"); + error!(%e, "{op}: draining no-row prepared result failed"); return fail_with_tds(dbc, stmt, statement_handle, client, &e); } - finish_execute(dbc, stmt, statement_handle, client, "SQLExecute") + finish_execute(dbc, stmt, statement_handle, client, op) +} + +/// Outcome of staging: either everything needed to run, or a request for the +/// application to stream data-at-execution parameters first. +pub(super) enum Staged { + Ready(Execution), + NeedData, } /// Validates statement state and builds the parameter list under the STMT lock, /// setting `EXEC_STARTED` on success. Application value buffers are read here by /// reference (no network I/O). -fn stage_execution(stmt: &StmtHandle) -> Result { +fn stage_execution(stmt: &StmtHandle, row: usize) -> Result { let Ok(mut stmt_state) = stmt.inner.lock() else { error!("SQLExecute: stmt mutex poisoned"); return Err(SQL_ERROR); @@ -152,23 +253,47 @@ fn stage_execution(stmt: &StmtHandle) -> Result { let (rewritten_sql, marker_count) = rewrite_param_markers(&sql); - let named_params = unsafe { build_named_params(&mut stmt_state, marker_count, "SQLExecute") }?; - let handle = stmt_state.prepared_handle; + let dae_order = unsafe { collect_dae_params(&stmt_state, marker_count) }; + if !dae_order.is_empty() { + let drop_handle = stmt_state.pending_unprepare.take(); + reset_for_execute(&mut stmt_state); + stmt_state.dae = Some(DaeState { + rewritten_sql, + marker_count, + handle, + drop_handle, + order: dae_order, + next: 0, + current: None, + data: vec![None; marker_count], + op: "SQLExecute", + }); + return Ok(Staged::NeedData); + } + + let named_params = + unsafe { build_named_params_row(&mut stmt_state, marker_count, "SQLExecute", None, row) }?; + let drop_handle = stmt_state.pending_unprepare.take(); - stmt_state.clear_state(STMT_STATE_EXEC_CONTEXT); - stmt_state.column_metadata.clear(); - stmt_state.current_row = None; - stmt_state.row_count = -1; - stmt_state.pending_row_counts.clear(); - stmt_state.set_state(STMT_STATE_EXEC_STARTED); + reset_for_execute(&mut stmt_state); - Ok(Execution { + Ok(Staged::Ready(Execution { rewritten_sql, named_params, handle, drop_handle, - }) + })) +} + +/// Clears per-execution result state and marks the statement as executing. +pub(super) fn reset_for_execute(stmt_state: &mut crate::handles::stmt::StmtState) { + stmt_state.clear_state(STMT_STATE_EXEC_CONTEXT); + stmt_state.column_metadata.clear(); + stmt_state.reset_rows(); + stmt_state.row_count = -1; + stmt_state.pending_row_counts.clear(); + stmt_state.set_state(STMT_STATE_EXEC_STARTED); } #[cfg(test)] @@ -238,7 +363,7 @@ mod tests { } #[test] - fn data_at_exec_parameter_returns_hyc00() { + fn data_at_exec_parameter_returns_need_data() { use crate::api::odbc_types::{ SQL_C_CHAR, SQL_DATA_AT_EXEC, SQL_PARAM_INPUT, SQL_VARCHAR, SqlLen, }; @@ -248,8 +373,6 @@ mod tests { set_prepared(h.stmt, "SELECT ?"); let stmt = unsafe { handle_from_raw::(h.stmt) }; - // Bind passes (SQL_C_CHAR → SQL_VARCHAR), but the data-at-execution - // indicator is only seen at execute time and is unsupported in Phase 1. let mut ind: SqlLen = SQL_DATA_AT_EXEC; stmt.inner .lock() @@ -267,10 +390,8 @@ mod tests { })); let ret = unsafe { sql_execute(h.stmt) }; - assert_eq!(ret, SQL_ERROR); - let state = stmt.inner.lock().unwrap(); - assert_eq!(state.diag_records[0].sql_state, SQLSTATE_HYC00); - assert!(!state.has_state(STMT_STATE_EXEC_STARTED)); + assert_eq!(ret, SQL_NEED_DATA); + assert!(stmt.inner.lock().unwrap().dae.is_some()); } #[test] @@ -287,7 +408,9 @@ mod tests { state.pending_unprepare = Some(42); } - let exec = stage_execution(stmt).expect("staging should succeed"); + let Staged::Ready(exec) = stage_execution(stmt, 0).expect("staging should succeed") else { + panic!("expected a ready execution"); + }; assert_eq!(exec.handle, None); assert_eq!(exec.drop_handle, Some(42)); @@ -308,7 +431,9 @@ mod tests { state.prepared_handle = Some(7); } - let exec = stage_execution(stmt).expect("staging should succeed"); + let Staged::Ready(exec) = stage_execution(stmt, 0).expect("staging should succeed") else { + panic!("expected a ready execution"); + }; assert_eq!(exec.handle, Some(7)); assert_eq!(exec.drop_handle, None); } diff --git a/mssql-odbc/src/api/exports.rs b/mssql-odbc/src/api/exports.rs index 0b124f09..b1696d22 100644 --- a/mssql-odbc/src/api/exports.rs +++ b/mssql-odbc/src/api/exports.rs @@ -11,8 +11,8 @@ //! Windows `.def` file or a C header listing the public API surface. use super::odbc_types::{ - SQL_CLOSE, SQL_RESET_PARAMS, SQL_SUCCESS, SqlHWnd, SqlHandle, SqlInteger, SqlLen, SqlPointer, - SqlReturn, SqlSmallInt, SqlULen, SqlUSmallInt, SqlWChar, + SQL_CLOSE, SQL_RESET_PARAMS, SQL_SUCCESS, SQL_UNBIND, SqlHWnd, SqlHandle, SqlInteger, SqlLen, + SqlPointer, SqlReturn, SqlSmallInt, SqlULen, SqlUSmallInt, SqlWChar, }; // ---- Handle allocation and management --------------------------------------- @@ -355,8 +355,8 @@ pub unsafe extern "C" fn SQLCloseCursor(statement_handle: SqlHandle) -> SqlRetur /// Frees resources associated with a statement handle. /// /// `SQL_CLOSE` closes the open cursor (no-op if none); `SQL_RESET_PARAMS` -/// releases all parameter bindings. `SQL_DROP` and `SQL_UNBIND` are not yet -/// implemented. +/// releases all parameter bindings; `SQL_UNBIND` releases all column bindings. +/// `SQL_DROP` is deprecated in favour of `SQLFreeHandle`. /// /// # Safety /// - `statement_handle` must be a valid STMT handle returned by `SQLAllocHandle`. @@ -371,10 +371,8 @@ pub unsafe extern "C" fn SQLFreeStmt( SQL_RESET_PARAMS => unsafe { super::bind_param::sql_free_stmt_reset_params(statement_handle) }, - _ => { - // TODO: SQL_DROP, SQL_UNBIND - SQL_SUCCESS - } + SQL_UNBIND => unsafe { super::bind_col::sql_free_stmt_unbind(statement_handle) }, + _ => SQL_SUCCESS, } } @@ -600,7 +598,7 @@ pub unsafe extern "C" fn SQLRowCount( unsafe { super::row_count::sql_row_count(statement_handle, row_count_ptr) } } -// ---- Attribute management (TO-BE-IMPLEMENTED) -------------------------------- +// ---- Attribute management ---------------------------------------------------- /// Retrieves a connection attribute. /// @@ -617,15 +615,15 @@ pub unsafe extern "C" fn SQLGetConnectAttrW( string_length_ptr: *mut SqlInteger, ) -> SqlReturn { crate::init_tracing(); - tracing::debug!( - ?connection_handle, - attribute, - ?value_ptr, - buffer_length, - ?string_length_ptr, - "SQLGetConnectAttrW called (stub)", - ); - super::odbc_types::SQL_ERROR + unsafe { + super::get_connect_attr::sql_get_connect_attr_w( + connection_handle, + attribute, + value_ptr, + buffer_length, + string_length_ptr, + ) + } } /// Sets a statement attribute. @@ -693,3 +691,416 @@ pub unsafe extern "C" fn SQLCancel(_statement_handle: SqlHandle) -> SqlReturn { crate::init_tracing(); SQL_SUCCESS } + +// ---- Transactions ------------------------------------------------------------ + +/// Commits or rolls back the current transaction. +/// +/// # Safety +/// - `handle` must be a valid ENV or DBC handle matching `handle_type`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn SQLEndTran( + handle_type: SqlSmallInt, + handle: SqlHandle, + completion_type: SqlSmallInt, +) -> SqlReturn { + crate::init_tracing(); + unsafe { super::end_tran::sql_end_tran(handle_type, handle, completion_type) } +} + +// ---- Column binding and block fetch ------------------------------------------ + +/// Binds a result column to an application buffer. +/// +/// # Safety +/// - `statement_handle` must be a valid STMT handle. +/// - `target_value_ptr` / `strlen_or_ind_ptr` must remain valid until the +/// column is unbound or the statement is freed. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn SQLBindCol( + statement_handle: SqlHandle, + column_number: SqlUSmallInt, + target_type: SqlSmallInt, + target_value_ptr: SqlPointer, + buffer_length: SqlLen, + strlen_or_ind_ptr: *mut SqlLen, +) -> SqlReturn { + crate::init_tracing(); + unsafe { + super::bind_col::sql_bind_col( + statement_handle, + column_number, + target_type, + target_value_ptr, + buffer_length, + strlen_or_ind_ptr, + ) + } +} + +/// Fetches the next rowset into the buffers registered by `SQLBindCol`. +/// +/// # Safety +/// - `statement_handle` must be a valid STMT handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn SQLFetchScroll( + statement_handle: SqlHandle, + fetch_orientation: SqlSmallInt, + fetch_offset: SqlLen, +) -> SqlReturn { + crate::init_tracing(); + unsafe { + super::fetch_scroll::sql_fetch_scroll(statement_handle, fetch_orientation, fetch_offset) + } +} + +/// Returns a descriptor field for a result column. +/// +/// # Safety +/// - `statement_handle` must be a valid STMT handle. +/// - Output pointers must be valid and writable for the sizes implied by +/// `buffer_length`. +#[unsafe(no_mangle)] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn SQLColAttributeW( + statement_handle: SqlHandle, + column_number: SqlUSmallInt, + field_identifier: SqlUSmallInt, + character_attribute_ptr: SqlPointer, + buffer_length: SqlSmallInt, + string_length_ptr: *mut SqlSmallInt, + numeric_attribute_ptr: *mut SqlLen, +) -> SqlReturn { + crate::init_tracing(); + unsafe { + super::col_attribute::sql_col_attribute_w( + statement_handle, + column_number, + field_identifier, + character_attribute_ptr, + buffer_length, + string_length_ptr, + numeric_attribute_ptr, + ) + } +} + +// ---- Descriptors and parameter metadata -------------------------------------- + +/// Sets a descriptor field. +/// +/// # Safety +/// - `descriptor_handle` must be a valid descriptor handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn SQLSetDescFieldW( + descriptor_handle: SqlHandle, + record_number: SqlSmallInt, + field_identifier: SqlSmallInt, + value_ptr: SqlPointer, + buffer_length: SqlInteger, +) -> SqlReturn { + crate::init_tracing(); + unsafe { + super::desc::sql_set_desc_field_w( + descriptor_handle, + record_number, + field_identifier, + value_ptr, + buffer_length, + ) + } +} + +/// Describes a parameter marker in a prepared statement. +/// +/// # Safety +/// - `statement_handle` must be a valid STMT handle. +/// - Output pointers must be valid and writable when non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn SQLDescribeParam( + statement_handle: SqlHandle, + parameter_number: SqlUSmallInt, + data_type_ptr: *mut SqlSmallInt, + parameter_size_ptr: *mut SqlULen, + decimal_digits_ptr: *mut SqlSmallInt, + nullable_ptr: *mut SqlSmallInt, +) -> SqlReturn { + crate::init_tracing(); + unsafe { + super::desc::sql_describe_param( + statement_handle, + parameter_number, + data_type_ptr, + parameter_size_ptr, + decimal_digits_ptr, + nullable_ptr, + ) + } +} + +/// Drives the data-at-execution loop. +/// +/// # Safety +/// - `statement_handle` must be a valid STMT handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn SQLParamData( + statement_handle: SqlHandle, + value_ptr_ptr: *mut SqlPointer, +) -> SqlReturn { + crate::init_tracing(); + unsafe { super::dae::sql_param_data(statement_handle, value_ptr_ptr) } +} + +/// Supplies a chunk of data-at-execution parameter data. +/// +/// # Safety +/// - `statement_handle` must be a valid STMT handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn SQLPutData( + statement_handle: SqlHandle, + data_ptr: SqlPointer, + str_len_or_ind: SqlLen, +) -> SqlReturn { + crate::init_tracing(); + unsafe { super::dae::sql_put_data(statement_handle, data_ptr, str_len_or_ind) } +} + +// ---- Catalog functions ------------------------------------------------------- + +/// Returns the list of tables matching the supplied search patterns. +/// +/// # Safety +/// - `statement_handle` must be a valid STMT handle. +/// - Each name pointer must be null or readable for its declared length. +#[unsafe(no_mangle)] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn SQLTablesW( + statement_handle: SqlHandle, + catalog_name: *const SqlWChar, + name_length_1: SqlSmallInt, + schema_name: *const SqlWChar, + name_length_2: SqlSmallInt, + table_name: *const SqlWChar, + name_length_3: SqlSmallInt, + table_type: *const SqlWChar, + name_length_4: SqlSmallInt, +) -> SqlReturn { + crate::init_tracing(); + unsafe { + super::catalog::sql_tables_w( + statement_handle, + catalog_name, + name_length_1, + schema_name, + name_length_2, + table_name, + name_length_3, + table_type, + name_length_4, + ) + } +} + +/// Returns the list of columns matching the supplied search patterns. +/// +/// # Safety +/// - `statement_handle` must be a valid STMT handle. +/// - Each name pointer must be null or readable for its declared length. +#[unsafe(no_mangle)] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn SQLColumnsW( + statement_handle: SqlHandle, + catalog_name: *const SqlWChar, + name_length_1: SqlSmallInt, + schema_name: *const SqlWChar, + name_length_2: SqlSmallInt, + table_name: *const SqlWChar, + name_length_3: SqlSmallInt, + column_name: *const SqlWChar, + name_length_4: SqlSmallInt, +) -> SqlReturn { + crate::init_tracing(); + unsafe { + super::catalog::sql_columns_w( + statement_handle, + catalog_name, + name_length_1, + schema_name, + name_length_2, + table_name, + name_length_3, + column_name, + name_length_4, + ) + } +} + +/// Returns the primary key columns of a table. +/// +/// # Safety +/// - `statement_handle` must be a valid STMT handle. +/// - Each name pointer must be null or readable for its declared length. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn SQLPrimaryKeysW( + statement_handle: SqlHandle, + catalog_name: *const SqlWChar, + name_length_1: SqlSmallInt, + schema_name: *const SqlWChar, + name_length_2: SqlSmallInt, + table_name: *const SqlWChar, + name_length_3: SqlSmallInt, +) -> SqlReturn { + crate::init_tracing(); + unsafe { + super::catalog::sql_primary_keys_w( + statement_handle, + catalog_name, + name_length_1, + schema_name, + name_length_2, + table_name, + name_length_3, + ) + } +} + +/// Returns foreign key relationships for the referenced tables. +/// +/// # Safety +/// - `statement_handle` must be a valid STMT handle. +/// - Each name pointer must be null or readable for its declared length. +#[unsafe(no_mangle)] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn SQLForeignKeysW( + statement_handle: SqlHandle, + pk_catalog_name: *const SqlWChar, + name_length_1: SqlSmallInt, + pk_schema_name: *const SqlWChar, + name_length_2: SqlSmallInt, + pk_table_name: *const SqlWChar, + name_length_3: SqlSmallInt, + fk_catalog_name: *const SqlWChar, + name_length_4: SqlSmallInt, + fk_schema_name: *const SqlWChar, + name_length_5: SqlSmallInt, + fk_table_name: *const SqlWChar, + name_length_6: SqlSmallInt, +) -> SqlReturn { + crate::init_tracing(); + unsafe { + super::catalog::sql_foreign_keys_w( + statement_handle, + pk_catalog_name, + name_length_1, + pk_schema_name, + name_length_2, + pk_table_name, + name_length_3, + fk_catalog_name, + name_length_4, + fk_schema_name, + name_length_5, + fk_table_name, + name_length_6, + ) + } +} + +/// Returns index and statistics information for a table. +/// +/// # Safety +/// - `statement_handle` must be a valid STMT handle. +/// - Each name pointer must be null or readable for its declared length. +#[unsafe(no_mangle)] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn SQLStatisticsW( + statement_handle: SqlHandle, + catalog_name: *const SqlWChar, + name_length_1: SqlSmallInt, + schema_name: *const SqlWChar, + name_length_2: SqlSmallInt, + table_name: *const SqlWChar, + name_length_3: SqlSmallInt, + unique: SqlUSmallInt, + reserved: SqlUSmallInt, +) -> SqlReturn { + crate::init_tracing(); + unsafe { + super::catalog::sql_statistics_w( + statement_handle, + catalog_name, + name_length_1, + schema_name, + name_length_2, + table_name, + name_length_3, + unique, + reserved, + ) + } +} + +/// Returns row identifier or row version columns for a table. +/// +/// # Safety +/// - `statement_handle` must be a valid STMT handle. +/// - Each name pointer must be null or readable for its declared length. +#[unsafe(no_mangle)] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn SQLSpecialColumnsW( + statement_handle: SqlHandle, + identifier_type: SqlUSmallInt, + catalog_name: *const SqlWChar, + name_length_1: SqlSmallInt, + schema_name: *const SqlWChar, + name_length_2: SqlSmallInt, + table_name: *const SqlWChar, + name_length_3: SqlSmallInt, + scope: SqlUSmallInt, + nullable: SqlUSmallInt, +) -> SqlReturn { + crate::init_tracing(); + unsafe { + super::catalog::sql_special_columns_w( + statement_handle, + identifier_type, + catalog_name, + name_length_1, + schema_name, + name_length_2, + table_name, + name_length_3, + scope, + nullable, + ) + } +} + +/// Returns the list of stored procedures matching the supplied patterns. +/// +/// # Safety +/// - `statement_handle` must be a valid STMT handle. +/// - Each name pointer must be null or readable for its declared length. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn SQLProceduresW( + statement_handle: SqlHandle, + catalog_name: *const SqlWChar, + name_length_1: SqlSmallInt, + schema_name: *const SqlWChar, + name_length_2: SqlSmallInt, + proc_name: *const SqlWChar, + name_length_3: SqlSmallInt, +) -> SqlReturn { + crate::init_tracing(); + unsafe { + super::catalog::sql_procedures_w( + statement_handle, + catalog_name, + name_length_1, + schema_name, + name_length_2, + proc_name, + name_length_3, + ) + } +} diff --git a/mssql-odbc/src/api/fetch.rs b/mssql-odbc/src/api/fetch.rs index 7e1e88f9..c90bdbeb 100644 --- a/mssql-odbc/src/api/fetch.rs +++ b/mssql-odbc/src/api/fetch.rs @@ -5,10 +5,11 @@ use tracing::{debug, error}; +use super::fetch_scroll::{fold_row_status, write_bound_columns}; use super::sqlstate::*; use crate::api::odbc_types::{ - SQL_ERROR, SQL_INVALID_HANDLE, SQL_NO_DATA, SQL_SUCCESS, SQL_SUCCESS_WITH_INFO, SqlHandle, - SqlReturn, + SQL_ERROR, SQL_INVALID_HANDLE, SQL_NO_DATA, SQL_ROW_NOROW, SQL_SUCCESS, SQL_SUCCESS_WITH_INFO, + SqlHandle, SqlReturn, SqlULen, SqlUSmallInt, }; use crate::error::free_errors; use crate::handles::stmt::STMT_STATE_CURSOR_OPEN; @@ -44,6 +45,11 @@ fn sql_fetch_safe(statement_handle: SqlHandle, stmt: &StmtHandle) -> SqlReturn { return SQL_ERROR; }; free_errors(&mut stmt_state); + if let Some(pending) = stmt_state.pending_fetch_error.take() { + error!("SQLFetch: replaying error raised after the previous rowset"); + stmt_state.diag_records.push(pending); + return SQL_ERROR; + } if !stmt_state.has_state(STMT_STATE_CURSOR_OPEN) { error!("SQLFetch: no open cursor on this statement"); post_diag(&mut stmt_state, ERR_INVALID_CURSOR_STATE); @@ -51,13 +57,123 @@ fn sql_fetch_safe(statement_handle: SqlHandle, stmt: &StmtHandle) -> SqlReturn { } } - fetch_rows_next(statement_handle, stmt) + fetch_rowset(statement_handle, stmt) +} + +/// Fetches up to `SQL_ATTR_ROW_ARRAY_SIZE` rows, materializing each into the +/// buffers registered by `SQLBindCol` and reporting per-row status. +/// +/// With the default rowset size of 1 and no bound columns this degenerates to a +/// single `fetch_rows_next` call, which is the `SQLFetch` + `SQLGetData` path. +pub(crate) fn fetch_rowset(statement_handle: SqlHandle, stmt: &StmtHandle) -> SqlReturn { + let (array_size, rows_fetched_ptr, row_status_ptr) = match stmt.inner.lock() { + Ok(state) => ( + state.row_array_size.max(1), + state.rows_fetched_ptr, + state.row_status_ptr, + ), + Err(_) => { + error!("SQLFetch: stmt mutex poisoned reading rowset attributes"); + return SQL_ERROR; + } + }; + + let mut aggregate = SQL_SUCCESS; + let mut fetched = 0usize; + + for slot in 0..array_size { + let rc = fetch_rows_next(statement_handle, stmt); + if rc == SQL_NO_DATA { + break; + } + if rc == SQL_ERROR { + if fetched == 0 { + unsafe { write_rowset_counters(rows_fetched_ptr, row_status_ptr, array_size, 0) }; + return SQL_ERROR; + } + // Rows from this rowset are still valid and must be handed back, so + // the error is parked and replayed on the next fetch call. + if let Ok(mut ss) = stmt.inner.lock() { + ss.pending_fetch_error = ss.diag_records.first().cloned(); + } + aggregate = SQL_SUCCESS_WITH_INFO; + break; + } + if rc == SQL_SUCCESS_WITH_INFO { + aggregate = SQL_SUCCESS_WITH_INFO; + } + + let row_status = write_bound_columns(stmt, slot); + aggregate = fold_row_status(aggregate, row_status); + unsafe { write_row_status(row_status_ptr, slot, row_status) }; + fetched += 1; + + if aggregate == SQL_ERROR { + break; + } + } + + unsafe { write_rowset_counters(rows_fetched_ptr, row_status_ptr, array_size, fetched) }; + + if fetched == 0 { SQL_NO_DATA } else { aggregate } +} + +/// Writes the fetched-row count and marks unfilled rowset slots as `SQL_ROW_NOROW`. +/// +/// # Safety +/// Both pointers are application-owned buffers registered through +/// `SQLSetStmtAttr`; when non-null they must have room for `array_size` +/// elements. +unsafe fn write_rowset_counters( + rows_fetched_ptr: *mut SqlULen, + row_status_ptr: *mut SqlUSmallInt, + array_size: usize, + fetched: usize, +) { + if !rows_fetched_ptr.is_null() { + unsafe { std::ptr::write_unaligned(rows_fetched_ptr, fetched as SqlULen) }; + } + if !row_status_ptr.is_null() { + for slot in fetched..array_size { + unsafe { std::ptr::write_unaligned(row_status_ptr.add(slot), SQL_ROW_NOROW) }; + } + } +} + +/// # Safety +/// `row_status_ptr` must be null or have room for at least `slot + 1` elements. +unsafe fn write_row_status(row_status_ptr: *mut SqlUSmallInt, slot: usize, status: SqlUSmallInt) { + if !row_status_ptr.is_null() { + unsafe { std::ptr::write_unaligned(row_status_ptr.add(slot), status) }; + } } /// Row materialization step for one forward fetch operation. fn fetch_rows_next(statement_handle: SqlHandle, stmt: &StmtHandle) -> SqlReturn { let dbc = stmt.parent_dbc(); + // Rows read ahead to release the connection are served first: they are the + // cursor's next rows and require no I/O. + match stmt.inner.lock() { + Ok(mut state) => { + if let Some(row) = state.buffered_rows.pop_front() { + state.current_row = Some(row); + state.reset_getdata(); + debug!("SQLFetch: row served from read-ahead buffer"); + return SQL_SUCCESS; + } + if state.buffered_eof { + state.current_row = None; + debug!("SQLFetch: read-ahead buffer exhausted; returning SQL_NO_DATA"); + return SQL_NO_DATA; + } + } + Err(_) => { + error!("SQLFetch: stmt mutex poisoned reading buffered rows"); + return SQL_ERROR; + } + } + let mut client = { let Ok(mut dbc_state) = dbc.inner.lock() else { error!("SQLFetch: dbc mutex poisoned"); @@ -145,6 +261,7 @@ fn fetch_rows_next(statement_handle: SqlHandle, stmt: &StmtHandle) -> SqlReturn return SQL_ERROR; }; stmt_state.current_row = Some(row); + stmt_state.reset_getdata(); // Drain INFO only after the lock is held so a poisoned mutex cannot // silently drop the messages. let info_messages = client.take_info_messages(); @@ -201,8 +318,7 @@ fn fetch_rows_next(statement_handle: SqlHandle, stmt: &StmtHandle) -> SqlReturn return SQL_ERROR; }; stmt_state.current_row = None; - // Don't clear CURSOR_OPEN here: the cursor stays open until - // SQLMoreResults / SQLCloseCursor / SQLFreeStmt(SQL_CLOSE). + stmt_state.buffered_eof = true; drop(stmt_state); if let Ok(mut dbc_state) = dbc.inner.lock() { dbc_state.client = Some(client); diff --git a/mssql-odbc/src/api/fetch_scroll.rs b/mssql-odbc/src/api/fetch_scroll.rs new file mode 100644 index 00000000..01503fdd --- /dev/null +++ b/mssql-odbc/src/api/fetch_scroll.rs @@ -0,0 +1,231 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Block-fetch support: bound-column materialization and `SQLFetchScroll`. + +use tracing::{debug, error}; + +use super::cdata::{WriteError, WriteOutcome, write_c_value}; +use super::odbc_types::{ + SQL_ERROR, SQL_FETCH_NEXT, SQL_INVALID_HANDLE, SQL_ROW_ERROR, SQL_ROW_SUCCESS, + SQL_ROW_SUCCESS_WITH_INFO, SQL_SUCCESS, SQL_SUCCESS_WITH_INFO, SQL_UNKNOWN_TYPE, SqlHandle, + SqlLen, SqlReturn, SqlSmallInt, +}; +use super::sqlstate::{ + ERR_INVALID_C_DATA_TYPE, ERR_RESTRICTED_DATA_TYPE, ERR_STRING_RIGHT_TRUNCATION, SQLSTATE_HY106, + post_diag, +}; +use crate::error::{free_errors, post_sql_error}; +use crate::handles::stmt::STMT_STATE_CURSOR_OPEN; +use crate::handles::{HandleType, StmtHandle, handle_from_raw}; +use crate::params::convert::c_type_stride; + +/// Implements `SQLFetchScroll`. +/// +/// Only `SQL_FETCH_NEXT` is supported: the driver exposes forward-only, +/// firehose cursors, so any other orientation is a fetch-type-out-of-range +/// error (HY106) exactly as msodbcsql reports for a forward-only cursor. +/// +/// # Safety +/// `statement_handle` must be a valid `StmtHandle` or null. +pub(crate) unsafe fn sql_fetch_scroll( + statement_handle: SqlHandle, + fetch_orientation: SqlSmallInt, + fetch_offset: SqlLen, +) -> SqlReturn { + debug!( + ?statement_handle, + fetch_orientation, fetch_offset, "SQLFetchScroll called" + ); + crate::ffi_entry!("SQLFetchScroll", unsafe { + sql_fetch_scroll_impl(statement_handle, fetch_orientation) + }) +} + +unsafe fn sql_fetch_scroll_impl( + statement_handle: SqlHandle, + fetch_orientation: SqlSmallInt, +) -> SqlReturn { + if statement_handle.is_null() { + error!("SQLFetchScroll: statement_handle is null"); + return SQL_INVALID_HANDLE; + } + let stmt = unsafe { handle_from_raw::(statement_handle) }; + debug_assert_eq!(stmt.object_type, HandleType::Stmt); + + { + let Ok(mut state) = stmt.inner.lock() else { + error!("SQLFetchScroll: stmt mutex poisoned"); + return SQL_ERROR; + }; + free_errors(&mut state); + if let Some(pending) = state.pending_fetch_error.take() { + error!("SQLFetchScroll: replaying error raised after the previous rowset"); + state.diag_records.push(pending); + return SQL_ERROR; + } + if fetch_orientation != SQL_FETCH_NEXT { + post_sql_error( + &mut state, + SQLSTATE_HY106, + 0, + "Fetch type out of range; the cursor is forward-only", + ); + return SQL_ERROR; + } + if !state.has_state(STMT_STATE_CURSOR_OPEN) { + drop(state); + return unsafe { super::fetch::sql_fetch(statement_handle) }; + } + } + + super::fetch::fetch_rowset(statement_handle, stmt) +} + +/// Copies the statement's current row into the application buffers registered +/// by `SQLBindCol`, at rowset slot `row_index` (column-wise binding). +/// +/// Returns the ODBC row status for this row. +pub(crate) fn write_bound_columns(stmt: &StmtHandle, row_index: usize) -> u16 { + let Ok(mut state) = stmt.inner.lock() else { + error!("fetch: stmt mutex poisoned writing bound columns"); + return SQL_ROW_ERROR; + }; + if state.bound_cols.is_empty() { + return SQL_ROW_SUCCESS; + } + let Some(row) = state.current_row.clone() else { + return SQL_ROW_ERROR; + }; + + let bindings = state.bound_cols.clone(); + let mut status = SQL_ROW_SUCCESS; + + for (idx, binding) in bindings.iter().enumerate() { + let Some(bc) = binding else { continue }; + let Some(value) = row.get(idx) else { continue }; + + // Column-wise binding: each column's buffer is an array of one element + // per rowset slot. ODBC derives the element stride from the C type for + // fixed-width targets and only falls back to `BufferLength` for + // character/binary ones — applications legitimately pass the *total* + // array size as `BufferLength` for fixed-width types, so using it as the + // stride would write past the end of the buffer. + let stride = c_type_stride(bc.target_type, SQL_UNKNOWN_TYPE, bc.buffer_length); + let data_ptr = if bc.target_value_ptr.is_null() { + std::ptr::null_mut() + } else { + unsafe { + bc.target_value_ptr + .cast::() + .add(row_index * stride) + .cast() + } + }; + let ind_ptr = if bc.strlen_or_ind_ptr.is_null() { + std::ptr::null_mut() + } else { + unsafe { bc.strlen_or_ind_ptr.add(row_index) } + }; + + match unsafe { write_c_value(value, bc.target_type, data_ptr, bc.buffer_length, ind_ptr) } { + Ok(WriteOutcome::Complete) => {} + Ok(WriteOutcome::Truncated) => { + post_diag(&mut state, ERR_STRING_RIGHT_TRUNCATION); + status = SQL_ROW_SUCCESS_WITH_INFO; + } + Err(WriteError::InvalidCType) => { + post_diag(&mut state, ERR_INVALID_C_DATA_TYPE); + return SQL_ROW_ERROR; + } + Err(WriteError::RestrictedConversion) => { + post_diag(&mut state, ERR_RESTRICTED_DATA_TYPE); + return SQL_ROW_ERROR; + } + Err(WriteError::OutOfRange) => { + post_sql_error( + &mut state, + crate::api::sqlstate::SQLSTATE_22003, + 0, + "Numeric value out of range", + ); + return SQL_ROW_ERROR; + } + } + } + + status +} + +/// Folds a per-row status into the aggregate return code for the rowset. +pub(crate) fn fold_row_status(current: SqlReturn, row_status: u16) -> SqlReturn { + match row_status { + SQL_ROW_ERROR => SQL_ERROR, + SQL_ROW_SUCCESS_WITH_INFO if current == SQL_SUCCESS => SQL_SUCCESS_WITH_INFO, + _ => current, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::odbc_types::{SQL_C_SLONG, SQL_FETCH_PRIOR, SQL_NULL_HANDLE}; + use crate::test_support::TestHandles; + use mssql_tds::datatypes::column_values::ColumnValues; + + #[test] + fn fetch_scroll_null_handle() { + let ret = unsafe { sql_fetch_scroll(SQL_NULL_HANDLE, SQL_FETCH_NEXT, 0) }; + assert_eq!(ret, SQL_INVALID_HANDLE); + } + + #[test] + fn fetch_scroll_rejects_non_next_orientation() { + let h = TestHandles::with_env_dbc_stmt(); + let ret = unsafe { sql_fetch_scroll(h.stmt, SQL_FETCH_PRIOR, 0) }; + assert_eq!(ret, SQL_ERROR); + + let stmt = unsafe { handle_from_raw::(h.stmt) }; + let state = stmt.inner.lock().unwrap(); + assert_eq!(state.diag_records[0].sql_state, SQLSTATE_HY106); + } + + #[test] + fn write_bound_columns_fills_slot() { + let h = TestHandles::with_env_dbc_stmt(); + let stmt = unsafe { handle_from_raw::(h.stmt) }; + let mut buf = [0i32; 4]; + let mut ind = [0 as SqlLen; 4]; + { + let mut state = stmt.inner.lock().unwrap(); + state.bound_cols = vec![Some(crate::handles::stmt::BoundCol { + target_type: SQL_C_SLONG, + target_value_ptr: buf.as_mut_ptr().cast(), + buffer_length: 4, + strlen_or_ind_ptr: ind.as_mut_ptr(), + })]; + state.current_row = Some(vec![ColumnValues::Int(99)]); + } + + assert_eq!(write_bound_columns(stmt, 2), SQL_ROW_SUCCESS); + assert_eq!(buf, [0, 0, 99, 0]); + assert_eq!(ind[2], 4); + } + + #[test] + fn write_bound_columns_no_bindings_is_success() { + let h = TestHandles::with_env_dbc_stmt(); + let stmt = unsafe { handle_from_raw::(h.stmt) }; + assert_eq!(write_bound_columns(stmt, 0), SQL_ROW_SUCCESS); + } + + #[test] + fn fold_row_status_promotes_info_and_error() { + assert_eq!(fold_row_status(SQL_SUCCESS, SQL_ROW_SUCCESS), SQL_SUCCESS); + assert_eq!( + fold_row_status(SQL_SUCCESS, SQL_ROW_SUCCESS_WITH_INFO), + SQL_SUCCESS_WITH_INFO + ); + assert_eq!(fold_row_status(SQL_SUCCESS, SQL_ROW_ERROR), SQL_ERROR); + } +} diff --git a/mssql-odbc/src/api/free_handle.rs b/mssql-odbc/src/api/free_handle.rs index ee48a7f6..e8991072 100644 --- a/mssql-odbc/src/api/free_handle.rs +++ b/mssql-odbc/src/api/free_handle.rs @@ -49,9 +49,11 @@ pub(crate) unsafe fn sql_free_handle(handle_type: SqlSmallInt, handle: SqlHandle /// Mirrors msodbcsql's `SQLFreeEnv` behavior. /// -/// No mutex is acquired - per the ODBC spec, the DM guarantees the -/// connection count on this ENV is 0 before calling `SQLFreeEnv`. DM also -/// ensures no concurrent SQLFreeHandle calls on the same handle. +/// A Driver Manager normally guarantees every child DBC is freed first, but +/// applications that load the driver directly (no DM) rely on the driver +/// cascading the free itself, so any surviving connections are dropped here +/// before the ENV goes away — otherwise they would hold dangling +/// `parent_env` pointers. /// /// # Safety /// `handle` must be a live `EnvHandle` created by `alloc_env`. @@ -67,13 +69,19 @@ unsafe fn free_env(handle: SqlHandle) -> SqlReturn { free_errors(&mut state); } - debug_assert!( - env.inner - .lock() - .map(|s| s.connections.is_empty()) - .unwrap_or(true), - "SQLFreeHandle(ENV): DM should have freed all DBCs before calling SQLFreeEnv" - ); + let orphans: Vec = match env.inner.lock() { + Ok(state) => state.connections.clone(), + Err(_) => Vec::new(), + }; + if !orphans.is_empty() { + debug!( + count = orphans.len(), + "SQLFreeHandle(ENV): cascading free of connections still allocated" + ); + for dbc in orphans { + unsafe { free_dbc(dbc) }; + } + } unsafe { free_handle::(handle) }; SQL_SUCCESS @@ -81,9 +89,9 @@ unsafe fn free_env(handle: SqlHandle) -> SqlReturn { /// Mirrors msodbcsql's `SQLFreeConnect` behavior. /// -/// No DBC mutex is acquired — the DM guarantees the DBC is disconnected -/// before calling `SQLFreeConnect`, and `SQLDisconnect` drops all child -/// handles. msodbcsql's `SQLFreeConnect` doesn't lock the connection mutex either. +/// Applications that load the driver without a Driver Manager expect freeing +/// a DBC to implicitly free its statements (and drop a live connection), so +/// both are handled here instead of asserting the DM did it. /// /// # Safety /// `handle` must be a live `DbcHandle` created by `alloc_dbc`. @@ -95,17 +103,33 @@ unsafe fn free_dbc(handle: SqlHandle) -> SqlReturn { "SQLFreeHandle(DBC): handle is not a DBC" ); + let still_connected = dbc + .inner + .lock() + .map(|s| s.client.is_some()) + .unwrap_or(false); + if still_connected { + debug!("SQLFreeHandle(DBC): connection still live — disconnecting first"); + unsafe { super::disconnect::sql_disconnect(handle) }; + } + if let Ok(mut state) = dbc.inner.lock() { free_errors(&mut state); } - debug_assert!( - dbc.inner - .lock() - .map(|s| s.statements.is_empty()) - .unwrap_or(true), - "SQLFreeHandle(DBC): DM should have freed all STMTs before calling SQLFreeConnect" - ); + let orphans: Vec = match dbc.inner.lock() { + Ok(state) => state.statements.clone(), + Err(_) => Vec::new(), + }; + if !orphans.is_empty() { + debug!( + count = orphans.len(), + "SQLFreeHandle(DBC): cascading free of statements still allocated" + ); + for stmt in orphans { + unsafe { free_stmt(stmt) }; + } + } // Unregister from parent ENV let env = unsafe { handle_from_raw::(dbc.parent_env) }; @@ -315,10 +339,9 @@ mod tests { } #[test] - fn free_env_with_outstanding_dbc_fails_in_debug() { - // The DM guarantees all DBCs are freed before calling SQLFreeEnv. - // The driver trusts this and frees unconditionally (matching msodbcsql). - // In debug builds, debug_assert! fires and catch_unwind returns SQL_ERROR. + fn free_env_cascades_to_outstanding_dbc() { + // Applications that load the driver without a DM expect SQLFreeEnv to + // implicitly free any connections still allocated on the ENV. let env = alloc_env(); let mut dbc: SqlHandle = ptr::null_mut(); @@ -326,17 +349,7 @@ mod tests { assert_eq!(ret, SQL_SUCCESS); let ret = unsafe { sql_free_handle(SQL_HANDLE_ENV, env) }; - if cfg!(debug_assertions) { - // debug_assert! panics, catch_unwind converts to SQL_ERROR. - assert_eq!(ret, SQL_ERROR); - // ENV was not freed due to panic — clean up both handles. - unsafe { free_handle::(dbc) }; - unsafe { free_handle::(env) }; - } else { - assert_eq!(ret, SQL_SUCCESS); - // ENV freed, DBC orphaned — clean up directly. - unsafe { free_handle::(dbc) }; - } + assert_eq!(ret, SQL_SUCCESS); } // --- Helper: alloc ENV + DBC for STMT tests --- @@ -383,10 +396,8 @@ mod tests { } #[test] - fn free_dbc_with_outstanding_stmt_fails_in_debug() { - // The DM guarantees all STMTs are freed before calling SQLFreeConnect. - // The driver trusts this and frees unconditionally (matching msodbcsql). - // In debug builds, debug_assert! fires and catch_unwind returns SQL_ERROR. + fn free_dbc_cascades_to_outstanding_stmt() { + // Without a DM, freeing a DBC must implicitly free its statements. let (env, dbc) = alloc_env_dbc(); let mut stmt: SqlHandle = ptr::null_mut(); @@ -394,17 +405,7 @@ mod tests { assert_eq!(ret, SQL_SUCCESS); let ret = unsafe { sql_free_handle(SQL_HANDLE_DBC, dbc) }; - if cfg!(debug_assertions) { - // debug_assert! panics, catch_unwind converts to SQL_ERROR. - assert_eq!(ret, SQL_ERROR); - // DBC was not freed due to panic — clean up all handles. - unsafe { free_handle::(stmt) }; - unsafe { sql_free_handle(SQL_HANDLE_DBC, dbc) }; - } else { - assert_eq!(ret, SQL_SUCCESS); - // DBC freed, STMT orphaned — clean up directly. - unsafe { free_handle::(stmt) }; - } + assert_eq!(ret, SQL_SUCCESS); unsafe { sql_free_handle(SQL_HANDLE_ENV, env) }; } diff --git a/mssql-odbc/src/api/get_connect_attr.rs b/mssql-odbc/src/api/get_connect_attr.rs new file mode 100644 index 00000000..3dd29d8b --- /dev/null +++ b/mssql-odbc/src/api/get_connect_attr.rs @@ -0,0 +1,238 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Implementation of SQLGetConnectAttrW. + +use tracing::{debug, error}; + +use super::sqlstate::*; +use crate::api::odbc_types::{ + SQL_ATTR_ACCESS_MODE, SQL_ATTR_AUTOCOMMIT, SQL_ATTR_CONNECTION_DEAD, + SQL_ATTR_CONNECTION_TIMEOUT, SQL_ATTR_CURRENT_CATALOG, SQL_ATTR_LOGIN_TIMEOUT, + SQL_ATTR_PACKET_SIZE, SQL_ATTR_TXN_ISOLATION, SQL_AUTOCOMMIT_OFF, SQL_AUTOCOMMIT_ON, + SQL_CD_FALSE, SQL_CD_TRUE, SQL_ERROR, SQL_INVALID_HANDLE, SQL_SUCCESS, SQL_SUCCESS_WITH_INFO, + SqlHandle, SqlInteger, SqlPointer, SqlReturn, SqlWChar, +}; +use crate::api::util::{copy_with_nul, write_if_some}; +use crate::error::free_errors; +use crate::handles::dbc::ConnectionState; +use crate::handles::{DbcHandle, HandleType, handle_from_raw}; + +/// ODBC read-only access mode indicator; the driver never restricts writes. +const SQL_MODE_READ_WRITE: u32 = 0; + +/// Retrieves the current setting of a connection attribute. +/// +/// Fixed-length (`SQLUINTEGER`) attributes plus the string-valued +/// `SQL_ATTR_CURRENT_CATALOG` are supported; anything else reports `HYC00`. +/// +/// # Safety +/// - `connection_handle` must be a valid `DbcHandle` from `SQLAllocHandle`. +/// - `value_ptr`, when non-null, must be writable for the attribute's type +/// (4 bytes for every attribute this driver answers). +/// - `string_length_ptr`, when non-null, must be writable for one `SqlInteger`. +pub(crate) unsafe fn sql_get_connect_attr_w( + connection_handle: SqlHandle, + attribute: SqlInteger, + value_ptr: SqlPointer, + buffer_length: SqlInteger, + string_length_ptr: *mut SqlInteger, +) -> SqlReturn { + debug!( + ?connection_handle, + attribute, + ?value_ptr, + buffer_length, + "SQLGetConnectAttrW called", + ); + + crate::ffi_entry!("SQLGetConnectAttrW", unsafe { + sql_get_connect_attr_w_impl( + connection_handle, + attribute, + value_ptr, + buffer_length, + string_length_ptr, + ) + }) +} + +unsafe fn sql_get_connect_attr_w_impl( + connection_handle: SqlHandle, + attribute: SqlInteger, + value_ptr: SqlPointer, + buffer_length: SqlInteger, + string_length_ptr: *mut SqlInteger, +) -> SqlReturn { + if connection_handle.is_null() { + error!("SQLGetConnectAttrW: connection_handle is null"); + return SQL_INVALID_HANDLE; + } + + let dbc = unsafe { handle_from_raw::(connection_handle) }; + debug_assert_eq!( + dbc.object_type, + HandleType::Dbc, + "SQLGetConnectAttrW: handle is not a DBC" + ); + + let Ok(mut state) = dbc.inner.lock() else { + error!("SQLGetConnectAttrW: dbc mutex poisoned"); + return SQL_ERROR; + }; + free_errors(&mut state); + + // String-valued attributes are written as UTF-16 with a byte length, so they + // cannot share the SQLUINTEGER path below. + if attribute == SQL_ATTR_CURRENT_CATALOG { + if value_ptr.is_null() { + error!(attribute, "SQLGetConnectAttrW: value_ptr is null"); + post_diag(&mut state, ERR_INVALID_NULL_POINTER); + return SQL_ERROR; + } + let catalog: Vec = state + .current_catalog + .as_deref() + .unwrap_or_default() + .encode_utf16() + .collect(); + let capacity_chars = if buffer_length > 0 { + (buffer_length as usize) / size_of::() + } else { + 0 + }; + let truncated = unsafe { + copy_with_nul( + value_ptr.cast::(), + capacity_chars, + catalog.as_slice(), + ) + }; + unsafe { + write_if_some( + string_length_ptr, + (catalog.len() * size_of::()) as SqlInteger, + ) + }; + return if truncated { + post_diag(&mut state, ERR_STRING_RIGHT_TRUNCATION); + SQL_SUCCESS_WITH_INFO + } else { + SQL_SUCCESS + }; + } + + let value: u32 = match attribute { + SQL_ATTR_AUTOCOMMIT => { + if state.autocommit { + SQL_AUTOCOMMIT_ON + } else { + SQL_AUTOCOMMIT_OFF + } + } + SQL_ATTR_TXN_ISOLATION => state.txn_isolation, + SQL_ATTR_CONNECTION_DEAD => { + if state.dead || state.connection_state != ConnectionState::Connected { + SQL_CD_TRUE + } else { + SQL_CD_FALSE + } + } + SQL_ATTR_ACCESS_MODE => SQL_MODE_READ_WRITE, + // Timeouts and packet size are accepted but not enforced; report the + // ODBC defaults rather than an error so generic callers keep working. + SQL_ATTR_LOGIN_TIMEOUT | SQL_ATTR_CONNECTION_TIMEOUT => 0, + SQL_ATTR_PACKET_SIZE => 0, + _ => { + error!(attribute, "SQLGetConnectAttrW: unsupported attribute"); + post_diag(&mut state, ERR_OPTIONAL_FEATURE_NOT_IMPLEMENTED); + return SQL_ERROR; + } + }; + + if value_ptr.is_null() { + error!(attribute, "SQLGetConnectAttrW: value_ptr is null"); + post_diag(&mut state, ERR_INVALID_NULL_POINTER); + return SQL_ERROR; + } + + // SAFETY: every attribute answered above is a fixed-length SQLUINTEGER, and + // the caller guarantees `value_ptr` is writable for that type. + unsafe { std::ptr::write_unaligned(value_ptr as *mut u32, value) }; + unsafe { write_if_some(string_length_ptr, size_of::() as SqlInteger) }; + SQL_SUCCESS +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::odbc_types::SQL_NULL_HANDLE; + use crate::test_support::TestHandles; + + fn get_attr(handle: SqlHandle, attribute: SqlInteger) -> (SqlReturn, u32) { + let mut value: u32 = u32::MAX; + let ret = unsafe { + sql_get_connect_attr_w( + handle, + attribute, + std::ptr::from_mut(&mut value) as SqlPointer, + size_of::() as SqlInteger, + std::ptr::null_mut(), + ) + }; + (ret, value) + } + + #[test] + fn null_handle_returns_invalid_handle() { + let (ret, _) = get_attr(SQL_NULL_HANDLE, SQL_ATTR_AUTOCOMMIT); + assert_eq!(ret, SQL_INVALID_HANDLE); + } + + #[test] + fn autocommit_defaults_to_on() { + let h = TestHandles::with_env_dbc(); + let (ret, value) = get_attr(h.dbc, SQL_ATTR_AUTOCOMMIT); + assert_eq!(ret, SQL_SUCCESS); + assert_eq!(value, SQL_AUTOCOMMIT_ON); + } + + #[test] + fn disconnected_connection_reports_dead() { + let h = TestHandles::with_env_dbc(); + let (ret, value) = get_attr(h.dbc, SQL_ATTR_CONNECTION_DEAD); + assert_eq!(ret, SQL_SUCCESS); + assert_eq!(value, SQL_CD_TRUE); + } + + #[test] + fn connected_connection_reports_alive() { + let h = TestHandles::with_env_dbc(); + h.mark_dbc_connected(); + let (ret, value) = get_attr(h.dbc, SQL_ATTR_CONNECTION_DEAD); + assert_eq!(ret, SQL_SUCCESS); + assert_eq!(value, SQL_CD_FALSE); + } + + #[test] + fn unsupported_attribute_returns_error() { + let h = TestHandles::with_env_dbc(); + let (ret, _) = get_attr(h.dbc, 4242); + assert_eq!(ret, SQL_ERROR); + } + + #[test] + fn null_value_pointer_is_rejected() { + let h = TestHandles::with_env_dbc(); + let ret = unsafe { + sql_get_connect_attr_w( + h.dbc, + SQL_ATTR_AUTOCOMMIT, + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + ) + }; + assert_eq!(ret, SQL_ERROR); + } +} diff --git a/mssql-odbc/src/api/get_data.rs b/mssql-odbc/src/api/get_data.rs index 1714c82a..5a48e219 100644 --- a/mssql-odbc/src/api/get_data.rs +++ b/mssql-odbc/src/api/get_data.rs @@ -3,19 +3,22 @@ //! Minimal SQLGetData implementation for Phase 1. +use mssql_tds::datatypes::column_values::ColumnValues; use tracing::{debug, error}; +use super::cdata::{ + StreamPayload, WriteError, WriteOutcome, stream_payload, wchar_capacity, write_c_value, +}; use super::odbc_types::{ - SQL_C_CHAR, SQL_C_WCHAR, SQL_ERROR, SQL_INVALID_HANDLE, SQL_NULL_DATA, SQL_SUCCESS, + SQL_C_CHAR, SQL_ERROR, SQL_INVALID_HANDLE, SQL_NO_DATA, SQL_NULL_DATA, SQL_SUCCESS, SQL_SUCCESS_WITH_INFO, SqlHandle, SqlLen, SqlPointer, SqlReturn, SqlSmallInt, SqlUSmallInt, + SqlWChar, }; use super::sqlstate::*; -use crate::api::odbc_types::SqlWChar; -use crate::api::util::{copy_with_nul, write_if_some}; +use super::util::write_if_some; use crate::error::{free_errors, post_sql_error}; -use crate::handles::stmt::STMT_STATE_CURSOR_OPEN; +use crate::handles::stmt::{STMT_STATE_CURSOR_OPEN, StmtState}; use crate::handles::{HandleType, StmtHandle, handle_from_raw}; -use mssql_tds::datatypes::column_values::ColumnValues; /// Implements SQLGetData for current-row retrieval. /// @@ -121,120 +124,212 @@ fn sql_get_data_safe( return SQL_ERROR; } - if target_type != SQL_C_CHAR && target_type != SQL_C_WCHAR { - post_sql_error( - &mut stmt_state, - SQLSTATE_HYC00, - 0, - "Target type not yet implemented", - ); + let value = row[col_index - 1].clone(); + + // A NULL buffer is the "how long is this value?" probe (and, for + // sql_variant, the call that primes SQL_CA_SS_VARIANT_TYPE). It must report + // the length without consuming any of the value, and must succeed even when + // the requested C type could not actually render the value — clients probe + // with SQL_C_BINARY before they know the underlying type. `BufferLength` is + // ignored for fixed-width C types, so a zero length only means "probe" for + // the character and binary targets. + // A NULL value can only be reported through the indicator, so an + // application that supplies none gets SQLSTATE 22002 and no buffer write. + // Callers depend on the failure to distinguish NULL from a real value. + if matches!(value, ColumnValues::Null) && strlen_or_ind_ptr.is_null() { + post_diag(&mut stmt_state, ERR_INDICATOR_REQUIRED); return SQL_ERROR; } - // Output buffer capacity in element units (u8 for SQL_C_CHAR, SqlWChar for - // SQL_C_WCHAR). buffer_length is always in bytes per the ODBC spec. - let buf_elements = if target_type == SQL_C_WCHAR { - (buffer_length as usize) / std::mem::size_of::() - } else { - buffer_length as usize - }; + let streamable = stream_payload(&value, target_type).is_some(); + if target_value_ptr.is_null() || (buffer_length == 0 && streamable) { + let indicator = if matches!(value, ColumnValues::Null) { + SQL_NULL_DATA + } else { + probe_length(&value, target_type) + }; + unsafe { write_if_some(strlen_or_ind_ptr, indicator) }; + return SQL_SUCCESS; + } - let value = &row[col_index - 1]; + // NULL is reported the same way for every target type: indicator + // SQL_NULL_DATA and SQL_SUCCESS. It must not go through the streaming path, + // which has no payload to render and would report a bogus conversion error + // for binary/UDT columns that clients fetch with SQL_C_BINARY. if matches!(value, ColumnValues::Null) { - unsafe { write_if_some(strlen_or_ind_ptr, SQL_NULL_DATA) }; - // Write a NUL terminator into the caller buffer when there's room. The - // helper handles null `dst` and zero-length uniformly. - if target_type == SQL_C_WCHAR { - unsafe { - copy_with_nul(target_value_ptr as *mut SqlWChar, buf_elements, &[]); - } - } else { - unsafe { - copy_with_nul(target_value_ptr as *mut u8, buf_elements, &[]); - } + if stmt_state.getdata_col == Some(col_index) && stmt_state.getdata_done { + return SQL_NO_DATA; } + stmt_state.getdata_col = Some(col_index); + stmt_state.getdata_offset = 0; + stmt_state.getdata_done = true; + unsafe { write_if_some(strlen_or_ind_ptr, SQL_NULL_DATA) }; return SQL_SUCCESS; } - let Some(as_text) = column_value_to_text(value) else { - post_sql_error( + if let Some(payload) = stream_payload(&value, target_type) { + let payload = match payload { + Ok(payload) => payload, + Err(WriteError::RestrictedConversion) => { + post_diag(&mut stmt_state, ERR_RESTRICTED_DATA_TYPE); + return SQL_ERROR; + } + Err(_) => { + post_diag(&mut stmt_state, ERR_INVALID_C_DATA_TYPE); + return SQL_ERROR; + } + }; + return get_data_chunk( &mut stmt_state, - SQLSTATE_HYC00, - 0, - "Column type conversion not yet implemented", + col_index, + &payload, + target_value_ptr, + buffer_length, + strlen_or_ind_ptr, ); - return SQL_ERROR; - }; + } - if target_type == SQL_C_WCHAR { - let utf16: Vec = as_text.encode_utf16().collect(); - write_string_result( - &mut stmt_state, - &utf16, - target_value_ptr as *mut SqlWChar, - buf_elements, - strlen_or_ind_ptr, - ) - } else { - write_string_result( - &mut stmt_state, - as_text.as_bytes(), - target_value_ptr as *mut u8, - buf_elements, + // Fixed-width targets are delivered whole; a repeat call yields SQL_NO_DATA. + if stmt_state.getdata_col == Some(col_index) && stmt_state.getdata_done { + return SQL_NO_DATA; + } + stmt_state.getdata_col = Some(col_index); + stmt_state.getdata_offset = 0; + stmt_state.getdata_done = true; + + match unsafe { + write_c_value( + &value, + target_type, + target_value_ptr, + buffer_length, strlen_or_ind_ptr, ) + } { + Ok(WriteOutcome::Complete) => SQL_SUCCESS, + Ok(WriteOutcome::Truncated) => { + post_diag(&mut stmt_state, ERR_STRING_RIGHT_TRUNCATION); + SQL_SUCCESS_WITH_INFO + } + Err(WriteError::InvalidCType) => { + post_diag(&mut stmt_state, ERR_INVALID_C_DATA_TYPE); + SQL_ERROR + } + Err(WriteError::RestrictedConversion) => { + post_diag(&mut stmt_state, ERR_RESTRICTED_DATA_TYPE); + SQL_ERROR + } + Err(WriteError::OutOfRange) => { + post_sql_error( + &mut stmt_state, + SQLSTATE_22003, + 0, + "Numeric value out of range", + ); + SQL_ERROR + } } } -/// Writes `src` to the caller's output buffer with ODBC string semantics: -/// the indicator (when present) reports the untruncated byte length, the -/// payload is NUL-terminated within the buffer, and truncation is reported via -/// SQLSTATE 01004 + `SQL_SUCCESS_WITH_INFO`. +/// Byte length reported for a zero-length `SQLGetData` probe. /// -/// `buf_elements` is the buffer capacity in units of `T` (not bytes). +/// Falls back to the narrow rendering when the requested C type cannot express +/// the value, so that a probe never fails. +fn probe_length(value: &ColumnValues, target_type: SqlSmallInt) -> SqlLen { + match stream_payload(value, target_type) { + Some(Ok(StreamPayload::Narrow(b))) | Some(Ok(StreamPayload::Binary(b))) => { + b.len() as SqlLen + } + Some(Ok(StreamPayload::Wide(w))) => (w.len() * size_of::()) as SqlLen, + _ => match stream_payload(value, SQL_C_CHAR) { + Some(Ok(StreamPayload::Narrow(b))) => b.len() as SqlLen, + _ => 0, + }, + } +} + +/// Copies the next chunk of a streamable column value into the caller's buffer, +/// advancing the per-column offset. /// -/// The caller-provided pointers are written through small `unsafe` blocks -/// inside this function; both pointer arguments are obligations of the FFI -/// caller (validated against the buffer length passed by the DM). -fn write_string_result( - stmt_state: &mut crate::handles::stmt::StmtState, - src: &[T], - target_value_ptr: *mut T, - buf_elements: usize, +/// Returns `SQL_SUCCESS_WITH_INFO` (01004) while data remains, `SQL_SUCCESS` on +/// the chunk that completes the value, and `SQL_NO_DATA` on any call after that. +/// The indicator always reports the number of bytes still available *before* +/// this call, which is what ODBC clients use to size the next read. +fn get_data_chunk( + stmt_state: &mut StmtState, + col_index: usize, + payload: &StreamPayload, + target_value_ptr: SqlPointer, + buffer_length: SqlLen, strlen_or_ind_ptr: *mut SqlLen, ) -> SqlReturn { - let byte_len = std::mem::size_of_val(src) as SqlLen; - unsafe { write_if_some(strlen_or_ind_ptr, byte_len) }; - let truncated = unsafe { copy_with_nul(target_value_ptr, buf_elements, src) }; - if truncated { - post_diag(stmt_state, ERR_STRING_RIGHT_TRUNCATION); - SQL_SUCCESS_WITH_INFO - } else { - SQL_SUCCESS + if stmt_state.getdata_col != Some(col_index) { + stmt_state.getdata_col = Some(col_index); + stmt_state.getdata_offset = 0; + stmt_state.getdata_done = false; } -} -fn column_value_to_text(v: &ColumnValues) -> Option { - match v { - ColumnValues::TinyInt(x) => Some(x.to_string()), - ColumnValues::SmallInt(x) => Some(x.to_string()), - ColumnValues::Int(x) => Some(x.to_string()), - ColumnValues::BigInt(x) => Some(x.to_string()), - ColumnValues::Real(x) => Some(x.to_string()), - ColumnValues::Float(x) => Some(x.to_string()), - ColumnValues::Bit(x) => Some(if *x { "1".into() } else { "0".into() }), - ColumnValues::String(s) => Some(s.to_utf8_string()), - ColumnValues::Uuid(u) => Some(u.to_string()), - ColumnValues::Null => Some(String::new()), - _ => None, + let (total_units, unit_size) = match payload { + StreamPayload::Narrow(b) | StreamPayload::Binary(b) => (b.len(), 1usize), + StreamPayload::Wide(w) => (w.len(), size_of::()), + }; + + if stmt_state.getdata_done { + return SQL_NO_DATA; + } + + let offset = stmt_state.getdata_offset; + let remaining = total_units.saturating_sub(offset); + unsafe { write_if_some(strlen_or_ind_ptr, (remaining * unit_size) as SqlLen) }; + + // Character targets reserve one unit for the terminator; binary does not. + let capacity = match payload { + StreamPayload::Narrow(_) => (buffer_length.max(0) as usize).saturating_sub(1), + StreamPayload::Wide(_) => wchar_capacity(buffer_length).saturating_sub(1), + StreamPayload::Binary(_) => buffer_length.max(0) as usize, + }; + let copied = remaining.min(capacity); + + unsafe { + match payload { + StreamPayload::Narrow(b) => { + let dst = target_value_ptr as *mut u8; + std::ptr::copy_nonoverlapping(b[offset..offset + copied].as_ptr(), dst, copied); + *dst.add(copied) = 0; + } + StreamPayload::Wide(w) => { + let dst = target_value_ptr as *mut SqlWChar; + std::ptr::copy_nonoverlapping(w[offset..offset + copied].as_ptr(), dst, copied); + *dst.add(copied) = 0; + } + StreamPayload::Binary(b) => { + std::ptr::copy_nonoverlapping( + b[offset..offset + copied].as_ptr(), + target_value_ptr as *mut u8, + copied, + ); + } + } + } + + stmt_state.getdata_offset = offset + copied; + if stmt_state.getdata_offset >= total_units { + stmt_state.getdata_done = true; + SQL_SUCCESS + } else { + post_diag(stmt_state, ERR_STRING_RIGHT_TRUNCATION); + SQL_SUCCESS_WITH_INFO } } #[cfg(test)] mod tests { use super::*; - use crate::api::odbc_types::{SQL_C_LONG, SQL_NULL_HANDLE}; + use crate::api::odbc_types::{ + SQL_C_CHAR, SQL_C_LONG, SQL_C_WCHAR, SQL_NULL_DATA, SQL_NULL_HANDLE, SqlWChar, + }; use crate::test_support::TestHandles; + use mssql_tds::datatypes::column_values::ColumnValues; use mssql_tds::datatypes::sql_string::SqlString; #[test] @@ -391,7 +486,7 @@ mod tests { sql_get_data( stmt, 1, - SQL_C_LONG, + 12345, (&mut out as *mut i32).cast(), std::mem::size_of::() as SqlLen, &mut ind, @@ -400,6 +495,34 @@ mod tests { assert_eq!(ret, SQL_ERROR); } + #[test] + fn get_data_long_target_type_succeeds() { + let h = TestHandles::with_env_dbc_stmt(); + let stmt = h.stmt; + let stmt_handle = unsafe { handle_from_raw::(stmt) }; + { + let mut s = stmt_handle.inner.lock().unwrap(); + s.set_state(STMT_STATE_CURSOR_OPEN); + s.current_row = Some(vec![ColumnValues::Int(7)]); + } + + let mut out: i32 = 0; + let mut ind: SqlLen = 0; + let ret = unsafe { + sql_get_data( + stmt, + 1, + SQL_C_LONG, + (&mut out as *mut i32).cast(), + std::mem::size_of::() as SqlLen, + &mut ind, + ) + }; + assert_eq!(ret, SQL_SUCCESS); + assert_eq!(out, 7); + assert_eq!(ind, 4); + } + #[test] fn get_data_invalid_column_index() { let h = TestHandles::with_env_dbc_stmt(); @@ -519,8 +642,8 @@ mod tests { }; assert_eq!(ret, SQL_SUCCESS); assert_eq!(ind, SQL_NULL_DATA); - // First slot must be NUL; nothing else touched. - assert_eq!(buf[0], 0); - assert_eq!(&buf[1..], &[0xDEAD; 3]); + // A NULL is reported through the indicator alone; ODBC leaves the + // buffer contents undefined, so nothing is written. + assert_eq!(buf, [0xDEAD; 4]); } } diff --git a/mssql-odbc/src/api/get_info.rs b/mssql-odbc/src/api/get_info.rs index 5db82963..c1566b57 100644 --- a/mssql-odbc/src/api/get_info.rs +++ b/mssql-odbc/src/api/get_info.rs @@ -20,6 +20,44 @@ use crate::api::util::{copy_with_nul, write_if_some}; use crate::error::free_errors; use crate::handles::{DbcHandle, HandleType, handle_from_raw}; +const SQL_DATA_SOURCE_NAME: SqlUSmallInt = 2; +const SQL_SERVER_NAME: SqlUSmallInt = 13; +const SQL_SEARCH_PATTERN_ESCAPE: SqlUSmallInt = 14; +const SQL_ACCESSIBLE_TABLES: SqlUSmallInt = 19; +const SQL_ACCESSIBLE_PROCEDURES: SqlUSmallInt = 20; +const SQL_PROCEDURES: SqlUSmallInt = 21; +const SQL_DATA_SOURCE_READ_ONLY: SqlUSmallInt = 25; +const SQL_DEFAULT_TXN_ISOLATION: SqlUSmallInt = 26; +const SQL_EXPRESSIONS_IN_ORDERBY: SqlUSmallInt = 27; +const SQL_MAX_COLUMN_NAME_LEN: SqlUSmallInt = 30; +const SQL_MAX_SCHEMA_NAME_LEN: SqlUSmallInt = 32; +const SQL_MAX_CATALOG_NAME_LEN: SqlUSmallInt = 34; +const SQL_MAX_TABLE_NAME_LEN: SqlUSmallInt = 35; +const SQL_MULTIPLE_ACTIVE_TXN: SqlUSmallInt = 37; +const SQL_OUTER_JOINS: SqlUSmallInt = 38; +const SQL_SCHEMA_TERM: SqlUSmallInt = 39; +const SQL_PROCEDURE_TERM: SqlUSmallInt = 40; +const SQL_CATALOG_NAME_SEPARATOR: SqlUSmallInt = 41; +const SQL_CATALOG_TERM: SqlUSmallInt = 42; +const SQL_TABLE_TERM: SqlUSmallInt = 45; +const SQL_TXN_CAPABLE: SqlUSmallInt = 46; +const SQL_USER_NAME: SqlUSmallInt = 47; +const SQL_NUMERIC_FUNCTIONS: SqlUSmallInt = 49; +const SQL_STRING_FUNCTIONS: SqlUSmallInt = 50; +const SQL_DATETIME_FUNCTIONS: SqlUSmallInt = 51; +const SQL_KEYWORDS: SqlUSmallInt = 89; +const SQL_SPECIAL_CHARACTERS: SqlUSmallInt = 94; +const SQL_MAX_STATEMENT_LEN: SqlUSmallInt = 105; +const SQL_LIKE_ESCAPE_CLAUSE: SqlUSmallInt = 113; +const SQL_SQL_CONFORMANCE: SqlUSmallInt = 118; +const SQL_MAX_IDENTIFIER_LEN: SqlUSmallInt = 10005; + +const SQL_TC_ALL: u16 = 2; +const SQL_SC_SQL92_ENTRY: u32 = 0x0000_0001; +const SQL_FN_NUM_SPT: u32 = 0x00FF_FFFF; +const SQL_FN_STR_SPT: u32 = 0x004F_FFFF; +const SQL_FN_TD_SPT: u32 = 0x001F_FFFF; + /// Returns driver/data-source metadata for a connection. /// /// # Safety @@ -104,6 +142,13 @@ fn sql_get_info_w_safe( write_u16(info_value_ptr, 0, string_length_ptr) } SQL_ACTIVE_STATEMENTS => write_u16(info_value_ptr, 0, string_length_ptr), + SQL_DATA_SOURCE_NAME => write_wide_str( + &mut state, + info_value_ptr, + buffer_length, + string_length_ptr, + "", + ), SQL_DRIVER_NAME => write_wide_str( &mut state, info_value_ptr, @@ -125,6 +170,106 @@ fn sql_get_info_w_safe( string_length_ptr, "03.80", ), + SQL_SERVER_NAME => write_wide_str( + &mut state, + info_value_ptr, + buffer_length, + string_length_ptr, + "localhost", + ), + SQL_SEARCH_PATTERN_ESCAPE => write_wide_str( + &mut state, + info_value_ptr, + buffer_length, + string_length_ptr, + "\\", + ), + SQL_ACCESSIBLE_TABLES + | SQL_ACCESSIBLE_PROCEDURES + | SQL_EXPRESSIONS_IN_ORDERBY + | SQL_MULTIPLE_ACTIVE_TXN + | SQL_OUTER_JOINS + | SQL_PROCEDURES + | SQL_LIKE_ESCAPE_CLAUSE + | SQL_NEED_LONG_DATA_LEN => write_wide_str( + &mut state, + info_value_ptr, + buffer_length, + string_length_ptr, + "Y", + ), + SQL_DATA_SOURCE_READ_ONLY => write_wide_str( + &mut state, + info_value_ptr, + buffer_length, + string_length_ptr, + "N", + ), + SQL_DEFAULT_TXN_ISOLATION => write_u32( + info_value_ptr, + crate::api::odbc_types::SQL_TXN_READ_COMMITTED, + string_length_ptr, + ), + SQL_MAX_COLUMN_NAME_LEN + | SQL_MAX_SCHEMA_NAME_LEN + | SQL_MAX_CATALOG_NAME_LEN + | SQL_MAX_TABLE_NAME_LEN + | SQL_MAX_IDENTIFIER_LEN => write_u16(info_value_ptr, 128, string_length_ptr), + SQL_MAX_STATEMENT_LEN => write_u32(info_value_ptr, 0, string_length_ptr), + SQL_SCHEMA_TERM => write_wide_str( + &mut state, + info_value_ptr, + buffer_length, + string_length_ptr, + "owner", + ), + SQL_PROCEDURE_TERM => write_wide_str( + &mut state, + info_value_ptr, + buffer_length, + string_length_ptr, + "stored procedure", + ), + SQL_CATALOG_NAME_SEPARATOR => write_wide_str( + &mut state, + info_value_ptr, + buffer_length, + string_length_ptr, + ".", + ), + SQL_CATALOG_TERM => write_wide_str( + &mut state, + info_value_ptr, + buffer_length, + string_length_ptr, + "database", + ), + SQL_TABLE_TERM => write_wide_str( + &mut state, + info_value_ptr, + buffer_length, + string_length_ptr, + "table", + ), + SQL_TXN_CAPABLE => write_u16(info_value_ptr, SQL_TC_ALL, string_length_ptr), + SQL_USER_NAME => write_wide_str( + &mut state, + info_value_ptr, + buffer_length, + string_length_ptr, + "", + ), + SQL_NUMERIC_FUNCTIONS => write_u32(info_value_ptr, SQL_FN_NUM_SPT, string_length_ptr), + SQL_STRING_FUNCTIONS => write_u32(info_value_ptr, SQL_FN_STR_SPT, string_length_ptr), + SQL_DATETIME_FUNCTIONS => write_u32(info_value_ptr, SQL_FN_TD_SPT, string_length_ptr), + SQL_KEYWORDS | SQL_SPECIAL_CHARACTERS => write_wide_str( + &mut state, + info_value_ptr, + buffer_length, + string_length_ptr, + "", + ), + SQL_SQL_CONFORMANCE => write_u32(info_value_ptr, SQL_SC_SQL92_ENTRY, string_length_ptr), SQL_ODBC_API_CONFORMANCE => write_u16(info_value_ptr, SQL_OAC_LEVEL2, string_length_ptr), SQL_ODBC_SQL_CONFORMANCE => write_u16(info_value_ptr, SQL_OSC_CORE, string_length_ptr), SQL_CURSOR_COMMIT_BEHAVIOR => write_u16(info_value_ptr, SQL_CB_CLOSE, string_length_ptr), @@ -166,13 +311,6 @@ fn sql_get_info_w_safe( string_length_ptr, "\"", ), - SQL_NEED_LONG_DATA_LEN => write_wide_str( - &mut state, - info_value_ptr, - buffer_length, - string_length_ptr, - "N", - ), SQL_ASYNC_DBC_FUNCTIONS => { write_u32(info_value_ptr, SQL_ASYNC_DBC_NOT_CAPABLE, string_length_ptr) } @@ -316,6 +454,12 @@ mod tests { (SQL_ODBC_SQL_CONFORMANCE, SQL_OSC_CORE), (SQL_CURSOR_COMMIT_BEHAVIOR, SQL_CB_CLOSE), (SQL_CURSOR_ROLLBACK_BEHAVIOR, SQL_CB_CLOSE), + (SQL_MAX_COLUMN_NAME_LEN, 128), + (SQL_MAX_SCHEMA_NAME_LEN, 128), + (SQL_MAX_CATALOG_NAME_LEN, 128), + (SQL_MAX_TABLE_NAME_LEN, 128), + (SQL_MAX_IDENTIFIER_LEN, 128), + (SQL_TXN_CAPABLE, SQL_TC_ALL), ] { let (rc, val, len) = get_u16(h.dbc, info_type); assert_eq!(rc, SQL_SUCCESS, "info_type {info_type}"); @@ -331,6 +475,15 @@ mod tests { (SQL_GETDATA_EXTENSIONS, SQL_GD_ANY_COLUMN | SQL_GD_ANY_ORDER), (SQL_ASYNC_DBC_FUNCTIONS, SQL_ASYNC_DBC_NOT_CAPABLE), (SQL_ASYNC_NOTIFICATION, SQL_ASYNC_NOTIFICATION_NOT_CAPABLE), + ( + SQL_DEFAULT_TXN_ISOLATION, + crate::api::odbc_types::SQL_TXN_READ_COMMITTED, + ), + (SQL_MAX_STATEMENT_LEN, 0), + (SQL_NUMERIC_FUNCTIONS, SQL_FN_NUM_SPT), + (SQL_STRING_FUNCTIONS, SQL_FN_STR_SPT), + (SQL_DATETIME_FUNCTIONS, SQL_FN_TD_SPT), + (SQL_SQL_CONFORMANCE, SQL_SC_SQL92_ENTRY), ] { let (rc, val, len) = get_u32(h.dbc, info_type); assert_eq!(rc, SQL_SUCCESS, "info_type {info_type}"); @@ -379,6 +532,44 @@ mod tests { assert_eq!(buf[n], 0); } + #[test] + fn getinfo_string_table_reports_expected_values() { + let h = TestHandles::with_env_dbc(); + for (info_type, expected) in [ + (SQL_SERVER_NAME, "localhost"), + (SQL_SEARCH_PATTERN_ESCAPE, "\\"), + (SQL_ACCESSIBLE_TABLES, "Y"), + (SQL_ACCESSIBLE_PROCEDURES, "Y"), + (SQL_PROCEDURES, "Y"), + (SQL_DATA_SOURCE_READ_ONLY, "N"), + (SQL_EXPRESSIONS_IN_ORDERBY, "Y"), + (SQL_MULTIPLE_ACTIVE_TXN, "Y"), + (SQL_OUTER_JOINS, "Y"), + (SQL_SCHEMA_TERM, "owner"), + (SQL_PROCEDURE_TERM, "stored procedure"), + (SQL_CATALOG_NAME_SEPARATOR, "."), + (SQL_CATALOG_TERM, "database"), + (SQL_TABLE_TERM, "table"), + (SQL_LIKE_ESCAPE_CLAUSE, "Y"), + (SQL_NEED_LONG_DATA_LEN, "Y"), + ] { + let mut buf = [0u16; 32]; + let mut len: SqlSmallInt = -1; + let rc = unsafe { + sql_get_info_w( + h.dbc, + info_type, + buf.as_mut_ptr() as SqlPointer, + (buf.len() * std::mem::size_of::()) as SqlSmallInt, + &mut len, + ) + }; + assert_eq!(rc, SQL_SUCCESS, "info_type {info_type}"); + let n = (len as usize) / 2; + assert_eq!(String::from_utf16_lossy(&buf[..n]), expected); + } + } + #[test] fn null_info_value_ptr_reports_length_only() { let h = TestHandles::with_env_dbc(); diff --git a/mssql-odbc/src/api/get_type_info.rs b/mssql-odbc/src/api/get_type_info.rs index c76decde..cfce264a 100644 --- a/mssql-odbc/src/api/get_type_info.rs +++ b/mssql-odbc/src/api/get_type_info.rs @@ -148,7 +148,7 @@ fn sql_get_type_info_w_safe( // failure cannot expose stale SQLNumResultCols/DescribeCol state. stmt_state.clear_state(STMT_STATE_EXEC_CONTEXT); stmt_state.column_metadata.clear(); - stmt_state.current_row = None; + stmt_state.reset_rows(); stmt_state.prepared_sql = None; // A cached prepared plan is superseded; release its server handle // (deferred) once we hold the client below. diff --git a/mssql-odbc/src/api/mod.rs b/mssql-odbc/src/api/mod.rs index 712b37e2..15f5efdc 100644 --- a/mssql-odbc/src/api/mod.rs +++ b/mssql-odbc/src/api/mod.rs @@ -2,17 +2,28 @@ // Licensed under the MIT License. pub(crate) mod alloc_handle; +pub(crate) mod ansi; +mod bind_col; mod bind_param; +mod catalog; +mod cdata; mod close_cursor; +mod col_attribute; +mod conn_exec; mod connect; +mod dae; +pub(crate) mod desc; mod describe_col; mod disconnect; mod driver_connect; +mod end_tran; mod exec_common; mod exec_direct; mod execute; pub(crate) mod fetch; +mod fetch_scroll; pub(crate) mod free_handle; +mod get_connect_attr; mod get_data; mod get_diag; mod get_env_attr; @@ -27,6 +38,7 @@ mod row_count; pub(crate) mod set_connect_attr; pub(crate) mod set_env_attr; pub(crate) mod set_stmt_attr; +pub(crate) mod spill; pub(crate) mod sqlstate; pub(crate) mod util; diff --git a/mssql-odbc/src/api/more_results.rs b/mssql-odbc/src/api/more_results.rs index 6ef0a2b7..0e5453d6 100644 --- a/mssql-odbc/src/api/more_results.rs +++ b/mssql-odbc/src/api/more_results.rs @@ -99,7 +99,18 @@ fn sql_more_results_safe(statement_handle: SqlHandle, stmt: &StmtHandle) -> SqlR client }; - match dbc.runtime.block_on(client.advance()) { + let pending = match stmt.inner.lock() { + Ok(mut ss) => ss.pending_result.take(), + Err(_) => None, + }; + let advanced = match pending { + // The boundary was already crossed while releasing the connection for + // another statement; the client is still positioned on that result. + Some(result) => Ok(result), + None => dbc.runtime.block_on(client.advance()), + }; + + match advanced { Ok(StatementResult::Rows) => { // Positioned on a new row-returning result set. Refresh metadata, // clear row state, keep CURSOR_OPEN and active_stmt set. @@ -114,7 +125,7 @@ fn sql_more_results_safe(statement_handle: SqlHandle, stmt: &StmtHandle) -> SqlR stmt_state.column_metadata = metadata; // Refresh the count for the newly-positioned result set (-1 for a SELECT). stmt_state.row_count = client.last_rows_affected(); - stmt_state.current_row = None; + stmt_state.reset_rows(); // Drain INFO only after the lock is held. let info_messages = client.take_info_messages(); let has_server_info = post_tds_info_messages(&mut stmt_state, &info_messages); @@ -147,7 +158,7 @@ fn sql_more_results_safe(statement_handle: SqlHandle, stmt: &StmtHandle) -> SqlR // Surface this no-row statement's own affected-row count for // SQLRowCount now that we are positioned on it. stmt_state.row_count = client.last_rows_affected(); - stmt_state.current_row = None; + stmt_state.reset_rows(); let info_messages = client.take_info_messages(); let has_server_info = post_tds_info_messages(&mut stmt_state, &info_messages); drop(stmt_state); diff --git a/mssql-odbc/src/api/odbc_types.rs b/mssql-odbc/src/api/odbc_types.rs index 82a804a5..fa0bf847 100644 --- a/mssql-odbc/src/api/odbc_types.rs +++ b/mssql-odbc/src/api/odbc_types.rs @@ -67,8 +67,45 @@ pub const SQL_ATTR_PACKET_SIZE: SqlInteger = 112; pub const SQL_ATTR_CONNECTION_TIMEOUT: SqlInteger = 113; pub const SQL_ATTR_ANSI_APP: SqlInteger = 115; +// Connection attributes the driver honors. +pub const SQL_ATTR_AUTOCOMMIT: SqlInteger = 102; +pub const SQL_ATTR_TXN_ISOLATION: SqlInteger = 108; +pub const SQL_ATTR_CURRENT_CATALOG: SqlInteger = 109; +pub const SQL_ATTR_RESET_CONNECTION: SqlInteger = 116; +pub const SQL_ATTR_CONNECTION_DEAD: SqlInteger = 1209; + +pub const SQL_AUTOCOMMIT_OFF: u32 = 0; +pub const SQL_AUTOCOMMIT_ON: u32 = 1; +pub const SQL_RESET_CONNECTION_YES: u32 = 1; +pub const SQL_CD_FALSE: u32 = 0; +pub const SQL_CD_TRUE: u32 = 1; + +// Transaction isolation levels (SQL_ATTR_TXN_ISOLATION / SQL_TXN_ISOLATION_OPTION). +pub const SQL_TXN_READ_UNCOMMITTED: u32 = 0x0000_0001; +pub const SQL_TXN_READ_COMMITTED: u32 = 0x0000_0002; +pub const SQL_TXN_REPEATABLE_READ: u32 = 0x0000_0004; +pub const SQL_TXN_SERIALIZABLE: u32 = 0x0000_0008; +/// msodbcsql-specific snapshot isolation level (`SQL_TXN_SS_SNAPSHOT`). +pub const SQL_TXN_SS_SNAPSHOT: u32 = 0x0000_0020; + +// SQLEndTran completion types. +pub const SQL_COMMIT: SqlSmallInt = 0; +pub const SQL_ROLLBACK: SqlSmallInt = 1; + +// SQLFetchScroll orientations. +pub const SQL_FETCH_NEXT: SqlSmallInt = 1; +pub const SQL_FETCH_FIRST: SqlSmallInt = 2; +pub const SQL_FETCH_LAST: SqlSmallInt = 3; +pub const SQL_FETCH_PRIOR: SqlSmallInt = 4; +pub const SQL_FETCH_ABSOLUTE: SqlSmallInt = 5; +pub const SQL_FETCH_RELATIVE: SqlSmallInt = 6; + // Sentinel `StringLength` meaning "the value is a pointer" (ODBC). pub const SQL_IS_POINTER: SqlInteger = -4; +pub const SQL_IS_UINTEGER: SqlInteger = -5; +pub const SQL_IS_INTEGER: SqlInteger = -6; +pub const SQL_IS_USMALLINT: SqlInteger = -7; +pub const SQL_IS_SMALLINT: SqlInteger = -8; // Four types of descriptor handles pub const SQL_ATTR_APP_ROW_DESC: SqlInteger = 10010; @@ -293,6 +330,42 @@ pub const SQL_C_SS_TIMESTAMPOFFSET: SqlSmallInt = SQL_C_TYPES_EXTENDED + 1; // 0 // sql_variant probe. pub const SQL_CA_SS_VARIANT_TYPE: SqlUSmallInt = 1215; +/// Descriptor field identifiers accepted by `SQLColAttributeW`. +pub const SQL_DESC_CONCISE_TYPE: SqlUSmallInt = 2; +pub const SQL_DESC_DISPLAY_SIZE: SqlUSmallInt = 6; +pub const SQL_DESC_UNSIGNED: SqlUSmallInt = 8; +pub const SQL_DESC_UPDATABLE: SqlUSmallInt = 10; +pub const SQL_DESC_AUTO_UNIQUE_VALUE: SqlUSmallInt = 11; +pub const SQL_DESC_CASE_SENSITIVE: SqlUSmallInt = 12; +pub const SQL_DESC_SEARCHABLE: SqlUSmallInt = 13; +pub const SQL_DESC_TYPE_NAME: SqlUSmallInt = 14; +pub const SQL_DESC_TABLE_NAME: SqlUSmallInt = 15; +pub const SQL_DESC_SCHEMA_NAME: SqlUSmallInt = 16; +pub const SQL_DESC_CATALOG_NAME: SqlUSmallInt = 17; +pub const SQL_DESC_LABEL: SqlUSmallInt = 18; +pub const SQL_DESC_BASE_COLUMN_NAME: SqlUSmallInt = 22; +pub const SQL_DESC_BASE_TABLE_NAME: SqlUSmallInt = 23; +pub const SQL_DESC_LITERAL_PREFIX: SqlUSmallInt = 27; +pub const SQL_DESC_LITERAL_SUFFIX: SqlUSmallInt = 28; +pub const SQL_DESC_LOCAL_TYPE_NAME: SqlUSmallInt = 29; +pub const SQL_DESC_FIXED_PREC_SCALE: SqlUSmallInt = 9; +pub const SQL_DESC_NUM_PREC_RADIX: SqlUSmallInt = 32; +pub const SQL_DESC_COUNT: SqlUSmallInt = 1001; +pub const SQL_DESC_TYPE: SqlUSmallInt = 1002; +pub const SQL_DESC_LENGTH: SqlUSmallInt = 1003; +pub const SQL_DESC_PRECISION: SqlUSmallInt = 1005; +pub const SQL_DESC_SCALE: SqlUSmallInt = 1006; +pub const SQL_DESC_NULLABLE: SqlUSmallInt = 1008; +pub const SQL_DESC_NAME: SqlUSmallInt = 1011; +pub const SQL_DESC_DATA_PTR: SqlUSmallInt = 1010; +pub const SQL_DESC_UNNAMED: SqlUSmallInt = 1012; +pub const SQL_DESC_OCTET_LENGTH: SqlUSmallInt = 1013; +/// ODBC 2.x column attribute aliases still emitted by some applications. +pub const SQL_COLUMN_NAME: SqlUSmallInt = 1; +pub const SQL_COLUMN_LENGTH: SqlUSmallInt = 3; +pub const SQL_COLUMN_PRECISION: SqlUSmallInt = 4; +pub const SQL_COLUMN_SCALE: SqlUSmallInt = 5; + // ---- Statement attribute identifiers (SQLSetStmtAttr / SQLGetStmtAttr) ------ pub const SQL_ATTR_ROW_BIND_TYPE: SqlInteger = 5; pub const SQL_ATTR_CURSOR_TYPE: SqlInteger = 6; @@ -319,6 +392,7 @@ pub const SQL_CONCUR_READ_ONLY: SqlULen = 1; pub const SQL_ROW_SUCCESS: SqlUSmallInt = 0; pub const SQL_ROW_SUCCESS_WITH_INFO: SqlUSmallInt = 6; pub const SQL_ROW_NOROW: SqlUSmallInt = 3; +pub const SQL_ROW_ERROR: SqlUSmallInt = 5; // ---- ODBC C interop structs (SQLBindCol / SQLGetData targets) --------------- /// Maximum byte length of a `SQL_NUMERIC_STRUCT` mantissa. diff --git a/mssql-odbc/src/api/prepare.rs b/mssql-odbc/src/api/prepare.rs index 74873673..87e6aa5e 100644 --- a/mssql-odbc/src/api/prepare.rs +++ b/mssql-odbc/src/api/prepare.rs @@ -73,6 +73,7 @@ unsafe fn sql_prepare_w_impl( ); let sql = unsafe { read_utf16(statement_text, text_length) }; + let sql = super::util::translate_odbc_escapes(&sql); sql_prepare_w_safe(stmt, sql) } @@ -112,9 +113,10 @@ fn sql_prepare_w_safe(stmt: &StmtHandle, sql: String) -> SqlReturn { // Re-preparing discards any prior prepared text and stale result metadata. // A prior prepared handle is orphaned for release at the next execute. stmt_state.prepared_sql = Some(sql); + stmt_state.described_params = None; stmt_state.orphan_prepared_handle(); stmt_state.column_metadata.clear(); - stmt_state.current_row = None; + stmt_state.reset_rows(); stmt_state.clear_state(STMT_STATE_EXEC_CONTEXT); stmt_state.set_state(STMT_STATE_PREPARED); diff --git a/mssql-odbc/src/api/set_connect_attr.rs b/mssql-odbc/src/api/set_connect_attr.rs index c144affc..5b36bf21 100644 --- a/mssql-odbc/src/api/set_connect_attr.rs +++ b/mssql-odbc/src/api/set_connect_attr.rs @@ -9,16 +9,52 @@ use tracing::{debug, error}; +use super::conn_exec::exec_on_connection; use super::sqlstate::*; +use super::util::read_utf16; use crate::api::odbc_types::{ - SQL_ATTR_ACCESS_MODE, SQL_ATTR_ANSI_APP, SQL_ATTR_CONNECTION_TIMEOUT, SQL_ATTR_LOGIN_TIMEOUT, - SQL_ATTR_PACKET_SIZE, SQL_COPT_SS_ACCESS_TOKEN, SQL_ERROR, SQL_INVALID_HANDLE, SQL_SUCCESS, - SqlHandle, SqlInteger, SqlPointer, SqlReturn, + SQL_ATTR_ACCESS_MODE, SQL_ATTR_ANSI_APP, SQL_ATTR_AUTOCOMMIT, SQL_ATTR_CONNECTION_TIMEOUT, + SQL_ATTR_CURRENT_CATALOG, SQL_ATTR_LOGIN_TIMEOUT, SQL_ATTR_PACKET_SIZE, + SQL_ATTR_RESET_CONNECTION, SQL_ATTR_TXN_ISOLATION, SQL_AUTOCOMMIT_OFF, SQL_AUTOCOMMIT_ON, + SQL_COPT_SS_ACCESS_TOKEN, SQL_ERROR, SQL_INVALID_HANDLE, SQL_NTS, SQL_SUCCESS, + SQL_TXN_READ_COMMITTED, SQL_TXN_READ_UNCOMMITTED, SQL_TXN_REPEATABLE_READ, + SQL_TXN_SERIALIZABLE, SQL_TXN_SS_SNAPSHOT, SqlHandle, SqlInteger, SqlPointer, SqlReturn, + SqlSmallInt, SqlWChar, }; use crate::error::{free_errors, post_sql_error}; use crate::handles::dbc::ConnectionState; use crate::handles::{DbcHandle, HandleType, handle_from_raw}; +/// Maps an ODBC isolation level to its `SET TRANSACTION ISOLATION LEVEL` clause. +pub(crate) fn isolation_level_sql(level: u32) -> Option<&'static str> { + match level { + SQL_TXN_READ_UNCOMMITTED => Some("READ UNCOMMITTED"), + SQL_TXN_READ_COMMITTED => Some("READ COMMITTED"), + SQL_TXN_REPEATABLE_READ => Some("REPEATABLE READ"), + SQL_TXN_SERIALIZABLE => Some("SERIALIZABLE"), + SQL_TXN_SS_SNAPSHOT => Some("SNAPSHOT"), + _ => None, + } +} + +/// Switches the session between autocommit and manual-commit mode. +/// +/// Manual-commit mode is expressed as `SET IMPLICIT_TRANSACTIONS ON`, matching +/// msodbcsql: the server opens a transaction on the next statement and +/// `SQLEndTran` closes it. Returning to autocommit commits any transaction that +/// is still open, because ODBC requires the switch itself to be a commit point. +pub(crate) fn apply_autocommit(dbc: &DbcHandle, autocommit: bool) -> SqlReturn { + let sql = if autocommit { + "IF @@TRANCOUNT > 0 COMMIT TRANSACTION; SET IMPLICIT_TRANSACTIONS OFF" + } else { + "SET IMPLICIT_TRANSACTIONS ON" + }; + match exec_on_connection(dbc, sql, "SQLSetConnectAttrW") { + Ok(()) => SQL_SUCCESS, + Err(rc) => rc, + } +} + /// Sets a connection attribute. /// /// For `SQL_COPT_SS_ACCESS_TOKEN`, `string_length` is ignored: real ODBC callers @@ -53,7 +89,7 @@ unsafe fn sql_set_connect_attr_w_impl( connection_handle: SqlHandle, attribute: SqlInteger, value_ptr: SqlPointer, - _string_length: SqlInteger, + string_length: SqlInteger, ) -> SqlReturn { if connection_handle.is_null() { error!("SQLSetConnectAttrW: connection_handle is null"); @@ -124,6 +160,124 @@ unsafe fn sql_set_connect_attr_w_impl( | SQL_ATTR_CONNECTION_TIMEOUT | SQL_ATTR_PACKET_SIZE | SQL_ATTR_ANSI_APP => SQL_SUCCESS, + SQL_ATTR_AUTOCOMMIT => { + let requested = value_ptr as usize as u32; + let autocommit = match requested { + SQL_AUTOCOMMIT_ON => true, + SQL_AUTOCOMMIT_OFF => false, + other => { + error!( + other, + "SQLSetConnectAttrW: invalid SQL_ATTR_AUTOCOMMIT value" + ); + post_diag(&mut state, ERR_INVALID_ATTRIBUTE_VALUE); + return SQL_ERROR; + } + }; + if autocommit == state.autocommit { + return SQL_SUCCESS; + } + state.autocommit = autocommit; + if state.connection_state != ConnectionState::Connected { + // Applied by SQLDriverConnect once the session exists. + return SQL_SUCCESS; + } + drop(state); + apply_autocommit(dbc, autocommit) + } + SQL_ATTR_TXN_ISOLATION => { + let requested = value_ptr as usize as u32; + let Some(level) = isolation_level_sql(requested) else { + error!( + requested, + "SQLSetConnectAttrW: invalid SQL_ATTR_TXN_ISOLATION value" + ); + post_diag(&mut state, ERR_INVALID_ATTRIBUTE_VALUE); + return SQL_ERROR; + }; + state.txn_isolation = requested; + if state.connection_state != ConnectionState::Connected { + return SQL_SUCCESS; + } + drop(state); + match exec_on_connection( + dbc, + &format!("SET TRANSACTION ISOLATION LEVEL {level}"), + "SQLSetConnectAttrW", + ) { + Ok(()) => SQL_SUCCESS, + Err(rc) => rc, + } + } + SQL_ATTR_CURRENT_CATALOG => { + if value_ptr.is_null() { + error!("SQLSetConnectAttrW: SQL_ATTR_CURRENT_CATALOG value is null"); + post_sql_error( + &mut state, + SQLSTATE_HY009, + 0, + "SQL_ATTR_CURRENT_CATALOG value pointer is null", + ); + return SQL_ERROR; + } + // `string_length` is in bytes (SQL_NTS when the caller passes a + // NUL-terminated string); `read_utf16` counts SQLWCHARs. + let chars = if string_length == SQL_NTS as SqlInteger { + SQL_NTS + } else { + match SqlSmallInt::try_from(string_length / 2) { + Ok(chars) => chars, + Err(_) => { + post_diag(&mut state, ERR_INVALID_ATTRIBUTE_VALUE); + return SQL_ERROR; + } + } + }; + let catalog = unsafe { read_utf16(value_ptr.cast::(), chars) }; + if catalog.is_empty() { + post_diag(&mut state, ERR_INVALID_ATTRIBUTE_VALUE); + return SQL_ERROR; + } + if state.connection_state != ConnectionState::Connected { + // Pre-connect: the catalog is carried by the connection string's + // Database keyword, which SQLDriverConnect already honors. + state.current_catalog = Some(catalog); + return SQL_SUCCESS; + } + drop(state); + let quoted = catalog.replace(']', "]]"); + match exec_on_connection(dbc, &format!("USE [{quoted}]"), "SQLSetConnectAttrW") { + Ok(()) => { + if let Ok(mut state) = dbc.inner.lock() { + state.current_catalog = Some(catalog); + } + SQL_SUCCESS + } + Err(rc) => rc, + } + } + SQL_ATTR_RESET_CONNECTION => { + // Pooling reset: the DM sets this just before returning a connection + // to the pool. There is no TDS reset primitive exposed here yet, so + // roll back any in-flight work and report success. + if state.connection_state != ConnectionState::Connected { + return SQL_SUCCESS; + } + let autocommit = state.autocommit; + drop(state); + if autocommit { + SQL_SUCCESS + } else { + match exec_on_connection( + dbc, + "IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION", + "SQLSetConnectAttrW", + ) { + Ok(()) => SQL_SUCCESS, + Err(rc) => rc, + } + } + } // Any other attribute is genuinely unsupported: surface a clear error // (HYC00) instead of silently pretending it took effect. _ => { diff --git a/mssql-odbc/src/api/set_stmt_attr.rs b/mssql-odbc/src/api/set_stmt_attr.rs index 082e8fd2..e0ff496d 100644 --- a/mssql-odbc/src/api/set_stmt_attr.rs +++ b/mssql-odbc/src/api/set_stmt_attr.rs @@ -30,8 +30,7 @@ use crate::api::odbc_types::{ SqlHandle, SqlInteger, SqlPointer, SqlReturn, SqlULen, SqlUSmallInt, }; use crate::api::sqlstate::{ - ERR_INVALID_ATTRIBUTE_IDENTIFIER, ERR_INVALID_ATTRIBUTE_VALUE, SQLSTATE_01S02, SQLSTATE_HYC00, - post_diag, + ERR_INVALID_ATTRIBUTE_IDENTIFIER, ERR_INVALID_ATTRIBUTE_VALUE, SQLSTATE_01S02, post_diag, }; use crate::api::util::write_if_some; use crate::error::{free_errors, post_sql_error}; @@ -121,29 +120,17 @@ fn sql_set_stmt_attr_w_safe( SQL_SUCCESS } SQL_ATTR_PARAMSET_SIZE => { - // Parameter arrays are not yet consumed (executemany batch insert is - // tracked separately). Accept the ODBC default of 1; reject a larger - // batch (HYC00) instead of silently executing only the first row, - // and reject 0 as an invalid value (HY024). + // Column-wise parameter arrays are executed row-by-row against the + // cached prepared handle (`execute.rs`). 0 is invalid (HY024). match value_ptr as SqlULen { - 1 => SQL_SUCCESS, 0 => { error!("SQLSetStmtAttrW: SQL_ATTR_PARAMSET_SIZE of 0 is invalid"); post_diag(&mut state, ERR_INVALID_ATTRIBUTE_VALUE); SQL_ERROR } n => { - error!( - paramset_size = n, - "SQLSetStmtAttrW: SQL_ATTR_PARAMSET_SIZE > 1 not supported" - ); - post_sql_error( - &mut state, - SQLSTATE_HYC00, - 0, - "Parameter arrays (SQL_ATTR_PARAMSET_SIZE > 1) are not supported", - ); - SQL_ERROR + state.paramset_size = n; + SQL_SUCCESS } } } @@ -527,11 +514,13 @@ mod tests { } #[test] - fn set_paramset_size_greater_than_one_rejected() { + fn set_paramset_size_greater_than_one_accepted() { let h = TestHandles::with_env_dbc_stmt(); let ret = unsafe { sql_set_stmt_attr_w(h.stmt, SQL_ATTR_PARAMSET_SIZE, 100 as SqlPointer, 0) }; - assert_eq!(ret, SQL_ERROR); + assert_eq!(ret, SQL_SUCCESS); + let stmt = unsafe { handle_from_raw::(h.stmt) }; + assert_eq!(stmt.inner.lock().unwrap().paramset_size, 100); } #[test] diff --git a/mssql-odbc/src/api/spill.rs b/mssql-odbc/src/api/spill.rs new file mode 100644 index 00000000..d319deba --- /dev/null +++ b/mssql-odbc/src/api/spill.rs @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Releasing a connection held by an open cursor by buffering its rows. +//! +//! ODBC without MARS allows a single active statement per connection, yet +//! msodbcsql happily serves a second statement while the first still has an +//! open cursor — as long as that cursor's result set already sits in the +//! driver's read buffer. Applications (mssql-python among them) rely on this: +//! they create a second cursor on the same connection while the first is only +//! partially consumed. +//! +//! This module reproduces that behaviour. When a statement finds the +//! connection claimed by another statement, it first asks the holder to spill: +//! the remaining rows of the holder's open result set are read off the wire +//! into memory, and if the batch ends there the connection is released. Rows +//! stay visible to the holder because `SQLFetch` drains the buffer before +//! touching the connection. +//! +//! Spilling is bounded. A result set larger than [`MAX_SPILL_ROWS`] keeps the +//! connection claimed and the caller still gets `HY000` — msodbcsql behaves +//! the same way once a result set outgrows its buffer. + +use std::collections::VecDeque; + +use mssql_tds::connection::tds_client::{ResultSet, StatementResult}; +use tracing::{debug, error}; + +use crate::api::odbc_types::SqlHandle; +use crate::handles::stmt::STMT_STATE_CURSOR_OPEN; +use crate::handles::{DbcHandle, StmtHandle, handle_from_raw}; + +/// Upper bound on rows buffered to free a connection. Chosen to keep the +/// worst-case footprint bounded while covering the small result sets that +/// interleaved-cursor applications actually produce. +const MAX_SPILL_ROWS: usize = 20_000; + +/// Attempts to release `dbc` from the statement identified by `busy_stmt` by +/// buffering the rest of its open result set. +/// +/// Returns `true` only when the connection is now idle. Any partially read +/// rows are always handed to the holder, so a `false` result never loses data. +/// +/// # Safety +/// `busy_stmt` is the `active_stmt` recorded on the connection, i.e. a live +/// statement handle allocated by this driver. +pub(crate) fn try_release_connection(dbc: &DbcHandle, busy_stmt: SqlHandle) -> bool { + if busy_stmt.is_null() { + return false; + } + let other: &StmtHandle = unsafe { handle_from_raw(busy_stmt) }; + + // Only an open, row-producing cursor can be spilled. Anything else holding + // the connection (a no-column result awaiting SQLMoreResults) must keep it. + let (spillable, already_eof) = match other.inner.lock() { + Ok(state) => ( + state.has_state(STMT_STATE_CURSOR_OPEN) && !state.column_metadata.is_empty(), + state.buffered_eof, + ), + Err(_) => return false, + }; + if !spillable { + return false; + } + + let mut client = { + let Ok(mut dbc_state) = dbc.inner.lock() else { + return false; + }; + if dbc_state.active_stmt != Some(busy_stmt) { + return false; + } + match dbc_state.client.take() { + Some(client) => client, + None => return false, + } + }; + + let mut rows: VecDeque> = + VecDeque::new(); + let mut reached_eof = already_eof; + let mut failed = false; + + while !reached_eof && rows.len() < MAX_SPILL_ROWS { + match dbc.runtime.block_on(client.next_row()) { + Ok(Some(row)) => rows.push_back(row), + Ok(None) => reached_eof = true, + Err(e) => { + error!(%e, "spill: failed reading ahead to release the connection"); + failed = true; + break; + } + } + } + + // The current result set is drained, but the batch's trailing DONE (and any + // further result set) is still on the wire. Cross that boundary here so a + // single-statement batch — the overwhelmingly common case — frees the + // connection. If another result follows, hand it to the holder as a pending + // result so its `SQLMoreResults` sees it without re-advancing. + let mut pending = None; + if reached_eof && !failed && client.has_open_batch() { + match dbc.runtime.block_on(client.advance()) { + Ok(StatementResult::End) => {} + Ok(other) => pending = Some(other), + Err(e) => { + error!(%e, "spill: failed advancing past the current result set"); + failed = true; + } + } + } + + // The batch is only finished when the current result set ended and no + // further result set follows; otherwise SQLMoreResults still needs the wire. + let released = reached_eof && !failed && pending.is_none() && !client.has_open_batch(); + + if let Ok(mut state) = other.inner.lock() { + state.buffered_rows.extend(rows); + state.buffered_eof = reached_eof; + state.pending_result = pending; + } + + if let Ok(mut dbc_state) = dbc.inner.lock() { + dbc_state.client = Some(client); + if released && dbc_state.active_stmt == Some(busy_stmt) { + dbc_state.active_stmt = None; + } + } + + debug!(released, "spill: read-ahead completed"); + released +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::odbc_types::SQL_NULL_HANDLE; + use crate::handles::handle_from_raw; + use crate::test_support::TestHandles; + + #[test] + fn null_statement_is_not_spillable() { + let handles = TestHandles::with_env_dbc(); + let dbc = unsafe { handle_from_raw::(handles.dbc) }; + assert!(!try_release_connection(dbc, SQL_NULL_HANDLE)); + } + + #[test] + fn statement_without_open_cursor_is_not_spillable() { + let handles = TestHandles::with_env_dbc_stmt(); + let dbc = unsafe { handle_from_raw::(handles.dbc) }; + assert!(!try_release_connection(dbc, handles.stmt)); + } +} diff --git a/mssql-odbc/src/api/sqlstate.rs b/mssql-odbc/src/api/sqlstate.rs index baa57637..0e1c21b0 100644 --- a/mssql-odbc/src/api/sqlstate.rs +++ b/mssql-odbc/src/api/sqlstate.rs @@ -17,6 +17,12 @@ pub(crate) const SQLSTATE_07009: [u8; 5] = *b"07009"; pub(crate) const SQLSTATE_08001: [u8; 5] = *b"08001"; pub(crate) const SQLSTATE_08003: [u8; 5] = *b"08003"; pub(crate) const SQLSTATE_24000: [u8; 5] = *b"24000"; +pub(crate) const SQLSTATE_42000: [u8; 5] = *b"42000"; +/// Numeric value out of range. +pub(crate) const SQLSTATE_22002: [u8; 5] = *b"22002"; +pub(crate) const SQLSTATE_22003: [u8; 5] = *b"22003"; +/// Fetch type out of range. +pub(crate) const SQLSTATE_HY106: [u8; 5] = *b"HY106"; pub(crate) const SQLSTATE_HY000: [u8; 5] = *b"HY000"; pub(crate) const SQLSTATE_HY003: [u8; 5] = *b"HY003"; pub(crate) const SQLSTATE_HY004: [u8; 5] = *b"HY004"; @@ -26,6 +32,8 @@ pub(crate) const SQLSTATE_HY010: [u8; 5] = *b"HY010"; pub(crate) const SQLSTATE_HY011: [u8; 5] = *b"HY011"; pub(crate) const SQLSTATE_HY024: [u8; 5] = *b"HY024"; pub(crate) const SQLSTATE_HY090: [u8; 5] = *b"HY090"; +/// Invalid descriptor field identifier. +pub(crate) const SQLSTATE_HY091: [u8; 5] = *b"HY091"; pub(crate) const SQLSTATE_HY092: [u8; 5] = *b"HY092"; pub(crate) const SQLSTATE_HY096: [u8; 5] = *b"HY096"; pub(crate) const SQLSTATE_HY110: [u8; 5] = *b"HY110"; @@ -89,6 +97,10 @@ pub(crate) const ERR_RESTRICTED_DATA_TYPE: DiagMsg = DiagMsg { state: SQLSTATE_07006, text: "Restricted data type attribute violation", }; +pub(crate) const ERR_INDICATOR_REQUIRED: DiagMsg = DiagMsg { + state: SQLSTATE_22002, + text: "Indicator variable required but not supplied", +}; pub(crate) const ERR_STRING_RIGHT_TRUNCATION: DiagMsg = DiagMsg { state: SQLSTATE_01004, text: "String data, right truncation", @@ -271,6 +283,21 @@ pub(crate) fn sqlstate_for_sql_error(error_number: u32) -> Option<[u8; 5]> { .map(|i| SERVER_ERROR_TO_SQL_STATE_MAP[i].1) } +/// SQLSTATE for a server error that is not in the explicit table. +/// +/// msodbcsql falls back to the error's severity class: informational messages +/// map to the general warning `01000`, ordinary statement-level errors to the +/// syntax/access-rule class `42000`, and only fatal (>= 19) errors to `HY000`. +/// Without this, every unmapped error surfaces as `HY000`, which callers treat +/// as a driver failure rather than a SQL error. +pub(crate) fn sqlstate_for_severity(class: i32) -> [u8; 5] { + match class { + ..=10 => SQLSTATE_01000, + 11..=18 => SQLSTATE_42000, + _ => SQLSTATE_HY000, + } +} + /// Post one ODBC diagnostic record per server error in `err`. /// /// For [`TdsError::SqlServerError`], iterates the server-reported errors in @@ -300,7 +327,15 @@ pub(crate) fn post_tds_error(state: &mut impl HasDiagnostics, err: &TdsError, de post_sql_error(state, default, 0, err.to_string()); } else { for e in &diagnostics.errors { - let sqlstate = sqlstate_for_sql_error(e.number).unwrap_or(default); + // The caller's `default` wins when it carries real context + // (e.g. `08001` at connect time); the severity fallback only + // refines the generic `HY000`. + let fallback = if default == SQLSTATE_HY000 { + sqlstate_for_severity(e.class) + } else { + default + }; + let sqlstate = sqlstate_for_sql_error(e.number).unwrap_or(fallback); let native = i32::try_from(e.number).unwrap_or(i32::MAX); post_sql_error( state, diff --git a/mssql-odbc/src/api/util.rs b/mssql-odbc/src/api/util.rs index e42ceb14..561e2b1e 100644 --- a/mssql-odbc/src/api/util.rs +++ b/mssql-odbc/src/api/util.rs @@ -77,6 +77,183 @@ pub(crate) unsafe fn read_utf16(ptr: *const SqlWChar, length: SqlSmallInt) -> St String::from_utf16_lossy(slice) } +/// Returns the text between `{` and its matching `}`, plus the index just past +/// the closing brace. +fn escape_body(chars: &[char], body_start: usize) -> (String, usize) { + let mut depth = 1usize; + let mut i = body_start; + let mut body = String::new(); + while i < chars.len() { + match chars[i] { + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + return (body, i + 1); + } + } + _ => {} + } + body.push(chars[i]); + i += 1; + } + (body, i) +} + +/// Offset just past the leading `call` keyword of an escape body. +fn keyword_span(body: &str) -> usize { + body.to_ascii_lowercase() + .find("call") + .map(|p| p + "call".len()) + .unwrap_or(0) +} + +/// Turns `proc(a, b)` into `proc a, b`, which is the only form `EXEC` accepts. +fn strip_call_parens(call: &str) -> String { + let call = call.trim(); + let Some(open) = call.find('(') else { + return call.to_string(); + }; + let Some(close) = call.rfind(')') else { + return call.to_string(); + }; + if close < open { + return call.to_string(); + } + format!( + "{} {}{}", + call[..open].trim(), + call[open + 1..close].trim(), + &call[close + 1..] + ) + .trim_end() + .to_string() +} + +/// Translates ODBC escape sequences into T-SQL. +/// +/// Applications write vendor-neutral escapes such as `{CALL proc(?)}`, +/// `{ts '2024-01-01 00:00:00'}` or `{fn UCASE(x)}`; SQL Server does not accept +/// the braces, so the driver has to unwrap them before sending the batch. +/// Escapes inside string literals, quoted identifiers, and comments are left +/// alone. +/// +/// The rewrite is deliberately shallow — it strips the braces and maps `CALL` +/// to `EXEC` — which covers what SQL Server understands natively. `{fn …}` and +/// `{oj …}` reduce to their body, and the datetime literal escapes reduce to +/// the quoted literal, which SQL Server converts implicitly. +pub(crate) fn translate_odbc_escapes(sql: &str) -> String { + if !sql.contains('{') { + return sql.to_string(); + } + + let mut out = String::with_capacity(sql.len()); + let bytes: Vec = sql.chars().collect(); + let mut i = 0usize; + // Depth of the escape bodies currently open, so the matching `}` is dropped. + let mut open_escapes: Vec<()> = Vec::new(); + + while i < bytes.len() { + let c = bytes[i]; + match c { + '\'' | '"' | '[' => { + let close = if c == '[' { ']' } else { c }; + out.push(c); + i += 1; + while i < bytes.len() { + out.push(bytes[i]); + if bytes[i] == close { + // A doubled delimiter is an escaped literal character. + if bytes.get(i + 1) == Some(&close) { + out.push(close); + i += 2; + continue; + } + i += 1; + break; + } + i += 1; + } + } + '-' if bytes.get(i + 1) == Some(&'-') => { + while i < bytes.len() && bytes[i] != '\n' { + out.push(bytes[i]); + i += 1; + } + } + '/' if bytes.get(i + 1) == Some(&'*') => { + while i < bytes.len() { + out.push(bytes[i]); + if bytes[i] == '/' && i > 0 && bytes[i - 1] == '*' && i > 1 { + i += 1; + break; + } + i += 1; + } + } + '{' => { + let body_start = i + 1; + let mut j = body_start; + while j < bytes.len() && bytes[j].is_whitespace() { + j += 1; + } + let word_start = j; + while j < bytes.len() && (bytes[j].is_alphanumeric() || bytes[j] == '_') { + j += 1; + } + let keyword: String = bytes[word_start..j].iter().collect(); + match keyword.to_ascii_lowercase().as_str() { + "call" => { + // `{CALL proc(a, b)}` becomes `EXEC proc a, b`: SQL + // Server rejects the parenthesised argument list. + let (body, next) = escape_body(&bytes, body_start); + out.push_str("EXEC "); + out.push_str(&strip_call_parens(&body[keyword_span(&body)..])); + i = next; + continue; + } + // `{? = call proc(…)}` returns the procedure's status; SQL + // Server spells that `EXEC ? = proc …`. + "" if bytes.get(word_start) == Some(&'?') => { + let (body, next) = escape_body(&bytes, body_start); + let after_call = body + .to_ascii_lowercase() + .find("call") + .map(|p| p + "call".len()) + .unwrap_or(0); + out.push_str("EXEC ? = "); + out.push_str(&strip_call_parens(&body[after_call..])); + i = next; + continue; + } + "fn" | "oj" | "escape" | "d" | "t" | "ts" | "guid" | "interval" | "limit" => { + if keyword.eq_ignore_ascii_case("escape") { + out.push_str("ESCAPE"); + } + i = j; + } + _ => { + // Not a recognised escape: emit the brace verbatim. + out.push('{'); + i = body_start; + continue; + } + } + open_escapes.push(()); + } + '}' if !open_escapes.is_empty() => { + open_escapes.pop(); + i += 1; + } + _ => { + out.push(c); + i += 1; + } + } + } + out +} + /// Rewrites ODBC `?` parameter markers to SQL Server named markers (`@P1`, /// `@P2`, …) and returns the rewritten SQL together with the marker count. /// @@ -231,9 +408,77 @@ pub(crate) fn rewrite_param_markers(sql: &str) -> (String, usize) { #[cfg(test)] mod tests { - use super::{copy_with_nul, read_utf16, rewrite_param_markers, write_if_some}; + use super::{ + copy_with_nul, read_utf16, rewrite_param_markers, translate_odbc_escapes, write_if_some, + }; use crate::api::odbc_types::{SQL_NTS, SqlWChar}; + #[test] + fn escape_without_braces_is_unchanged() { + assert_eq!(translate_odbc_escapes("SELECT 1"), "SELECT 1"); + } + + #[test] + fn escape_call_becomes_exec_without_parens() { + assert_eq!( + translate_odbc_escapes("{CALL dbo.GetProjects(?)}"), + "EXEC dbo.GetProjects ?" + ); + } + + #[test] + fn escape_call_without_arguments() { + assert_eq!( + translate_odbc_escapes("{call dbo.Refresh}"), + "EXEC dbo.Refresh" + ); + } + + #[test] + fn escape_call_with_return_value() { + assert_eq!( + translate_odbc_escapes("{? = call dbo.Total(?, ?)}"), + "EXEC ? = dbo.Total ?, ?" + ); + } + + #[test] + fn escape_scalar_function_is_unwrapped() { + assert_eq!( + translate_odbc_escapes("SELECT {fn UCASE(name)} FROM t"), + "SELECT UCASE(name) FROM t" + ); + } + + #[test] + fn escape_timestamp_literal_is_unwrapped() { + assert_eq!( + translate_odbc_escapes("SELECT {ts '2024-01-01 00:00:00'}"), + "SELECT '2024-01-01 00:00:00'" + ); + } + + #[test] + fn escape_like_escape_clause_is_rewritten() { + assert_eq!( + translate_odbc_escapes("WHERE a LIKE 'x!%' {escape '!'}"), + "WHERE a LIKE 'x!%' ESCAPE '!'" + ); + } + + #[test] + fn escape_inside_string_literal_is_left_alone() { + assert_eq!( + translate_odbc_escapes("SELECT '{CALL nope}'"), + "SELECT '{CALL nope}'" + ); + } + + #[test] + fn escape_unknown_keyword_passes_through() { + assert_eq!(translate_odbc_escapes("SELECT {json a}"), "SELECT {json a}"); + } + #[test] fn rewrite_no_markers_is_unchanged() { let (out, n) = rewrite_param_markers("SELECT 1"); diff --git a/mssql-odbc/src/connection/connection_string_parser.rs b/mssql-odbc/src/connection/connection_string_parser.rs index 30f8cfb1..9b494610 100644 --- a/mssql-odbc/src/connection/connection_string_parser.rs +++ b/mssql-odbc/src/connection/connection_string_parser.rs @@ -48,7 +48,6 @@ const KNOWN_IGNORED_KEYS: &[&str] = &[ "description", "desc", "driver", - "app", "wsid", "language", "network", @@ -145,6 +144,7 @@ enum ConnAttrKey { PacketSize, HostNameInCert, ServerCertificate, + App, Count, } @@ -199,6 +199,8 @@ pub(crate) struct ConnectionParams { pub(crate) uid: String, pub(crate) pwd: String, pub(crate) trust_server_certificate: bool, + /// `APP=` — reported to the server as the application name. + pub(crate) app: Option, pub(crate) encrypt: Option, pub(crate) authentication: Option, pub(crate) trusted_connection: Option, @@ -383,6 +385,7 @@ const MAPPED_KEYS: &[(&str, ConnAttrKey)] = &[ ("packetsize", ConnAttrKey::PacketSize), ("hostnameincertificate", ConnAttrKey::HostNameInCert), ("servercertificate", ConnAttrKey::ServerCertificate), + ("app", ConnAttrKey::App), ]; fn classify_key(lower: &str) -> KeyClass { @@ -413,6 +416,7 @@ fn assign_value( ConnAttrKey::Database => params.database = value.to_string(), ConnAttrKey::Uid => params.uid = value.to_string(), ConnAttrKey::Pwd => params.pwd = value.to_string(), + ConnAttrKey::App => params.app = Some(value.to_string()), ConnAttrKey::TrustServerCert => { validate_attr(lower, value, YES_NO)?; params.trust_server_certificate = is_yes(value); @@ -1290,7 +1294,6 @@ mod tests { for key in [ "Driver", "DSN", - "APP", "WSID", "Language", "Network", diff --git a/mssql-odbc/src/handles/dbc.rs b/mssql-odbc/src/handles/dbc.rs index 83af697b..63401dd4 100644 --- a/mssql-odbc/src/handles/dbc.rs +++ b/mssql-odbc/src/handles/dbc.rs @@ -5,8 +5,8 @@ use std::ffi::c_void; use std::sync::{Arc, Mutex}; use mssql_tds::connection::tds_client::TdsClient; -use tokio::runtime::Runtime; +use super::env::OdbcRuntime; use super::{EnvHandle, HandleType, HasObjectType}; use crate::error::{DiagRecord, HasDiagnostics}; @@ -35,7 +35,7 @@ pub(crate) struct DbcHandle { /// the ENV owns the DBC's lifetime, not the other way around. pub(crate) parent_env: *mut c_void, /// Shared Tokio runtime from the parent ENV. - pub(crate) runtime: Arc, + pub(crate) runtime: Arc, pub(crate) inner: Mutex, } @@ -63,6 +63,18 @@ pub(crate) struct DbcState { /// Pre-connect access token set via `SQL_COPT_SS_ACCESS_TOKEN`. /// Consumed by `SQLDriverConnect` to select `AccessToken` authentication. pub(crate) access_token: Option, + /// `SQL_ATTR_AUTOCOMMIT`. ODBC defaults to on; turning it off makes the + /// driver issue `SET IMPLICIT_TRANSACTIONS ON` so the server opens a + /// transaction on the next statement (msodbcsql parity). + pub(crate) autocommit: bool, + /// `SQL_ATTR_TXN_ISOLATION`, applied via `SET TRANSACTION ISOLATION LEVEL`. + pub(crate) txn_isolation: u32, + /// `SQL_ATTR_CURRENT_CATALOG`. Tracks the database the session is using so + /// `SQLGetConnectAttr` can report it without a round trip. + pub(crate) current_catalog: Option, + /// Set once the connection is known to be unusable, so + /// `SQL_ATTR_CONNECTION_DEAD` can report it without a round trip. + pub(crate) dead: bool, } // Manual `Debug` so the bearer access token is never rendered in logs or panic @@ -79,6 +91,9 @@ impl std::fmt::Debug for DbcState { "access_token", &self.access_token.as_ref().map(|_| ""), ) + .field("autocommit", &self.autocommit) + .field("txn_isolation", &self.txn_isolation) + .field("dead", &self.dead) .finish() } } @@ -93,7 +108,7 @@ impl HasDiagnostics for DbcState { } impl DbcHandle { - pub(crate) fn new(parent_env: *mut c_void, runtime: Arc) -> Self { + pub(crate) fn new(parent_env: *mut c_void, runtime: Arc) -> Self { Self { object_type: HandleType::Dbc, parent_env, @@ -105,6 +120,10 @@ impl DbcHandle { active_stmt: None, client: None, access_token: None, + autocommit: true, + txn_isolation: crate::api::odbc_types::SQL_TXN_READ_COMMITTED, + current_catalog: None, + dead: false, }), } } diff --git a/mssql-odbc/src/handles/env.rs b/mssql-odbc/src/handles/env.rs index e65c76ca..7127418d 100644 --- a/mssql-odbc/src/handles/env.rs +++ b/mssql-odbc/src/handles/env.rs @@ -3,6 +3,8 @@ use std::ffi::c_void; use std::io; +use std::mem::ManuallyDrop; +use std::ops::Deref; use std::sync::{Arc, Mutex}; use tokio::runtime::Runtime; @@ -37,6 +39,31 @@ impl TryFrom for OdbcVersion { /// Environment handle /// +/// Owns the Tokio runtime and detaches its worker threads on teardown. +/// +/// Windows terminates every other thread before running process-exit +/// callbacks, so a plain `Runtime::drop` — which joins the workers — trips +/// std's thread-lifecycle assertion and aborts the host process. +/// `shutdown_background` detaches instead of joining. +#[derive(Debug)] +pub(crate) struct OdbcRuntime(ManuallyDrop); + +impl Deref for OdbcRuntime { + type Target = Runtime; + + fn deref(&self) -> &Runtime { + &self.0 + } +} + +impl Drop for OdbcRuntime { + fn drop(&mut self) { + // SAFETY: `Drop::drop` runs once and `self.0` is never read afterwards. + let runtime = unsafe { ManuallyDrop::take(&mut self.0) }; + runtime.shutdown_background(); + } +} + /// One ENV is typically allocated per application. It owns connection handles /// and stores environment-level attributes (ODBC version, connection pooling mode). /// @@ -50,7 +77,7 @@ pub(crate) struct EnvHandle { pub(crate) inner: Mutex, /// Shared Tokio runtime for all connections on this ENV. /// Wrapped in `Arc` so DBCs can hold a reference without lifetime issues. - pub(crate) runtime: Arc, + pub(crate) runtime: Arc, } /// Mutable state within an environment handle, protected by `inner`. @@ -90,7 +117,7 @@ impl EnvHandle { output_nts: true, // SQL_ATTR_OUTPUT_NTS defaults to SQL_TRUE connections: Vec::new(), }), - runtime: Arc::new(runtime), + runtime: Arc::new(OdbcRuntime(ManuallyDrop::new(runtime))), }) } } diff --git a/mssql-odbc/src/handles/stmt.rs b/mssql-odbc/src/handles/stmt.rs index 7d9a713f..916fcca2 100644 --- a/mssql-odbc/src/handles/stmt.rs +++ b/mssql-odbc/src/handles/stmt.rs @@ -54,6 +54,11 @@ pub(crate) struct StmtState { /// SQL text stored by `SQLPrepare`, awaiting execution. The server-side /// prepare is deferred to `SQLExecute`. pub(crate) prepared_sql: Option, + /// Parameter metadata from `sp_describe_undeclared_parameters`, cached for + /// the life of the prepared text because callers describe every parameter + /// in turn and each probe costs a round trip. Invalidated whenever + /// `prepared_sql` changes. + pub(crate) described_params: Option>, /// Parameters bound via `SQLBindParameter`, indexed by `(ParameterNumber /// - 1)`. `None` slots are gaps left by binding a higher ordinal first. pub(crate) bound_params: Vec>, @@ -71,6 +76,24 @@ pub(crate) struct StmtState { pub(crate) pending_unprepare: Option, /// Current fetched row, populated by SQLFetch for later SQLGetData support. pub(crate) current_row: Option>, + /// Rows of the open result set that were read off the wire ahead of time so + /// the connection could be handed to another statement. `SQLFetch` drains + /// this before touching the connection. Mirrors msodbcsql, which serves a + /// second statement as soon as the first result set is fully buffered. + pub(crate) buffered_rows: VecDeque>, + /// `true` once `buffered_rows` holds the complete remainder of the open + /// result set, so an empty buffer means `SQL_NO_DATA` rather than a read. + pub(crate) buffered_eof: bool, + /// Error raised by the server *after* one or more rows of the current + /// rowset were already delivered. The rows are still handed to the + /// application, so the diagnostic is held here and re-posted by the next + /// fetch call, which reports `SQL_ERROR`. Without this the cursor would + /// simply appear closed and the real SQLSTATE would be lost behind 24000. + pub(crate) pending_fetch_error: Option, + /// Result boundary already crossed on this statement's behalf while trying + /// to release the connection (see `api::spill`). `SQLMoreResults` consumes + /// this instead of advancing the client again. + pub(crate) pending_result: Option, /// Rows affected by the last execution, reported by `SQLRowCount`. `-1` /// means "not available" (no statement executed yet, a result-returning /// SELECT, DDL, or `SET NOCOUNT ON`) — matching msodbcsql's @@ -95,11 +118,96 @@ pub(crate) struct StmtState { /// Row binding orientation (`SQL_ATTR_ROW_BIND_TYPE`): `SQL_BIND_BY_COLUMN` /// (0) for column-wise arrays, otherwise a row-struct byte size. pub(crate) row_bind_type: SqlULen, + /// Result columns bound via `SQLBindCol`, indexed by `(ColumnNumber - 1)`. + /// `None` slots are gaps left by binding a higher ordinal first, or columns + /// explicitly unbound with a null buffer pointer. + pub(crate) bound_cols: Vec>, + /// Column currently being streamed by `SQLGetData`. + pub(crate) getdata_col: Option, + /// Units of that column already delivered. + pub(crate) getdata_offset: usize, + /// Whether the streamed column has been fully delivered. + pub(crate) getdata_done: bool, + /// Number of parameter-array elements per execution (`SQL_ATTR_PARAMSET_SIZE`). + pub(crate) paramset_size: SqlULen, + /// In-flight data-at-execution state, present between the `SQL_NEED_DATA` + /// return from `SQLExecute`/`SQLExecDirect` and the final `SQLParamData` + /// that actually runs the statement. + pub(crate) dae: Option, /// Statement lifecycle/status flags used for ODBC API state checks. pub(crate) state_flags: u32, } +/// Deferred execution state for data-at-execution (DAE) parameters. +/// +/// ODBC streams oversized parameter values *after* `SQLExecute` returns +/// `SQL_NEED_DATA`: the application loops `SQLParamData` (which names the next +/// hungry parameter) and `SQLPutData` (which feeds it) until every DAE +/// parameter is satisfied, at which point `SQLParamData` performs the real +/// execution. The statement text and prepared-handle bookkeeping captured at +/// staging time are parked here for that final call. +#[derive(Debug)] +pub(crate) struct DaeState { + /// Rewritten SQL (`?` markers replaced with `@P`). + pub(crate) rewritten_sql: String, + /// Number of parameter markers in the statement. + pub(crate) marker_count: usize, + /// Cached server-side prepared handle, if the statement was already + /// prepared. + pub(crate) handle: Option, + /// A superseded prepared handle to drop on the server during execution. + pub(crate) drop_handle: Option, + /// Zero-based indexes of parameters awaiting data, in ODBC order. + pub(crate) order: Vec, + /// Position within `order` of the next parameter to hand out. + pub(crate) next: usize, + /// Parameter currently being fed by `SQLPutData`. + pub(crate) current: Option, + /// Accumulated bytes per parameter index (`None` = the application sent + /// `SQL_NULL_DATA`). + pub(crate) data: Vec>>, + /// Entry point that started the DAE sequence, for diagnostics. + pub(crate) op: &'static str, +} + +/// A result column bound to an application buffer by `SQLBindCol`. +/// +/// For block fetches the buffers are arrays of `row_array_size` elements, each +/// `buffer_length` bytes wide (column-wise binding). +#[derive(Debug, Clone, Copy)] +pub(crate) struct BoundCol { + pub(crate) target_type: crate::api::odbc_types::SqlSmallInt, + pub(crate) target_value_ptr: *mut c_void, + pub(crate) buffer_length: crate::api::odbc_types::SqlLen, + pub(crate) strlen_or_ind_ptr: *mut crate::api::odbc_types::SqlLen, +} + +// SAFETY: the raw pointers are application-owned buffers that the ODBC contract +// requires to stay valid until the column is unbound; they are only written +// while the statement mutex is held, exactly like `rows_fetched_ptr`. +unsafe impl Send for BoundCol {} +unsafe impl Sync for BoundCol {} + impl StmtState { + /// Discards the current row and any rows buffered ahead of the cursor. + /// Called whenever the cursor is repositioned, closed, or re-executed. + pub(crate) fn reset_rows(&mut self) { + self.current_row = None; + self.buffered_rows.clear(); + self.buffered_eof = false; + self.pending_result = None; + self.pending_fetch_error = None; + self.reset_getdata(); + } + + /// Clears `SQLGetData` streaming position; called whenever the current row + /// changes, since offsets are only meaningful within a single row. + pub(crate) fn reset_getdata(&mut self) { + self.getdata_col = None; + self.getdata_offset = 0; + self.getdata_done = false; + } + pub(crate) fn has_state(&self, mask: u32) -> bool { (self.state_flags & mask) != 0 } @@ -156,16 +264,27 @@ impl StmtHandle { diag_records: Vec::new(), column_metadata: Vec::new(), prepared_sql: None, + described_params: None, bound_params: Vec::new(), prepared_handle: None, pending_unprepare: None, current_row: None, + buffered_rows: VecDeque::new(), + buffered_eof: false, + pending_result: None, + pending_fetch_error: None, row_count: -1, pending_row_counts: VecDeque::new(), row_array_size: 1, rows_fetched_ptr: std::ptr::null_mut(), row_status_ptr: std::ptr::null_mut(), row_bind_type: crate::api::odbc_types::SQL_BIND_BY_COLUMN, + bound_cols: Vec::new(), + getdata_col: None, + getdata_offset: 0, + getdata_done: false, + paramset_size: 1, + dae: None, state_flags: 0, }), } diff --git a/mssql-odbc/src/params/convert.rs b/mssql-odbc/src/params/convert.rs index d8978d18..8dba11ef 100644 --- a/mssql-odbc/src/params/convert.rs +++ b/mssql-odbc/src/params/convert.rs @@ -4,27 +4,43 @@ //! Conversion from a bound application parameter buffer (`BoundParam`) to a //! TDS RPC parameter (`RpcParameter`). //! -//! Phase 1 mirrors `SQLGetData`'s supported C types: only `SQL_C_CHAR` -//! (→ `varchar`) and `SQL_C_WCHAR` (→ `nvarchar`). Every other C type, plus -//! data-at-execution and default parameters, is rejected with `HYC00`; an -//! invalid negative `StrLen_or_Ind` is rejected with `HY090`. - -use std::slice; +//! The buffer is first decoded into a normalized [`CValue`] by +//! [`crate::params::cvalue`], then coerced to the TDS type implied by the +//! application's `ParameterType` (`SQL_*`). Data-at-execution and default +//! parameters are still rejected with `HYC00`; an invalid negative +//! `StrLen_or_Ind` is rejected with `HY090`. +use mssql_tds::datatypes::column_values::{SqlDate, SqlDateTime2, SqlDateTimeOffset, SqlTime}; +use mssql_tds::datatypes::decoder::DecimalParts; use mssql_tds::datatypes::sql_string::{EncodingType, SqlString}; use mssql_tds::datatypes::sqltypes::SqlType; use mssql_tds::message::parameters::rpc_parameters::{RpcParameter, StatusFlags}; +use uuid::Uuid; use crate::api::odbc_types::{ - SQL_BIGINT, SQL_BINARY, SQL_BIT, SQL_C_CHAR, SQL_C_DEFAULT, SQL_C_LONG, SQL_C_WCHAR, SQL_CHAR, - SQL_DATA_AT_EXEC, SQL_DECIMAL, SQL_DEFAULT_PARAM, SQL_DOUBLE, SQL_FLOAT, SQL_GUID, SQL_INTEGER, - SQL_LEN_DATA_AT_EXEC_OFFSET, SQL_LONGVARBINARY, SQL_LONGVARCHAR, SQL_NTS, SQL_NULL_DATA, - SQL_NUMERIC, SQL_REAL, SQL_SMALLINT, SQL_SS_TIME2, SQL_SS_TIMESTAMPOFFSET, SQL_TINYINT, - SQL_TYPE_DATE, SQL_TYPE_TIME, SQL_TYPE_TIMESTAMP, SQL_VARBINARY, SQL_VARCHAR, SQL_WCHAR, - SQL_WLONGVARCHAR, SQL_WVARCHAR, SqlLen, SqlSmallInt, + SQL_BIGINT, SQL_BINARY, SQL_BIT, SQL_C_BINARY, SQL_C_BIT, SQL_C_CHAR, SQL_C_DATE, + SQL_C_DEFAULT, SQL_C_DOUBLE, SQL_C_FLOAT, SQL_C_GUID, SQL_C_LONG, SQL_C_NUMERIC, SQL_C_SBIGINT, + SQL_C_SHORT, SQL_C_SLONG, SQL_C_SS_TIME2, SQL_C_SS_TIMESTAMPOFFSET, SQL_C_SSHORT, + SQL_C_STINYINT, SQL_C_TIME, SQL_C_TIMESTAMP, SQL_C_TINYINT, SQL_C_TYPE_DATE, SQL_C_TYPE_TIME, + SQL_C_TYPE_TIMESTAMP, SQL_C_UBIGINT, SQL_C_ULONG, SQL_C_USHORT, SQL_C_UTINYINT, SQL_C_WCHAR, + SQL_CHAR, SQL_DATA_AT_EXEC, SQL_DATETIME, SQL_DECIMAL, SQL_DEFAULT_PARAM, SQL_DOUBLE, + SQL_FLOAT, SQL_GUID, SQL_INTEGER, SQL_LEN_DATA_AT_EXEC_OFFSET, SQL_LONGVARBINARY, + SQL_LONGVARCHAR, SQL_NTS, SQL_NULL_DATA, SQL_NUMERIC, SQL_REAL, SQL_SMALLINT, SQL_SS_TIME2, + SQL_SS_TIMESTAMPOFFSET, SQL_TIME, SQL_TIMESTAMP, SQL_TINYINT, SQL_TYPE_DATE, SQL_TYPE_TIME, + SQL_TYPE_TIMESTAMP, SQL_VARBINARY, SQL_VARCHAR, SQL_WCHAR, SQL_WLONGVARCHAR, SQL_WVARCHAR, + SqlDateStruct, SqlGuid, SqlLen, SqlNumericStruct, SqlSmallInt, SqlSsTime2Struct, + SqlSsTimestampoffsetStruct, SqlTimeStruct, SqlTimestampStruct, }; use crate::api::sqlstate::ERR_INVALID_STRING_OR_BUFFER_LENGTH; use crate::params::BoundParam; +use crate::params::cvalue::{CValue, read_c_value}; + +/// Days from 0001-01-01 to 1970-01-01. +const DAYS_YEAR_ONE_TO_EPOCH: i64 = 719_162; +/// SQL Server `datetime2`/`time` default scale. +const DEFAULT_TIME_SCALE: u8 = 7; +/// Widest `decimal`/`numeric` precision SQL Server accepts. +const MAX_DECIMAL_PRECISION: u8 = 38; /// Why a bound parameter could not be converted. /// @@ -35,8 +51,10 @@ use crate::params::BoundParam; /// [`message`]: ParamConvError::message #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ParamConvError { - /// The application's C type is not supported in Phase 1. + /// The application's C type is not a recognized `SQL_C_*` value. UnsupportedCType(SqlSmallInt), + /// The C value cannot be represented in the requested SQL type. + UnsupportedConversion(SqlSmallInt, SqlSmallInt), /// The parameter uses data-at-execution (`SQLPutData`). DataAtExecUnsupported, /// The parameter requested its default value. @@ -49,6 +67,7 @@ impl ParamConvError { pub(crate) fn message(self) -> &'static str { match self { Self::UnsupportedCType(_) => "Parameter C type not yet implemented", + Self::UnsupportedConversion(..) => "Restricted data type attribute violation", Self::DataAtExecUnsupported => "Data-at-execution parameters not yet implemented", Self::DefaultParamUnsupported => "Default parameters not yet implemented", Self::InvalidLength(_) => ERR_INVALID_STRING_OR_BUFFER_LENGTH.text, @@ -56,16 +75,105 @@ impl ParamConvError { } } -/// Converts a bound parameter into a named (`@P1`-style) RPC parameter. +/// Converts a bound parameter into a named RPC parameter, optionally taking the +/// value from data streamed via `SQLPutData` instead of the application buffer. /// /// # Safety /// See [`bound_param_to_value`]. -pub(crate) unsafe fn bound_param_to_rpc( +pub(crate) unsafe fn bound_param_to_rpc_with_data( name: String, param: &BoundParam, + dae: Option>, ) -> Result { - let value = unsafe { bound_param_to_value(param) }?; - Ok(RpcParameter::new(Some(name), StatusFlags::NONE, value)) + let value = match dae { + Some(Some(bytes)) => { + let c_type = effective_c_type(param.c_type, param.sql_type); + let cvalue = unsafe { + read_c_value( + c_type, + bytes.as_ptr(), + bytes.len() as SqlLen, + param.buffer_length, + ) + } + .ok_or(ParamConvError::UnsupportedCType(param.c_type))?; + to_sql_type(&cvalue, param).ok_or(ParamConvError::UnsupportedConversion( + param.c_type, + param.sql_type, + ))? + } + Some(None) => null_value(param), + None => unsafe { bound_param_to_value(param) }?, + }; + let numeric_meta = null_decimal_meta(&value, param); + let rpc = RpcParameter::new(Some(name), StatusFlags::NONE, value); + Ok(match numeric_meta { + Some((precision, scale)) => rpc.with_numeric_meta(precision, scale), + None => rpc, + }) +} + +/// Precision and scale to declare for a NULL `decimal`/`numeric` parameter. +/// +/// A NULL carries no precision, so it would otherwise be declared with the TDS +/// default. When a prepared plan is built from a parameter-array row whose +/// value is NULL, that narrow declaration is reused for every later row and +/// rejects wider values, so the application's binding is used instead. Only +/// NULLs need this: a value declares itself. +fn null_decimal_meta(value: &SqlType, param: &BoundParam) -> Option<(u8, u8)> { + if !matches!(value, SqlType::Decimal(None) | SqlType::Numeric(None)) { + return None; + } + let scale = u8::try_from(param.decimal_digits) + .unwrap_or(0) + .min(MAX_DECIMAL_PRECISION); + // An unspecified column size means the application never told us how wide + // the column is, so the widest declaration is the only safe one. + let precision = match u8::try_from(param.column_size).unwrap_or(0) { + 0 => MAX_DECIMAL_PRECISION, + p => p.max(scale).min(MAX_DECIMAL_PRECISION), + }; + Some((precision, scale)) +} + +/// Reports whether an indicator value requests data-at-execution. +pub(crate) fn is_data_at_exec(indicator: SqlLen) -> bool { + indicator == SQL_DATA_AT_EXEC || indicator <= SQL_LEN_DATA_AT_EXEC_OFFSET +} + +/// Byte stride between consecutive elements of a column-wise parameter array. +/// +/// ODBC derives the stride from the C type for fixed-length buffers and from +/// `BufferLength` for character/binary buffers. Applications routinely pass +/// `BufferLength = 0` for fixed-length types, so the natural width wins there. +pub(crate) fn c_type_stride( + c_type: SqlSmallInt, + sql_type: SqlSmallInt, + buffer_length: SqlLen, +) -> usize { + let fixed = match effective_c_type(c_type, sql_type) { + SQL_C_BIT | SQL_C_STINYINT | SQL_C_UTINYINT | SQL_C_TINYINT => 1, + SQL_C_SSHORT | SQL_C_USHORT | SQL_C_SHORT => 2, + SQL_C_SLONG | SQL_C_ULONG | SQL_C_LONG => 4, + SQL_C_SBIGINT | SQL_C_UBIGINT => 8, + SQL_C_FLOAT => 4, + SQL_C_DOUBLE => 8, + SQL_C_TYPE_DATE | SQL_C_DATE => size_of::(), + SQL_C_TYPE_TIME | SQL_C_TIME => size_of::(), + SQL_C_TYPE_TIMESTAMP | SQL_C_TIMESTAMP => size_of::(), + SQL_C_SS_TIME2 => size_of::(), + SQL_C_SS_TIMESTAMPOFFSET => size_of::(), + SQL_C_GUID => size_of::(), + SQL_C_NUMERIC => size_of::(), + _ => 0, + }; + if fixed > 0 { + fixed + } else if buffer_length > 0 { + buffer_length as usize + } else { + 1 + } } /// Reads the application's value buffer and produces the corresponding @@ -82,9 +190,11 @@ pub(crate) unsafe fn bound_param_to_value(param: &BoundParam) -> Result Result Result { - let bytes = - unsafe { read_char_bytes(param.parameter_value_ptr as *const u8, len_spec) }; - let text = String::from_utf8_lossy(&bytes).into_owned(); - SqlType::VarcharMax(Some(SqlString::from_utf8_string(text))) + let cvalue = unsafe { + read_c_value( + c_type, + param.parameter_value_ptr as *const u8, + len_spec, + param.buffer_length, + ) + } + .ok_or(ParamConvError::UnsupportedCType(param.c_type))?; + + to_sql_type(&cvalue, param).ok_or(ParamConvError::UnsupportedConversion( + param.c_type, + param.sql_type, + )) +} + +/// Resolves `SQL_C_DEFAULT` to the C type implied by the SQL type, per the ODBC +/// default-mapping table. +fn effective_c_type(c_type: SqlSmallInt, sql_type: SqlSmallInt) -> SqlSmallInt { + if c_type != SQL_C_DEFAULT { + return c_type; + } + match sql_type { + SQL_WCHAR | SQL_WVARCHAR | SQL_WLONGVARCHAR => SQL_C_WCHAR, + SQL_BINARY | SQL_VARBINARY | SQL_LONGVARBINARY => SQL_C_BINARY, + SQL_BIT => SQL_C_BIT, + SQL_TINYINT => SQL_C_TINYINT, + SQL_SMALLINT => SQL_C_SSHORT, + SQL_INTEGER => SQL_C_SLONG, + SQL_BIGINT => SQL_C_SBIGINT, + SQL_REAL => SQL_C_FLOAT, + SQL_FLOAT | SQL_DOUBLE => SQL_C_DOUBLE, + SQL_GUID => SQL_C_GUID, + SQL_TYPE_DATE | SQL_DATETIME => SQL_C_TYPE_DATE, + SQL_TYPE_TIME | SQL_TIME => SQL_C_TYPE_TIME, + SQL_SS_TIME2 => SQL_C_SS_TIME2, + SQL_TYPE_TIMESTAMP | SQL_TIMESTAMP => SQL_C_TYPE_TIMESTAMP, + SQL_SS_TIMESTAMPOFFSET => SQL_C_SS_TIMESTAMPOFFSET, + _ => SQL_C_CHAR, + } +} + +/// Typed NULL for the requested SQL type. +/// +/// A NULL still has to be declared with the application's `ColumnSize` and +/// `DecimalDigits`: the server derives the RPC parameter's type from the first +/// execution, so a NULL declared as the default `decimal(18,0)` would reject +/// wider values on later executions of the same parameter array. +fn null_value(param: &BoundParam) -> SqlType { + let sql_type = param.sql_type; + let c_type = effective_c_type(param.c_type, param.sql_type); + match sql_type { + SQL_CHAR | SQL_VARCHAR | SQL_LONGVARCHAR => SqlType::VarcharMax(None), + SQL_WCHAR | SQL_WVARCHAR | SQL_WLONGVARCHAR => SqlType::NVarcharMax(None), + SQL_BINARY | SQL_VARBINARY | SQL_LONGVARBINARY => SqlType::VarBinaryMax(None), + SQL_BIT => SqlType::Bit(None), + SQL_TINYINT => SqlType::TinyInt(None), + SQL_SMALLINT => SqlType::SmallInt(None), + SQL_INTEGER => SqlType::Int(None), + SQL_BIGINT => SqlType::BigInt(None), + SQL_REAL => SqlType::Real(None), + SQL_FLOAT | SQL_DOUBLE => SqlType::Float(None), + SQL_DECIMAL | SQL_NUMERIC => SqlType::Decimal(None), + SQL_GUID => SqlType::Uuid(None), + SQL_TYPE_DATE | SQL_DATETIME => SqlType::Date(None), + SQL_TYPE_TIME | SQL_TIME | SQL_SS_TIME2 => SqlType::Time(None), + SQL_TYPE_TIMESTAMP | SQL_TIMESTAMP => SqlType::DateTime2(None), + SQL_SS_TIMESTAMPOFFSET => SqlType::DateTimeOffset(None), + // No parameter type was supplied: fall back to the C type's family so + // the server still receives a typed NULL. + _ => match c_type { + SQL_C_WCHAR => SqlType::NVarcharMax(None), + SQL_C_BINARY => SqlType::VarBinaryMax(None), + _ => SqlType::VarcharMax(None), + }, + } +} + +fn utf16_bytes(text: &str) -> Vec { + text.encode_utf16().flat_map(u16::to_le_bytes).collect() +} + +/// SQL Server's `tinyint` is unsigned 0..=255, but ODBC reads `SQL_C_TINYINT` +/// as a signed byte, so applications binding 128..=255 hand us -128..=-1. +/// Reinterpret that half of the range instead of rejecting it, matching +/// msodbcsql, which accepts the whole byte. +fn to_tinyint(value: i64) -> Option { + match value { + 0..=255 => u8::try_from(value).ok(), + -128..=-1 => Some(value as u8), + _ => None, + } +} + +/// Coerces a decoded C value into the TDS type named by the application's +/// `ParameterType`. Returns `None` when the pairing is not convertible. +fn to_sql_type(cvalue: &CValue, param: &BoundParam) -> Option { + let value = match param.sql_type { + SQL_CHAR | SQL_VARCHAR | SQL_LONGVARCHAR => { + SqlType::VarcharMax(Some(SqlString::from_utf8_string(cvalue.to_text()))) + } + SQL_WCHAR | SQL_WVARCHAR | SQL_WLONGVARCHAR => SqlType::NVarcharMax(Some(SqlString::new( + utf16_bytes(&cvalue.to_text()), + EncodingType::Utf16, + ))), + SQL_BINARY | SQL_VARBINARY | SQL_LONGVARBINARY => { + SqlType::VarBinaryMax(Some(to_bytes(cvalue)?)) + } + SQL_BIT => SqlType::Bit(Some(to_i64(cvalue)? != 0)), + SQL_TINYINT => SqlType::TinyInt(Some(to_tinyint(to_i64(cvalue)?)?)), + SQL_SMALLINT => SqlType::SmallInt(Some(i16::try_from(to_i64(cvalue)?).ok()?)), + SQL_INTEGER => SqlType::Int(Some(i32::try_from(to_i64(cvalue)?).ok()?)), + SQL_BIGINT => SqlType::BigInt(Some(to_i64(cvalue)?)), + SQL_REAL => SqlType::Real(Some(to_f64(cvalue)? as f32)), + SQL_FLOAT | SQL_DOUBLE => SqlType::Float(Some(to_f64(cvalue)?)), + SQL_DECIMAL | SQL_NUMERIC => SqlType::Decimal(Some(to_decimal(cvalue, param)?)), + SQL_GUID => SqlType::Uuid(Some(to_uuid(cvalue)?)), + SQL_TYPE_DATE | SQL_DATETIME => SqlType::Date(Some(to_date(cvalue)?)), + SQL_TYPE_TIME | SQL_TIME | SQL_SS_TIME2 => SqlType::Time(Some(to_time(cvalue, param)?)), + SQL_TYPE_TIMESTAMP | SQL_TIMESTAMP => { + SqlType::DateTime2(Some(to_datetime2(cvalue, param)?)) } - SQL_C_WCHAR => { - let bytes = - unsafe { read_wchar_bytes(param.parameter_value_ptr as *const u16, len_spec) }; - SqlType::NVarcharMax(Some(SqlString::new(bytes, EncodingType::Utf16))) + SQL_SS_TIMESTAMPOFFSET => SqlType::DateTimeOffset(Some(to_datetimeoffset(cvalue, param)?)), + // Unknown parameter type: send the value in its natural family. + _ => natural_sql_type(cvalue), + }; + Some(value) +} + +/// The TDS type a C value maps to when the application supplied no usable +/// `ParameterType`. +fn natural_sql_type(cvalue: &CValue) -> SqlType { + match cvalue { + CValue::Text { text, wide: false } => { + SqlType::VarcharMax(Some(SqlString::from_utf8_string(text.clone()))) + } + CValue::Bytes(b) => SqlType::VarBinaryMax(Some(b.clone())), + CValue::Int(v) => SqlType::BigInt(Some(*v)), + CValue::Bool(v) => SqlType::Bit(Some(*v)), + CValue::Float(v) => SqlType::Float(Some(*v)), + other => SqlType::NVarcharMax(Some(SqlString::new( + utf16_bytes(&other.to_text()), + EncodingType::Utf16, + ))), + } +} + +fn to_bytes(cvalue: &CValue) -> Option> { + match cvalue { + CValue::Bytes(b) => Some(b.clone()), + CValue::Text { text, wide } => Some(if *wide { + utf16_bytes(text) + } else { + text.clone().into_bytes() + }), + CValue::Guid(_) => to_uuid(cvalue).map(|u| u.as_bytes().to_vec()), + _ => None, + } +} + +fn to_i64(cvalue: &CValue) -> Option { + match cvalue { + CValue::Int(v) => Some(*v), + CValue::UInt(v) => i64::try_from(*v).ok(), + CValue::Bool(v) => Some(i64::from(*v)), + CValue::Float(v) => Some(v.round() as i64), + CValue::Numeric(_) => to_f64(cvalue).map(|f| f.round() as i64), + CValue::Text { text, .. } => { + let t = text.trim(); + t.parse::() + .ok() + .or_else(|| t.parse::().ok().map(|f| f.round() as i64)) + } + _ => None, + } +} + +fn to_f64(cvalue: &CValue) -> Option { + match cvalue { + CValue::Float(v) => Some(*v), + CValue::Int(v) => Some(*v as f64), + CValue::UInt(v) => Some(*v as f64), + CValue::Bool(v) => Some(f64::from(u8::from(*v))), + CValue::Numeric(_) | CValue::Text { .. } => cvalue.to_text().trim().parse::().ok(), + _ => None, + } +} + +fn to_decimal(cvalue: &CValue, param: &BoundParam) -> Option { + let text = match cvalue { + CValue::Text { .. } | CValue::Numeric(_) | CValue::Int(_) | CValue::UInt(_) => { + cvalue.to_text() + } + CValue::Bool(v) => u8::from(*v).to_string(), + CValue::Float(v) => format!("{v}"), + _ => return None, + }; + let text = text.trim(); + let (precision, scale) = decimal_precision_scale(text, cvalue, param); + DecimalParts::from_string(text, precision, scale).ok() +} + +/// Picks the precision/scale to encode with, honouring the application's +/// `ColumnSize`/`DecimalDigits` when they are usable and otherwise deriving +/// them from the literal so no digits are lost. +fn decimal_precision_scale(text: &str, cvalue: &CValue, param: &BoundParam) -> (u8, u8) { + if let CValue::Numeric(n) = cvalue + && n.precision > 0 + && n.precision <= MAX_DECIMAL_PRECISION + && n.scale >= 0 + { + return (n.precision, n.scale as u8); + } + let literal_scale = text + .split_once('.') + .map_or(0usize, |(_, frac)| frac.trim_end_matches('0').len()); + let literal_digits = text.chars().filter(char::is_ascii_digit).count(); + + let app_scale = u8::try_from(param.decimal_digits).unwrap_or(0); + let app_precision = u8::try_from(param.column_size).unwrap_or(0); + + let scale = app_scale.max(u8::try_from(literal_scale).unwrap_or(0)); + let precision = app_precision + .max(u8::try_from(literal_digits).unwrap_or(0)) + .max(scale.saturating_add(1)) + .min(MAX_DECIMAL_PRECISION); + (precision, scale.min(precision)) +} + +fn to_uuid(cvalue: &CValue) -> Option { + match cvalue { + CValue::Guid(g) => Some(Uuid::from_fields(g.data1, g.data2, g.data3, &g.data4)), + CValue::Text { text, .. } => Uuid::parse_str(text.trim()).ok(), + CValue::Bytes(b) => <[u8; 16]>::try_from(b.as_slice()) + .ok() + .map(Uuid::from_bytes), + _ => None, + } +} + +/// Howard Hinnant's `days_from_civil`: days relative to 1970-01-01. +fn days_from_civil(year: i64, month: u32, day: u32) -> i64 { + let y = if month <= 2 { year - 1 } else { year }; + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = y - era * 400; + let m = i64::from(month); + let doy = (153 * (if month > 2 { m - 3 } else { m + 9 }) + 2) / 5 + i64::from(day) - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146_097 + doe - 719_468 +} + +fn to_date(cvalue: &CValue) -> Option { + let (y, m, d) = date_parts(cvalue)?; + let days = days_from_civil(y, m, d) + DAYS_YEAR_ONE_TO_EPOCH; + SqlDate::create(u32::try_from(days).ok()?).ok() +} + +fn date_parts(cvalue: &CValue) -> Option<(i64, u32, u32)> { + match cvalue { + CValue::Date(d) => Some((i64::from(d.year), u32::from(d.month), u32::from(d.day))), + CValue::Timestamp(t) => Some((i64::from(t.year), u32::from(t.month), u32::from(t.day))), + CValue::TimestampOffset(t) => { + Some((i64::from(t.year), u32::from(t.month), u32::from(t.day))) } - other => return Err(ParamConvError::UnsupportedCType(other)), + CValue::Text { text, .. } => parse_date_text(text.trim()), + _ => None, + } +} + +fn parse_date_text(text: &str) -> Option<(i64, u32, u32)> { + let head = text.split([' ', 'T']).next()?; + let mut parts = head.split('-'); + let y = parts.next()?.parse::().ok()?; + let m = parts.next()?.parse::().ok()?; + let d = parts.next()?.parse::().ok()?; + Some((y, m, d)) +} + +fn time_nanos(cvalue: &CValue) -> Option { + let (h, mi, s, n) = match cvalue { + CValue::Time { + hour, + minute, + second, + nanos, + } => ( + u64::from(*hour), + u64::from(*minute), + u64::from(*second), + u64::from(*nanos), + ), + CValue::Timestamp(t) => ( + u64::from(t.hour), + u64::from(t.minute), + u64::from(t.second), + u64::from(t.fraction), + ), + CValue::TimestampOffset(t) => ( + u64::from(t.hour), + u64::from(t.minute), + u64::from(t.second), + u64::from(t.fraction), + ), + CValue::Text { text, .. } => return parse_time_text(text.trim()), + _ => return None, }; + Some(((h * 3600 + mi * 60 + s) * 1_000_000_000) + n) +} - Ok(value) +fn parse_time_text(text: &str) -> Option { + let tail = text.split([' ', 'T']).next_back()?; + let tail = tail.split('+').next()?; + let mut parts = tail.split(':'); + let h = parts.next()?.parse::().ok()?; + let mi = parts.next()?.parse::().ok()?; + let (sec_text, frac_text) = match parts.next() { + Some(rest) => match rest.split_once('.') { + Some((s, f)) => (s, Some(f)), + None => (rest, None), + }, + None => ("0", None), + }; + let s = sec_text.parse::().ok()?; + let nanos = match frac_text { + Some(f) => { + let digits: String = f.chars().filter(char::is_ascii_digit).take(9).collect(); + format!("{digits:0<9}").parse::().ok()? + } + None => 0, + }; + Some(((h * 3600 + mi * 60 + s) * 1_000_000_000) + nanos) } -/// Typed NULL for the supported C types. -fn null_value(c_type: SqlSmallInt) -> Result { - match c_type { - SQL_C_CHAR => Ok(SqlType::VarcharMax(None)), - SQL_C_WCHAR => Ok(SqlType::NVarcharMax(None)), - other => Err(ParamConvError::UnsupportedCType(other)), +/// Uses the application's `DecimalDigits` as the fractional-second scale, +/// falling back to `datetime2`'s default of 7. +fn time_scale(param: &BoundParam) -> u8 { + match u8::try_from(param.decimal_digits) { + Ok(s) if s <= 7 => s, + _ => DEFAULT_TIME_SCALE, } } +/// TDS carries time-of-day as 100-nanosecond ticks since midnight, so callers +/// must convert before populating [`SqlTime`]. +fn time_ticks(cvalue: &CValue) -> Option { + Some(time_nanos(cvalue)? / 100) +} + +fn to_time(cvalue: &CValue, param: &BoundParam) -> Option { + Some(SqlTime { + time_nanoseconds: time_ticks(cvalue)?, + scale: time_scale(param), + }) +} + +fn to_datetime2(cvalue: &CValue, param: &BoundParam) -> Option { + let (y, m, d) = date_parts(cvalue)?; + let days = days_from_civil(y, m, d) + DAYS_YEAR_ONE_TO_EPOCH; + Some(SqlDateTime2 { + days: u32::try_from(days).ok()?, + time: SqlTime { + time_nanoseconds: time_ticks(cvalue).unwrap_or(0), + scale: time_scale(param), + }, + }) +} + +fn to_datetimeoffset(cvalue: &CValue, param: &BoundParam) -> Option { + let offset = match cvalue { + CValue::TimestampOffset(t) => t.timezone_hour * 60 + t.timezone_minute, + _ => 0, + }; + Some(SqlDateTimeOffset { + datetime2: to_datetime2(cvalue, param)?, + offset, + }) +} + /// Known ODBC SQL data type identifiers (plus SQL Server extensions) accepted /// at bind time. Conversion support is checked separately. pub(crate) fn is_valid_sql_type(sql_type: SqlSmallInt) -> bool { @@ -156,6 +617,10 @@ pub(crate) fn is_valid_sql_type(sql_type: SqlSmallInt) -> bool { | SQL_TYPE_DATE | SQL_TYPE_TIME | SQL_TYPE_TIMESTAMP + // ODBC 2.x aliases; mssql-python still binds Python `date` as 9. + | SQL_DATETIME + | SQL_TIME + | SQL_TIMESTAMP | SQL_SS_TIME2 | SQL_SS_TIMESTAMPOFFSET ) @@ -163,78 +628,86 @@ pub(crate) fn is_valid_sql_type(sql_type: SqlSmallInt) -> bool { /// Known ODBC C type identifiers accepted at bind time. pub(crate) fn is_valid_c_type(c_type: SqlSmallInt) -> bool { - matches!( - c_type, - SQL_C_CHAR | SQL_C_WCHAR | SQL_C_LONG | SQL_C_DEFAULT - ) + c_family(c_type).is_some() || c_type == SQL_C_DEFAULT } -/// Whether the C type → SQL type conversion is supported. Phase 1 only allows -/// same-family character conversions: `SQL_C_CHAR` → narrow character SQL types -/// (`CHAR`/`VARCHAR`/`LONGVARCHAR`) and `SQL_C_WCHAR` → the wide character SQL -/// types (`WCHAR`/`WVARCHAR`/`WLONGVARCHAR`). Every other pairing is rejected -/// (`07006`). -pub(crate) fn is_valid_conversion(c_type: SqlSmallInt, sql_type: SqlSmallInt) -> bool { - match c_type { - SQL_C_CHAR => matches!(sql_type, SQL_CHAR | SQL_VARCHAR | SQL_LONGVARCHAR), - SQL_C_WCHAR => matches!(sql_type, SQL_WCHAR | SQL_WVARCHAR | SQL_WLONGVARCHAR), - _ => false, - } +/// C-type family, used to decide which conversions `SQLBindParameter` accepts. +#[derive(PartialEq, Eq, Clone, Copy)] +enum Family { + Char, + Binary, + Number, + Guid, + Date, + Time, + Timestamp, } -/// Reads narrow (`SQL_C_CHAR`) bytes. `len_spec` is a byte count, or `SQL_NTS` -/// for a NUL-terminated string. -/// -/// # Safety -/// `ptr`, if non-null, must be readable for the resolved length (or up to the -/// first NUL when `len_spec == SQL_NTS`). -unsafe fn read_char_bytes(ptr: *const u8, len_spec: SqlLen) -> Vec { - if ptr.is_null() { - return Vec::new(); - } - let len = if len_spec == SQL_NTS as SqlLen { - let mut n = 0usize; - while unsafe { *ptr.add(n) } != 0 { - n += 1; - } - n - } else if len_spec < 0 { - 0 - } else { - len_spec as usize +fn c_family(c_type: SqlSmallInt) -> Option { + let family = match c_type { + SQL_C_CHAR | SQL_C_WCHAR => Family::Char, + SQL_C_BINARY => Family::Binary, + SQL_C_BIT | SQL_C_TINYINT | SQL_C_STINYINT | SQL_C_UTINYINT | SQL_C_SHORT + | SQL_C_SSHORT | SQL_C_USHORT | SQL_C_LONG | SQL_C_SLONG | SQL_C_ULONG | SQL_C_SBIGINT + | SQL_C_UBIGINT | SQL_C_FLOAT | SQL_C_DOUBLE | SQL_C_NUMERIC => Family::Number, + SQL_C_GUID => Family::Guid, + SQL_C_DATE | SQL_C_TYPE_DATE => Family::Date, + SQL_C_TIME | SQL_C_TYPE_TIME | SQL_C_SS_TIME2 => Family::Time, + SQL_C_TIMESTAMP | SQL_C_TYPE_TIMESTAMP | SQL_C_SS_TIMESTAMPOFFSET => Family::Timestamp, + _ => return None, }; - unsafe { slice::from_raw_parts(ptr, len).to_vec() } + Some(family) } -/// Reads wide (`SQL_C_WCHAR`) data as UTF-16LE bytes. `len_spec` is a **byte** -/// count per the ODBC spec, or `SQL_NTS` for a NUL-terminated string. -/// -/// # Safety -/// `ptr`, if non-null, must be readable for the resolved number of `u16` units -/// (or up to the first NUL when `len_spec == SQL_NTS`). -unsafe fn read_wchar_bytes(ptr: *const u16, len_spec: SqlLen) -> Vec { - if ptr.is_null() { - return Vec::new(); - } - let units = if len_spec == SQL_NTS as SqlLen { - let mut n = 0usize; - while unsafe { *ptr.add(n) } != 0 { - n += 1; +fn sql_family(sql_type: SqlSmallInt) -> Option { + let family = match sql_type { + SQL_CHAR | SQL_VARCHAR | SQL_LONGVARCHAR | SQL_WCHAR | SQL_WVARCHAR | SQL_WLONGVARCHAR => { + Family::Char } - n - } else if len_spec < 0 { - 0 - } else { - (len_spec as usize) / std::mem::size_of::() + SQL_BINARY | SQL_VARBINARY | SQL_LONGVARBINARY => Family::Binary, + SQL_DECIMAL | SQL_NUMERIC | SQL_SMALLINT | SQL_INTEGER | SQL_BIGINT | SQL_TINYINT + | SQL_BIT | SQL_REAL | SQL_FLOAT | SQL_DOUBLE => Family::Number, + SQL_GUID => Family::Guid, + SQL_TYPE_DATE | SQL_DATETIME => Family::Date, + SQL_TYPE_TIME | SQL_TIME | SQL_SS_TIME2 => Family::Time, + SQL_TYPE_TIMESTAMP | SQL_TIMESTAMP | SQL_SS_TIMESTAMPOFFSET => Family::Timestamp, + _ => return None, }; - let slice = unsafe { slice::from_raw_parts(ptr, units) }; - slice.iter().flat_map(|u| u.to_le_bytes()).collect() + Some(family) +} + +/// Whether the C type → SQL type conversion is supported. Mirrors the ODBC +/// conversion matrix: character C types convert to everything, and every C type +/// converts to a character SQL type. The other families only convert within +/// themselves, except that a date or time widens into a timestamp, a timestamp +/// narrows back to either, and GUIDs interchange with binary. +pub(crate) fn is_valid_conversion(c_type: SqlSmallInt, sql_type: SqlSmallInt) -> bool { + if c_type == SQL_C_DEFAULT { + return is_valid_sql_type(sql_type); + } + let (Some(from), Some(to)) = (c_family(c_type), sql_family(sql_type)) else { + return false; + }; + if from == Family::Char || to == Family::Char || from == to { + return true; + } + matches!( + (from, to), + (Family::Binary, Family::Guid) + | (Family::Guid, Family::Binary) + | (Family::Date, Family::Timestamp) + | (Family::Time, Family::Timestamp) + | (Family::Timestamp, Family::Date) + | (Family::Timestamp, Family::Time) + ) } #[cfg(test)] mod tests { use super::*; - use crate::api::odbc_types::{SQL_C_LONG, SQL_NO_TOTAL, SQL_PARAM_INPUT}; + use crate::api::odbc_types::{ + SQL_NO_TOTAL, SQL_PARAM_INPUT, SqlDateStruct, SqlGuid, SqlNumericStruct, SqlTimestampStruct, + }; use std::ffi::c_void; fn param(c_type: SqlSmallInt, ptr: *mut c_void, ind: *mut SqlLen) -> BoundParam { @@ -250,13 +723,78 @@ mod tests { } } + fn typed( + c_type: SqlSmallInt, + sql_type: SqlSmallInt, + ptr: *mut c_void, + ind: *mut SqlLen, + ) -> BoundParam { + let mut p = param(c_type, ptr, ind); + p.sql_type = sql_type; + p + } + + #[test] + fn null_decimal_declares_the_bound_precision_and_scale() { + let mut p = param(SQL_C_CHAR, std::ptr::null_mut(), std::ptr::null_mut()); + p.column_size = 31; + p.decimal_digits = 10; + assert_eq!( + null_decimal_meta(&SqlType::Decimal(None), &p), + Some((31, 10)) + ); + } + + #[test] + fn null_decimal_without_a_column_size_declares_the_widest_type() { + let mut p = param(SQL_C_CHAR, std::ptr::null_mut(), std::ptr::null_mut()); + p.decimal_digits = 4; + assert_eq!( + null_decimal_meta(&SqlType::Numeric(None), &p), + Some((MAX_DECIMAL_PRECISION, 4)) + ); + } + + #[test] + fn null_decimal_precision_is_never_below_its_scale() { + let mut p = param(SQL_C_CHAR, std::ptr::null_mut(), std::ptr::null_mut()); + p.column_size = 2; + p.decimal_digits = 10; + assert_eq!( + null_decimal_meta(&SqlType::Decimal(None), &p), + Some((10, 10)) + ); + } + + #[test] + fn non_null_and_non_decimal_values_declare_themselves() { + let p = param(SQL_C_CHAR, std::ptr::null_mut(), std::ptr::null_mut()); + assert_eq!(null_decimal_meta(&SqlType::Int(None), &p), None); + assert_eq!( + null_decimal_meta( + &SqlType::Decimal(Some(DecimalParts { + is_positive: true, + scale: 2, + precision: 10, + int_parts: vec![1], + })), + &p + ), + None + ); + } + #[test] fn char_nts_becomes_varchar() { let mut buf: Vec = b"hello\0".to_vec(); let mut ind: SqlLen = SQL_NTS as SqlLen; - let p = param(SQL_C_CHAR, buf.as_mut_ptr() as *mut c_void, &mut ind); - let value = unsafe { bound_param_to_value(&p) }.unwrap(); - match value { + let p = typed( + SQL_C_CHAR, + SQL_VARCHAR, + buf.as_mut_ptr() as *mut c_void, + &mut ind, + ); + match unsafe { bound_param_to_value(&p) }.unwrap() { SqlType::VarcharMax(Some(s)) => assert_eq!(s.to_utf8_string(), "hello"), other => panic!("expected VarcharMax(Some), got {other:?}"), } @@ -266,9 +804,13 @@ mod tests { fn wchar_explicit_length_becomes_nvarchar() { let mut buf: Vec = "hi".encode_utf16().collect(); let mut ind: SqlLen = (buf.len() * 2) as SqlLen; - let p = param(SQL_C_WCHAR, buf.as_mut_ptr() as *mut c_void, &mut ind); - let value = unsafe { bound_param_to_value(&p) }.unwrap(); - match value { + let p = typed( + SQL_C_WCHAR, + SQL_WVARCHAR, + buf.as_mut_ptr() as *mut c_void, + &mut ind, + ); + match unsafe { bound_param_to_value(&p) }.unwrap() { SqlType::NVarcharMax(Some(s)) => assert_eq!(s.to_utf8_string(), "hi"), other => panic!("expected NVarcharMax(Some), got {other:?}"), } @@ -277,91 +819,362 @@ mod tests { #[test] fn null_indicator_yields_typed_null() { let mut ind: SqlLen = SQL_NULL_DATA; - let p = param(SQL_C_CHAR, std::ptr::null_mut(), &mut ind); - let value = unsafe { bound_param_to_value(&p) }.unwrap(); - assert!(matches!(value, SqlType::VarcharMax(None))); + let p = typed(SQL_C_CHAR, SQL_VARCHAR, std::ptr::null_mut(), &mut ind); + assert!(matches!( + unsafe { bound_param_to_value(&p) }.unwrap(), + SqlType::VarcharMax(None) + )); + } + + #[test] + fn null_indicator_for_integer_yields_int_null() { + let mut ind: SqlLen = SQL_NULL_DATA; + let p = typed(SQL_C_SLONG, SQL_INTEGER, std::ptr::null_mut(), &mut ind); + assert!(matches!( + unsafe { bound_param_to_value(&p) }.unwrap(), + SqlType::Int(None) + )); + } + + #[test] + fn tinyint_c_type_binds_to_tinyint() { + let mut v: i8 = 42; + let mut ind: SqlLen = 1; + let p = typed( + SQL_C_TINYINT, + SQL_TINYINT, + &mut v as *mut i8 as *mut c_void, + &mut ind, + ); + assert_eq!( + unsafe { bound_param_to_value(&p) }.unwrap(), + SqlType::TinyInt(Some(42)) + ); + } + + #[test] + fn bigint_c_type_binds_to_bigint() { + let mut v: i64 = -9_000_000_000; + let mut ind: SqlLen = 8; + let p = typed( + SQL_C_SBIGINT, + SQL_BIGINT, + &mut v as *mut i64 as *mut c_void, + &mut ind, + ); + assert_eq!( + unsafe { bound_param_to_value(&p) }.unwrap(), + SqlType::BigInt(Some(-9_000_000_000)) + ); + } + + #[test] + fn double_binds_to_float() { + let mut v: f64 = 2.5; + let mut ind: SqlLen = 8; + let p = typed( + SQL_C_DOUBLE, + SQL_DOUBLE, + &mut v as *mut f64 as *mut c_void, + &mut ind, + ); + assert_eq!( + unsafe { bound_param_to_value(&p) }.unwrap(), + SqlType::Float(Some(2.5)) + ); + } + + #[test] + fn bit_binds_to_bit() { + let mut v: u8 = 1; + let mut ind: SqlLen = 1; + let p = typed( + SQL_C_BIT, + SQL_BIT, + &mut v as *mut u8 as *mut c_void, + &mut ind, + ); + assert_eq!( + unsafe { bound_param_to_value(&p) }.unwrap(), + SqlType::Bit(Some(true)) + ); + } + + #[test] + fn binary_binds_to_varbinary() { + let mut buf = [1u8, 2, 3]; + let mut ind: SqlLen = 3; + let p = typed( + SQL_C_BINARY, + SQL_VARBINARY, + buf.as_mut_ptr() as *mut c_void, + &mut ind, + ); + assert_eq!( + unsafe { bound_param_to_value(&p) }.unwrap(), + SqlType::VarBinaryMax(Some(vec![1, 2, 3])) + ); + } + + #[test] + fn integer_widens_from_narrow_c_type() { + let mut v: i16 = 7; + let mut ind: SqlLen = 2; + let p = typed( + SQL_C_SSHORT, + SQL_INTEGER, + &mut v as *mut i16 as *mut c_void, + &mut ind, + ); + assert_eq!( + unsafe { bound_param_to_value(&p) }.unwrap(), + SqlType::Int(Some(7)) + ); + } + + #[test] + fn char_converts_to_integer() { + let mut buf: Vec = b"123\0".to_vec(); + let mut ind: SqlLen = SQL_NTS as SqlLen; + let p = typed( + SQL_C_CHAR, + SQL_INTEGER, + buf.as_mut_ptr() as *mut c_void, + &mut ind, + ); + assert_eq!( + unsafe { bound_param_to_value(&p) }.unwrap(), + SqlType::Int(Some(123)) + ); + } + + #[test] + fn integer_converts_to_varchar() { + let mut v: i32 = -17; + let mut ind: SqlLen = 4; + let p = typed( + SQL_C_SLONG, + SQL_VARCHAR, + &mut v as *mut i32 as *mut c_void, + &mut ind, + ); + match unsafe { bound_param_to_value(&p) }.unwrap() { + SqlType::VarcharMax(Some(s)) => assert_eq!(s.to_utf8_string(), "-17"), + other => panic!("expected VarcharMax, got {other:?}"), + } + } + + #[test] + fn numeric_struct_binds_to_decimal() { + let mut n = SqlNumericStruct { + precision: 6, + scale: 2, + sign: 1, + val: [0; 16], + }; + n.val[0] = 0xD2; + n.val[1] = 0x04; + let mut ind: SqlLen = 19; + let p = typed( + SQL_C_NUMERIC, + SQL_DECIMAL, + &mut n as *mut SqlNumericStruct as *mut c_void, + &mut ind, + ); + match unsafe { bound_param_to_value(&p) }.unwrap() { + SqlType::Decimal(Some(d)) => assert_eq!(d.to_decimal_string(), "12.34"), + other => panic!("expected Decimal, got {other:?}"), + } + } + + #[test] + fn char_binds_to_decimal() { + let mut buf: Vec = b"-3.50\0".to_vec(); + let mut ind: SqlLen = SQL_NTS as SqlLen; + let mut p = typed( + SQL_C_CHAR, + SQL_DECIMAL, + buf.as_mut_ptr() as *mut c_void, + &mut ind, + ); + p.column_size = 10; + p.decimal_digits = 2; + match unsafe { bound_param_to_value(&p) }.unwrap() { + SqlType::Decimal(Some(d)) => assert_eq!(d.to_decimal_string(), "-3.50"), + other => panic!("expected Decimal, got {other:?}"), + } + } + + #[test] + fn timestamp_binds_to_datetime2() { + let mut ts = SqlTimestampStruct { + year: 2024, + month: 3, + day: 15, + hour: 12, + minute: 30, + second: 45, + fraction: 500_000_000, + }; + let mut ind: SqlLen = 16; + let mut p = typed( + SQL_C_TYPE_TIMESTAMP, + SQL_TYPE_TIMESTAMP, + &mut ts as *mut SqlTimestampStruct as *mut c_void, + &mut ind, + ); + p.decimal_digits = 7; + match unsafe { bound_param_to_value(&p) }.unwrap() { + SqlType::DateTime2(Some(dt)) => { + assert_eq!(dt.days, (days_from_civil(2024, 3, 15) + 719_162) as u32); + assert_eq!( + dt.time.time_nanoseconds, + (12 * 3600 + 30 * 60 + 45) * 10_000_000 + 5_000_000 + ); + } + other => panic!("expected DateTime2, got {other:?}"), + } + } + + #[test] + fn date_struct_binds_to_date() { + let mut d = SqlDateStruct { + year: 1970, + month: 1, + day: 1, + }; + let mut ind: SqlLen = 6; + let p = typed( + SQL_C_TYPE_DATE, + SQL_TYPE_DATE, + &mut d as *mut SqlDateStruct as *mut c_void, + &mut ind, + ); + match unsafe { bound_param_to_value(&p) }.unwrap() { + SqlType::Date(Some(date)) => assert_eq!(date.get_days(), 719_162), + other => panic!("expected Date, got {other:?}"), + } + } + + #[test] + fn guid_binds_to_uuid() { + let mut g = SqlGuid { + data1: 0x0123_4567, + data2: 0x89AB, + data3: 0xCDEF, + data4: [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF], + }; + let mut ind: SqlLen = 16; + let p = typed( + SQL_C_GUID, + SQL_GUID, + &mut g as *mut SqlGuid as *mut c_void, + &mut ind, + ); + match unsafe { bound_param_to_value(&p) }.unwrap() { + SqlType::Uuid(Some(u)) => { + assert_eq!(u.to_string(), "01234567-89ab-cdef-0123-456789abcdef"); + } + other => panic!("expected Uuid, got {other:?}"), + } } #[test] fn unsupported_c_type_is_rejected() { let mut ind: SqlLen = 4; let mut val: i32 = 7; - let p = param(SQL_C_LONG, &mut val as *mut i32 as *mut c_void, &mut ind); - let err = unsafe { bound_param_to_value(&p) }.unwrap_err(); - assert_eq!(err, ParamConvError::UnsupportedCType(SQL_C_LONG)); + let p = param(12345, &mut val as *mut i32 as *mut c_void, &mut ind); + assert_eq!( + unsafe { bound_param_to_value(&p) }.unwrap_err(), + ParamConvError::UnsupportedCType(12345) + ); + } + + #[test] + fn incompatible_conversion_is_rejected() { + let mut d = SqlDateStruct { + year: 2020, + month: 1, + day: 1, + }; + let mut ind: SqlLen = 6; + let p = typed( + SQL_C_TYPE_DATE, + SQL_INTEGER, + &mut d as *mut SqlDateStruct as *mut c_void, + &mut ind, + ); + assert_eq!( + unsafe { bound_param_to_value(&p) }.unwrap_err(), + ParamConvError::UnsupportedConversion(SQL_C_TYPE_DATE, SQL_INTEGER) + ); } #[test] fn data_at_exec_is_rejected() { let mut ind: SqlLen = SQL_DATA_AT_EXEC; let p = param(SQL_C_CHAR, std::ptr::null_mut(), &mut ind); - let err = unsafe { bound_param_to_value(&p) }.unwrap_err(); - assert_eq!(err, ParamConvError::DataAtExecUnsupported); + assert_eq!( + unsafe { bound_param_to_value(&p) }.unwrap_err(), + ParamConvError::DataAtExecUnsupported + ); } #[test] fn invalid_indicator_is_rejected() { let mut ind: SqlLen = SQL_NO_TOTAL; let p = param(SQL_C_CHAR, std::ptr::null_mut(), &mut ind); - let err = unsafe { bound_param_to_value(&p) }.unwrap_err(); - assert_eq!(err, ParamConvError::InvalidLength(SQL_NO_TOTAL)); + assert_eq!( + unsafe { bound_param_to_value(&p) }.unwrap_err(), + ParamConvError::InvalidLength(SQL_NO_TOTAL) + ); } #[test] - fn conversion_allows_same_family_only() { + fn conversion_matrix_allows_char_and_same_family() { assert!(is_valid_conversion(SQL_C_CHAR, SQL_VARCHAR)); assert!(is_valid_conversion(SQL_C_WCHAR, SQL_WVARCHAR)); - // Cross-family, non-character, and unsupported C types are rejected. - assert!(!is_valid_conversion(SQL_C_CHAR, SQL_WVARCHAR)); - assert!(!is_valid_conversion(SQL_C_WCHAR, SQL_VARCHAR)); - assert!(!is_valid_conversion(SQL_C_CHAR, SQL_INTEGER)); - assert!(!is_valid_conversion(SQL_C_LONG, SQL_INTEGER)); + // Character C types reach every SQL family, and vice versa. + assert!(is_valid_conversion(SQL_C_CHAR, SQL_WVARCHAR)); + assert!(is_valid_conversion(SQL_C_WCHAR, SQL_VARCHAR)); + assert!(is_valid_conversion(SQL_C_CHAR, SQL_INTEGER)); + assert!(is_valid_conversion(SQL_C_SLONG, SQL_VARCHAR)); + // Same numeric family. + assert!(is_valid_conversion(SQL_C_TINYINT, SQL_TINYINT)); + assert!(is_valid_conversion(SQL_C_DOUBLE, SQL_DECIMAL)); + // Date widens to timestamp; timestamp narrows to date. + assert!(is_valid_conversion(SQL_C_TYPE_DATE, SQL_TYPE_TIMESTAMP)); + assert!(is_valid_conversion(SQL_C_TYPE_TIMESTAMP, SQL_TYPE_DATE)); + // Cross-family pairings that ODBC does not define. + assert!(!is_valid_conversion(SQL_C_TYPE_DATE, SQL_INTEGER)); + assert!(!is_valid_conversion(SQL_C_SLONG, SQL_TYPE_TIMESTAMP)); + assert!(!is_valid_conversion(SQL_C_SLONG, SQL_GUID)); } #[test] fn default_param_indicator_is_rejected() { let mut ind: SqlLen = SQL_DEFAULT_PARAM; let p = param(SQL_C_CHAR, std::ptr::null_mut(), &mut ind); - let err = unsafe { bound_param_to_value(&p) }.unwrap_err(); - assert_eq!(err, ParamConvError::DefaultParamUnsupported); + assert_eq!( + unsafe { bound_param_to_value(&p) }.unwrap_err(), + ParamConvError::DefaultParamUnsupported + ); } #[test] fn null_indicator_wchar_yields_typed_null() { let mut ind: SqlLen = SQL_NULL_DATA; - let p = param(SQL_C_WCHAR, std::ptr::null_mut(), &mut ind); - let value = unsafe { bound_param_to_value(&p) }.unwrap(); - assert!(matches!(value, SqlType::NVarcharMax(None))); - } - - #[test] - fn null_indicator_unsupported_c_type_is_rejected() { - let mut ind: SqlLen = SQL_NULL_DATA; - let p = param(SQL_C_LONG, std::ptr::null_mut(), &mut ind); - let err = unsafe { bound_param_to_value(&p) }.unwrap_err(); - assert_eq!(err, ParamConvError::UnsupportedCType(SQL_C_LONG)); + let p = typed(SQL_C_WCHAR, SQL_WVARCHAR, std::ptr::null_mut(), &mut ind); + assert!(matches!( + unsafe { bound_param_to_value(&p) }.unwrap(), + SqlType::NVarcharMax(None) + )); } #[test] - fn read_char_bytes_edge_cases() { - assert!(unsafe { read_char_bytes(std::ptr::null(), 5) }.is_empty()); - let buf = b"abc"; - // Negative (non-NTS) length yields no bytes. - assert!(unsafe { read_char_bytes(buf.as_ptr(), -5) }.is_empty()); - // Explicit positive length reads exactly that many bytes. - assert_eq!(unsafe { read_char_bytes(buf.as_ptr(), 3) }, b"abc"); - } - - #[test] - fn read_wchar_bytes_edge_cases() { - assert!(unsafe { read_wchar_bytes(std::ptr::null(), 5) }.is_empty()); - let units: Vec = "hi".encode_utf16().chain(std::iter::once(0)).collect(); - // SQL_NTS reads u16 units up to the NUL terminator. - assert_eq!( - unsafe { read_wchar_bytes(units.as_ptr(), SQL_NTS as SqlLen) }, - vec![b'h', 0, b'i', 0] - ); - // Negative (non-NTS) length yields no bytes. - assert!(unsafe { read_wchar_bytes(units.as_ptr(), -5) }.is_empty()); + fn days_from_civil_matches_known_epochs() { + assert_eq!(days_from_civil(1970, 1, 1), 0); + assert_eq!(days_from_civil(1, 1, 1) + DAYS_YEAR_ONE_TO_EPOCH, 0); + assert_eq!(days_from_civil(1900, 1, 1) + 25_567, 0); } } diff --git a/mssql-odbc/src/params/cvalue.rs b/mssql-odbc/src/params/cvalue.rs new file mode 100644 index 00000000..d7da6635 --- /dev/null +++ b/mssql-odbc/src/params/cvalue.rs @@ -0,0 +1,410 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Reading an application parameter buffer into a normalized [`CValue`]. +//! +//! `SQLBindParameter` hands the driver a raw pointer plus a `SQL_C_*` tag. This +//! module decodes that buffer once, independently of the target SQL type, so the +//! C-type → SQL-type matrix in [`super::convert`] only has to reason about +//! normalized values. + +use std::slice; + +use crate::api::odbc_types::{ + SQL_C_BINARY, SQL_C_BIT, SQL_C_CHAR, SQL_C_DATE, SQL_C_DOUBLE, SQL_C_FLOAT, SQL_C_GUID, + SQL_C_LONG, SQL_C_NUMERIC, SQL_C_SBIGINT, SQL_C_SHORT, SQL_C_SLONG, SQL_C_SS_TIME2, + SQL_C_SS_TIMESTAMPOFFSET, SQL_C_SSHORT, SQL_C_STINYINT, SQL_C_TIME, SQL_C_TIMESTAMP, + SQL_C_TINYINT, SQL_C_TYPE_DATE, SQL_C_TYPE_TIME, SQL_C_TYPE_TIMESTAMP, SQL_C_UBIGINT, + SQL_C_ULONG, SQL_C_USHORT, SQL_C_UTINYINT, SQL_C_WCHAR, SQL_NTS, SqlDateStruct, SqlGuid, + SqlLen, SqlNumericStruct, SqlSmallInt, SqlSsTime2Struct, SqlSsTimestampoffsetStruct, + SqlTimeStruct, SqlTimestampStruct, +}; + +/// A parameter buffer decoded according to its `SQL_C_*` type. +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum CValue { + /// Character data from `SQL_C_CHAR` / `SQL_C_WCHAR`, already decoded to + /// Rust `String`. `wide` records which of the two it came from. + Text { + text: String, + wide: bool, + }, + /// `SQL_C_BINARY`. + Bytes(Vec), + /// Any signed integer C type, plus `SQL_C_BIT`. + Int(i64), + /// `SQL_C_UBIGINT` values that do not fit in `i64`. + UInt(u64), + /// `SQL_C_FLOAT` / `SQL_C_DOUBLE`. + Float(f64), + /// `SQL_C_BIT`. + Bool(bool), + Date(SqlDateStruct), + /// Hour/minute/second plus nanoseconds (`SQL_C_TYPE_TIME`, `SQL_C_SS_TIME2`). + Time { + hour: u16, + minute: u16, + second: u16, + nanos: u32, + }, + Timestamp(SqlTimestampStruct), + TimestampOffset(SqlSsTimestampoffsetStruct), + Numeric(SqlNumericStruct), + Guid(SqlGuid), +} + +impl CValue { + /// Renders the value as text for character SQL targets. + pub(crate) fn to_text(&self) -> String { + match self { + Self::Text { text, .. } => text.clone(), + Self::Bytes(b) => b.iter().map(|byte| format!("{byte:02X}")).collect(), + Self::Int(v) => v.to_string(), + Self::UInt(v) => v.to_string(), + Self::Float(v) => format_float(*v), + Self::Bool(v) => u8::from(*v).to_string(), + Self::Date(d) => format!("{:04}-{:02}-{:02}", d.year, d.month, d.day), + Self::Time { + hour, + minute, + second, + nanos, + } => format_time(*hour, *minute, *second, *nanos), + Self::Timestamp(t) => format!( + "{:04}-{:02}-{:02} {}", + t.year, + t.month, + t.day, + format_time(t.hour, t.minute, t.second, t.fraction) + ), + Self::TimestampOffset(t) => format!( + "{:04}-{:02}-{:02} {} {}{:02}:{:02}", + t.year, + t.month, + t.day, + format_time(t.hour, t.minute, t.second, t.fraction), + if t.timezone_hour < 0 || t.timezone_minute < 0 { + '-' + } else { + '+' + }, + t.timezone_hour.abs(), + t.timezone_minute.abs() + ), + Self::Numeric(n) => numeric_to_string(n), + Self::Guid(g) => guid_to_string(g), + } + } +} + +/// Formats a float the way msodbcsql renders it into a character buffer: +/// shortest round-trippable form, without a trailing `.0` for integral values. +fn format_float(v: f64) -> String { + if v == v.trunc() && v.abs() < 1e15 { + format!("{}", v as i64) + } else { + format!("{v}") + } +} + +fn format_time(hour: u16, minute: u16, second: u16, nanos: u32) -> String { + if nanos == 0 { + format!("{hour:02}:{minute:02}:{second:02}") + } else { + let frac = format!("{nanos:09}"); + format!( + "{hour:02}:{minute:02}:{second:02}.{}", + frac.trim_end_matches('0') + ) + } +} + +fn guid_to_string(g: &SqlGuid) -> String { + let mut tail = String::new(); + for (i, b) in g.data4.iter().enumerate() { + if i == 2 { + tail.push('-'); + } + tail.push_str(&format!("{b:02X}")); + } + format!("{:08X}-{:04X}-{:04X}-{}", g.data1, g.data2, g.data3, tail) +} + +/// Renders `SQL_NUMERIC_STRUCT` as a decimal literal. +pub(crate) fn numeric_to_string(n: &SqlNumericStruct) -> String { + let mut mantissa: u128 = 0; + for (i, b) in n.val.iter().enumerate().take(16) { + mantissa |= u128::from(*b) << (8 * i); + } + let digits = mantissa.to_string(); + let scale = n.scale.max(0) as usize; + let body = if scale == 0 { + digits + } else if digits.len() > scale { + let split = digits.len() - scale; + format!("{}.{}", &digits[..split], &digits[split..]) + } else { + format!("0.{}{}", "0".repeat(scale - digits.len()), digits) + }; + if n.sign == 0 && mantissa != 0 { + format!("-{body}") + } else { + body + } +} + +/// Reads the application buffer for `c_type`. +/// +/// Returns `None` when the C type is not recognized. +/// +/// # Safety +/// `ptr` must be readable for the size implied by `c_type` (or for `len_spec` +/// bytes for the variable-length types), per the ODBC binding contract. +pub(crate) unsafe fn read_c_value( + c_type: SqlSmallInt, + ptr: *const u8, + len_spec: SqlLen, + buffer_length: SqlLen, +) -> Option { + if ptr.is_null() { + return match c_type { + SQL_C_CHAR => Some(CValue::Text { + text: String::new(), + wide: false, + }), + SQL_C_WCHAR => Some(CValue::Text { + text: String::new(), + wide: true, + }), + SQL_C_BINARY => Some(CValue::Bytes(Vec::new())), + _ => None, + }; + } + + let value = match c_type { + SQL_C_CHAR => { + let bytes = unsafe { read_char_bytes(ptr, len_spec) }; + CValue::Text { + text: crate::api::ansi::decode(&bytes), + wide: false, + } + } + SQL_C_WCHAR => { + let units = unsafe { read_wchar_units(ptr as *const u16, len_spec) }; + CValue::Text { + text: String::from_utf16_lossy(&units), + wide: true, + } + } + SQL_C_BINARY => { + let len = if len_spec >= 0 { + len_spec as usize + } else if buffer_length > 0 { + buffer_length as usize + } else { + 0 + }; + CValue::Bytes(unsafe { slice::from_raw_parts(ptr, len) }.to_vec()) + } + SQL_C_BIT => CValue::Bool(unsafe { *ptr } != 0), + SQL_C_TINYINT | SQL_C_STINYINT => CValue::Int(i64::from(unsafe { *(ptr as *const i8) })), + SQL_C_UTINYINT => CValue::Int(i64::from(unsafe { *ptr })), + SQL_C_SHORT | SQL_C_SSHORT => CValue::Int(i64::from(unsafe { read::(ptr) })), + SQL_C_USHORT => CValue::Int(i64::from(unsafe { read::(ptr) })), + SQL_C_LONG | SQL_C_SLONG => CValue::Int(i64::from(unsafe { read::(ptr) })), + SQL_C_ULONG => CValue::Int(i64::from(unsafe { read::(ptr) })), + SQL_C_SBIGINT => CValue::Int(unsafe { read::(ptr) }), + SQL_C_UBIGINT => { + let v = unsafe { read::(ptr) }; + match i64::try_from(v) { + Ok(i) => CValue::Int(i), + Err(_) => CValue::UInt(v), + } + } + SQL_C_FLOAT => CValue::Float(f64::from(unsafe { read::(ptr) })), + SQL_C_DOUBLE => CValue::Float(unsafe { read::(ptr) }), + SQL_C_NUMERIC => CValue::Numeric(unsafe { read::(ptr) }), + SQL_C_GUID => CValue::Guid(unsafe { read::(ptr) }), + SQL_C_DATE | SQL_C_TYPE_DATE => CValue::Date(unsafe { read::(ptr) }), + SQL_C_TIME | SQL_C_TYPE_TIME => { + let t = unsafe { read::(ptr) }; + CValue::Time { + hour: t.hour, + minute: t.minute, + second: t.second, + nanos: 0, + } + } + SQL_C_SS_TIME2 => { + let t = unsafe { read::(ptr) }; + CValue::Time { + hour: t.hour, + minute: t.minute, + second: t.second, + nanos: t.fraction, + } + } + SQL_C_TIMESTAMP | SQL_C_TYPE_TIMESTAMP => { + CValue::Timestamp(unsafe { read::(ptr) }) + } + SQL_C_SS_TIMESTAMPOFFSET => { + CValue::TimestampOffset(unsafe { read::(ptr) }) + } + _ => return None, + }; + Some(value) +} + +/// Reads a `#[repr(C)]` POD from a possibly unaligned application buffer. +/// +/// # Safety +/// `ptr` must be readable for `size_of::()` bytes. +unsafe fn read(ptr: *const u8) -> T { + unsafe { (ptr as *const T).read_unaligned() } +} + +/// Reads narrow bytes. `len_spec` is a byte count, or `SQL_NTS` for a +/// NUL-terminated string. +/// +/// # Safety +/// `ptr` must be readable for the resolved length (or up to the first NUL when +/// `len_spec == SQL_NTS`). +pub(crate) unsafe fn read_char_bytes(ptr: *const u8, len_spec: SqlLen) -> Vec { + if ptr.is_null() { + return Vec::new(); + } + let len = if len_spec == SQL_NTS as SqlLen { + let mut n = 0usize; + while unsafe { *ptr.add(n) } != 0 { + n += 1; + } + n + } else if len_spec < 0 { + 0 + } else { + len_spec as usize + }; + unsafe { slice::from_raw_parts(ptr, len) }.to_vec() +} + +/// Reads wide data as UTF-16 code units. `len_spec` is a **byte** count per the +/// ODBC spec, or `SQL_NTS` for a NUL-terminated string. +/// +/// # Safety +/// `ptr` must be readable for the resolved number of `u16` units (or up to the +/// first NUL when `len_spec == SQL_NTS`). +pub(crate) unsafe fn read_wchar_units(ptr: *const u16, len_spec: SqlLen) -> Vec { + if ptr.is_null() { + return Vec::new(); + } + let units = if len_spec == SQL_NTS as SqlLen { + let mut n = 0usize; + while unsafe { ptr.add(n).read_unaligned() } != 0 { + n += 1; + } + n + } else if len_spec < 0 { + 0 + } else { + (len_spec as usize) / size_of::() + }; + (0..units) + .map(|i| unsafe { ptr.add(i).read_unaligned() }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_signed_bigint() { + let v: i64 = -42; + let got = unsafe { read_c_value(SQL_C_SBIGINT, &v as *const i64 as *const u8, 8, 8) }; + assert_eq!(got, Some(CValue::Int(-42))); + } + + #[test] + fn reads_tinyint_as_signed() { + let v: i8 = -3; + let got = unsafe { read_c_value(SQL_C_TINYINT, &v as *const i8 as *const u8, 1, 1) }; + assert_eq!(got, Some(CValue::Int(-3))); + } + + #[test] + fn reads_bit() { + let v: u8 = 1; + let got = unsafe { read_c_value(SQL_C_BIT, &v as *const u8, 1, 1) }; + assert_eq!(got, Some(CValue::Bool(true))); + } + + #[test] + fn reads_double() { + let v: f64 = 1.5; + let got = unsafe { read_c_value(SQL_C_DOUBLE, &v as *const f64 as *const u8, 8, 8) }; + assert_eq!(got, Some(CValue::Float(1.5))); + } + + #[test] + fn reads_binary_using_indicator() { + let buf = [1u8, 2, 3, 4]; + let got = unsafe { read_c_value(SQL_C_BINARY, buf.as_ptr(), 3, 4) }; + assert_eq!(got, Some(CValue::Bytes(vec![1, 2, 3]))); + } + + #[test] + fn unknown_c_type_is_none() { + let v: u8 = 0; + assert!(unsafe { read_c_value(12345, &v as *const u8, 1, 1) }.is_none()); + } + + #[test] + fn numeric_struct_renders_scaled_decimal() { + let mut n = SqlNumericStruct { + precision: 10, + scale: 2, + sign: 1, + val: [0; 16], + }; + n.val[0] = 0xD2; // 1234 -> "12.34" + n.val[1] = 0x04; + assert_eq!(numeric_to_string(&n), "12.34"); + n.sign = 0; + assert_eq!(numeric_to_string(&n), "-12.34"); + } + + #[test] + fn numeric_struct_pads_small_mantissa() { + let mut n = SqlNumericStruct { + precision: 10, + scale: 4, + sign: 1, + val: [0; 16], + }; + n.val[0] = 5; + assert_eq!(numeric_to_string(&n), "0.0005"); + } + + #[test] + fn guid_renders_canonical_text() { + let g = SqlGuid { + data1: 0x0123_4567, + data2: 0x89AB, + data3: 0xCDEF, + data4: [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF], + }; + assert_eq!(guid_to_string(&g), "01234567-89AB-CDEF-0123-456789ABCDEF"); + } + + #[test] + fn float_text_drops_trailing_zero() { + assert_eq!(format_float(3.0), "3"); + assert_eq!(format_float(3.25), "3.25"); + } + + #[test] + fn time_text_trims_fraction() { + assert_eq!(format_time(1, 2, 3, 0), "01:02:03"); + assert_eq!(format_time(1, 2, 3, 500_000_000), "01:02:03.5"); + } +} diff --git a/mssql-odbc/src/params/mod.rs b/mssql-odbc/src/params/mod.rs index 787e6fee..991ca2d2 100644 --- a/mssql-odbc/src/params/mod.rs +++ b/mssql-odbc/src/params/mod.rs @@ -5,5 +5,6 @@ mod bound_param; pub(crate) mod convert; +pub(crate) mod cvalue; pub(crate) use bound_param::BoundParam; diff --git a/mssql-tds/src/connection/tds_client.rs b/mssql-tds/src/connection/tds_client.rs index 7fd3155b..2d11ac3b 100644 --- a/mssql-tds/src/connection/tds_client.rs +++ b/mssql-tds/src/connection/tds_client.rs @@ -1888,7 +1888,18 @@ impl TdsClient { // `CREATE; INSERT; SELECT` exposes the INSERT's row count and // the SELECT, not the bare CREATE. `rows_affected` is // `Some(n)` only when the DONE carried a COUNT. - if has_count || saw_message { + // An assignment statement (`SET @v = …`, `SELECT @v = …`) + // reports DONEINPROC(COUNT, CurCmd=SELECT) with no rows and + // no messages. msodbcsql does not surface those as result + // sets, and stopping on one hides a following PRINT's INFO + // token from the execute() return. Row-returning SELECTs + // arrive with COLMETADATA and never reach this branch, and + // INSERT/UPDATE/DELETE carry their own CurCmd, so their + // counts still terminate navigation here. + let is_assignment = has_count + && !saw_message + && done.cur_cmd == crate::token::tokens::CurrentCommand::Select; + if (has_count || saw_message) && !is_assignment { self.execution_context.set_has_open_batch(!is_last); return Ok(ResultBoundaryKind::NoRows { rows_affected: if has_count { diff --git a/mssql-tds/src/connection/transport/network_transport.rs b/mssql-tds/src/connection/transport/network_transport.rs index 57dbd00e..5b808c42 100644 --- a/mssql-tds/src/connection/transport/network_transport.rs +++ b/mssql-tds/src/connection/transport/network_transport.rs @@ -1301,7 +1301,7 @@ impl TdsPacketReader for NetworkTransport { } let string = self - .read_unicode_with_byte_length((length << 1) as usize) + .read_unicode_with_byte_length(length as usize * 2) .await?; Ok(Some(string)) } @@ -1309,7 +1309,7 @@ impl TdsPacketReader for NetworkTransport { async fn read_varchar_u8_length(&mut self) -> TdsResult { let length: u8 = self.read_byte().await?; let string = self - .read_unicode_with_byte_length((length << 1) as usize) + .read_unicode_with_byte_length(length as usize * 2) .await?; Ok(string) } diff --git a/mssql-tds/src/datatypes/decoder.rs b/mssql-tds/src/datatypes/decoder.rs index fa6bb22a..6f3d8383 100644 --- a/mssql-tds/src/datatypes/decoder.rs +++ b/mssql-tds/src/datatypes/decoder.rs @@ -1992,7 +1992,7 @@ impl DecimalParts { /// Convert DecimalParts to a string representation suitable for Python Decimal. /// Returns a string like "123.45", "-0.01", etc. - fn to_decimal_string(&self) -> String { + pub fn to_decimal_string(&self) -> String { // Convert int_parts to u128 // int_parts[0] is the least significant, int_parts[n-1] is most significant let u128_value = self diff --git a/mssql-tds/src/io/packet_reader.rs b/mssql-tds/src/io/packet_reader.rs index 63698ad6..6dc1c3c8 100644 --- a/mssql-tds/src/io/packet_reader.rs +++ b/mssql-tds/src/io/packet_reader.rs @@ -429,7 +429,7 @@ impl TdsPacketReader for PacketReader<'_> { } let string = self - .read_unicode_with_byte_length((length << 1) as usize) + .read_unicode_with_byte_length(length as usize * 2) .await?; Ok(Some(string)) } @@ -437,7 +437,7 @@ impl TdsPacketReader for PacketReader<'_> { async fn read_varchar_u8_length(&mut self) -> TdsResult { let length: u8 = self.read_byte().await?; let string = self - .read_unicode_with_byte_length((length << 1) as usize) + .read_unicode_with_byte_length(length as usize * 2) .await?; Ok(string) } @@ -450,8 +450,9 @@ impl TdsPacketReader for PacketReader<'_> { } async fn read_unicode_with_byte_length(&mut self, byte_length: usize) -> TdsResult { - // Prevent OOM by limiting maximum string allocation to twice the u8 length. - const MAX_STRING_BYTE_LENGTH: usize = u8::MAX as usize * 2; + // A US_VARCHAR carries up to u16::MAX UTF-16 code units, so an 8000- + // character PRINT message is legal. Cap at that, not at u8::MAX. + const MAX_STRING_BYTE_LENGTH: usize = u16::MAX as usize * 2; if byte_length > MAX_STRING_BYTE_LENGTH { return Err(crate::error::Error::UsageError(format!( "Unicode string byte length {byte_length} exceeds maximum allowed size of {MAX_STRING_BYTE_LENGTH} bytes" diff --git a/mssql-tds/src/message/parameters/rpc_parameters.rs b/mssql-tds/src/message/parameters/rpc_parameters.rs index 7f8600e0..148c3d5f 100644 --- a/mssql-tds/src/message/parameters/rpc_parameters.rs +++ b/mssql-tds/src/message/parameters/rpc_parameters.rs @@ -113,6 +113,12 @@ pub struct RpcParameter { /// `SqlParameter.ForceColumnEncryption`; a client-side directive that is /// never sent on the wire. force_column_encryption: bool, + + /// Precision and scale the application declared for a `decimal`/`numeric` + /// parameter. A NULL value carries no precision of its own, so without this + /// the parameter would be declared with the TDS default and a prepared plan + /// built from a NULL row would reject wider values on later executions. + numeric_meta: Option<(u8, u8)>, } impl RpcParameter { @@ -124,9 +130,17 @@ impl RpcParameter { value, encrypted: None, force_column_encryption: false, + numeric_meta: None, } } + /// Declares the precision and scale to use for a `decimal`/`numeric` + /// parameter, overriding whatever the value itself carries. + pub fn with_numeric_meta(mut self, precision: u8, scale: u8) -> Self { + self.numeric_meta = Some((precision, scale)); + self + } + /// Requires this parameter to be encrypted under Always Encrypted. /// /// When set, the driver fails with a usage error if the server reports the @@ -499,7 +513,13 @@ fn build_parameter_list_string_impl( if let Some(param_name) = ¶m.name { // TODO: while persisting types with length, we need to compute the length and // add the length after the type name. e.g. Nvarchar(200), varchar(100) etc. - let param_type_name = RpcParameter::get_sql_name(¶m.value)?; + let param_type_name = match (¶m.value, param.numeric_meta) { + (SqlType::Decimal(_) | SqlType::Numeric(_), Some((precision, scale))) => { + let tds_type = TdsDataType::from(¶m.value); + format!("{}({precision}, {scale})", tds_type.get_meta_type_name()?) + } + _ => RpcParameter::get_sql_name(¶m.value)?, + }; if first_param { first_param = false; } else {