From 2270520e79ddfef68814f7e3a6babeb4f4305545 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:04:53 -0700 Subject: [PATCH 01/12] Add ODBC surface required by mssql-python Implements the driver entry points, conversions and lifecycle semantics the mssql-python pybind layer depends on when it loads the driver directly with no Driver Manager: the 15 missing exports, connection attributes and transactions, a shared SQL-to-C conversion layer, column-wise block fetch, catalog functions, descriptor fields, bounded read-ahead so a second statement can run while a cursor is open, and cascading handle frees. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e698c659-8b42-43f7-99ab-46f534b2489b --- mssql-odbc/src/api/bind_col.rs | 207 +++++ mssql-odbc/src/api/catalog.rs | 405 +++++++++ mssql-odbc/src/api/cdata.rs | 777 ++++++++++++++++++ mssql-odbc/src/api/close_cursor.rs | 2 +- mssql-odbc/src/api/col_attribute.rs | 229 ++++++ mssql-odbc/src/api/conn_exec.rs | 78 ++ mssql-odbc/src/api/desc.rs | 238 ++++++ mssql-odbc/src/api/describe_col.rs | 6 +- mssql-odbc/src/api/disconnect.rs | 16 +- mssql-odbc/src/api/driver_connect.rs | 29 +- mssql-odbc/src/api/end_tran.rs | 192 +++++ mssql-odbc/src/api/exec_common.rs | 18 +- mssql-odbc/src/api/exec_direct.rs | 4 +- mssql-odbc/src/api/execute.rs | 2 +- mssql-odbc/src/api/exports.rs | 447 +++++++++- mssql-odbc/src/api/fetch.rs | 114 ++- mssql-odbc/src/api/fetch_scroll.rs | 220 +++++ mssql-odbc/src/api/free_handle.rs | 101 +-- mssql-odbc/src/api/get_connect_attr.rs | 190 +++++ mssql-odbc/src/api/get_data.rs | 177 ++-- mssql-odbc/src/api/get_type_info.rs | 2 +- mssql-odbc/src/api/mod.rs | 10 + mssql-odbc/src/api/more_results.rs | 4 +- mssql-odbc/src/api/odbc_types.rs | 74 ++ mssql-odbc/src/api/prepare.rs | 2 +- mssql-odbc/src/api/set_connect_attr.rs | 111 ++- mssql-odbc/src/api/spill.rs | 136 +++ mssql-odbc/src/api/sqlstate.rs | 6 + mssql-odbc/src/handles/dbc.rs | 21 +- mssql-odbc/src/handles/env.rs | 31 +- mssql-odbc/src/handles/stmt.rs | 41 + mssql-odbc/tests/e2e/CMakeLists.txt | 1 + .../e2e/tests/mssql_python_parity_test.cpp | 493 +++++++++++ mssql-tds/src/datatypes/decoder.rs | 2 +- 34 files changed, 4169 insertions(+), 217 deletions(-) create mode 100644 mssql-odbc/src/api/bind_col.rs create mode 100644 mssql-odbc/src/api/catalog.rs create mode 100644 mssql-odbc/src/api/cdata.rs create mode 100644 mssql-odbc/src/api/col_attribute.rs create mode 100644 mssql-odbc/src/api/conn_exec.rs create mode 100644 mssql-odbc/src/api/desc.rs create mode 100644 mssql-odbc/src/api/end_tran.rs create mode 100644 mssql-odbc/src/api/fetch_scroll.rs create mode 100644 mssql-odbc/src/api/get_connect_attr.rs create mode 100644 mssql-odbc/src/api/spill.rs create mode 100644 mssql-odbc/tests/e2e/tests/mssql_python_parity_test.cpp 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/catalog.rs b/mssql-odbc/src/api/catalog.rs new file mode 100644 index 00000000..a19d7d31 --- /dev/null +++ b/mssql-odbc/src/api/catalog.rs @@ -0,0 +1,405 @@ +// 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_direct::sql_exec_direct_w_safe; +use super::odbc_types::{SQL_INVALID_HANDLE, SqlHandle, SqlReturn, SqlSmallInt, SqlWChar}; +use super::util::read_utf16; +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 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. +fn build_exec(catalog: &Arg, proc_name: &str, args: &[String]) -> String { + let qualified = match catalog { + Some(db) if !db.is_empty() => { + format!("[{}].sys.{}", db.replace(']', "]]"), proc_name) + } + _ => format!("sys.{proc_name}"), + }; + format!("EXEC {} {}", qualified, args.join(", ")) +} + +/// 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) -> 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"); + sql_exec_direct_w_safe(statement_handle, stmt, sql) +} + +/// 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(), + literal(&types), + "1".to_string(), + ], + ); + run_catalog(statement_handle, "SQLTablesW", sql) + }) +} + +/// 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( + &catalog, + "sp_columns_100", + &[ + literal(&table), + literal(&schema), + "NULL".to_string(), + literal(&column), + "NULL".to_string(), + "3".to_string(), + "1".to_string(), + ], + ); + run_catalog(statement_handle, "SQLColumnsW", sql) + }) +} + +/// 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) + }) +} + +/// 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) + }) +} + +/// 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(), + "NULL".to_string(), + is_unique.to_string(), + accuracy.to_string(), + ], + ); + run_catalog(statement_handle, "SQLStatisticsW", sql) + }) +} + +/// 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) + }) +} + +/// 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) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::odbc_types::{SQL_NTS, SQL_NULL_HANDLE}; + + 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_eq!(sql, "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.starts_with("EXEC [we]]ird].sys.sp_tables")); + } + + #[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 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); + } +} diff --git a/mssql-odbc/src/api/cdata.rs b/mssql-odbc/src/api/cdata.rs new file mode 100644 index 00000000..c2dc106d --- /dev/null +++ b/mssql-odbc/src/api/cdata.rs @@ -0,0 +1,777 @@ +// 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 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. +enum Cell { + Int(i64), + UInt(u64), + Double(f64), + 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)] +struct CivilDate { + year: i32, + month: u32, + day: u32, +} + +#[derive(Clone, Copy, Default)] +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, + } +} + +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, + } +} + +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_to_f64(lsb: i32, msb: i32) -> f64 { + let combined = ((msb as i64) << 32) | ((lsb as u32) as i64); + combined as f64 / 10_000.0 +} + +/// Projects a TDS column value onto the normalized [`Cell`] model. +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::Double(f64::from(m.int_val) / 10_000.0), + ColumnValues::Money(m) => Cell::Double(money_to_f64(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_nanos(t.time_nanoseconds, t.scale)), + ColumnValues::DateTime2(dt) => Cell::Timestamp( + civil_from_days(i64::from(dt.days) - DAYS_YEAR_ONE_TO_EPOCH), + time_from_nanos(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_nanos( + 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::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 + ) + } + } + } + + 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::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::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 text = cell.to_text(); + Ok(unsafe { + write_text( + text.as_bytes(), + 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 | SQL_C_TINYINT => { + 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_C_UTINYINT => { + 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, + } +} + +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 + } +} + +#[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() { + let value = ColumnValues::Time(SqlTime { + time_nanoseconds: (13 * 3600 + 45 * 60 + 7) * 1_000_000_000 + 123_456_700, + 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..e383b7a4 --- /dev/null +++ b/mssql-odbc/src/api/col_attribute.rs @@ -0,0 +1,229 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Implementation of SQLColAttributeW — per-column descriptor field access. + +use tracing::{debug, error}; + +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 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_DESC_COUNT => state.column_metadata.len() as SqlLen, + 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); + } +} diff --git a/mssql-odbc/src/api/conn_exec.rs b/mssql-odbc/src/api/conn_exec.rs new file mode 100644 index 00000000..a9959ff0 --- /dev/null +++ b/mssql-odbc/src/api/conn_exec.rs @@ -0,0 +1,78 @@ +// 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); + } + if state.active_stmt.is_some() { + error!("{op}: connection is busy with another statement's results"); + post_diag(&mut state, ERR_CONNECTION_BUSY); + return Err(SQL_ERROR); + } + 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/desc.rs b/mssql-odbc/src/api/desc.rs new file mode 100644 index 00000000..742ae1eb --- /dev/null +++ b/mssql-odbc/src/api/desc.rs @@ -0,0 +1,238 @@ +// 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_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 + }) +} + +/// Implements `SQLDescribeParam`. +/// +/// Server-side parameter description requires `sp_describe_undeclared_parameters`, +/// which is not yet wired up. Reporting the optional feature as unsupported is +/// the documented behaviour for drivers that cannot describe parameters, and +/// callers fall back to their own type inference. +/// +/// # Safety +/// `statement_handle` must be a valid `StmtHandle` or null; the output pointers +/// are not written. +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); + let Ok(mut state) = stmt.inner.lock() else { + error!("SQLDescribeParam: stmt mutex poisoned"); + return SQL_ERROR; + }; + free_errors(&mut state); + post_sql_error( + &mut state, + super::sqlstate::SQLSTATE_HYC00, + 0, + "Optional feature not implemented: parameter description", + ); + SQL_ERROR + }) +} + +/// Implements `SQLParamData`. +/// +/// The driver never returns `SQL_NEED_DATA`, so reaching this entry point means +/// the application called it out of sequence. +/// +/// # Safety +/// `statement_handle` must be a valid `StmtHandle` or null. +pub(crate) unsafe fn sql_param_data( + statement_handle: SqlHandle, + _value_ptr_ptr: *mut SqlPointer, +) -> SqlReturn { + crate::ffi_entry!("SQLParamData", unsafe { + sequence_error(statement_handle, "SQLParamData") + }) +} + +/// Implements `SQLPutData`. +/// +/// # Safety +/// `statement_handle` must be a valid `StmtHandle` or null. +pub(crate) unsafe fn sql_put_data( + statement_handle: SqlHandle, + _data_ptr: SqlPointer, + _str_len_or_ind: SqlLen, +) -> SqlReturn { + crate::ffi_entry!("SQLPutData", unsafe { + sequence_error(statement_handle, "SQLPutData") + }) +} + +/// # Safety +/// `statement_handle` must be a valid `StmtHandle` or null. +unsafe fn sequence_error(statement_handle: SqlHandle, name: &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); + let Ok(mut state) = stmt.inner.lock() else { + error!("{name}: stmt mutex poisoned"); + return SQL_ERROR; + }; + free_errors(&mut state); + post_sql_error( + &mut state, + super::sqlstate::SQLSTATE_HY010, + 0, + "Function sequence error", + ); + SQL_ERROR +} + +#[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); + } + + #[test] + fn param_data_out_of_sequence() { + let h = TestHandles::with_env_dbc_stmt(); + let ret = unsafe { sql_param_data(h.stmt, std::ptr::null_mut()) }; + assert_eq!(ret, SQL_ERROR); + } + + #[test] + fn put_data_out_of_sequence() { + let h = TestHandles::with_env_dbc_stmt(); + let ret = unsafe { sql_put_data(h.stmt, std::ptr::null_mut(), 0) }; + 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..50a835f5 100644 --- a/mssql-odbc/src/api/describe_col.rs +++ b/mssql-odbc/src/api/describe_col.rs @@ -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, @@ -215,7 +215,7 @@ fn odbc_sql_type(meta: &mssql_tds::query::metadata::ColumnMetadata) -> SqlSmallI } } -fn column_size(meta: &mssql_tds::query::metadata::ColumnMetadata) -> u64 { +pub(crate) fn column_size(meta: &mssql_tds::query::metadata::ColumnMetadata) -> u64 { // PLP / `*(max)` / xml / json: ColumnSize is "unbounded". Report 0 per ODBC spec if meta.is_plp() { return 0; @@ -282,7 +282,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..9aeb69d1 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 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..e07e23f1 100644 --- a/mssql-odbc/src/api/exec_common.rs +++ b/mssql-odbc/src/api/exec_common.rs @@ -60,13 +60,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 diff --git a/mssql-odbc/src/api/exec_direct.rs b/mssql-odbc/src/api/exec_direct.rs index 19103ef4..ead17154 100644 --- a/mssql-odbc/src/api/exec_direct.rs +++ b/mssql-odbc/src/api/exec_direct.rs @@ -72,7 +72,7 @@ unsafe fn sql_exec_direct_w_impl( 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,7 +106,7 @@ 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; diff --git a/mssql-odbc/src/api/execute.rs b/mssql-odbc/src/api/execute.rs index d2a6d61a..02853f84 100644 --- a/mssql-odbc/src/api/execute.rs +++ b/mssql-odbc/src/api/execute.rs @@ -158,7 +158,7 @@ fn stage_execution(stmt: &StmtHandle) -> Result { 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.reset_rows(); stmt_state.row_count = -1; stmt_state.pending_row_counts.clear(); stmt_state.set_state(STMT_STATE_EXEC_STARTED); diff --git a/mssql-odbc/src/api/exports.rs b/mssql-odbc/src/api/exports.rs index 0b124f09..382391f2 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::desc::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::desc::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..29cef8c6 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; @@ -51,13 +52,117 @@ 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; + } + 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); + 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"); @@ -201,8 +306,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..2a7a8e39 --- /dev/null +++ b/mssql-odbc/src/api/fetch_scroll.rs @@ -0,0 +1,220 @@ +// 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, 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}; + +/// 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 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 + // `buffer_length`-byte elements, one per rowset slot. + let data_ptr = if bc.target_value_ptr.is_null() { + std::ptr::null_mut() + } else { + unsafe { + bc.target_value_ptr + .cast::() + .add(row_index * bc.buffer_length.max(0) as usize) + .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..e83e3f8d --- /dev/null +++ b/mssql-odbc/src/api/get_connect_attr.rs @@ -0,0 +1,190 @@ +// 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_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, SqlHandle, SqlInteger, SqlPointer, SqlReturn, +}; +use crate::api::util::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. +/// +/// Only fixed-length (`SQLUINTEGER`) attributes are supported; string-valued +/// attributes report `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, string_length_ptr) + }) +} + +unsafe fn sql_get_connect_attr_w_impl( + connection_handle: SqlHandle, + attribute: SqlInteger, + value_ptr: SqlPointer, + 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); + + 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..9293c56f 100644 --- a/mssql-odbc/src/api/get_data.rs +++ b/mssql-odbc/src/api/get_data.rs @@ -5,17 +5,15 @@ use tracing::{debug, error}; +use super::cdata::{WriteError, WriteOutcome, write_c_value}; use super::odbc_types::{ - SQL_C_CHAR, SQL_C_WCHAR, SQL_ERROR, SQL_INVALID_HANDLE, SQL_NULL_DATA, SQL_SUCCESS, - SQL_SUCCESS_WITH_INFO, SqlHandle, SqlLen, SqlPointer, SqlReturn, SqlSmallInt, SqlUSmallInt, + SQL_ERROR, SQL_INVALID_HANDLE, SQL_SUCCESS, SQL_SUCCESS_WITH_INFO, SqlHandle, SqlLen, + SqlPointer, SqlReturn, SqlSmallInt, SqlUSmallInt, }; use super::sqlstate::*; -use crate::api::odbc_types::SqlWChar; -use crate::api::util::{copy_with_nul, write_if_some}; 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 mssql_tds::datatypes::column_values::ColumnValues; /// Implements SQLGetData for current-row retrieval. /// @@ -121,120 +119,49 @@ 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", - ); - 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 value = &row[col_index - 1]; - 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, &[]); - } - } - return SQL_SUCCESS; - } - - let Some(as_text) = column_value_to_text(value) else { - post_sql_error( - &mut stmt_state, - SQLSTATE_HYC00, - 0, - "Column type conversion not yet implemented", - ); - 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, + let value = row[col_index - 1].clone(); + match unsafe { + write_c_value( + &value, + target_type, + target_value_ptr, + buffer_length, strlen_or_ind_ptr, ) - } -} - -/// 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`. -/// -/// `buf_elements` is the buffer capacity in units of `T` (not bytes). -/// -/// 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, - 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 - } -} - -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, + } { + 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 + } } } #[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 +318,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 +327,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(); 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..ba148984 100644 --- a/mssql-odbc/src/api/mod.rs +++ b/mssql-odbc/src/api/mod.rs @@ -2,17 +2,26 @@ // Licensed under the MIT License. pub(crate) mod alloc_handle; +mod bind_col; mod bind_param; +mod catalog; +mod cdata; mod close_cursor; +mod col_attribute; +mod conn_exec; mod connect; +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 +36,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..7853b0bf 100644 --- a/mssql-odbc/src/api/more_results.rs +++ b/mssql-odbc/src/api/more_results.rs @@ -114,7 +114,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 +147,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..0fb8c886 100644 --- a/mssql-odbc/src/api/prepare.rs +++ b/mssql-odbc/src/api/prepare.rs @@ -114,7 +114,7 @@ fn sql_prepare_w_safe(stmt: &StmtHandle, sql: String) -> SqlReturn { stmt_state.prepared_sql = Some(sql); 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..e630c930 100644 --- a/mssql-odbc/src/api/set_connect_attr.rs +++ b/mssql-odbc/src/api/set_connect_attr.rs @@ -9,16 +9,50 @@ use tracing::{debug, error}; +use super::conn_exec::exec_on_connection; use super::sqlstate::*; 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_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_SUCCESS, SQL_TXN_READ_COMMITTED, SQL_TXN_READ_UNCOMMITTED, + SQL_TXN_REPEATABLE_READ, SQL_TXN_SERIALIZABLE, SQL_TXN_SS_SNAPSHOT, SqlHandle, SqlInteger, + SqlPointer, SqlReturn, }; 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 @@ -124,6 +158,77 @@ 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_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/spill.rs b/mssql-odbc/src/api/spill.rs new file mode 100644 index 00000000..f0ec555b --- /dev/null +++ b/mssql-odbc/src/api/spill.rs @@ -0,0 +1,136 @@ +// 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; +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 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 && !client.has_open_batch(); + + if let Ok(mut state) = other.inner.lock() { + state.buffered_rows.extend(rows); + state.buffered_eof = reached_eof; + } + + 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..ec7a96c8 100644 --- a/mssql-odbc/src/api/sqlstate.rs +++ b/mssql-odbc/src/api/sqlstate.rs @@ -17,6 +17,10 @@ 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"; +/// Numeric value out of range. +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 +30,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"; diff --git a/mssql-odbc/src/handles/dbc.rs b/mssql-odbc/src/handles/dbc.rs index 83af697b..a17846de 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,15 @@ 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, + /// 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 +88,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 +105,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 +117,9 @@ impl DbcHandle { active_stmt: None, client: None, access_token: None, + autocommit: true, + txn_isolation: crate::api::odbc_types::SQL_TXN_READ_COMMITTED, + 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..31a81a43 100644 --- a/mssql-odbc/src/handles/stmt.rs +++ b/mssql-odbc/src/handles/stmt.rs @@ -71,6 +71,14 @@ 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, /// 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 +103,41 @@ 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>, /// Statement lifecycle/status flags used for ODBC API state checks. pub(crate) state_flags: u32, } +/// 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; + } + pub(crate) fn has_state(&self, mask: u32) -> bool { (self.state_flags & mask) != 0 } @@ -160,12 +198,15 @@ impl StmtHandle { prepared_handle: None, pending_unprepare: None, current_row: None, + buffered_rows: VecDeque::new(), + buffered_eof: false, 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(), state_flags: 0, }), } diff --git a/mssql-odbc/tests/e2e/CMakeLists.txt b/mssql-odbc/tests/e2e/CMakeLists.txt index e8ee5379..11a74ddc 100644 --- a/mssql-odbc/tests/e2e/CMakeLists.txt +++ b/mssql-odbc/tests/e2e/CMakeLists.txt @@ -125,3 +125,4 @@ add_odbc_test(more_results_test tests/more_results_test.cpp) add_odbc_test(execute_test tests/execute_test.cpp) add_odbc_test(get_type_info_test tests/get_type_info_test.cpp) add_odbc_test(row_count_test tests/row_count_test.cpp) +add_odbc_test(mssql_python_parity_test tests/mssql_python_parity_test.cpp) diff --git a/mssql-odbc/tests/e2e/tests/mssql_python_parity_test.cpp b/mssql-odbc/tests/e2e/tests/mssql_python_parity_test.cpp new file mode 100644 index 00000000..1bd8799e --- /dev/null +++ b/mssql-odbc/tests/e2e/tests/mssql_python_parity_test.cpp @@ -0,0 +1,493 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// mssql_python_parity_test.cpp +// +// Single-file GoogleTest suite covering the ODBC surface that mssql-python +// drives directly. mssql-python does not go through a Driver Manager: it +// LoadLibrary's the driver and calls the exported entrypoints itself, so the +// contract exercised here is exactly the set of calls its pybind layer makes +// (ddbc_bindings.cpp). Every case below maps to a behaviour the Python suite +// depends on, which makes this file the fast regression gate for the +// mssql-python parity work. + +#include "odbc_test_fixture.h" + +#include +#include +#include + +namespace { + +/// mssql-python asks for SQL_CA_SS_VARIANT_TYPE on every column of a result +/// set to detect sql_variant; the value is a SQL Server driver-specific field. +constexpr SQLUSMALLINT kSqlCaSsVariantType = 1215; + +/// Fixture that connects once per test and exposes small helpers for the +/// mssql-python call patterns. +class PythonParityTest : public ODBCTest { +protected: + void SetUp() override { + ODBCTest::SetUp(); + if (!ODBCTestConfig::Instance().HasConnection()) { + GTEST_SKIP() << "No connection configured (set ODBC_TEST_* env vars)"; + } + Connect(); + } + + /// Runs |sql| on |hstmt| and asserts it succeeded. + void Exec(SQLHSTMT hstmt, const std::string& sql) { + SqlTString text = ODBCTestUtils::ToSqlTStr(sql); + ASSERT_SQL_OK(SQLExecDirectW(hstmt, reinterpret_cast(text.data()), SQL_NTS), + SQL_HANDLE_STMT, hstmt); + } + + /// Fetches a single SQL_C_SLONG column from a one-row query. + SQLINTEGER ScalarLong(const std::string& sql) { + Exec(stmt_, sql); + EXPECT_EQ(SQL_SUCCESS, SQLFetch(stmt_)); + SQLINTEGER value = 0; + SQLLEN indicator = 0; + EXPECT_SQL_OK(SQLGetData(stmt_, 1, SQL_C_SLONG, &value, sizeof(value), &indicator), + SQL_HANDLE_STMT, stmt_); + SQLFreeStmt(stmt_, SQL_CLOSE); + return value; + } +}; + +// --------------------------------------------------------------------------- +// Connection attributes and transactions +// +// mssql-python calls SQLSetConnectAttr(SQL_ATTR_AUTOCOMMIT) immediately after +// connecting and raises if it fails, then drives commit/rollback exclusively +// through SQLEndTran. A failure in any of these aborts every Python test. +// --------------------------------------------------------------------------- + +TEST_F(PythonParityTest, AutocommitRoundTrips) { + ASSERT_SQL_OK(SQLSetConnectAttr(dbc_, SQL_ATTR_AUTOCOMMIT, + reinterpret_cast(SQL_AUTOCOMMIT_OFF), 0), + SQL_HANDLE_DBC, dbc_); + + SQLUINTEGER value = 0xFFFF; + SQLINTEGER length = 0; + ASSERT_SQL_OK(SQLGetConnectAttrW(dbc_, SQL_ATTR_AUTOCOMMIT, &value, sizeof(value), &length), + SQL_HANDLE_DBC, dbc_); + EXPECT_EQ(static_cast(SQL_AUTOCOMMIT_OFF), value); + + ASSERT_SQL_OK(SQLSetConnectAttr(dbc_, SQL_ATTR_AUTOCOMMIT, + reinterpret_cast(SQL_AUTOCOMMIT_ON), 0), + SQL_HANDLE_DBC, dbc_); + ASSERT_SQL_OK(SQLGetConnectAttrW(dbc_, SQL_ATTR_AUTOCOMMIT, &value, sizeof(value), &length), + SQL_HANDLE_DBC, dbc_); + EXPECT_EQ(static_cast(SQL_AUTOCOMMIT_ON), value); +} + +TEST_F(PythonParityTest, ManualCommitPersistsRows) { + ExecDirectIgnoreError("DROP TABLE IF EXISTS #parity_commit"); + Exec(stmt_, "CREATE TABLE #parity_commit (id INT)"); + + ASSERT_SQL_OK(SQLSetConnectAttr(dbc_, SQL_ATTR_AUTOCOMMIT, + reinterpret_cast(SQL_AUTOCOMMIT_OFF), 0), + SQL_HANDLE_DBC, dbc_); + Exec(stmt_, "INSERT INTO #parity_commit VALUES (1)"); + ASSERT_SQL_OK(SQLEndTran(SQL_HANDLE_DBC, dbc_, SQL_COMMIT), SQL_HANDLE_DBC, dbc_); + + EXPECT_EQ(1, ScalarLong("SELECT COUNT(*) FROM #parity_commit")); + SQLSetConnectAttr(dbc_, SQL_ATTR_AUTOCOMMIT, reinterpret_cast(SQL_AUTOCOMMIT_ON), 0); +} + +TEST_F(PythonParityTest, ManualRollbackDiscardsRows) { + ExecDirectIgnoreError("DROP TABLE IF EXISTS #parity_rollback"); + Exec(stmt_, "CREATE TABLE #parity_rollback (id INT)"); + + ASSERT_SQL_OK(SQLSetConnectAttr(dbc_, SQL_ATTR_AUTOCOMMIT, + reinterpret_cast(SQL_AUTOCOMMIT_OFF), 0), + SQL_HANDLE_DBC, dbc_); + Exec(stmt_, "INSERT INTO #parity_rollback VALUES (1)"); + ASSERT_SQL_OK(SQLEndTran(SQL_HANDLE_DBC, dbc_, SQL_ROLLBACK), SQL_HANDLE_DBC, dbc_); + + EXPECT_EQ(0, ScalarLong("SELECT COUNT(*) FROM #parity_rollback")); + SQLSetConnectAttr(dbc_, SQL_ATTR_AUTOCOMMIT, reinterpret_cast(SQL_AUTOCOMMIT_ON), 0); +} + +TEST_F(PythonParityTest, EndTranInAutocommitIsANoOp) { + // Python's Connection.commit() is unconditional, so committing while + // autocommit is on must succeed instead of raising 25000. + EXPECT_SQL_OK(SQLEndTran(SQL_HANDLE_DBC, dbc_, SQL_COMMIT), SQL_HANDLE_DBC, dbc_); + EXPECT_SQL_OK(SQLEndTran(SQL_HANDLE_DBC, dbc_, SQL_ROLLBACK), SQL_HANDLE_DBC, dbc_); +} + +// --------------------------------------------------------------------------- +// Block fetch — the fetchmany()/fetchall() hot path +// +// FetchBatchData() unbinds, binds every column column-wise with an array of +// |fetchSize| elements, then calls SQLFetchScroll(SQL_FETCH_NEXT, 0) and reads +// SQL_ATTR_ROWS_FETCHED_PTR. Column-wise offsets and the indicator array are +// the parts most likely to regress. +// --------------------------------------------------------------------------- + +TEST_F(PythonParityTest, BlockFetchFillsColumnWiseArrays) { + constexpr SQLULEN kRowsetSize = 4; + + ASSERT_SQL_OK(SQLSetStmtAttr(stmt_, SQL_ATTR_ROW_ARRAY_SIZE, + reinterpret_cast(kRowsetSize), 0), + SQL_HANDLE_STMT, stmt_); + SQLULEN rows_fetched = 0; + ASSERT_SQL_OK(SQLSetStmtAttr(stmt_, SQL_ATTR_ROWS_FETCHED_PTR, &rows_fetched, 0), + SQL_HANDLE_STMT, stmt_); + + Exec(stmt_, + "SELECT v, CAST(v AS VARCHAR(16)) AS t FROM (VALUES (10),(20),(30)) AS s(v) ORDER BY v"); + + SQLINTEGER ints[kRowsetSize] = {}; + SQLLEN int_ind[kRowsetSize] = {}; + SQLWCHAR text[kRowsetSize][32] = {}; + SQLLEN text_ind[kRowsetSize] = {}; + + ASSERT_SQL_OK(SQLFreeStmt(stmt_, SQL_UNBIND), SQL_HANDLE_STMT, stmt_); + ASSERT_SQL_OK(SQLBindCol(stmt_, 1, SQL_C_SLONG, ints, sizeof(SQLINTEGER), int_ind), + SQL_HANDLE_STMT, stmt_); + ASSERT_SQL_OK(SQLBindCol(stmt_, 2, SQL_C_WCHAR, text, sizeof(text[0]), text_ind), + SQL_HANDLE_STMT, stmt_); + + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetchScroll(stmt_, SQL_FETCH_NEXT, 0))); + ASSERT_EQ(3u, rows_fetched); + EXPECT_EQ(10, ints[0]); + EXPECT_EQ(20, ints[1]); + EXPECT_EQ(30, ints[2]); + EXPECT_EQ(static_cast(sizeof(SQLINTEGER)), int_ind[0]); + EXPECT_EQ(std::string("10"), ODBCTestUtils::ToNarrow(SqlTString( + reinterpret_cast(text[0])))); + EXPECT_EQ(std::string("30"), ODBCTestUtils::ToNarrow(SqlTString( + reinterpret_cast(text[2])))); + + EXPECT_EQ(SQL_NO_DATA, SQLFetchScroll(stmt_, SQL_FETCH_NEXT, 0)); +} + +TEST_F(PythonParityTest, BlockFetchReportsNullIndicators) { + constexpr SQLULEN kRowsetSize = 2; + ASSERT_SQL_OK(SQLSetStmtAttr(stmt_, SQL_ATTR_ROW_ARRAY_SIZE, + reinterpret_cast(kRowsetSize), 0), + SQL_HANDLE_STMT, stmt_); + SQLULEN rows_fetched = 0; + ASSERT_SQL_OK(SQLSetStmtAttr(stmt_, SQL_ATTR_ROWS_FETCHED_PTR, &rows_fetched, 0), + SQL_HANDLE_STMT, stmt_); + + Exec(stmt_, "SELECT CAST(NULL AS INT) UNION ALL SELECT 7"); + + SQLINTEGER values[kRowsetSize] = {}; + SQLLEN indicators[kRowsetSize] = {}; + ASSERT_SQL_OK(SQLFreeStmt(stmt_, SQL_UNBIND), SQL_HANDLE_STMT, stmt_); + ASSERT_SQL_OK(SQLBindCol(stmt_, 1, SQL_C_SLONG, values, sizeof(SQLINTEGER), indicators), + SQL_HANDLE_STMT, stmt_); + + ASSERT_TRUE(SQL_SUCCEEDED(SQLFetchScroll(stmt_, SQL_FETCH_NEXT, 0))); + ASSERT_EQ(2u, rows_fetched); + EXPECT_EQ(SQL_NULL_DATA, indicators[0]); + EXPECT_EQ(7, values[1]); +} + +TEST_F(PythonParityTest, FetchScrollRejectsNonForwardOrientations) { + Exec(stmt_, "SELECT 1"); + EXPECT_EQ(SQL_ERROR, SQLFetchScroll(stmt_, SQL_FETCH_PRIOR, 0)); + EXPECT_SQLSTATE(SQL_HANDLE_STMT, stmt_, "HY106"); +} + +// --------------------------------------------------------------------------- +// Interleaved cursors on one connection +// +// Connection.cursor() allocates another HSTMT on the same HDBC. Without MARS +// the driver must still serve the second statement once the first result set +// is buffered, and the first cursor's remaining rows must survive. +// --------------------------------------------------------------------------- + +TEST_F(PythonParityTest, SecondCursorRunsWhileFirstIsOpen) { + Exec(stmt_, "SELECT 1 AS n UNION ALL SELECT 2 ORDER BY n"); + ASSERT_EQ(SQL_SUCCESS, SQLFetch(stmt_)); + + SQLINTEGER first = 0; + SQLLEN ind = 0; + ASSERT_SQL_OK(SQLGetData(stmt_, 1, SQL_C_SLONG, &first, sizeof(first), &ind), SQL_HANDLE_STMT, + stmt_); + EXPECT_EQ(1, first); + + SQLHSTMT other = AllocStmt(); + Exec(other, "SELECT 42"); + ASSERT_EQ(SQL_SUCCESS, SQLFetch(other)); + SQLINTEGER answer = 0; + ASSERT_SQL_OK(SQLGetData(other, 1, SQL_C_SLONG, &answer, sizeof(answer), &ind), SQL_HANDLE_STMT, + other); + EXPECT_EQ(42, answer); + FreeStmt(other); + + // The first cursor keeps its position across the interleaved statement. + ASSERT_EQ(SQL_SUCCESS, SQLFetch(stmt_)); + SQLINTEGER second = 0; + ASSERT_SQL_OK(SQLGetData(stmt_, 1, SQL_C_SLONG, &second, sizeof(second), &ind), SQL_HANDLE_STMT, + stmt_); + EXPECT_EQ(2, second); +} + +// --------------------------------------------------------------------------- +// SQLGetData conversions +// +// Python materializes every cell through SQLGetData with the C type chosen +// from the column's SQL type, so the conversion matrix is load-bearing for the +// whole data-type test module. +// --------------------------------------------------------------------------- + +TEST_F(PythonParityTest, GetDataConvertsCommonTypes) { + Exec(stmt_, + "SELECT CAST('abc' AS NVARCHAR(10)), CAST(1.5 AS FLOAT), CAST(3 AS BIGINT), " + "CAST('2024-02-29' AS DATE), CAST(1 AS BIT)"); + ASSERT_EQ(SQL_SUCCESS, SQLFetch(stmt_)); + + SQLWCHAR text[32] = {}; + SQLLEN ind = 0; + ASSERT_SQL_OK(SQLGetData(stmt_, 1, SQL_C_WCHAR, text, sizeof(text), &ind), SQL_HANDLE_STMT, + stmt_); + EXPECT_EQ(std::string("abc"), + ODBCTestUtils::ToNarrow(SqlTString(reinterpret_cast(text)))); + + double real = 0.0; + ASSERT_SQL_OK(SQLGetData(stmt_, 2, SQL_C_DOUBLE, &real, sizeof(real), &ind), SQL_HANDLE_STMT, + stmt_); + EXPECT_DOUBLE_EQ(1.5, real); + + SQLBIGINT big = 0; + ASSERT_SQL_OK(SQLGetData(stmt_, 3, SQL_C_SBIGINT, &big, sizeof(big), &ind), SQL_HANDLE_STMT, + stmt_); + EXPECT_EQ(3, big); + + SQL_DATE_STRUCT date{}; + ASSERT_SQL_OK(SQLGetData(stmt_, 4, SQL_C_TYPE_DATE, &date, sizeof(date), &ind), SQL_HANDLE_STMT, + stmt_); + EXPECT_EQ(2024, date.year); + EXPECT_EQ(2, date.month); + EXPECT_EQ(29, date.day); + + unsigned char bit = 0; + ASSERT_SQL_OK(SQLGetData(stmt_, 5, SQL_C_BIT, &bit, sizeof(bit), &ind), SQL_HANDLE_STMT, stmt_); + EXPECT_EQ(1, bit); +} + +TEST_F(PythonParityTest, GetDataReportsNullAndTruncation) { + Exec(stmt_, "SELECT CAST(NULL AS INT), CAST('abcdef' AS VARCHAR(10))"); + ASSERT_EQ(SQL_SUCCESS, SQLFetch(stmt_)); + + SQLINTEGER value = 123; + SQLLEN ind = 0; + ASSERT_SQL_OK(SQLGetData(stmt_, 1, SQL_C_SLONG, &value, sizeof(value), &ind), SQL_HANDLE_STMT, + stmt_); + EXPECT_EQ(SQL_NULL_DATA, ind); + + SQLCHAR small[4] = {}; + EXPECT_EQ(SQL_SUCCESS_WITH_INFO, SQLGetData(stmt_, 2, SQL_C_CHAR, small, sizeof(small), &ind)); + EXPECT_SQLSTATE(SQL_HANDLE_STMT, stmt_, "01004"); +} + +// --------------------------------------------------------------------------- +// SQLColAttributeW +// +// Python queries SQL_CA_SS_VARIANT_TYPE per column and falls back to None when +// it fails; it also relies on the standard descriptor fields for cursor +// metadata. +// --------------------------------------------------------------------------- + +TEST_F(PythonParityTest, ColAttributeReportsNameTypeAndCount) { + Exec(stmt_, "SELECT CAST(1 AS INT) AS answer"); + + SQLLEN numeric = 0; + ASSERT_SQL_OK(SQLColAttributeW(stmt_, 0, SQL_DESC_COUNT, nullptr, 0, nullptr, &numeric), + SQL_HANDLE_STMT, stmt_); + EXPECT_EQ(1, numeric); + + SQLWCHAR name[64] = {}; + SQLSMALLINT name_len = 0; + ASSERT_SQL_OK( + SQLColAttributeW(stmt_, 1, SQL_DESC_NAME, name, sizeof(name), &name_len, nullptr), + SQL_HANDLE_STMT, stmt_); + EXPECT_EQ(std::string("answer"), + ODBCTestUtils::ToNarrow(SqlTString(reinterpret_cast(name)))); + + ASSERT_SQL_OK(SQLColAttributeW(stmt_, 1, SQL_DESC_CONCISE_TYPE, nullptr, 0, nullptr, &numeric), + SQL_HANDLE_STMT, stmt_); + EXPECT_EQ(SQL_INTEGER, numeric); +} + +TEST_F(PythonParityTest, ColAttributeVariantTypeDoesNotCrash) { + Exec(stmt_, "SELECT CAST(1 AS INT)"); + SQLLEN numeric = 0; + // Either answer is acceptable — Python treats a failure as "not a variant" — + // but the call must not fault or leave the statement unusable. + SQLColAttributeW(stmt_, 1, kSqlCaSsVariantType, nullptr, 0, nullptr, &numeric); + EXPECT_EQ(SQL_SUCCESS, SQLFetch(stmt_)); +} + +// --------------------------------------------------------------------------- +// Catalog functions +// +// Cursor.tables()/columns()/primaryKeys()/... map one-to-one onto these calls +// and assert on the ODBC-defined column layout. +// --------------------------------------------------------------------------- + +TEST_F(PythonParityTest, TablesReturnsOdbcShapedResultSet) { + ExecDirectIgnoreError("DROP TABLE dbo.parity_catalog"); + Exec(stmt_, "CREATE TABLE dbo.parity_catalog (id INT NOT NULL PRIMARY KEY, label NVARCHAR(20))"); + SQLFreeStmt(stmt_, SQL_CLOSE); + + SqlTString table = ODBCTestUtils::ToSqlTStr("parity_catalog"); + ASSERT_SQL_OK(SQLTablesW(stmt_, nullptr, 0, nullptr, 0, + reinterpret_cast(table.data()), SQL_NTS, nullptr, 0), + SQL_HANDLE_STMT, stmt_); + + SQLSMALLINT columns = 0; + ASSERT_SQL_OK(SQLNumResultCols(stmt_, &columns), SQL_HANDLE_STMT, stmt_); + EXPECT_EQ(5, columns); + EXPECT_EQ(SQL_SUCCESS, SQLFetch(stmt_)); + SQLFreeStmt(stmt_, SQL_CLOSE); + + ExecDirectIgnoreError("DROP TABLE dbo.parity_catalog"); +} + +TEST_F(PythonParityTest, ColumnsAndPrimaryKeysSucceed) { + ExecDirectIgnoreError("DROP TABLE dbo.parity_keys"); + Exec(stmt_, "CREATE TABLE dbo.parity_keys (id INT NOT NULL PRIMARY KEY, label NVARCHAR(20))"); + SQLFreeStmt(stmt_, SQL_CLOSE); + + SqlTString table = ODBCTestUtils::ToSqlTStr("parity_keys"); + ASSERT_SQL_OK(SQLColumnsW(stmt_, nullptr, 0, nullptr, 0, + reinterpret_cast(table.data()), SQL_NTS, nullptr, 0), + SQL_HANDLE_STMT, stmt_); + EXPECT_EQ(SQL_SUCCESS, SQLFetch(stmt_)); + SQLFreeStmt(stmt_, SQL_CLOSE); + + table = ODBCTestUtils::ToSqlTStr("parity_keys"); + ASSERT_SQL_OK(SQLPrimaryKeysW(stmt_, nullptr, 0, nullptr, 0, + reinterpret_cast(table.data()), SQL_NTS), + SQL_HANDLE_STMT, stmt_); + EXPECT_EQ(SQL_SUCCESS, SQLFetch(stmt_)); + SQLFreeStmt(stmt_, SQL_CLOSE); + + ExecDirectIgnoreError("DROP TABLE dbo.parity_keys"); +} + +TEST_F(PythonParityTest, ProceduresAndStatisticsSucceed) { + ASSERT_SQL_OK(SQLProceduresW(stmt_, nullptr, 0, nullptr, 0, nullptr, 0), SQL_HANDLE_STMT, + stmt_); + SQLFreeStmt(stmt_, SQL_CLOSE); + + ExecDirectIgnoreError("DROP TABLE dbo.parity_stats"); + Exec(stmt_, "CREATE TABLE dbo.parity_stats (id INT NOT NULL PRIMARY KEY)"); + SQLFreeStmt(stmt_, SQL_CLOSE); + + SqlTString table = ODBCTestUtils::ToSqlTStr("parity_stats"); + ASSERT_SQL_OK(SQLStatisticsW(stmt_, nullptr, 0, nullptr, 0, + reinterpret_cast(table.data()), SQL_NTS, + SQL_INDEX_ALL, SQL_QUICK), + SQL_HANDLE_STMT, stmt_); + SQLFreeStmt(stmt_, SQL_CLOSE); + + ExecDirectIgnoreError("DROP TABLE dbo.parity_stats"); +} + +// --------------------------------------------------------------------------- +// Parameter binding +// +// BindParameters() feeds SQLBindParameter for every Python argument, and the +// SQL_C_NUMERIC path additionally sets precision/scale on the APD through +// SQLSetDescFieldW — a failure there makes every decimal parameter raise. +// --------------------------------------------------------------------------- + +TEST_F(PythonParityTest, BoundParametersRoundTrip) { + SqlTString sql = ODBCTestUtils::ToSqlTStr("SELECT ? + ?, ?"); + ASSERT_SQL_OK(SQLPrepareW(stmt_, reinterpret_cast(sql.data()), SQL_NTS), + SQL_HANDLE_STMT, stmt_); + + SQLINTEGER left = 40; + SQLINTEGER right = 2; + SQLWCHAR text[] = {L'h', L'i', 0}; + SQLLEN int_len = 0; + SQLLEN text_len = SQL_NTS; + + ASSERT_SQL_OK(SQLBindParameter(stmt_, 1, SQL_PARAM_INPUT, SQL_C_SLONG, SQL_INTEGER, 0, 0, &left, + 0, &int_len), + SQL_HANDLE_STMT, stmt_); + ASSERT_SQL_OK(SQLBindParameter(stmt_, 2, SQL_PARAM_INPUT, SQL_C_SLONG, SQL_INTEGER, 0, 0, + &right, 0, &int_len), + SQL_HANDLE_STMT, stmt_); + ASSERT_SQL_OK(SQLBindParameter(stmt_, 3, SQL_PARAM_INPUT, SQL_C_WCHAR, SQL_WVARCHAR, 2, 0, text, + sizeof(text), &text_len), + SQL_HANDLE_STMT, stmt_); + + ASSERT_SQL_OK(SQLExecute(stmt_), SQL_HANDLE_STMT, stmt_); + ASSERT_EQ(SQL_SUCCESS, SQLFetch(stmt_)); + + SQLINTEGER sum = 0; + SQLLEN ind = 0; + ASSERT_SQL_OK(SQLGetData(stmt_, 1, SQL_C_SLONG, &sum, sizeof(sum), &ind), SQL_HANDLE_STMT, + stmt_); + EXPECT_EQ(42, sum); +} + +TEST_F(PythonParityTest, SetDescFieldOnApdSucceedsForNumeric) { + SQLHDESC apd = nullptr; + SQLINTEGER length = 0; + if (!SQL_SUCCEEDED(SQLGetStmtAttr(stmt_, SQL_ATTR_APP_PARAM_DESC, &apd, 0, &length))) { + GTEST_SKIP() << "APD handle unavailable"; + } + // Python sets these three fields, in this order, for every decimal argument. + EXPECT_SQL_OK(SQLSetDescFieldW(apd, 1, SQL_DESC_TYPE, reinterpret_cast(SQL_C_NUMERIC), + 0), + SQL_HANDLE_DESC, apd); + EXPECT_SQL_OK(SQLSetDescFieldW(apd, 1, SQL_DESC_PRECISION, reinterpret_cast(18), 0), + SQL_HANDLE_DESC, apd); + EXPECT_SQL_OK(SQLSetDescFieldW(apd, 1, SQL_DESC_SCALE, reinterpret_cast(4), 0), + SQL_HANDLE_DESC, apd); +} + +// --------------------------------------------------------------------------- +// Statement lifecycle +// --------------------------------------------------------------------------- + +TEST_F(PythonParityTest, UnbindClearsPreviousBindings) { + Exec(stmt_, "SELECT 1, 2"); + SQLINTEGER first = 0; + SQLLEN ind = 0; + ASSERT_SQL_OK(SQLBindCol(stmt_, 1, SQL_C_SLONG, &first, sizeof(first), &ind), SQL_HANDLE_STMT, + stmt_); + ASSERT_SQL_OK(SQLFreeStmt(stmt_, SQL_UNBIND), SQL_HANDLE_STMT, stmt_); + + ASSERT_EQ(SQL_SUCCESS, SQLFetch(stmt_)); + EXPECT_EQ(0, first) << "SQL_UNBIND must detach the buffer"; +} + +TEST_F(PythonParityTest, RowCountReportsAffectedRows) { + ExecDirectIgnoreError("DROP TABLE IF EXISTS #parity_rowcount"); + Exec(stmt_, "CREATE TABLE #parity_rowcount (id INT)"); + SQLFreeStmt(stmt_, SQL_CLOSE); + + Exec(stmt_, "INSERT INTO #parity_rowcount VALUES (1),(2),(3)"); + SQLLEN affected = 0; + ASSERT_SQL_OK(SQLRowCount(stmt_, &affected), SQL_HANDLE_STMT, stmt_); + EXPECT_EQ(3, affected); +} + +TEST_F(PythonParityTest, MoreResultsWalksMultiStatementBatch) { + Exec(stmt_, "SELECT 1; SELECT 2"); + ASSERT_EQ(SQL_SUCCESS, SQLFetch(stmt_)); + + SQLINTEGER value = 0; + SQLLEN ind = 0; + ASSERT_SQL_OK(SQLGetData(stmt_, 1, SQL_C_SLONG, &value, sizeof(value), &ind), SQL_HANDLE_STMT, + stmt_); + EXPECT_EQ(1, value); + + ASSERT_TRUE(SQL_SUCCEEDED(SQLMoreResults(stmt_))); + ASSERT_EQ(SQL_SUCCESS, SQLFetch(stmt_)); + ASSERT_SQL_OK(SQLGetData(stmt_, 1, SQL_C_SLONG, &value, sizeof(value), &ind), SQL_HANDLE_STMT, + stmt_); + EXPECT_EQ(2, value); + + EXPECT_EQ(SQL_NO_DATA, SQLMoreResults(stmt_)); +} + +} // namespace 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 From c7694720278b283a8dba5758fbd38654875cc5a5 Mon Sep 17 00:00:00 2001 From: Saurabh Singh <1623701+saurabh500@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:36:36 -0700 Subject: [PATCH 02/12] Add data-at-execution, parameter arrays, and typed variant/UDT fetch Implements SQLParamData/SQLPutData for data-at-execution parameters, column-wise parameter arrays for executemany, chunked SQLGetData with offset tracking, and SQL Server-specific type reporting for sql_variant and UDT columns. Character columns with a collation-derived encoding are now passed through in their original code page for SQL_C_CHAR targets. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e698c659-8b42-43f7-99ab-46f534b2489b --- mssql-odbc/Cargo.toml | 2 + mssql-odbc/src/api/bind_param.rs | 5 +- mssql-odbc/src/api/cdata.rs | 88 ++- mssql-odbc/src/api/col_attribute.rs | 10 + mssql-odbc/src/api/dae.rs | 338 +++++++++ mssql-odbc/src/api/desc.rs | 67 -- mssql-odbc/src/api/describe_col.rs | 16 +- mssql-odbc/src/api/exec_common.rs | 79 ++- mssql-odbc/src/api/execute.rs | 169 ++++- mssql-odbc/src/api/exports.rs | 4 +- mssql-odbc/src/api/fetch.rs | 2 + mssql-odbc/src/api/get_data.rs | 153 +++- mssql-odbc/src/api/mod.rs | 1 + mssql-odbc/src/api/set_stmt_attr.rs | 29 +- mssql-odbc/src/handles/stmt.rs | 58 ++ mssql-odbc/src/params/convert.rs | 1004 +++++++++++++++++++++++---- mssql-odbc/src/params/cvalue.rs | 410 +++++++++++ mssql-odbc/src/params/mod.rs | 1 + 18 files changed, 2150 insertions(+), 286 deletions(-) create mode 100644 mssql-odbc/src/api/dae.rs create mode 100644 mssql-odbc/src/params/cvalue.rs 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/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/cdata.rs b/mssql-odbc/src/api/cdata.rs index c2dc106d..7c641ca9 100644 --- a/mssql-odbc/src/api/cdata.rs +++ b/mssql-odbc/src/api/cdata.rs @@ -10,6 +10,7 @@ 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}; @@ -555,7 +556,7 @@ fn numeric_struct(d: &DecimalParts) -> SqlNumericStruct { } } -fn wchar_capacity(buffer_length: SqlLen) -> usize { +pub(crate) fn wchar_capacity(buffer_length: SqlLen) -> usize { (buffer_length.max(0) as usize) / std::mem::size_of::() } @@ -605,6 +606,91 @@ unsafe fn write_binary( } } +/// 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. +fn narrow_bytes(value: &ColumnValues) -> Option> { + if let ColumnValues::String(s) = value + && matches!(s.encoding_type(), EncodingType::LcidBased(_)) + { + return Some(s.bytes.clone()); + } + Some(to_cell(value)?.to_text().into_bytes()) +} + +/// 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::*; diff --git a/mssql-odbc/src/api/col_attribute.rs b/mssql-odbc/src/api/col_attribute.rs index e383b7a4..a6c9a339 100644 --- a/mssql-odbc/src/api/col_attribute.rs +++ b/mssql-odbc/src/api/col_attribute.rs @@ -5,6 +5,7 @@ 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::{ @@ -119,6 +120,15 @@ unsafe fn sql_col_attribute_w_impl( let numeric: SqlLen = match field_identifier { SQL_DESC_COUNT => state.column_metadata.len() as SqlLen, + // 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 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 index 742ae1eb..71cbfb21 100644 --- a/mssql-odbc/src/api/desc.rs +++ b/mssql-odbc/src/api/desc.rs @@ -119,59 +119,6 @@ pub(crate) unsafe fn sql_describe_param( }) } -/// Implements `SQLParamData`. -/// -/// The driver never returns `SQL_NEED_DATA`, so reaching this entry point means -/// the application called it out of sequence. -/// -/// # Safety -/// `statement_handle` must be a valid `StmtHandle` or null. -pub(crate) unsafe fn sql_param_data( - statement_handle: SqlHandle, - _value_ptr_ptr: *mut SqlPointer, -) -> SqlReturn { - crate::ffi_entry!("SQLParamData", unsafe { - sequence_error(statement_handle, "SQLParamData") - }) -} - -/// Implements `SQLPutData`. -/// -/// # Safety -/// `statement_handle` must be a valid `StmtHandle` or null. -pub(crate) unsafe fn sql_put_data( - statement_handle: SqlHandle, - _data_ptr: SqlPointer, - _str_len_or_ind: SqlLen, -) -> SqlReturn { - crate::ffi_entry!("SQLPutData", unsafe { - sequence_error(statement_handle, "SQLPutData") - }) -} - -/// # Safety -/// `statement_handle` must be a valid `StmtHandle` or null. -unsafe fn sequence_error(statement_handle: SqlHandle, name: &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); - let Ok(mut state) = stmt.inner.lock() else { - error!("{name}: stmt mutex poisoned"); - return SQL_ERROR; - }; - free_errors(&mut state); - post_sql_error( - &mut state, - super::sqlstate::SQLSTATE_HY010, - 0, - "Function sequence error", - ); - SQL_ERROR -} - #[cfg(test)] mod tests { use super::*; @@ -221,18 +168,4 @@ mod tests { }; assert_eq!(ret, SQL_ERROR); } - - #[test] - fn param_data_out_of_sequence() { - let h = TestHandles::with_env_dbc_stmt(); - let ret = unsafe { sql_param_data(h.stmt, std::ptr::null_mut()) }; - assert_eq!(ret, SQL_ERROR); - } - - #[test] - fn put_data_out_of_sequence() { - let h = TestHandles::with_env_dbc_stmt(); - let ret = unsafe { sql_put_data(h.stmt, std::ptr::null_mut(), 0) }; - assert_eq!(ret, SQL_ERROR); - } } diff --git a/mssql-odbc/src/api/describe_col.rs b/mssql-odbc/src/api/describe_col.rs index 50a835f5..c9370a54 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_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, @@ -210,7 +210,13 @@ pub(crate) fn odbc_sql_type(meta: &mssql_tds::query::metadata::ColumnMetadata) - 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, } } diff --git a/mssql-odbc/src/api/exec_common.rs b/mssql-odbc/src/api/exec_common.rs index e07e23f1..8495c491 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. @@ -203,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 { @@ -211,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); @@ -233,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. /// diff --git a/mssql-odbc/src/api/execute.rs b/mssql-odbc/src/api/execute.rs index 02853f84..d6232966 100644 --- a/mssql-odbc/src/api/execute.rs +++ b/mssql-odbc/src/api/execute.rs @@ -9,13 +9,18 @@ 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::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 +50,89 @@ 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") +} - let mut client = match claim_connection(dbc, stmt, statement_handle, "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; + } + } + + 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 +169,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 +184,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 +229,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(); + reset_for_execute(&mut stmt_state); + + 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); - - Ok(Execution { - rewritten_sql, - named_params, - handle, - drop_handle, - }) } #[cfg(test)] @@ -238,7 +339,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 +349,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 +366,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 +384,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 +407,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 382391f2..b1696d22 100644 --- a/mssql-odbc/src/api/exports.rs +++ b/mssql-odbc/src/api/exports.rs @@ -848,7 +848,7 @@ pub unsafe extern "C" fn SQLParamData( value_ptr_ptr: *mut SqlPointer, ) -> SqlReturn { crate::init_tracing(); - unsafe { super::desc::sql_param_data(statement_handle, value_ptr_ptr) } + unsafe { super::dae::sql_param_data(statement_handle, value_ptr_ptr) } } /// Supplies a chunk of data-at-execution parameter data. @@ -862,7 +862,7 @@ pub unsafe extern "C" fn SQLPutData( str_len_or_ind: SqlLen, ) -> SqlReturn { crate::init_tracing(); - unsafe { super::desc::sql_put_data(statement_handle, data_ptr, str_len_or_ind) } + unsafe { super::dae::sql_put_data(statement_handle, data_ptr, str_len_or_ind) } } // ---- Catalog functions ------------------------------------------------------- diff --git a/mssql-odbc/src/api/fetch.rs b/mssql-odbc/src/api/fetch.rs index 29cef8c6..5f40c053 100644 --- a/mssql-odbc/src/api/fetch.rs +++ b/mssql-odbc/src/api/fetch.rs @@ -148,6 +148,7 @@ fn fetch_rows_next(statement_handle: SqlHandle, stmt: &StmtHandle) -> SqlReturn 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; } @@ -250,6 +251,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(); diff --git a/mssql-odbc/src/api/get_data.rs b/mssql-odbc/src/api/get_data.rs index 9293c56f..ad286c95 100644 --- a/mssql-odbc/src/api/get_data.rs +++ b/mssql-odbc/src/api/get_data.rs @@ -3,16 +3,21 @@ //! Minimal SQLGetData implementation for Phase 1. +use mssql_tds::datatypes::column_values::ColumnValues; use tracing::{debug, error}; -use super::cdata::{WriteError, WriteOutcome, write_c_value}; +use super::cdata::{ + StreamPayload, WriteError, WriteOutcome, stream_payload, wchar_capacity, write_c_value, +}; use super::odbc_types::{ - SQL_ERROR, SQL_INVALID_HANDLE, SQL_SUCCESS, SQL_SUCCESS_WITH_INFO, SqlHandle, SqlLen, - SqlPointer, SqlReturn, SqlSmallInt, SqlUSmallInt, + 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 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}; /// Implements SQLGetData for current-row retrieval. @@ -120,6 +125,55 @@ fn sql_get_data_safe( } 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. + 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; + } + + 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, + col_index, + &payload, + target_value_ptr, + buffer_length, + strlen_or_ind_ptr, + ); + } + + // 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, @@ -154,6 +208,97 @@ fn sql_get_data_safe( } } +/// Byte length reported for a zero-length `SQLGetData` probe. +/// +/// 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. +/// +/// 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 { + 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; + } + + 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::*; diff --git a/mssql-odbc/src/api/mod.rs b/mssql-odbc/src/api/mod.rs index ba148984..0490c4ef 100644 --- a/mssql-odbc/src/api/mod.rs +++ b/mssql-odbc/src/api/mod.rs @@ -10,6 +10,7 @@ mod close_cursor; mod col_attribute; mod conn_exec; mod connect; +mod dae; mod desc; mod describe_col; mod disconnect; 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/handles/stmt.rs b/mssql-odbc/src/handles/stmt.rs index 31a81a43..7c9d223b 100644 --- a/mssql-odbc/src/handles/stmt.rs +++ b/mssql-odbc/src/handles/stmt.rs @@ -107,10 +107,54 @@ pub(crate) struct StmtState { /// `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 @@ -136,6 +180,15 @@ impl StmtState { self.current_row = None; self.buffered_rows.clear(); self.buffered_eof = false; + 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 { @@ -207,6 +260,11 @@ impl StmtHandle { 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..2c801c9b 100644 --- a/mssql-odbc/src/params/convert.rs +++ b/mssql-odbc/src/params/convert.rs @@ -4,27 +4,42 @@ //! 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_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, 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 +50,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 +66,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,18 +74,82 @@ 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) }?; + 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.sql_type, + effective_c_type(param.c_type, param.sql_type), + ), + None => unsafe { bound_param_to_value(param) }?, + }; Ok(RpcParameter::new(Some(name), StatusFlags::NONE, value)) } +/// 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 /// [`SqlType`]. /// @@ -82,9 +164,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_C_TYPE_DATE, + SQL_TYPE_TIME => SQL_C_TYPE_TIME, + SQL_SS_TIME2 => SQL_C_SS_TIME2, + SQL_TYPE_TIMESTAMP => SQL_C_TYPE_TIMESTAMP, + SQL_SS_TIMESTAMPOFFSET => SQL_C_SS_TIMESTAMPOFFSET, + _ => SQL_C_CHAR, + } +} + +/// Typed NULL for the requested SQL type. +fn null_value(sql_type: SqlSmallInt, c_type: SqlSmallInt) -> SqlType { + 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 => SqlType::Date(None), + SQL_TYPE_TIME | SQL_SS_TIME2 => SqlType::Time(None), + SQL_TYPE_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() +} + +/// 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(u8::try_from(to_i64(cvalue)?).ok()?)), + 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 => SqlType::Date(Some(to_date(cvalue)?)), + SQL_TYPE_TIME | SQL_SS_TIME2 => SqlType::Time(Some(to_time(cvalue, param)?)), + SQL_TYPE_TIMESTAMP => SqlType::DateTime2(Some(to_datetime2(cvalue, param)?)), + 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)) } - 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))) + _ => 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, } } +fn to_time(cvalue: &CValue, param: &BoundParam) -> Option { + Some(SqlTime { + time_nanoseconds: time_nanos(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_nanos(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 { @@ -163,78 +571,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 => Family::Date, + SQL_TYPE_TIME | SQL_SS_TIME2 => Family::Time, + SQL_TYPE_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 +666,28 @@ 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 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 +697,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 +712,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) * 1_000_000_000 + 500_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)); - } - - #[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"); + 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_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..38d49f2f --- /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: String::from_utf8_lossy(&bytes).into_owned(), + 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; From 620f20ddb356710298dd4cba5fe4598b7e902b00 Mon Sep 17 00:00:00 2001 From: Saurabh Singh <1623701+saurabh500@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:52:50 -0700 Subject: [PATCH 03/12] Make mssql-python parity gtest self-contained Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e698c659-8b42-43f7-99ab-46f534b2489b --- mssql-odbc/tests/e2e/CMakeLists.txt | 8 +- .../e2e/tests/mssql_python_parity_test.cpp | 321 +++++++++++++++++- 2 files changed, 316 insertions(+), 13 deletions(-) diff --git a/mssql-odbc/tests/e2e/CMakeLists.txt b/mssql-odbc/tests/e2e/CMakeLists.txt index 11a74ddc..73abb685 100644 --- a/mssql-odbc/tests/e2e/CMakeLists.txt +++ b/mssql-odbc/tests/e2e/CMakeLists.txt @@ -125,4 +125,10 @@ add_odbc_test(more_results_test tests/more_results_test.cpp) add_odbc_test(execute_test tests/execute_test.cpp) add_odbc_test(get_type_info_test tests/get_type_info_test.cpp) add_odbc_test(row_count_test tests/row_count_test.cpp) -add_odbc_test(mssql_python_parity_test tests/mssql_python_parity_test.cpp) + +# The mssql-python parity suite binds the driver by path instead of going +# through the Driver Manager, mirroring how mssql-python loads it. It needs no +# odbc_test_lib and no registered driver, only MSSQL_ODBC_DLL/MSSQL_ODBC_CONNSTR. +add_executable(mssql_python_parity_test tests/mssql_python_parity_test.cpp) +target_link_libraries(mssql_python_parity_test PRIVATE gtest) +add_test(NAME mssql_python_parity_test COMMAND mssql_python_parity_test) diff --git a/mssql-odbc/tests/e2e/tests/mssql_python_parity_test.cpp b/mssql-odbc/tests/e2e/tests/mssql_python_parity_test.cpp index 1bd8799e..0520597f 100644 --- a/mssql-odbc/tests/e2e/tests/mssql_python_parity_test.cpp +++ b/mssql-odbc/tests/e2e/tests/mssql_python_parity_test.cpp @@ -8,9 +8,23 @@ // (ddbc_bindings.cpp). Every case below maps to a behaviour the Python suite // depends on, which makes this file the fast regression gate for the // mssql-python parity work. +// +// The suite is deliberately self-contained: it binds the driver by path +// (MSSQL_ODBC_DLL, defaulting to msodbcsql18.dll next to the binary) instead of +// linking odbc32.lib, so it exercises the same load path as mssql-python and +// needs no Driver Manager registration. +// +// Configure with MSSQL_ODBC_DLL and MSSQL_ODBC_CONNSTR. + +#include + +#include -#include "odbc_test_fixture.h" +#include +#include +#include +#include #include #include #include @@ -21,25 +35,298 @@ namespace { /// set to detect sql_variant; the value is a SQL Server driver-specific field. constexpr SQLUSMALLINT kSqlCaSsVariantType = 1215; -/// Fixture that connects once per test and exposes small helpers for the +using SqlTString = std::wstring; + +std::wstring ToWide(const std::string& s) { + return std::wstring(s.begin(), s.end()); +} + +std::string GetEnvOr(const char* name, const char* fallback) { + char* buf = nullptr; + size_t len = 0; + if (_dupenv_s(&buf, &len, name) == 0 && buf != nullptr) { + std::string value(buf); + free(buf); + if (!value.empty()) { + return value; + } + } + return fallback; +} + +// --------------------------------------------------------------------------- +// Driver binding +// +// mssql-python resolves each entrypoint by name from the loaded module. The +// table below mirrors that, so a missing export shows up here as a load +// failure rather than as an opaque Python traceback. +// --------------------------------------------------------------------------- + +struct DriverApi { + HMODULE module = nullptr; + + SQLRETURN(SQL_API* AllocHandle)(SQLSMALLINT, SQLHANDLE, SQLHANDLE*) = nullptr; + SQLRETURN(SQL_API* FreeHandle)(SQLSMALLINT, SQLHANDLE) = nullptr; + SQLRETURN(SQL_API* SetEnvAttr)(SQLHENV, SQLINTEGER, SQLPOINTER, SQLINTEGER) = nullptr; + SQLRETURN(SQL_API* DriverConnect)(SQLHDBC, SQLHWND, SQLWCHAR*, SQLSMALLINT, SQLWCHAR*, + SQLSMALLINT, SQLSMALLINT*, SQLUSMALLINT) = nullptr; + SQLRETURN(SQL_API* Disconnect)(SQLHDBC) = nullptr; + SQLRETURN(SQL_API* GetDiagRec)(SQLSMALLINT, SQLHANDLE, SQLSMALLINT, SQLWCHAR*, SQLINTEGER*, + SQLWCHAR*, SQLSMALLINT, SQLSMALLINT*) = nullptr; + + SQLRETURN(SQL_API* SetConnectAttr)(SQLHDBC, SQLINTEGER, SQLPOINTER, SQLINTEGER) = nullptr; + SQLRETURN(SQL_API* GetConnectAttr)(SQLHDBC, SQLINTEGER, SQLPOINTER, SQLINTEGER, + SQLINTEGER*) = nullptr; + SQLRETURN(SQL_API* EndTran)(SQLSMALLINT, SQLHANDLE, SQLSMALLINT) = nullptr; + + SQLRETURN(SQL_API* ExecDirect)(SQLHSTMT, SQLWCHAR*, SQLINTEGER) = nullptr; + SQLRETURN(SQL_API* Prepare)(SQLHSTMT, SQLWCHAR*, SQLINTEGER) = nullptr; + SQLRETURN(SQL_API* Execute)(SQLHSTMT) = nullptr; + SQLRETURN(SQL_API* Fetch)(SQLHSTMT) = nullptr; + SQLRETURN(SQL_API* FetchScroll)(SQLHSTMT, SQLSMALLINT, SQLLEN) = nullptr; + SQLRETURN(SQL_API* GetData)(SQLHSTMT, SQLUSMALLINT, SQLSMALLINT, SQLPOINTER, SQLLEN, + SQLLEN*) = nullptr; + SQLRETURN(SQL_API* BindCol)(SQLHSTMT, SQLUSMALLINT, SQLSMALLINT, SQLPOINTER, SQLLEN, + SQLLEN*) = nullptr; + SQLRETURN(SQL_API* BindParameter)(SQLHSTMT, SQLUSMALLINT, SQLSMALLINT, SQLSMALLINT, SQLSMALLINT, + SQLULEN, SQLSMALLINT, SQLPOINTER, SQLLEN, SQLLEN*) = nullptr; + SQLRETURN(SQL_API* NumResultCols)(SQLHSTMT, SQLSMALLINT*) = nullptr; + SQLRETURN(SQL_API* RowCount)(SQLHSTMT, SQLLEN*) = nullptr; + SQLRETURN(SQL_API* MoreResults)(SQLHSTMT) = nullptr; + SQLRETURN(SQL_API* FreeStmt)(SQLHSTMT, SQLUSMALLINT) = nullptr; + SQLRETURN(SQL_API* SetStmtAttr)(SQLHSTMT, SQLINTEGER, SQLPOINTER, SQLINTEGER) = nullptr; + SQLRETURN(SQL_API* GetStmtAttr)(SQLHSTMT, SQLINTEGER, SQLPOINTER, SQLINTEGER, + SQLINTEGER*) = nullptr; + SQLRETURN(SQL_API* SetDescField)(SQLHDESC, SQLSMALLINT, SQLSMALLINT, SQLPOINTER, + SQLINTEGER) = nullptr; + SQLRETURN(SQL_API* ColAttribute)(SQLHSTMT, SQLUSMALLINT, SQLUSMALLINT, SQLPOINTER, SQLSMALLINT, + SQLSMALLINT*, SQLLEN*) = nullptr; + + SQLRETURN(SQL_API* Tables)(SQLHSTMT, SQLWCHAR*, SQLSMALLINT, SQLWCHAR*, SQLSMALLINT, SQLWCHAR*, + SQLSMALLINT, SQLWCHAR*, SQLSMALLINT) = nullptr; + SQLRETURN(SQL_API* Columns)(SQLHSTMT, SQLWCHAR*, SQLSMALLINT, SQLWCHAR*, SQLSMALLINT, SQLWCHAR*, + SQLSMALLINT, SQLWCHAR*, SQLSMALLINT) = nullptr; + SQLRETURN(SQL_API* PrimaryKeys)(SQLHSTMT, SQLWCHAR*, SQLSMALLINT, SQLWCHAR*, SQLSMALLINT, + SQLWCHAR*, SQLSMALLINT) = nullptr; + SQLRETURN(SQL_API* Procedures)(SQLHSTMT, SQLWCHAR*, SQLSMALLINT, SQLWCHAR*, SQLSMALLINT, + SQLWCHAR*, SQLSMALLINT) = nullptr; + SQLRETURN(SQL_API* Statistics)(SQLHSTMT, SQLWCHAR*, SQLSMALLINT, SQLWCHAR*, SQLSMALLINT, + SQLWCHAR*, SQLSMALLINT, SQLUSMALLINT, SQLUSMALLINT) = nullptr; + + std::string load_error; +}; + +DriverApi& Api() { + static DriverApi api; + return api; +} + +template +bool Bind(HMODULE module, const char* name, Fn& slot, std::string& error) { + auto proc = GetProcAddress(module, name); + if (proc == nullptr) { + if (error.empty()) { + error = "missing export: "; + error += name; + } + return false; + } + slot = reinterpret_cast(proc); + return true; +} + +bool LoadDriver(DriverApi& api) { + const std::string path = GetEnvOr("MSSQL_ODBC_DLL", "msodbcsql18.dll"); + api.module = LoadLibraryA(path.c_str()); + if (api.module == nullptr) { + api.load_error = "LoadLibrary failed for " + path + " (error " + + std::to_string(static_cast(GetLastError())) + ")"; + return false; + } + + std::string& err = api.load_error; + bool ok = true; + ok &= Bind(api.module, "SQLAllocHandle", api.AllocHandle, err); + ok &= Bind(api.module, "SQLFreeHandle", api.FreeHandle, err); + ok &= Bind(api.module, "SQLSetEnvAttr", api.SetEnvAttr, err); + ok &= Bind(api.module, "SQLDriverConnectW", api.DriverConnect, err); + ok &= Bind(api.module, "SQLDisconnect", api.Disconnect, err); + ok &= Bind(api.module, "SQLGetDiagRecW", api.GetDiagRec, err); + ok &= Bind(api.module, "SQLSetConnectAttrW", api.SetConnectAttr, err); + ok &= Bind(api.module, "SQLGetConnectAttrW", api.GetConnectAttr, err); + ok &= Bind(api.module, "SQLEndTran", api.EndTran, err); + ok &= Bind(api.module, "SQLExecDirectW", api.ExecDirect, err); + ok &= Bind(api.module, "SQLPrepareW", api.Prepare, err); + ok &= Bind(api.module, "SQLExecute", api.Execute, err); + ok &= Bind(api.module, "SQLFetch", api.Fetch, err); + ok &= Bind(api.module, "SQLFetchScroll", api.FetchScroll, err); + ok &= Bind(api.module, "SQLGetData", api.GetData, err); + ok &= Bind(api.module, "SQLBindCol", api.BindCol, err); + ok &= Bind(api.module, "SQLBindParameter", api.BindParameter, err); + ok &= Bind(api.module, "SQLNumResultCols", api.NumResultCols, err); + ok &= Bind(api.module, "SQLRowCount", api.RowCount, err); + ok &= Bind(api.module, "SQLMoreResults", api.MoreResults, err); + ok &= Bind(api.module, "SQLFreeStmt", api.FreeStmt, err); + ok &= Bind(api.module, "SQLSetStmtAttrW", api.SetStmtAttr, err); + ok &= Bind(api.module, "SQLGetStmtAttrW", api.GetStmtAttr, err); + ok &= Bind(api.module, "SQLSetDescFieldW", api.SetDescField, err); + ok &= Bind(api.module, "SQLColAttributeW", api.ColAttribute, err); + ok &= Bind(api.module, "SQLTablesW", api.Tables, err); + ok &= Bind(api.module, "SQLColumnsW", api.Columns, err); + ok &= Bind(api.module, "SQLPrimaryKeysW", api.PrimaryKeys, err); + ok &= Bind(api.module, "SQLProceduresW", api.Procedures, err); + ok &= Bind(api.module, "SQLStatisticsW", api.Statistics, err); + return ok; +} + +// The tests below are written against the plain ODBC names; route them at the +// loaded module so the file stays readable while bypassing the Driver Manager. +#define SQLAllocHandle Api().AllocHandle +#define SQLFreeHandle Api().FreeHandle +#define SQLSetEnvAttr Api().SetEnvAttr +#define SQLDriverConnectW Api().DriverConnect +#define SQLDisconnect Api().Disconnect +#define SQLGetDiagRecW Api().GetDiagRec +#undef SQLSetConnectAttr +#define SQLSetConnectAttr Api().SetConnectAttr +#define SQLGetConnectAttrW Api().GetConnectAttr +#define SQLEndTran Api().EndTran +#define SQLExecDirectW Api().ExecDirect +#define SQLPrepareW Api().Prepare +#define SQLExecute Api().Execute +#define SQLFetch Api().Fetch +#define SQLFetchScroll Api().FetchScroll +#define SQLGetData Api().GetData +#define SQLBindCol Api().BindCol +#define SQLBindParameter Api().BindParameter +#define SQLNumResultCols Api().NumResultCols +#define SQLRowCount Api().RowCount +#define SQLMoreResults Api().MoreResults +#define SQLFreeStmt Api().FreeStmt +#undef SQLSetStmtAttr +#define SQLSetStmtAttr Api().SetStmtAttr +#undef SQLGetStmtAttr +#define SQLGetStmtAttr Api().GetStmtAttr +#define SQLSetDescFieldW Api().SetDescField +#define SQLColAttributeW Api().ColAttribute +#define SQLTablesW Api().Tables +#define SQLColumnsW Api().Columns +#define SQLPrimaryKeysW Api().PrimaryKeys +#define SQLProceduresW Api().Procedures +#define SQLStatisticsW Api().Statistics + +std::wstring DiagField(SQLSMALLINT handle_type, SQLHANDLE handle, bool want_state) { + SQLWCHAR state[6] = {}; + SQLWCHAR message[1024] = {}; + SQLINTEGER native = 0; + SQLSMALLINT length = 0; + if (!SQL_SUCCEEDED(SQLGetDiagRecW(handle_type, handle, 1, state, &native, message, + static_cast(std::size(message)), &length))) { + return L""; + } + return want_state ? std::wstring(state) : std::wstring(message); +} + +std::string Narrow(const std::wstring& value) { + return std::string(value.begin(), value.end()); +} + +std::string DiagMessage(SQLSMALLINT handle_type, SQLHANDLE handle) { + return "[" + Narrow(DiagField(handle_type, handle, true)) + "] " + + Narrow(DiagField(handle_type, handle, false)); +} + +std::string DiagState(SQLSMALLINT handle_type, SQLHANDLE handle) { + return Narrow(DiagField(handle_type, handle, true)); +} + +#define ASSERT_SQL_OK(rc, handle_type, handle) \ + do { \ + SQLRETURN _rc = (rc); \ + ASSERT_TRUE(SQL_SUCCEEDED(_rc)) << "rc=" << _rc << " " << DiagMessage(handle_type, handle); \ + } while (0) + +#define EXPECT_SQL_OK(rc, handle_type, handle) \ + do { \ + SQLRETURN _rc = (rc); \ + EXPECT_TRUE(SQL_SUCCEEDED(_rc)) << "rc=" << _rc << " " << DiagMessage(handle_type, handle); \ + } while (0) + +#define EXPECT_SQLSTATE(handle_type, handle, expected_state) \ + EXPECT_EQ(std::string(expected_state), DiagState(handle_type, handle)) + +/// Compatibility shim so the test bodies keep the shared-fixture spelling. +struct ODBCTestUtils { + static SqlTString ToSqlTStr(const std::string& s) { return ToWide(s); } + static std::string ToNarrow(const SqlTString& s) { return Narrow(s); } +}; + +/// Fixture that connects once per test and exposes helpers for the /// mssql-python call patterns. -class PythonParityTest : public ODBCTest { +class PythonParityTest : public ::testing::Test { protected: + SQLHENV env_ = nullptr; + SQLHDBC dbc_ = nullptr; + SQLHSTMT stmt_ = nullptr; + void SetUp() override { - ODBCTest::SetUp(); - if (!ODBCTestConfig::Instance().HasConnection()) { - GTEST_SKIP() << "No connection configured (set ODBC_TEST_* env vars)"; + if (Api().module == nullptr) { + GTEST_SKIP() << "driver not loaded: " << Api().load_error; + } + const std::string conn = GetEnvOr("MSSQL_ODBC_CONNSTR", ""); + if (conn.empty()) { + GTEST_SKIP() << "set MSSQL_ODBC_CONNSTR to run parity tests"; + } + + ASSERT_SQL_OK(SQLAllocHandle(SQL_HANDLE_ENV, nullptr, &env_), SQL_HANDLE_ENV, env_); + ASSERT_SQL_OK(SQLSetEnvAttr(env_, SQL_ATTR_ODBC_VERSION, + reinterpret_cast(SQL_OV_ODBC3), 0), + SQL_HANDLE_ENV, env_); + ASSERT_SQL_OK(SQLAllocHandle(SQL_HANDLE_DBC, env_, &dbc_), SQL_HANDLE_ENV, env_); + + std::wstring wide = ToWide(conn); + ASSERT_SQL_OK(SQLDriverConnectW(dbc_, nullptr, wide.data(), + static_cast(wide.size()), nullptr, 0, nullptr, + SQL_DRIVER_NOPROMPT), + SQL_HANDLE_DBC, dbc_); + ASSERT_SQL_OK(SQLAllocHandle(SQL_HANDLE_STMT, dbc_, &stmt_), SQL_HANDLE_DBC, dbc_); + } + + void TearDown() override { + if (stmt_ != nullptr) { + SQLFreeHandle(SQL_HANDLE_STMT, stmt_); + } + if (dbc_ != nullptr) { + SQLDisconnect(dbc_); + SQLFreeHandle(SQL_HANDLE_DBC, dbc_); + } + if (env_ != nullptr) { + SQLFreeHandle(SQL_HANDLE_ENV, env_); } - Connect(); } /// Runs |sql| on |hstmt| and asserts it succeeded. void Exec(SQLHSTMT hstmt, const std::string& sql) { - SqlTString text = ODBCTestUtils::ToSqlTStr(sql); - ASSERT_SQL_OK(SQLExecDirectW(hstmt, reinterpret_cast(text.data()), SQL_NTS), - SQL_HANDLE_STMT, hstmt); + std::wstring text = ToWide(sql); + ASSERT_SQL_OK(SQLExecDirectW(hstmt, text.data(), SQL_NTS), SQL_HANDLE_STMT, hstmt); + } + + /// Runs |sql| and swallows failures; used for best-effort cleanup. + void ExecDirectIgnoreError(const std::string& sql) { + std::wstring text = ToWide(sql); + SQLExecDirectW(stmt_, text.data(), SQL_NTS); + SQLFreeStmt(stmt_, SQL_CLOSE); } + /// Allocates an extra statement on the same connection. + SQLHSTMT AllocStmt() { + SQLHSTMT handle = nullptr; + EXPECT_SQL_OK(SQLAllocHandle(SQL_HANDLE_STMT, dbc_, &handle), SQL_HANDLE_DBC, dbc_); + return handle; + } + + void FreeStmt(SQLHSTMT handle) { SQLFreeHandle(SQL_HANDLE_STMT, handle); } + /// Fetches a single SQL_C_SLONG column from a one-row query. SQLINTEGER ScalarLong(const std::string& sql) { Exec(stmt_, sql); @@ -53,6 +340,7 @@ class PythonParityTest : public ODBCTest { } }; + // --------------------------------------------------------------------------- // Connection attributes and transactions // @@ -279,8 +567,8 @@ TEST_F(PythonParityTest, GetDataReportsNullAndTruncation) { stmt_); EXPECT_EQ(SQL_NULL_DATA, ind); - SQLCHAR small[4] = {}; - EXPECT_EQ(SQL_SUCCESS_WITH_INFO, SQLGetData(stmt_, 2, SQL_C_CHAR, small, sizeof(small), &ind)); + SQLCHAR tiny_buf[4] = {}; + EXPECT_EQ(SQL_SUCCESS_WITH_INFO, SQLGetData(stmt_, 2, SQL_C_CHAR, tiny_buf, sizeof(tiny_buf), &ind)); EXPECT_SQLSTATE(SQL_HANDLE_STMT, stmt_, "01004"); } @@ -490,4 +778,13 @@ TEST_F(PythonParityTest, MoreResultsWalksMultiStatementBatch) { EXPECT_EQ(SQL_NO_DATA, SQLMoreResults(stmt_)); } + } // namespace + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + if (!LoadDriver(Api())) { + fprintf(stderr, "warning: %s\n", Api().load_error.c_str()); + } + return RUN_ALL_TESTS(); +} \ No newline at end of file From 66062cac7b2a532e1d184791b6565dbb616f4e15 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Wed, 5 Aug 2026 00:34:40 -0700 Subject: [PATCH 04/12] Fix connection-busy on commit, catalog result shape, and deferred execute errors Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e698c659-8b42-43f7-99ab-46f534b2489b --- mssql-odbc/src/api/catalog.rs | 121 +++++++++++++++++++++++++--- mssql-odbc/src/api/col_attribute.rs | 31 ++++++- mssql-odbc/src/api/conn_exec.rs | 24 +++++- mssql-odbc/src/api/exec_common.rs | 17 ++++ 4 files changed, 176 insertions(+), 17 deletions(-) diff --git a/mssql-odbc/src/api/catalog.rs b/mssql-odbc/src/api/catalog.rs index a19d7d31..11bb53e8 100644 --- a/mssql-odbc/src/api/catalog.rs +++ b/mssql-odbc/src/api/catalog.rs @@ -11,9 +11,18 @@ use tracing::{debug, error}; -use super::exec_direct::sql_exec_direct_w_safe; -use super::odbc_types::{SQL_INVALID_HANDLE, SqlHandle, SqlReturn, SqlSmallInt, SqlWChar}; +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. @@ -52,6 +61,14 @@ fn build_exec(catalog: &Arg, proc_name: &str, args: &[String]) -> String { format!("EXEC {} {}", 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. @@ -66,7 +83,51 @@ unsafe fn run_catalog(statement_handle: SqlHandle, name: &str, sql: String) -> S let stmt = unsafe { handle_from_raw::(statement_handle) }; debug_assert_eq!(stmt.object_type, HandleType::Stmt); debug!(%sql, "{name}: executing catalog query"); - sql_exec_direct_w_safe(statement_handle, stmt, sql) + 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.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); + } + + finish_execute(dbc, stmt, statement_handle, client, name) } /// Implements `SQLTablesW`. @@ -129,17 +190,16 @@ pub(crate) unsafe fn sql_columns_w( 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( + let sql = build_exec_named( &catalog, "sp_columns_100", &[ - literal(&table), - literal(&schema), - "NULL".to_string(), - literal(&column), - "NULL".to_string(), - "3".to_string(), - "1".to_string(), + ("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) @@ -338,7 +398,9 @@ pub(crate) unsafe fn sql_procedures_w( #[cfg(test)] mod tests { use super::*; - use crate::api::odbc_types::{SQL_NTS, SQL_NULL_HANDLE}; + 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() @@ -368,6 +430,19 @@ mod tests { assert!(sql.starts_with("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"); @@ -402,4 +477,26 @@ mod tests { }; 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/col_attribute.rs b/mssql-odbc/src/api/col_attribute.rs index a6c9a339..e72dccd6 100644 --- a/mssql-odbc/src/api/col_attribute.rs +++ b/mssql-odbc/src/api/col_attribute.rs @@ -79,6 +79,11 @@ unsafe fn sql_col_attribute_w_impl( 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; @@ -119,7 +124,6 @@ unsafe fn sql_col_attribute_w_impl( } let numeric: SqlLen = match field_identifier { - SQL_DESC_COUNT => state.column_metadata.len() as SqlLen, // 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 @@ -236,4 +240,29 @@ mod tests { }; 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 index a9959ff0..b83379c6 100644 --- a/mssql-odbc/src/api/conn_exec.rs +++ b/mssql-odbc/src/api/conn_exec.rs @@ -36,10 +36,26 @@ pub(crate) fn exec_on_connection(dbc: &DbcHandle, sql: &str, op: &str) -> Result post_diag(&mut state, ERR_CONNECTION_DOES_NOT_EXIST); return Err(SQL_ERROR); } - if state.active_stmt.is_some() { - error!("{op}: connection is busy with another statement's results"); - post_diag(&mut state, ERR_CONNECTION_BUSY); - 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"); diff --git a/mssql-odbc/src/api/exec_common.rs b/mssql-odbc/src/api/exec_common.rs index 8495c491..ccb94fe5 100644 --- a/mssql-odbc/src/api/exec_common.rs +++ b/mssql-odbc/src/api/exec_common.rs @@ -411,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(); From ace63cf51076cd64bc8377657f492577f50c9692 Mon Sep 17 00:00:00 2001 From: Saurabh Singh <1623701+saurabh500@users.noreply.github.com> Date: Wed, 5 Aug 2026 00:57:57 -0700 Subject: [PATCH 05/12] Add SQL_ATTR_CURRENT_CATALOG, SQL_NUMERIC type mapping, and SQLGetInfo values Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e698c659-8b42-43f7-99ab-46f534b2489b --- mssql-odbc/src/api/describe_col.rs | 13 +- mssql-odbc/src/api/get_connect_attr.rs | 62 +++++++- mssql-odbc/src/api/get_info.rs | 205 ++++++++++++++++++++++++- mssql-odbc/src/api/set_connect_attr.rs | 61 +++++++- mssql-odbc/src/handles/dbc.rs | 4 + 5 files changed, 319 insertions(+), 26 deletions(-) diff --git a/mssql-odbc/src/api/describe_col.rs b/mssql-odbc/src/api/describe_col.rs index c9370a54..629e27bf 100644 --- a/mssql-odbc/src/api/describe_col.rs +++ b/mssql-odbc/src/api/describe_col.rs @@ -9,8 +9,8 @@ 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_SS_UDT, - SQL_SS_VARIANT, SQL_SUCCESS, SQL_SUCCESS_WITH_INFO, SQL_TINYINT, SQL_TYPE_DATE, + 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, }; @@ -182,10 +182,11 @@ pub(crate) fn odbc_sql_type(meta: &mssql_tds::query::metadata::ColumnMetadata) - 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 diff --git a/mssql-odbc/src/api/get_connect_attr.rs b/mssql-odbc/src/api/get_connect_attr.rs index e83e3f8d..3dd29d8b 100644 --- a/mssql-odbc/src/api/get_connect_attr.rs +++ b/mssql-odbc/src/api/get_connect_attr.rs @@ -8,11 +8,12 @@ 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_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, SqlHandle, SqlInteger, SqlPointer, SqlReturn, + 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::write_if_some; +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}; @@ -22,8 +23,8 @@ const SQL_MODE_READ_WRITE: u32 = 0; /// Retrieves the current setting of a connection attribute. /// -/// Only fixed-length (`SQLUINTEGER`) attributes are supported; string-valued -/// attributes report `HYC00`. +/// 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`. @@ -46,7 +47,13 @@ pub(crate) unsafe fn sql_get_connect_attr_w( ); crate::ffi_entry!("SQLGetConnectAttrW", unsafe { - sql_get_connect_attr_w_impl(connection_handle, attribute, value_ptr, string_length_ptr) + sql_get_connect_attr_w_impl( + connection_handle, + attribute, + value_ptr, + buffer_length, + string_length_ptr, + ) }) } @@ -54,6 +61,7 @@ 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() { @@ -74,6 +82,46 @@ unsafe fn sql_get_connect_attr_w_impl( }; 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 { 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/set_connect_attr.rs b/mssql-odbc/src/api/set_connect_attr.rs index e630c930..5b36bf21 100644 --- a/mssql-odbc/src/api/set_connect_attr.rs +++ b/mssql-odbc/src/api/set_connect_attr.rs @@ -11,13 +11,15 @@ 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_AUTOCOMMIT, SQL_ATTR_CONNECTION_TIMEOUT, - 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_SUCCESS, SQL_TXN_READ_COMMITTED, SQL_TXN_READ_UNCOMMITTED, - SQL_TXN_REPEATABLE_READ, SQL_TXN_SERIALIZABLE, SQL_TXN_SS_SNAPSHOT, SqlHandle, SqlInteger, - SqlPointer, SqlReturn, + 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; @@ -87,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"); @@ -207,6 +209,53 @@ unsafe fn sql_set_connect_attr_w_impl( 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 diff --git a/mssql-odbc/src/handles/dbc.rs b/mssql-odbc/src/handles/dbc.rs index a17846de..63401dd4 100644 --- a/mssql-odbc/src/handles/dbc.rs +++ b/mssql-odbc/src/handles/dbc.rs @@ -69,6 +69,9 @@ pub(crate) struct DbcState { 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, @@ -119,6 +122,7 @@ impl DbcHandle { access_token: None, autocommit: true, txn_isolation: crate::api::odbc_types::SQL_TXN_READ_COMMITTED, + current_catalog: None, dead: false, }), } From 614aae6a457d5e1cf44145af686e0b9cc93fe926 Mon Sep 17 00:00:00 2001 From: Saurabh Singh <1623701+saurabh500@users.noreply.github.com> Date: Wed, 5 Aug 2026 01:09:26 -0700 Subject: [PATCH 06/12] Encode SQL_C_CHAR data in the client ANSI code page Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e698c659-8b42-43f7-99ab-46f534b2489b --- mssql-odbc/src/api/ansi.rs | 178 ++++++++++++++++++++++++++++++++ mssql-odbc/src/api/cdata.rs | 8 +- mssql-odbc/src/api/mod.rs | 1 + mssql-odbc/src/params/cvalue.rs | 2 +- 4 files changed, 185 insertions(+), 4 deletions(-) create mode 100644 mssql-odbc/src/api/ansi.rs 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/cdata.rs b/mssql-odbc/src/api/cdata.rs index 7c641ca9..9c30d66d 100644 --- a/mssql-odbc/src/api/cdata.rs +++ b/mssql-odbc/src/api/cdata.rs @@ -319,10 +319,10 @@ pub(crate) unsafe fn write_c_value( match target_type { SQL_C_CHAR | SQL_C_DEFAULT => { - let text = cell.to_text(); + let ansi = narrow_bytes(value).ok_or(WriteError::RestrictedConversion)?; Ok(unsafe { write_text( - text.as_bytes(), + &ansi, target_value_ptr as *mut u8, buffer_length.max(0) as usize, strlen_or_ind_ptr, @@ -654,13 +654,15 @@ pub(crate) fn stream_payload( /// 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(to_cell(value)?.to_text().into_bytes()) + Some(crate::api::ansi::encode(&to_cell(value)?.to_text())) } /// Maps a column value to the `SQL_C_*` code msodbcsql reports through diff --git a/mssql-odbc/src/api/mod.rs b/mssql-odbc/src/api/mod.rs index 0490c4ef..eef3e439 100644 --- a/mssql-odbc/src/api/mod.rs +++ b/mssql-odbc/src/api/mod.rs @@ -2,6 +2,7 @@ // Licensed under the MIT License. pub(crate) mod alloc_handle; +pub(crate) mod ansi; mod bind_col; mod bind_param; mod catalog; diff --git a/mssql-odbc/src/params/cvalue.rs b/mssql-odbc/src/params/cvalue.rs index 38d49f2f..d7da6635 100644 --- a/mssql-odbc/src/params/cvalue.rs +++ b/mssql-odbc/src/params/cvalue.rs @@ -185,7 +185,7 @@ pub(crate) unsafe fn read_c_value( SQL_C_CHAR => { let bytes = unsafe { read_char_bytes(ptr, len_spec) }; CValue::Text { - text: String::from_utf8_lossy(&bytes).into_owned(), + text: crate::api::ansi::decode(&bytes), wide: false, } } From 28fdbde33450eb6df4a0a088f8d21d730d78bb43 Mon Sep 17 00:00:00 2001 From: Saurabh Singh <1623701+saurabh500@users.noreply.github.com> Date: Wed, 5 Aug 2026 04:02:08 -0700 Subject: [PATCH 07/12] Surface PRINT output, widen the string cap, and declare NULL decimals Statement-wise navigation stopped on the COUNT-flagged DONEINPROC that a SET assignment emits, leaving a following PRINT unread, so server messages never reached the client. The unicode string cap used a shift that both truncated at 255 characters and read as a bug; it is a byte count of a u16 length. A NULL decimal parameter carries no precision of its own. When a prepared plan is built from a parameter-array row whose value is NULL, the default declaration was reused for every later row and rejected wider values, so callers can now declare the precision and scale explicitly. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e698c659-8b42-43f7-99ab-46f534b2489b --- mssql-tds/src/connection/tds_client.rs | 13 ++++++++++- .../connection/transport/network_transport.rs | 4 ++-- mssql-tds/src/io/packet_reader.rs | 9 ++++---- .../src/message/parameters/rpc_parameters.rs | 22 ++++++++++++++++++- 4 files changed, 40 insertions(+), 8 deletions(-) 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/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 { From 7b01cfdeb333f2e23247643ffe7aa1fc44297004 Mon Sep 17 00:00:00 2001 From: Saurabh Singh <1623701+saurabh500@users.noreply.github.com> Date: Wed, 5 Aug 2026 04:02:18 -0700 Subject: [PATCH 08/12] Correct parameter and column data conversions The TDS time fields count 100-nanosecond ticks, not nanoseconds, so every time, datetime2, and datetimeoffset value was scaled 100x. Money and smallmoney went through f64 and lost precision; they now keep their scaled integer representation. SQL_C_TINYINT is unsigned, ODBC 2.x applications bind dates and times with the older type codes, a UDT column reports its own length rather than zero, and NULL decimal parameters carry the application's precision and scale. SQLDescribeParam was a stub returning a fixed guess; it now asks the server through sp_describe_undeclared_parameters and caches the answer. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e698c659-8b42-43f7-99ab-46f534b2489b --- mssql-odbc/src/api/cdata.rs | 58 ++++-- mssql-odbc/src/api/desc.rs | 308 +++++++++++++++++++++++++++-- mssql-odbc/src/api/describe_col.rs | 5 + mssql-odbc/src/params/convert.rs | 165 +++++++++++++--- 4 files changed, 472 insertions(+), 64 deletions(-) diff --git a/mssql-odbc/src/api/cdata.rs b/mssql-odbc/src/api/cdata.rs index 9c30d66d..f97e875e 100644 --- a/mssql-odbc/src/api/cdata.rs +++ b/mssql-odbc/src/api/cdata.rs @@ -36,10 +36,14 @@ pub(crate) enum WriteError { } /// Normalized view of a column value, decoupled from the TDS representation. -enum Cell { +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), @@ -52,14 +56,14 @@ enum Cell { } #[derive(Clone, Copy)] -struct CivilDate { +pub(in crate::api) struct CivilDate { year: i32, month: u32, day: u32, } #[derive(Clone, Copy, Default)] -struct CivilTime { +pub(in crate::api) struct CivilTime { hour: u32, minute: u32, second: u32, @@ -93,6 +97,9 @@ fn civil_from_days(z: i64) -> CivilDate { } } +/// 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 { @@ -104,6 +111,11 @@ fn time_from_nanos(total_nanos: u64, scale: u8) -> CivilTime { } } +/// 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) @@ -122,13 +134,19 @@ impl CivilTime { } } -fn money_to_f64(lsb: i32, msb: i32) -> f64 { - let combined = ((msb as i64) << 32) | ((lsb as u32) as i64); - combined as f64 / 10_000.0 +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. -fn to_cell(v: &ColumnValues) -> Option { +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)), @@ -143,19 +161,19 @@ fn to_cell(v: &ColumnValues) -> Option { 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::Double(f64::from(m.int_val) / 10_000.0), - ColumnValues::Money(m) => Cell::Double(money_to_f64(m.lsb_part, m.msb_part)), + 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_nanos(t.time_nanoseconds, t.scale)), + 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_nanos(dt.time.time_nanoseconds, dt.time.scale), + 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_nanos( + time_from_ticks( dto.datetime2.time.time_nanoseconds, dto.datetime2.time.scale, ), @@ -185,6 +203,7 @@ impl Cell { 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(), @@ -207,11 +226,12 @@ impl Cell { } } - fn as_i64(&self) -> Option { + 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(), @@ -224,6 +244,7 @@ impl Cell { 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(), @@ -355,12 +376,15 @@ pub(crate) unsafe fn write_c_value( 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 | SQL_C_TINYINT => { + 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_C_UTINYINT => { + // 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) }) @@ -779,8 +803,10 @@ mod tests { #[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) * 1_000_000_000 + 123_456_700, + time_nanoseconds: (13 * 3600 + 45 * 60 + 7) * 10_000_000 + 1_234_567, scale: 7, }); let mut out = SqlSsTime2Struct::default(); diff --git a/mssql-odbc/src/api/desc.rs b/mssql-odbc/src/api/desc.rs index 71cbfb21..cc49ad18 100644 --- a/mssql-odbc/src/api/desc.rs +++ b/mssql-odbc/src/api/desc.rs @@ -10,7 +10,7 @@ use tracing::{debug, error}; use super::odbc_types::*; -use super::sqlstate::SQLSTATE_HY091; +use super::sqlstate::{SQLSTATE_07009, SQLSTATE_HY091}; use crate::error::{free_errors, post_sql_error}; use crate::handles::{HandleType, StmtHandle, handle_from_raw}; @@ -75,23 +75,242 @@ pub(crate) unsafe fn sql_set_desc_field_w( }) } -/// Implements `SQLDescribeParam`. +/// 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. /// -/// Server-side parameter description requires `sp_describe_undeclared_parameters`, -/// which is not yet wired up. Reporting the optional feature as unsupported is -/// the documented behaviour for drivers that cannot describe parameters, and +/// 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 -/// are not written. +/// 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, + data_type_ptr: *mut SqlSmallInt, + parameter_size_ptr: *mut SqlULen, + decimal_digits_ptr: *mut SqlSmallInt, + nullable_ptr: *mut SqlSmallInt, ) -> SqlReturn { debug!( ?statement_handle, @@ -104,18 +323,69 @@ pub(crate) unsafe fn sql_describe_param( } let stmt = handle_from_raw::(statement_handle); debug_assert_eq!(stmt.object_type, HandleType::Stmt); - let Ok(mut state) = stmt.inner.lock() else { + + 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, }; - free_errors(&mut state); - post_sql_error( - &mut state, - super::sqlstate::SQLSTATE_HYC00, - 0, - "Optional feature not implemented: parameter description", - ); - 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 }) } diff --git a/mssql-odbc/src/api/describe_col.rs b/mssql-odbc/src/api/describe_col.rs index 629e27bf..cac42ded 100644 --- a/mssql-odbc/src/api/describe_col.rs +++ b/mssql-odbc/src/api/describe_col.rs @@ -223,6 +223,11 @@ pub(crate) fn odbc_sql_type(meta: &mssql_tds::query::metadata::ColumnMetadata) - } 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; diff --git a/mssql-odbc/src/params/convert.rs b/mssql-odbc/src/params/convert.rs index 2c801c9b..8dba11ef 100644 --- a/mssql-odbc/src/params/convert.rs +++ b/mssql-odbc/src/params/convert.rs @@ -23,12 +23,13 @@ use crate::api::odbc_types::{ 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_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, SqlDateStruct, SqlGuid, SqlLen, SqlNumericStruct, - SqlSmallInt, SqlSsTime2Struct, SqlSsTimestampoffsetStruct, SqlTimeStruct, SqlTimestampStruct, + 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; @@ -101,13 +102,38 @@ pub(crate) unsafe fn bound_param_to_rpc_with_data( param.sql_type, ))? } - Some(None) => null_value( - param.sql_type, - effective_c_type(param.c_type, param.sql_type), - ), + Some(None) => null_value(param), None => unsafe { bound_param_to_value(param) }?, }; - Ok(RpcParameter::new(Some(name), StatusFlags::NONE, value)) + 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. @@ -168,7 +194,7 @@ pub(crate) unsafe fn bound_param_to_value(param: &BoundParam) -> Result SqlSmallInt { SQL_REAL => SQL_C_FLOAT, SQL_FLOAT | SQL_DOUBLE => SQL_C_DOUBLE, SQL_GUID => SQL_C_GUID, - SQL_TYPE_DATE => SQL_C_TYPE_DATE, - SQL_TYPE_TIME => SQL_C_TYPE_TIME, + 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_C_TYPE_TIMESTAMP, + 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. -fn null_value(sql_type: SqlSmallInt, c_type: SqlSmallInt) -> SqlType { +/// +/// 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), @@ -242,9 +275,9 @@ fn null_value(sql_type: SqlSmallInt, c_type: SqlSmallInt) -> SqlType { SQL_FLOAT | SQL_DOUBLE => SqlType::Float(None), SQL_DECIMAL | SQL_NUMERIC => SqlType::Decimal(None), SQL_GUID => SqlType::Uuid(None), - SQL_TYPE_DATE => SqlType::Date(None), - SQL_TYPE_TIME | SQL_SS_TIME2 => SqlType::Time(None), - SQL_TYPE_TIMESTAMP => SqlType::DateTime2(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. @@ -260,6 +293,18 @@ 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 { @@ -275,7 +320,7 @@ fn to_sql_type(cvalue: &CValue, param: &BoundParam) -> Option { SqlType::VarBinaryMax(Some(to_bytes(cvalue)?)) } SQL_BIT => SqlType::Bit(Some(to_i64(cvalue)? != 0)), - SQL_TINYINT => SqlType::TinyInt(Some(u8::try_from(to_i64(cvalue)?).ok()?)), + 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)?)), @@ -283,9 +328,11 @@ fn to_sql_type(cvalue: &CValue, param: &BoundParam) -> Option { 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 => SqlType::Date(Some(to_date(cvalue)?)), - SQL_TYPE_TIME | SQL_SS_TIME2 => SqlType::Time(Some(to_time(cvalue, param)?)), - SQL_TYPE_TIMESTAMP => SqlType::DateTime2(Some(to_datetime2(cvalue, param)?)), + 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_SS_TIMESTAMPOFFSET => SqlType::DateTimeOffset(Some(to_datetimeoffset(cvalue, param)?)), // Unknown parameter type: send the value in its natural family. _ => natural_sql_type(cvalue), @@ -506,9 +553,15 @@ fn time_scale(param: &BoundParam) -> u8 { } } +/// 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_nanos(cvalue)?, + time_nanoseconds: time_ticks(cvalue)?, scale: time_scale(param), }) } @@ -519,7 +572,7 @@ fn to_datetime2(cvalue: &CValue, param: &BoundParam) -> Option { Some(SqlDateTime2 { days: u32::try_from(days).ok()?, time: SqlTime { - time_nanoseconds: time_nanos(cvalue).unwrap_or(0), + time_nanoseconds: time_ticks(cvalue).unwrap_or(0), scale: time_scale(param), }, }) @@ -564,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 ) @@ -611,9 +668,9 @@ fn sql_family(sql_type: SqlSmallInt) -> Option { 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 => Family::Date, - SQL_TYPE_TIME | SQL_SS_TIME2 => Family::Time, - SQL_TYPE_TIMESTAMP | SQL_SS_TIMESTAMPOFFSET => Family::Timestamp, + 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, }; Some(family) @@ -677,6 +734,56 @@ mod tests { 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(); @@ -922,7 +1029,7 @@ mod tests { 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) * 1_000_000_000 + 500_000_000 + (12 * 3600 + 30 * 60 + 45) * 10_000_000 + 5_000_000 ); } other => panic!("expected DateTime2, got {other:?}"), From 7e606f122460fd261ddc3b0e2e113908fdb6252e Mon Sep 17 00:00:00 2001 From: Saurabh Singh <1623701+saurabh500@users.noreply.github.com> Date: Wed, 5 Aug 2026 04:02:29 -0700 Subject: [PATCH 09/12] Fix rowset writes, NULL reporting, and errors raised mid-rowset Bound columns were strided by the application's buffer length, which ODBC ignores for fixed-width C types. An application passing the whole array size there corrupted memory beyond the first row. SQLGetData reported a NULL as success with an untouched buffer when no indicator was supplied, so callers read stale memory instead of seeing the required 22002. It now fails as the specification requires. An error arriving after the first row of a rowset was discarded once the partial rowset was returned, and the next call reported an invalid cursor instead of the real failure. The diagnostic is now replayed. The connection stayed claimed until the last result set was read, and a parameter array left each row's cursor open; both are now released, except for the final row, whose OUTPUT rows the caller still expects. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e698c659-8b42-43f7-99ab-46f534b2489b --- mssql-odbc/src/api/execute.rs | 24 ++++++++++++++++++++++++ mssql-odbc/src/api/fetch.rs | 10 ++++++++++ mssql-odbc/src/api/fetch_scroll.rs | 21 ++++++++++++++++----- mssql-odbc/src/api/get_data.rs | 29 ++++++++++++++++++++++++++--- mssql-odbc/src/api/more_results.rs | 13 ++++++++++++- mssql-odbc/src/api/spill.rs | 22 ++++++++++++++++++++-- mssql-odbc/src/handles/stmt.rs | 20 ++++++++++++++++++++ 7 files changed, 128 insertions(+), 11 deletions(-) diff --git a/mssql-odbc/src/api/execute.rs b/mssql-odbc/src/api/execute.rs index d6232966..926fbe7a 100644 --- a/mssql-odbc/src/api/execute.rs +++ b/mssql-odbc/src/api/execute.rs @@ -9,6 +9,7 @@ use tracing::{debug, error}; use mssql_tds::connection::tds_client::StatementResult; use mssql_tds::message::parameters::rpc_parameters::RpcParameter; +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, }; @@ -114,6 +115,29 @@ fn execute_param_array( { total_rows += state.row_count; } + + // 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() { diff --git a/mssql-odbc/src/api/fetch.rs b/mssql-odbc/src/api/fetch.rs index 5f40c053..c90bdbeb 100644 --- a/mssql-odbc/src/api/fetch.rs +++ b/mssql-odbc/src/api/fetch.rs @@ -45,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); @@ -86,6 +91,11 @@ pub(crate) fn fetch_rowset(statement_handle: SqlHandle, stmt: &StmtHandle) -> Sq 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; } diff --git a/mssql-odbc/src/api/fetch_scroll.rs b/mssql-odbc/src/api/fetch_scroll.rs index 2a7a8e39..01503fdd 100644 --- a/mssql-odbc/src/api/fetch_scroll.rs +++ b/mssql-odbc/src/api/fetch_scroll.rs @@ -8,8 +8,8 @@ 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, SqlHandle, SqlLen, SqlReturn, - SqlSmallInt, + 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, @@ -18,6 +18,7 @@ use super::sqlstate::{ 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`. /// @@ -58,6 +59,11 @@ unsafe fn sql_fetch_scroll_impl( 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, @@ -99,15 +105,20 @@ pub(crate) fn write_bound_columns(stmt: &StmtHandle, row_index: usize) -> u16 { 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 - // `buffer_length`-byte elements, one per rowset slot. + // 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 * bc.buffer_length.max(0) as usize) + .add(row_index * stride) .cast() } }; diff --git a/mssql-odbc/src/api/get_data.rs b/mssql-odbc/src/api/get_data.rs index ad286c95..5a48e219 100644 --- a/mssql-odbc/src/api/get_data.rs +++ b/mssql-odbc/src/api/get_data.rs @@ -133,6 +133,14 @@ fn sql_get_data_safe( // 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; + } + 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) { @@ -144,6 +152,21 @@ fn sql_get_data_safe( return SQL_SUCCESS; } + // 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) { + 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; + } + if let Some(payload) = stream_payload(&value, target_type) { let payload = match payload { Ok(payload) => payload, @@ -619,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/more_results.rs b/mssql-odbc/src/api/more_results.rs index 7853b0bf..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. diff --git a/mssql-odbc/src/api/spill.rs b/mssql-odbc/src/api/spill.rs index f0ec555b..d319deba 100644 --- a/mssql-odbc/src/api/spill.rs +++ b/mssql-odbc/src/api/spill.rs @@ -23,7 +23,7 @@ use std::collections::VecDeque; -use mssql_tds::connection::tds_client::ResultSet; +use mssql_tds::connection::tds_client::{ResultSet, StatementResult}; use tracing::{debug, error}; use crate::api::odbc_types::SqlHandle; @@ -93,13 +93,31 @@ pub(crate) fn try_release_connection(dbc: &DbcHandle, busy_stmt: SqlHandle) -> b } } + // 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 && !client.has_open_batch(); + 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() { diff --git a/mssql-odbc/src/handles/stmt.rs b/mssql-odbc/src/handles/stmt.rs index 7c9d223b..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>, @@ -79,6 +84,16 @@ pub(crate) struct StmtState { /// `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 @@ -180,6 +195,8 @@ impl StmtState { self.current_row = None; self.buffered_rows.clear(); self.buffered_eof = false; + self.pending_result = None; + self.pending_fetch_error = None; self.reset_getdata(); } @@ -247,12 +264,15 @@ 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, From d28cea5494271780eda04b8d074c93426cbc8b2e Mon Sep 17 00:00:00 2001 From: Saurabh Singh <1623701+saurabh500@users.noreply.github.com> Date: Wed, 5 Aug 2026 04:02:38 -0700 Subject: [PATCH 10/12] Translate ODBC escapes and return ODBC 3 catalog results Applications write procedure calls as {CALL p(?)}, which SQL Server cannot parse; escape sequences are now translated before the batch is sent, and a call's parenthesised argument list becomes an EXEC argument list. Catalog functions returned the ODBC 2.x column names the system procedures emit, dropped every row when no index name was supplied, quoted a table type list as a single element, and failed outright on a catalog that does not exist rather than returning no rows. Also maps APP in the connection string to the application name and derives the SQLSTATE class from the server's severity when the error number is unknown. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e698c659-8b42-43f7-99ab-46f534b2489b --- mssql-odbc/src/api/catalog.rs | 208 +++++++++++++-- mssql-odbc/src/api/driver_connect.rs | 3 + mssql-odbc/src/api/exec_direct.rs | 2 + mssql-odbc/src/api/mod.rs | 2 +- mssql-odbc/src/api/prepare.rs | 2 + mssql-odbc/src/api/sqlstate.rs | 31 ++- mssql-odbc/src/api/util.rs | 247 +++++++++++++++++- .../connection/connection_string_parser.rs | 7 +- 8 files changed, 476 insertions(+), 26 deletions(-) diff --git a/mssql-odbc/src/api/catalog.rs b/mssql-odbc/src/api/catalog.rs index 11bb53e8..193999ad 100644 --- a/mssql-odbc/src/api/catalog.rs +++ b/mssql-odbc/src/api/catalog.rs @@ -39,6 +39,25 @@ unsafe fn opt_arg(ptr: *const SqlWChar, len: SqlSmallInt) -> Arg { } } +/// 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 { @@ -51,14 +70,33 @@ fn literal(arg: &Arg) -> String { /// /// 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 qualified = match catalog { - Some(db) if !db.is_empty() => { - format!("[{}].sys.{}", db.replace(']', "]]"), proc_name) - } - _ => format!("sys.{proc_name}"), + let Some(db) = catalog.as_deref().filter(|db| !db.is_empty()) else { + return format!("EXEC sys.{} {}", proc_name, args.join(", ")); }; - format!("EXEC {} {}", qualified, 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 { @@ -75,7 +113,12 @@ fn build_exec_named(catalog: &Arg, proc_name: &str, args: &[(&str, String)]) -> /// /// # Safety /// `statement_handle` must be a valid `StmtHandle` or null. -unsafe fn run_catalog(statement_handle: SqlHandle, name: &str, sql: String) -> SqlReturn { +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; @@ -102,6 +145,7 @@ unsafe fn run_catalog(statement_handle: SqlHandle, name: &str, sql: String) -> S 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); @@ -127,7 +171,21 @@ unsafe fn run_catalog(statement_handle: SqlHandle, name: &str, sql: String) -> S return fail_with_tds(dbc, stmt, statement_handle, client, &e); } - finish_execute(dbc, stmt, statement_handle, client, name) + 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`. @@ -161,11 +219,16 @@ pub(crate) unsafe fn sql_tables_w( // three-part qualified, but sp_tables validates it against the // current database, so pass NULL. "NULL".to_string(), - literal(&types), + table_type_literal(&types), "1".to_string(), ], ); - run_catalog(statement_handle, "SQLTablesW", sql) + run_catalog( + statement_handle, + "SQLTablesW", + sql, + &[(0, "TABLE_CAT"), (1, "TABLE_SCHEM")], + ) }) } @@ -202,7 +265,19 @@ pub(crate) unsafe fn sql_columns_w( ("fUsePattern", "1".to_string()), ], ); - run_catalog(statement_handle, "SQLColumnsW", sql) + 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"), + ], + ) }) } @@ -228,7 +303,12 @@ pub(crate) unsafe fn sql_primary_keys_w( "sp_pkeys", &[literal(&table), literal(&schema), "NULL".to_string()], ); - run_catalog(statement_handle, "SQLPrimaryKeysW", sql) + run_catalog( + statement_handle, + "SQLPrimaryKeysW", + sql, + &[(0, "TABLE_CAT"), (1, "TABLE_SCHEM")], + ) }) } @@ -278,7 +358,17 @@ pub(crate) unsafe fn sql_foreign_keys_w( "NULL".to_string(), ], ); - run_catalog(statement_handle, "SQLForeignKeysW", sql) + run_catalog( + statement_handle, + "SQLForeignKeysW", + sql, + &[ + (0, "PKTABLE_CAT"), + (1, "PKTABLE_SCHEM"), + (4, "FKTABLE_CAT"), + (5, "FKTABLE_SCHEM"), + ], + ) }) } @@ -313,12 +403,24 @@ pub(crate) unsafe fn sql_statistics_w( literal(&table), literal(&schema), "NULL".to_string(), - "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) + run_catalog( + statement_handle, + "SQLStatisticsW", + sql, + &[ + (0, "TABLE_CAT"), + (1, "TABLE_SCHEM"), + (7, "ORDINAL_POSITION"), + (9, "ASC_OR_DESC"), + ], + ) }) } @@ -360,7 +462,16 @@ pub(crate) unsafe fn sql_special_columns_w( "3".to_string(), ], ); - run_catalog(statement_handle, "SQLSpecialColumnsW", sql) + run_catalog( + statement_handle, + "SQLSpecialColumnsW", + sql, + &[ + (4, "COLUMN_SIZE"), + (5, "BUFFER_LENGTH"), + (6, "DECIMAL_DIGITS"), + ], + ) }) } @@ -391,7 +502,12 @@ pub(crate) unsafe fn sql_procedures_w( "1".to_string(), ], ); - run_catalog(statement_handle, "SQLProceduresW", sql) + run_catalog( + statement_handle, + "SQLProceduresW", + sql, + &[(0, "PROCEDURE_CAT"), (1, "PROCEDURE_SCHEM")], + ) }) } @@ -415,7 +531,7 @@ mod tests { #[test] fn build_exec_qualifies_with_catalog() { let sql = build_exec(&Some("mydb".into()), "sp_tables", &["NULL".into()]); - assert_eq!(sql, "EXEC [mydb].sys.sp_tables NULL"); + assert!(sql.ends_with("ELSE EXEC [mydb].sys.sp_tables NULL")); } #[test] @@ -427,7 +543,7 @@ mod tests { #[test] fn build_exec_escapes_bracket_in_catalog() { let sql = build_exec(&Some("we]ird".into()), "sp_tables", &[]); - assert!(sql.starts_with("EXEC [we]]ird].sys.sp_tables")); + assert!(sql.contains("EXEC [we]]ird].sys.sp_tables")); } #[test] @@ -462,6 +578,50 @@ mod tests { 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 { @@ -489,8 +649,14 @@ mod tests { state.pending_row_counts.push_back(3); } - let ret = - unsafe { run_catalog(h.stmt, "SQLTablesW", "EXEC sys.sp_tables NULL".to_string()) }; + 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(); diff --git a/mssql-odbc/src/api/driver_connect.rs b/mssql-odbc/src/api/driver_connect.rs index 9aeb69d1..5df8af0c 100644 --- a/mssql-odbc/src/api/driver_connect.rs +++ b/mssql-odbc/src/api/driver_connect.rs @@ -376,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/exec_direct.rs b/mssql-odbc/src/api/exec_direct.rs index ead17154..8bd3b55e 100644 --- a/mssql-odbc/src/api/exec_direct.rs +++ b/mssql-odbc/src/api/exec_direct.rs @@ -69,6 +69,7 @@ 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) } @@ -110,6 +111,7 @@ pub(crate) fn sql_exec_direct_w_safe( 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/mod.rs b/mssql-odbc/src/api/mod.rs index eef3e439..15f5efdc 100644 --- a/mssql-odbc/src/api/mod.rs +++ b/mssql-odbc/src/api/mod.rs @@ -12,7 +12,7 @@ mod col_attribute; mod conn_exec; mod connect; mod dae; -mod desc; +pub(crate) mod desc; mod describe_col; mod disconnect; mod driver_connect; diff --git a/mssql-odbc/src/api/prepare.rs b/mssql-odbc/src/api/prepare.rs index 0fb8c886..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,6 +113,7 @@ 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.reset_rows(); diff --git a/mssql-odbc/src/api/sqlstate.rs b/mssql-odbc/src/api/sqlstate.rs index ec7a96c8..0e1c21b0 100644 --- a/mssql-odbc/src/api/sqlstate.rs +++ b/mssql-odbc/src/api/sqlstate.rs @@ -17,7 +17,9 @@ 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"; @@ -95,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", @@ -277,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 @@ -306,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", From 633a888f78f896a59d262b59e34625abe516d009 Mon Sep 17 00:00:00 2001 From: Saurabh Singh <1623701+saurabh500@users.noreply.github.com> Date: Wed, 5 Aug 2026 04:06:40 -0700 Subject: [PATCH 11/12] Document the mssql-python parity gap analysis Records every defect the mssql-python integration suite found in this driver, what caused it, and how many tests it accounted for. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e698c659-8b42-43f7-99ab-46f534b2489b --- mssql-odbc/docs/mssql-python-gap-analysis.md | 337 +++++++++++++++++++ 1 file changed, 337 insertions(+) create mode 100644 mssql-odbc/docs/mssql-python-gap-analysis.md 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. From b8ed06718a8c1b1c73312d21f7ba7d0f635b493f Mon Sep 17 00:00:00 2001 From: Saurabh Singh <1623701+saurabh500@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:41:29 -0700 Subject: [PATCH 12/12] Move the mssql-python parity suite to its own change The suite includes windows.h and binds the driver with LoadLibraryW, but CMakeLists.txt added it unconditionally, so the Linux and Linux ARM builds failed to compile it. It now lives on its own, with every case disabled, so it can be enabled one case at a time as the API work lands. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e698c659-8b42-43f7-99ab-46f534b2489b --- mssql-odbc/tests/e2e/CMakeLists.txt | 7 - .../e2e/tests/mssql_python_parity_test.cpp | 790 ------------------ 2 files changed, 797 deletions(-) delete mode 100644 mssql-odbc/tests/e2e/tests/mssql_python_parity_test.cpp diff --git a/mssql-odbc/tests/e2e/CMakeLists.txt b/mssql-odbc/tests/e2e/CMakeLists.txt index 73abb685..e8ee5379 100644 --- a/mssql-odbc/tests/e2e/CMakeLists.txt +++ b/mssql-odbc/tests/e2e/CMakeLists.txt @@ -125,10 +125,3 @@ add_odbc_test(more_results_test tests/more_results_test.cpp) add_odbc_test(execute_test tests/execute_test.cpp) add_odbc_test(get_type_info_test tests/get_type_info_test.cpp) add_odbc_test(row_count_test tests/row_count_test.cpp) - -# The mssql-python parity suite binds the driver by path instead of going -# through the Driver Manager, mirroring how mssql-python loads it. It needs no -# odbc_test_lib and no registered driver, only MSSQL_ODBC_DLL/MSSQL_ODBC_CONNSTR. -add_executable(mssql_python_parity_test tests/mssql_python_parity_test.cpp) -target_link_libraries(mssql_python_parity_test PRIVATE gtest) -add_test(NAME mssql_python_parity_test COMMAND mssql_python_parity_test) diff --git a/mssql-odbc/tests/e2e/tests/mssql_python_parity_test.cpp b/mssql-odbc/tests/e2e/tests/mssql_python_parity_test.cpp deleted file mode 100644 index 0520597f..00000000 --- a/mssql-odbc/tests/e2e/tests/mssql_python_parity_test.cpp +++ /dev/null @@ -1,790 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// mssql_python_parity_test.cpp -// -// Single-file GoogleTest suite covering the ODBC surface that mssql-python -// drives directly. mssql-python does not go through a Driver Manager: it -// LoadLibrary's the driver and calls the exported entrypoints itself, so the -// contract exercised here is exactly the set of calls its pybind layer makes -// (ddbc_bindings.cpp). Every case below maps to a behaviour the Python suite -// depends on, which makes this file the fast regression gate for the -// mssql-python parity work. -// -// The suite is deliberately self-contained: it binds the driver by path -// (MSSQL_ODBC_DLL, defaulting to msodbcsql18.dll next to the binary) instead of -// linking odbc32.lib, so it exercises the same load path as mssql-python and -// needs no Driver Manager registration. -// -// Configure with MSSQL_ODBC_DLL and MSSQL_ODBC_CONNSTR. - -#include - -#include - -#include -#include -#include - -#include -#include -#include -#include - -namespace { - -/// mssql-python asks for SQL_CA_SS_VARIANT_TYPE on every column of a result -/// set to detect sql_variant; the value is a SQL Server driver-specific field. -constexpr SQLUSMALLINT kSqlCaSsVariantType = 1215; - -using SqlTString = std::wstring; - -std::wstring ToWide(const std::string& s) { - return std::wstring(s.begin(), s.end()); -} - -std::string GetEnvOr(const char* name, const char* fallback) { - char* buf = nullptr; - size_t len = 0; - if (_dupenv_s(&buf, &len, name) == 0 && buf != nullptr) { - std::string value(buf); - free(buf); - if (!value.empty()) { - return value; - } - } - return fallback; -} - -// --------------------------------------------------------------------------- -// Driver binding -// -// mssql-python resolves each entrypoint by name from the loaded module. The -// table below mirrors that, so a missing export shows up here as a load -// failure rather than as an opaque Python traceback. -// --------------------------------------------------------------------------- - -struct DriverApi { - HMODULE module = nullptr; - - SQLRETURN(SQL_API* AllocHandle)(SQLSMALLINT, SQLHANDLE, SQLHANDLE*) = nullptr; - SQLRETURN(SQL_API* FreeHandle)(SQLSMALLINT, SQLHANDLE) = nullptr; - SQLRETURN(SQL_API* SetEnvAttr)(SQLHENV, SQLINTEGER, SQLPOINTER, SQLINTEGER) = nullptr; - SQLRETURN(SQL_API* DriverConnect)(SQLHDBC, SQLHWND, SQLWCHAR*, SQLSMALLINT, SQLWCHAR*, - SQLSMALLINT, SQLSMALLINT*, SQLUSMALLINT) = nullptr; - SQLRETURN(SQL_API* Disconnect)(SQLHDBC) = nullptr; - SQLRETURN(SQL_API* GetDiagRec)(SQLSMALLINT, SQLHANDLE, SQLSMALLINT, SQLWCHAR*, SQLINTEGER*, - SQLWCHAR*, SQLSMALLINT, SQLSMALLINT*) = nullptr; - - SQLRETURN(SQL_API* SetConnectAttr)(SQLHDBC, SQLINTEGER, SQLPOINTER, SQLINTEGER) = nullptr; - SQLRETURN(SQL_API* GetConnectAttr)(SQLHDBC, SQLINTEGER, SQLPOINTER, SQLINTEGER, - SQLINTEGER*) = nullptr; - SQLRETURN(SQL_API* EndTran)(SQLSMALLINT, SQLHANDLE, SQLSMALLINT) = nullptr; - - SQLRETURN(SQL_API* ExecDirect)(SQLHSTMT, SQLWCHAR*, SQLINTEGER) = nullptr; - SQLRETURN(SQL_API* Prepare)(SQLHSTMT, SQLWCHAR*, SQLINTEGER) = nullptr; - SQLRETURN(SQL_API* Execute)(SQLHSTMT) = nullptr; - SQLRETURN(SQL_API* Fetch)(SQLHSTMT) = nullptr; - SQLRETURN(SQL_API* FetchScroll)(SQLHSTMT, SQLSMALLINT, SQLLEN) = nullptr; - SQLRETURN(SQL_API* GetData)(SQLHSTMT, SQLUSMALLINT, SQLSMALLINT, SQLPOINTER, SQLLEN, - SQLLEN*) = nullptr; - SQLRETURN(SQL_API* BindCol)(SQLHSTMT, SQLUSMALLINT, SQLSMALLINT, SQLPOINTER, SQLLEN, - SQLLEN*) = nullptr; - SQLRETURN(SQL_API* BindParameter)(SQLHSTMT, SQLUSMALLINT, SQLSMALLINT, SQLSMALLINT, SQLSMALLINT, - SQLULEN, SQLSMALLINT, SQLPOINTER, SQLLEN, SQLLEN*) = nullptr; - SQLRETURN(SQL_API* NumResultCols)(SQLHSTMT, SQLSMALLINT*) = nullptr; - SQLRETURN(SQL_API* RowCount)(SQLHSTMT, SQLLEN*) = nullptr; - SQLRETURN(SQL_API* MoreResults)(SQLHSTMT) = nullptr; - SQLRETURN(SQL_API* FreeStmt)(SQLHSTMT, SQLUSMALLINT) = nullptr; - SQLRETURN(SQL_API* SetStmtAttr)(SQLHSTMT, SQLINTEGER, SQLPOINTER, SQLINTEGER) = nullptr; - SQLRETURN(SQL_API* GetStmtAttr)(SQLHSTMT, SQLINTEGER, SQLPOINTER, SQLINTEGER, - SQLINTEGER*) = nullptr; - SQLRETURN(SQL_API* SetDescField)(SQLHDESC, SQLSMALLINT, SQLSMALLINT, SQLPOINTER, - SQLINTEGER) = nullptr; - SQLRETURN(SQL_API* ColAttribute)(SQLHSTMT, SQLUSMALLINT, SQLUSMALLINT, SQLPOINTER, SQLSMALLINT, - SQLSMALLINT*, SQLLEN*) = nullptr; - - SQLRETURN(SQL_API* Tables)(SQLHSTMT, SQLWCHAR*, SQLSMALLINT, SQLWCHAR*, SQLSMALLINT, SQLWCHAR*, - SQLSMALLINT, SQLWCHAR*, SQLSMALLINT) = nullptr; - SQLRETURN(SQL_API* Columns)(SQLHSTMT, SQLWCHAR*, SQLSMALLINT, SQLWCHAR*, SQLSMALLINT, SQLWCHAR*, - SQLSMALLINT, SQLWCHAR*, SQLSMALLINT) = nullptr; - SQLRETURN(SQL_API* PrimaryKeys)(SQLHSTMT, SQLWCHAR*, SQLSMALLINT, SQLWCHAR*, SQLSMALLINT, - SQLWCHAR*, SQLSMALLINT) = nullptr; - SQLRETURN(SQL_API* Procedures)(SQLHSTMT, SQLWCHAR*, SQLSMALLINT, SQLWCHAR*, SQLSMALLINT, - SQLWCHAR*, SQLSMALLINT) = nullptr; - SQLRETURN(SQL_API* Statistics)(SQLHSTMT, SQLWCHAR*, SQLSMALLINT, SQLWCHAR*, SQLSMALLINT, - SQLWCHAR*, SQLSMALLINT, SQLUSMALLINT, SQLUSMALLINT) = nullptr; - - std::string load_error; -}; - -DriverApi& Api() { - static DriverApi api; - return api; -} - -template -bool Bind(HMODULE module, const char* name, Fn& slot, std::string& error) { - auto proc = GetProcAddress(module, name); - if (proc == nullptr) { - if (error.empty()) { - error = "missing export: "; - error += name; - } - return false; - } - slot = reinterpret_cast(proc); - return true; -} - -bool LoadDriver(DriverApi& api) { - const std::string path = GetEnvOr("MSSQL_ODBC_DLL", "msodbcsql18.dll"); - api.module = LoadLibraryA(path.c_str()); - if (api.module == nullptr) { - api.load_error = "LoadLibrary failed for " + path + " (error " + - std::to_string(static_cast(GetLastError())) + ")"; - return false; - } - - std::string& err = api.load_error; - bool ok = true; - ok &= Bind(api.module, "SQLAllocHandle", api.AllocHandle, err); - ok &= Bind(api.module, "SQLFreeHandle", api.FreeHandle, err); - ok &= Bind(api.module, "SQLSetEnvAttr", api.SetEnvAttr, err); - ok &= Bind(api.module, "SQLDriverConnectW", api.DriverConnect, err); - ok &= Bind(api.module, "SQLDisconnect", api.Disconnect, err); - ok &= Bind(api.module, "SQLGetDiagRecW", api.GetDiagRec, err); - ok &= Bind(api.module, "SQLSetConnectAttrW", api.SetConnectAttr, err); - ok &= Bind(api.module, "SQLGetConnectAttrW", api.GetConnectAttr, err); - ok &= Bind(api.module, "SQLEndTran", api.EndTran, err); - ok &= Bind(api.module, "SQLExecDirectW", api.ExecDirect, err); - ok &= Bind(api.module, "SQLPrepareW", api.Prepare, err); - ok &= Bind(api.module, "SQLExecute", api.Execute, err); - ok &= Bind(api.module, "SQLFetch", api.Fetch, err); - ok &= Bind(api.module, "SQLFetchScroll", api.FetchScroll, err); - ok &= Bind(api.module, "SQLGetData", api.GetData, err); - ok &= Bind(api.module, "SQLBindCol", api.BindCol, err); - ok &= Bind(api.module, "SQLBindParameter", api.BindParameter, err); - ok &= Bind(api.module, "SQLNumResultCols", api.NumResultCols, err); - ok &= Bind(api.module, "SQLRowCount", api.RowCount, err); - ok &= Bind(api.module, "SQLMoreResults", api.MoreResults, err); - ok &= Bind(api.module, "SQLFreeStmt", api.FreeStmt, err); - ok &= Bind(api.module, "SQLSetStmtAttrW", api.SetStmtAttr, err); - ok &= Bind(api.module, "SQLGetStmtAttrW", api.GetStmtAttr, err); - ok &= Bind(api.module, "SQLSetDescFieldW", api.SetDescField, err); - ok &= Bind(api.module, "SQLColAttributeW", api.ColAttribute, err); - ok &= Bind(api.module, "SQLTablesW", api.Tables, err); - ok &= Bind(api.module, "SQLColumnsW", api.Columns, err); - ok &= Bind(api.module, "SQLPrimaryKeysW", api.PrimaryKeys, err); - ok &= Bind(api.module, "SQLProceduresW", api.Procedures, err); - ok &= Bind(api.module, "SQLStatisticsW", api.Statistics, err); - return ok; -} - -// The tests below are written against the plain ODBC names; route them at the -// loaded module so the file stays readable while bypassing the Driver Manager. -#define SQLAllocHandle Api().AllocHandle -#define SQLFreeHandle Api().FreeHandle -#define SQLSetEnvAttr Api().SetEnvAttr -#define SQLDriverConnectW Api().DriverConnect -#define SQLDisconnect Api().Disconnect -#define SQLGetDiagRecW Api().GetDiagRec -#undef SQLSetConnectAttr -#define SQLSetConnectAttr Api().SetConnectAttr -#define SQLGetConnectAttrW Api().GetConnectAttr -#define SQLEndTran Api().EndTran -#define SQLExecDirectW Api().ExecDirect -#define SQLPrepareW Api().Prepare -#define SQLExecute Api().Execute -#define SQLFetch Api().Fetch -#define SQLFetchScroll Api().FetchScroll -#define SQLGetData Api().GetData -#define SQLBindCol Api().BindCol -#define SQLBindParameter Api().BindParameter -#define SQLNumResultCols Api().NumResultCols -#define SQLRowCount Api().RowCount -#define SQLMoreResults Api().MoreResults -#define SQLFreeStmt Api().FreeStmt -#undef SQLSetStmtAttr -#define SQLSetStmtAttr Api().SetStmtAttr -#undef SQLGetStmtAttr -#define SQLGetStmtAttr Api().GetStmtAttr -#define SQLSetDescFieldW Api().SetDescField -#define SQLColAttributeW Api().ColAttribute -#define SQLTablesW Api().Tables -#define SQLColumnsW Api().Columns -#define SQLPrimaryKeysW Api().PrimaryKeys -#define SQLProceduresW Api().Procedures -#define SQLStatisticsW Api().Statistics - -std::wstring DiagField(SQLSMALLINT handle_type, SQLHANDLE handle, bool want_state) { - SQLWCHAR state[6] = {}; - SQLWCHAR message[1024] = {}; - SQLINTEGER native = 0; - SQLSMALLINT length = 0; - if (!SQL_SUCCEEDED(SQLGetDiagRecW(handle_type, handle, 1, state, &native, message, - static_cast(std::size(message)), &length))) { - return L""; - } - return want_state ? std::wstring(state) : std::wstring(message); -} - -std::string Narrow(const std::wstring& value) { - return std::string(value.begin(), value.end()); -} - -std::string DiagMessage(SQLSMALLINT handle_type, SQLHANDLE handle) { - return "[" + Narrow(DiagField(handle_type, handle, true)) + "] " + - Narrow(DiagField(handle_type, handle, false)); -} - -std::string DiagState(SQLSMALLINT handle_type, SQLHANDLE handle) { - return Narrow(DiagField(handle_type, handle, true)); -} - -#define ASSERT_SQL_OK(rc, handle_type, handle) \ - do { \ - SQLRETURN _rc = (rc); \ - ASSERT_TRUE(SQL_SUCCEEDED(_rc)) << "rc=" << _rc << " " << DiagMessage(handle_type, handle); \ - } while (0) - -#define EXPECT_SQL_OK(rc, handle_type, handle) \ - do { \ - SQLRETURN _rc = (rc); \ - EXPECT_TRUE(SQL_SUCCEEDED(_rc)) << "rc=" << _rc << " " << DiagMessage(handle_type, handle); \ - } while (0) - -#define EXPECT_SQLSTATE(handle_type, handle, expected_state) \ - EXPECT_EQ(std::string(expected_state), DiagState(handle_type, handle)) - -/// Compatibility shim so the test bodies keep the shared-fixture spelling. -struct ODBCTestUtils { - static SqlTString ToSqlTStr(const std::string& s) { return ToWide(s); } - static std::string ToNarrow(const SqlTString& s) { return Narrow(s); } -}; - -/// Fixture that connects once per test and exposes helpers for the -/// mssql-python call patterns. -class PythonParityTest : public ::testing::Test { -protected: - SQLHENV env_ = nullptr; - SQLHDBC dbc_ = nullptr; - SQLHSTMT stmt_ = nullptr; - - void SetUp() override { - if (Api().module == nullptr) { - GTEST_SKIP() << "driver not loaded: " << Api().load_error; - } - const std::string conn = GetEnvOr("MSSQL_ODBC_CONNSTR", ""); - if (conn.empty()) { - GTEST_SKIP() << "set MSSQL_ODBC_CONNSTR to run parity tests"; - } - - ASSERT_SQL_OK(SQLAllocHandle(SQL_HANDLE_ENV, nullptr, &env_), SQL_HANDLE_ENV, env_); - ASSERT_SQL_OK(SQLSetEnvAttr(env_, SQL_ATTR_ODBC_VERSION, - reinterpret_cast(SQL_OV_ODBC3), 0), - SQL_HANDLE_ENV, env_); - ASSERT_SQL_OK(SQLAllocHandle(SQL_HANDLE_DBC, env_, &dbc_), SQL_HANDLE_ENV, env_); - - std::wstring wide = ToWide(conn); - ASSERT_SQL_OK(SQLDriverConnectW(dbc_, nullptr, wide.data(), - static_cast(wide.size()), nullptr, 0, nullptr, - SQL_DRIVER_NOPROMPT), - SQL_HANDLE_DBC, dbc_); - ASSERT_SQL_OK(SQLAllocHandle(SQL_HANDLE_STMT, dbc_, &stmt_), SQL_HANDLE_DBC, dbc_); - } - - void TearDown() override { - if (stmt_ != nullptr) { - SQLFreeHandle(SQL_HANDLE_STMT, stmt_); - } - if (dbc_ != nullptr) { - SQLDisconnect(dbc_); - SQLFreeHandle(SQL_HANDLE_DBC, dbc_); - } - if (env_ != nullptr) { - SQLFreeHandle(SQL_HANDLE_ENV, env_); - } - } - - /// Runs |sql| on |hstmt| and asserts it succeeded. - void Exec(SQLHSTMT hstmt, const std::string& sql) { - std::wstring text = ToWide(sql); - ASSERT_SQL_OK(SQLExecDirectW(hstmt, text.data(), SQL_NTS), SQL_HANDLE_STMT, hstmt); - } - - /// Runs |sql| and swallows failures; used for best-effort cleanup. - void ExecDirectIgnoreError(const std::string& sql) { - std::wstring text = ToWide(sql); - SQLExecDirectW(stmt_, text.data(), SQL_NTS); - SQLFreeStmt(stmt_, SQL_CLOSE); - } - - /// Allocates an extra statement on the same connection. - SQLHSTMT AllocStmt() { - SQLHSTMT handle = nullptr; - EXPECT_SQL_OK(SQLAllocHandle(SQL_HANDLE_STMT, dbc_, &handle), SQL_HANDLE_DBC, dbc_); - return handle; - } - - void FreeStmt(SQLHSTMT handle) { SQLFreeHandle(SQL_HANDLE_STMT, handle); } - - /// Fetches a single SQL_C_SLONG column from a one-row query. - SQLINTEGER ScalarLong(const std::string& sql) { - Exec(stmt_, sql); - EXPECT_EQ(SQL_SUCCESS, SQLFetch(stmt_)); - SQLINTEGER value = 0; - SQLLEN indicator = 0; - EXPECT_SQL_OK(SQLGetData(stmt_, 1, SQL_C_SLONG, &value, sizeof(value), &indicator), - SQL_HANDLE_STMT, stmt_); - SQLFreeStmt(stmt_, SQL_CLOSE); - return value; - } -}; - - -// --------------------------------------------------------------------------- -// Connection attributes and transactions -// -// mssql-python calls SQLSetConnectAttr(SQL_ATTR_AUTOCOMMIT) immediately after -// connecting and raises if it fails, then drives commit/rollback exclusively -// through SQLEndTran. A failure in any of these aborts every Python test. -// --------------------------------------------------------------------------- - -TEST_F(PythonParityTest, AutocommitRoundTrips) { - ASSERT_SQL_OK(SQLSetConnectAttr(dbc_, SQL_ATTR_AUTOCOMMIT, - reinterpret_cast(SQL_AUTOCOMMIT_OFF), 0), - SQL_HANDLE_DBC, dbc_); - - SQLUINTEGER value = 0xFFFF; - SQLINTEGER length = 0; - ASSERT_SQL_OK(SQLGetConnectAttrW(dbc_, SQL_ATTR_AUTOCOMMIT, &value, sizeof(value), &length), - SQL_HANDLE_DBC, dbc_); - EXPECT_EQ(static_cast(SQL_AUTOCOMMIT_OFF), value); - - ASSERT_SQL_OK(SQLSetConnectAttr(dbc_, SQL_ATTR_AUTOCOMMIT, - reinterpret_cast(SQL_AUTOCOMMIT_ON), 0), - SQL_HANDLE_DBC, dbc_); - ASSERT_SQL_OK(SQLGetConnectAttrW(dbc_, SQL_ATTR_AUTOCOMMIT, &value, sizeof(value), &length), - SQL_HANDLE_DBC, dbc_); - EXPECT_EQ(static_cast(SQL_AUTOCOMMIT_ON), value); -} - -TEST_F(PythonParityTest, ManualCommitPersistsRows) { - ExecDirectIgnoreError("DROP TABLE IF EXISTS #parity_commit"); - Exec(stmt_, "CREATE TABLE #parity_commit (id INT)"); - - ASSERT_SQL_OK(SQLSetConnectAttr(dbc_, SQL_ATTR_AUTOCOMMIT, - reinterpret_cast(SQL_AUTOCOMMIT_OFF), 0), - SQL_HANDLE_DBC, dbc_); - Exec(stmt_, "INSERT INTO #parity_commit VALUES (1)"); - ASSERT_SQL_OK(SQLEndTran(SQL_HANDLE_DBC, dbc_, SQL_COMMIT), SQL_HANDLE_DBC, dbc_); - - EXPECT_EQ(1, ScalarLong("SELECT COUNT(*) FROM #parity_commit")); - SQLSetConnectAttr(dbc_, SQL_ATTR_AUTOCOMMIT, reinterpret_cast(SQL_AUTOCOMMIT_ON), 0); -} - -TEST_F(PythonParityTest, ManualRollbackDiscardsRows) { - ExecDirectIgnoreError("DROP TABLE IF EXISTS #parity_rollback"); - Exec(stmt_, "CREATE TABLE #parity_rollback (id INT)"); - - ASSERT_SQL_OK(SQLSetConnectAttr(dbc_, SQL_ATTR_AUTOCOMMIT, - reinterpret_cast(SQL_AUTOCOMMIT_OFF), 0), - SQL_HANDLE_DBC, dbc_); - Exec(stmt_, "INSERT INTO #parity_rollback VALUES (1)"); - ASSERT_SQL_OK(SQLEndTran(SQL_HANDLE_DBC, dbc_, SQL_ROLLBACK), SQL_HANDLE_DBC, dbc_); - - EXPECT_EQ(0, ScalarLong("SELECT COUNT(*) FROM #parity_rollback")); - SQLSetConnectAttr(dbc_, SQL_ATTR_AUTOCOMMIT, reinterpret_cast(SQL_AUTOCOMMIT_ON), 0); -} - -TEST_F(PythonParityTest, EndTranInAutocommitIsANoOp) { - // Python's Connection.commit() is unconditional, so committing while - // autocommit is on must succeed instead of raising 25000. - EXPECT_SQL_OK(SQLEndTran(SQL_HANDLE_DBC, dbc_, SQL_COMMIT), SQL_HANDLE_DBC, dbc_); - EXPECT_SQL_OK(SQLEndTran(SQL_HANDLE_DBC, dbc_, SQL_ROLLBACK), SQL_HANDLE_DBC, dbc_); -} - -// --------------------------------------------------------------------------- -// Block fetch — the fetchmany()/fetchall() hot path -// -// FetchBatchData() unbinds, binds every column column-wise with an array of -// |fetchSize| elements, then calls SQLFetchScroll(SQL_FETCH_NEXT, 0) and reads -// SQL_ATTR_ROWS_FETCHED_PTR. Column-wise offsets and the indicator array are -// the parts most likely to regress. -// --------------------------------------------------------------------------- - -TEST_F(PythonParityTest, BlockFetchFillsColumnWiseArrays) { - constexpr SQLULEN kRowsetSize = 4; - - ASSERT_SQL_OK(SQLSetStmtAttr(stmt_, SQL_ATTR_ROW_ARRAY_SIZE, - reinterpret_cast(kRowsetSize), 0), - SQL_HANDLE_STMT, stmt_); - SQLULEN rows_fetched = 0; - ASSERT_SQL_OK(SQLSetStmtAttr(stmt_, SQL_ATTR_ROWS_FETCHED_PTR, &rows_fetched, 0), - SQL_HANDLE_STMT, stmt_); - - Exec(stmt_, - "SELECT v, CAST(v AS VARCHAR(16)) AS t FROM (VALUES (10),(20),(30)) AS s(v) ORDER BY v"); - - SQLINTEGER ints[kRowsetSize] = {}; - SQLLEN int_ind[kRowsetSize] = {}; - SQLWCHAR text[kRowsetSize][32] = {}; - SQLLEN text_ind[kRowsetSize] = {}; - - ASSERT_SQL_OK(SQLFreeStmt(stmt_, SQL_UNBIND), SQL_HANDLE_STMT, stmt_); - ASSERT_SQL_OK(SQLBindCol(stmt_, 1, SQL_C_SLONG, ints, sizeof(SQLINTEGER), int_ind), - SQL_HANDLE_STMT, stmt_); - ASSERT_SQL_OK(SQLBindCol(stmt_, 2, SQL_C_WCHAR, text, sizeof(text[0]), text_ind), - SQL_HANDLE_STMT, stmt_); - - ASSERT_TRUE(SQL_SUCCEEDED(SQLFetchScroll(stmt_, SQL_FETCH_NEXT, 0))); - ASSERT_EQ(3u, rows_fetched); - EXPECT_EQ(10, ints[0]); - EXPECT_EQ(20, ints[1]); - EXPECT_EQ(30, ints[2]); - EXPECT_EQ(static_cast(sizeof(SQLINTEGER)), int_ind[0]); - EXPECT_EQ(std::string("10"), ODBCTestUtils::ToNarrow(SqlTString( - reinterpret_cast(text[0])))); - EXPECT_EQ(std::string("30"), ODBCTestUtils::ToNarrow(SqlTString( - reinterpret_cast(text[2])))); - - EXPECT_EQ(SQL_NO_DATA, SQLFetchScroll(stmt_, SQL_FETCH_NEXT, 0)); -} - -TEST_F(PythonParityTest, BlockFetchReportsNullIndicators) { - constexpr SQLULEN kRowsetSize = 2; - ASSERT_SQL_OK(SQLSetStmtAttr(stmt_, SQL_ATTR_ROW_ARRAY_SIZE, - reinterpret_cast(kRowsetSize), 0), - SQL_HANDLE_STMT, stmt_); - SQLULEN rows_fetched = 0; - ASSERT_SQL_OK(SQLSetStmtAttr(stmt_, SQL_ATTR_ROWS_FETCHED_PTR, &rows_fetched, 0), - SQL_HANDLE_STMT, stmt_); - - Exec(stmt_, "SELECT CAST(NULL AS INT) UNION ALL SELECT 7"); - - SQLINTEGER values[kRowsetSize] = {}; - SQLLEN indicators[kRowsetSize] = {}; - ASSERT_SQL_OK(SQLFreeStmt(stmt_, SQL_UNBIND), SQL_HANDLE_STMT, stmt_); - ASSERT_SQL_OK(SQLBindCol(stmt_, 1, SQL_C_SLONG, values, sizeof(SQLINTEGER), indicators), - SQL_HANDLE_STMT, stmt_); - - ASSERT_TRUE(SQL_SUCCEEDED(SQLFetchScroll(stmt_, SQL_FETCH_NEXT, 0))); - ASSERT_EQ(2u, rows_fetched); - EXPECT_EQ(SQL_NULL_DATA, indicators[0]); - EXPECT_EQ(7, values[1]); -} - -TEST_F(PythonParityTest, FetchScrollRejectsNonForwardOrientations) { - Exec(stmt_, "SELECT 1"); - EXPECT_EQ(SQL_ERROR, SQLFetchScroll(stmt_, SQL_FETCH_PRIOR, 0)); - EXPECT_SQLSTATE(SQL_HANDLE_STMT, stmt_, "HY106"); -} - -// --------------------------------------------------------------------------- -// Interleaved cursors on one connection -// -// Connection.cursor() allocates another HSTMT on the same HDBC. Without MARS -// the driver must still serve the second statement once the first result set -// is buffered, and the first cursor's remaining rows must survive. -// --------------------------------------------------------------------------- - -TEST_F(PythonParityTest, SecondCursorRunsWhileFirstIsOpen) { - Exec(stmt_, "SELECT 1 AS n UNION ALL SELECT 2 ORDER BY n"); - ASSERT_EQ(SQL_SUCCESS, SQLFetch(stmt_)); - - SQLINTEGER first = 0; - SQLLEN ind = 0; - ASSERT_SQL_OK(SQLGetData(stmt_, 1, SQL_C_SLONG, &first, sizeof(first), &ind), SQL_HANDLE_STMT, - stmt_); - EXPECT_EQ(1, first); - - SQLHSTMT other = AllocStmt(); - Exec(other, "SELECT 42"); - ASSERT_EQ(SQL_SUCCESS, SQLFetch(other)); - SQLINTEGER answer = 0; - ASSERT_SQL_OK(SQLGetData(other, 1, SQL_C_SLONG, &answer, sizeof(answer), &ind), SQL_HANDLE_STMT, - other); - EXPECT_EQ(42, answer); - FreeStmt(other); - - // The first cursor keeps its position across the interleaved statement. - ASSERT_EQ(SQL_SUCCESS, SQLFetch(stmt_)); - SQLINTEGER second = 0; - ASSERT_SQL_OK(SQLGetData(stmt_, 1, SQL_C_SLONG, &second, sizeof(second), &ind), SQL_HANDLE_STMT, - stmt_); - EXPECT_EQ(2, second); -} - -// --------------------------------------------------------------------------- -// SQLGetData conversions -// -// Python materializes every cell through SQLGetData with the C type chosen -// from the column's SQL type, so the conversion matrix is load-bearing for the -// whole data-type test module. -// --------------------------------------------------------------------------- - -TEST_F(PythonParityTest, GetDataConvertsCommonTypes) { - Exec(stmt_, - "SELECT CAST('abc' AS NVARCHAR(10)), CAST(1.5 AS FLOAT), CAST(3 AS BIGINT), " - "CAST('2024-02-29' AS DATE), CAST(1 AS BIT)"); - ASSERT_EQ(SQL_SUCCESS, SQLFetch(stmt_)); - - SQLWCHAR text[32] = {}; - SQLLEN ind = 0; - ASSERT_SQL_OK(SQLGetData(stmt_, 1, SQL_C_WCHAR, text, sizeof(text), &ind), SQL_HANDLE_STMT, - stmt_); - EXPECT_EQ(std::string("abc"), - ODBCTestUtils::ToNarrow(SqlTString(reinterpret_cast(text)))); - - double real = 0.0; - ASSERT_SQL_OK(SQLGetData(stmt_, 2, SQL_C_DOUBLE, &real, sizeof(real), &ind), SQL_HANDLE_STMT, - stmt_); - EXPECT_DOUBLE_EQ(1.5, real); - - SQLBIGINT big = 0; - ASSERT_SQL_OK(SQLGetData(stmt_, 3, SQL_C_SBIGINT, &big, sizeof(big), &ind), SQL_HANDLE_STMT, - stmt_); - EXPECT_EQ(3, big); - - SQL_DATE_STRUCT date{}; - ASSERT_SQL_OK(SQLGetData(stmt_, 4, SQL_C_TYPE_DATE, &date, sizeof(date), &ind), SQL_HANDLE_STMT, - stmt_); - EXPECT_EQ(2024, date.year); - EXPECT_EQ(2, date.month); - EXPECT_EQ(29, date.day); - - unsigned char bit = 0; - ASSERT_SQL_OK(SQLGetData(stmt_, 5, SQL_C_BIT, &bit, sizeof(bit), &ind), SQL_HANDLE_STMT, stmt_); - EXPECT_EQ(1, bit); -} - -TEST_F(PythonParityTest, GetDataReportsNullAndTruncation) { - Exec(stmt_, "SELECT CAST(NULL AS INT), CAST('abcdef' AS VARCHAR(10))"); - ASSERT_EQ(SQL_SUCCESS, SQLFetch(stmt_)); - - SQLINTEGER value = 123; - SQLLEN ind = 0; - ASSERT_SQL_OK(SQLGetData(stmt_, 1, SQL_C_SLONG, &value, sizeof(value), &ind), SQL_HANDLE_STMT, - stmt_); - EXPECT_EQ(SQL_NULL_DATA, ind); - - SQLCHAR tiny_buf[4] = {}; - EXPECT_EQ(SQL_SUCCESS_WITH_INFO, SQLGetData(stmt_, 2, SQL_C_CHAR, tiny_buf, sizeof(tiny_buf), &ind)); - EXPECT_SQLSTATE(SQL_HANDLE_STMT, stmt_, "01004"); -} - -// --------------------------------------------------------------------------- -// SQLColAttributeW -// -// Python queries SQL_CA_SS_VARIANT_TYPE per column and falls back to None when -// it fails; it also relies on the standard descriptor fields for cursor -// metadata. -// --------------------------------------------------------------------------- - -TEST_F(PythonParityTest, ColAttributeReportsNameTypeAndCount) { - Exec(stmt_, "SELECT CAST(1 AS INT) AS answer"); - - SQLLEN numeric = 0; - ASSERT_SQL_OK(SQLColAttributeW(stmt_, 0, SQL_DESC_COUNT, nullptr, 0, nullptr, &numeric), - SQL_HANDLE_STMT, stmt_); - EXPECT_EQ(1, numeric); - - SQLWCHAR name[64] = {}; - SQLSMALLINT name_len = 0; - ASSERT_SQL_OK( - SQLColAttributeW(stmt_, 1, SQL_DESC_NAME, name, sizeof(name), &name_len, nullptr), - SQL_HANDLE_STMT, stmt_); - EXPECT_EQ(std::string("answer"), - ODBCTestUtils::ToNarrow(SqlTString(reinterpret_cast(name)))); - - ASSERT_SQL_OK(SQLColAttributeW(stmt_, 1, SQL_DESC_CONCISE_TYPE, nullptr, 0, nullptr, &numeric), - SQL_HANDLE_STMT, stmt_); - EXPECT_EQ(SQL_INTEGER, numeric); -} - -TEST_F(PythonParityTest, ColAttributeVariantTypeDoesNotCrash) { - Exec(stmt_, "SELECT CAST(1 AS INT)"); - SQLLEN numeric = 0; - // Either answer is acceptable — Python treats a failure as "not a variant" — - // but the call must not fault or leave the statement unusable. - SQLColAttributeW(stmt_, 1, kSqlCaSsVariantType, nullptr, 0, nullptr, &numeric); - EXPECT_EQ(SQL_SUCCESS, SQLFetch(stmt_)); -} - -// --------------------------------------------------------------------------- -// Catalog functions -// -// Cursor.tables()/columns()/primaryKeys()/... map one-to-one onto these calls -// and assert on the ODBC-defined column layout. -// --------------------------------------------------------------------------- - -TEST_F(PythonParityTest, TablesReturnsOdbcShapedResultSet) { - ExecDirectIgnoreError("DROP TABLE dbo.parity_catalog"); - Exec(stmt_, "CREATE TABLE dbo.parity_catalog (id INT NOT NULL PRIMARY KEY, label NVARCHAR(20))"); - SQLFreeStmt(stmt_, SQL_CLOSE); - - SqlTString table = ODBCTestUtils::ToSqlTStr("parity_catalog"); - ASSERT_SQL_OK(SQLTablesW(stmt_, nullptr, 0, nullptr, 0, - reinterpret_cast(table.data()), SQL_NTS, nullptr, 0), - SQL_HANDLE_STMT, stmt_); - - SQLSMALLINT columns = 0; - ASSERT_SQL_OK(SQLNumResultCols(stmt_, &columns), SQL_HANDLE_STMT, stmt_); - EXPECT_EQ(5, columns); - EXPECT_EQ(SQL_SUCCESS, SQLFetch(stmt_)); - SQLFreeStmt(stmt_, SQL_CLOSE); - - ExecDirectIgnoreError("DROP TABLE dbo.parity_catalog"); -} - -TEST_F(PythonParityTest, ColumnsAndPrimaryKeysSucceed) { - ExecDirectIgnoreError("DROP TABLE dbo.parity_keys"); - Exec(stmt_, "CREATE TABLE dbo.parity_keys (id INT NOT NULL PRIMARY KEY, label NVARCHAR(20))"); - SQLFreeStmt(stmt_, SQL_CLOSE); - - SqlTString table = ODBCTestUtils::ToSqlTStr("parity_keys"); - ASSERT_SQL_OK(SQLColumnsW(stmt_, nullptr, 0, nullptr, 0, - reinterpret_cast(table.data()), SQL_NTS, nullptr, 0), - SQL_HANDLE_STMT, stmt_); - EXPECT_EQ(SQL_SUCCESS, SQLFetch(stmt_)); - SQLFreeStmt(stmt_, SQL_CLOSE); - - table = ODBCTestUtils::ToSqlTStr("parity_keys"); - ASSERT_SQL_OK(SQLPrimaryKeysW(stmt_, nullptr, 0, nullptr, 0, - reinterpret_cast(table.data()), SQL_NTS), - SQL_HANDLE_STMT, stmt_); - EXPECT_EQ(SQL_SUCCESS, SQLFetch(stmt_)); - SQLFreeStmt(stmt_, SQL_CLOSE); - - ExecDirectIgnoreError("DROP TABLE dbo.parity_keys"); -} - -TEST_F(PythonParityTest, ProceduresAndStatisticsSucceed) { - ASSERT_SQL_OK(SQLProceduresW(stmt_, nullptr, 0, nullptr, 0, nullptr, 0), SQL_HANDLE_STMT, - stmt_); - SQLFreeStmt(stmt_, SQL_CLOSE); - - ExecDirectIgnoreError("DROP TABLE dbo.parity_stats"); - Exec(stmt_, "CREATE TABLE dbo.parity_stats (id INT NOT NULL PRIMARY KEY)"); - SQLFreeStmt(stmt_, SQL_CLOSE); - - SqlTString table = ODBCTestUtils::ToSqlTStr("parity_stats"); - ASSERT_SQL_OK(SQLStatisticsW(stmt_, nullptr, 0, nullptr, 0, - reinterpret_cast(table.data()), SQL_NTS, - SQL_INDEX_ALL, SQL_QUICK), - SQL_HANDLE_STMT, stmt_); - SQLFreeStmt(stmt_, SQL_CLOSE); - - ExecDirectIgnoreError("DROP TABLE dbo.parity_stats"); -} - -// --------------------------------------------------------------------------- -// Parameter binding -// -// BindParameters() feeds SQLBindParameter for every Python argument, and the -// SQL_C_NUMERIC path additionally sets precision/scale on the APD through -// SQLSetDescFieldW — a failure there makes every decimal parameter raise. -// --------------------------------------------------------------------------- - -TEST_F(PythonParityTest, BoundParametersRoundTrip) { - SqlTString sql = ODBCTestUtils::ToSqlTStr("SELECT ? + ?, ?"); - ASSERT_SQL_OK(SQLPrepareW(stmt_, reinterpret_cast(sql.data()), SQL_NTS), - SQL_HANDLE_STMT, stmt_); - - SQLINTEGER left = 40; - SQLINTEGER right = 2; - SQLWCHAR text[] = {L'h', L'i', 0}; - SQLLEN int_len = 0; - SQLLEN text_len = SQL_NTS; - - ASSERT_SQL_OK(SQLBindParameter(stmt_, 1, SQL_PARAM_INPUT, SQL_C_SLONG, SQL_INTEGER, 0, 0, &left, - 0, &int_len), - SQL_HANDLE_STMT, stmt_); - ASSERT_SQL_OK(SQLBindParameter(stmt_, 2, SQL_PARAM_INPUT, SQL_C_SLONG, SQL_INTEGER, 0, 0, - &right, 0, &int_len), - SQL_HANDLE_STMT, stmt_); - ASSERT_SQL_OK(SQLBindParameter(stmt_, 3, SQL_PARAM_INPUT, SQL_C_WCHAR, SQL_WVARCHAR, 2, 0, text, - sizeof(text), &text_len), - SQL_HANDLE_STMT, stmt_); - - ASSERT_SQL_OK(SQLExecute(stmt_), SQL_HANDLE_STMT, stmt_); - ASSERT_EQ(SQL_SUCCESS, SQLFetch(stmt_)); - - SQLINTEGER sum = 0; - SQLLEN ind = 0; - ASSERT_SQL_OK(SQLGetData(stmt_, 1, SQL_C_SLONG, &sum, sizeof(sum), &ind), SQL_HANDLE_STMT, - stmt_); - EXPECT_EQ(42, sum); -} - -TEST_F(PythonParityTest, SetDescFieldOnApdSucceedsForNumeric) { - SQLHDESC apd = nullptr; - SQLINTEGER length = 0; - if (!SQL_SUCCEEDED(SQLGetStmtAttr(stmt_, SQL_ATTR_APP_PARAM_DESC, &apd, 0, &length))) { - GTEST_SKIP() << "APD handle unavailable"; - } - // Python sets these three fields, in this order, for every decimal argument. - EXPECT_SQL_OK(SQLSetDescFieldW(apd, 1, SQL_DESC_TYPE, reinterpret_cast(SQL_C_NUMERIC), - 0), - SQL_HANDLE_DESC, apd); - EXPECT_SQL_OK(SQLSetDescFieldW(apd, 1, SQL_DESC_PRECISION, reinterpret_cast(18), 0), - SQL_HANDLE_DESC, apd); - EXPECT_SQL_OK(SQLSetDescFieldW(apd, 1, SQL_DESC_SCALE, reinterpret_cast(4), 0), - SQL_HANDLE_DESC, apd); -} - -// --------------------------------------------------------------------------- -// Statement lifecycle -// --------------------------------------------------------------------------- - -TEST_F(PythonParityTest, UnbindClearsPreviousBindings) { - Exec(stmt_, "SELECT 1, 2"); - SQLINTEGER first = 0; - SQLLEN ind = 0; - ASSERT_SQL_OK(SQLBindCol(stmt_, 1, SQL_C_SLONG, &first, sizeof(first), &ind), SQL_HANDLE_STMT, - stmt_); - ASSERT_SQL_OK(SQLFreeStmt(stmt_, SQL_UNBIND), SQL_HANDLE_STMT, stmt_); - - ASSERT_EQ(SQL_SUCCESS, SQLFetch(stmt_)); - EXPECT_EQ(0, first) << "SQL_UNBIND must detach the buffer"; -} - -TEST_F(PythonParityTest, RowCountReportsAffectedRows) { - ExecDirectIgnoreError("DROP TABLE IF EXISTS #parity_rowcount"); - Exec(stmt_, "CREATE TABLE #parity_rowcount (id INT)"); - SQLFreeStmt(stmt_, SQL_CLOSE); - - Exec(stmt_, "INSERT INTO #parity_rowcount VALUES (1),(2),(3)"); - SQLLEN affected = 0; - ASSERT_SQL_OK(SQLRowCount(stmt_, &affected), SQL_HANDLE_STMT, stmt_); - EXPECT_EQ(3, affected); -} - -TEST_F(PythonParityTest, MoreResultsWalksMultiStatementBatch) { - Exec(stmt_, "SELECT 1; SELECT 2"); - ASSERT_EQ(SQL_SUCCESS, SQLFetch(stmt_)); - - SQLINTEGER value = 0; - SQLLEN ind = 0; - ASSERT_SQL_OK(SQLGetData(stmt_, 1, SQL_C_SLONG, &value, sizeof(value), &ind), SQL_HANDLE_STMT, - stmt_); - EXPECT_EQ(1, value); - - ASSERT_TRUE(SQL_SUCCEEDED(SQLMoreResults(stmt_))); - ASSERT_EQ(SQL_SUCCESS, SQLFetch(stmt_)); - ASSERT_SQL_OK(SQLGetData(stmt_, 1, SQL_C_SLONG, &value, sizeof(value), &ind), SQL_HANDLE_STMT, - stmt_); - EXPECT_EQ(2, value); - - EXPECT_EQ(SQL_NO_DATA, SQLMoreResults(stmt_)); -} - - -} // namespace - -int main(int argc, char** argv) { - ::testing::InitGoogleTest(&argc, argv); - if (!LoadDriver(Api())) { - fprintf(stderr, "warning: %s\n", Api().load_error.c_str()); - } - return RUN_ALL_TESTS(); -} \ No newline at end of file