diff --git a/mssql-odbc/src/api/disconnect.rs b/mssql-odbc/src/api/disconnect.rs index f1a76f17..5464dc87 100644 --- a/mssql-odbc/src/api/disconnect.rs +++ b/mssql-odbc/src/api/disconnect.rs @@ -92,6 +92,7 @@ mod tests { SQL_ATTR_ODBC_VERSION, SQL_HANDLE_DBC, SQL_HANDLE_ENV, SQL_NULL_HANDLE, SQL_OV_ODBC3_80, }; use crate::api::set_env_attr::sql_set_env_attr; + use crate::test_support::TestHandles; #[test] fn disconnect_when_not_connected() { @@ -130,4 +131,17 @@ mod tests { assert_eq!(ret, SQL_INVALID_HANDLE); // TODO: verify SQLSTATE HY009 via SQLGetDiagRec } + + #[test] + fn connected_disconnect_succeeds() { + let h = TestHandles::with_env_dbc(); + h.mark_dbc_connected(); + + assert_eq!(unsafe { sql_disconnect(h.dbc) }, SQL_SUCCESS); + + let dbc = unsafe { handle_from_raw::(h.dbc) }; + let state = dbc.inner.lock().unwrap(); + assert_eq!(state.connection_state, ConnectionState::Disconnected); + assert!(state.active_stmt.is_none()); + } } diff --git a/mssql-odbc/src/api/exec_direct.rs b/mssql-odbc/src/api/exec_direct.rs index 0c9a56ce..5732a5a3 100644 --- a/mssql-odbc/src/api/exec_direct.rs +++ b/mssql-odbc/src/api/exec_direct.rs @@ -261,6 +261,7 @@ fn clear_exec_started(stmt: &StmtHandle) { mod tests { use super::*; use crate::api::odbc_types::{SQL_NTS, SQL_NULL_HANDLE}; + use crate::handles::dbc::DbcHandle; use crate::test_support::TestHandles; #[test] @@ -293,4 +294,46 @@ mod tests { // DBC is not connected assert_eq!(ret, SQL_ERROR); } + + #[test] + fn connected_without_client_returns_error() { + let h = TestHandles::with_env_dbc_stmt(); + h.mark_dbc_connected(); + + let sql: Vec = "SELECT 1".encode_utf16().chain(Some(0)).collect(); + assert_eq!( + unsafe { sql_exec_direct_w(h.stmt, sql.as_ptr(), SQL_NTS) }, + SQL_ERROR + ); + + let stmt = unsafe { handle_from_raw::(h.stmt) }; + let stmt_state = stmt.inner.lock().unwrap(); + assert_eq!( + stmt_state.diag_records[0].sql_state, + ERR_NO_ACTIVE_TDS_CLIENT.state + ); + } + + #[test] + fn connected_but_busy_with_other_statement_returns_error() { + let mut h = TestHandles::with_env_dbc_stmt(); + h.mark_dbc_connected(); + let other_stmt = h.alloc_extra_stmt(); + + let dbc = unsafe { handle_from_raw::(h.dbc) }; + dbc.inner.lock().unwrap().active_stmt = Some(other_stmt); + + let sql: Vec = "SELECT 1".encode_utf16().chain(Some(0)).collect(); + assert_eq!( + unsafe { sql_exec_direct_w(h.stmt, sql.as_ptr(), SQL_NTS) }, + SQL_ERROR + ); + + let stmt = unsafe { handle_from_raw::(h.stmt) }; + let stmt_state = stmt.inner.lock().unwrap(); + assert_eq!( + stmt_state.diag_records[0].sql_state, + ERR_CONNECTION_BUSY.state + ); + } } diff --git a/mssql-odbc/src/api/exports.rs b/mssql-odbc/src/api/exports.rs index 90ab9f53..6c7930f6 100644 --- a/mssql-odbc/src/api/exports.rs +++ b/mssql-odbc/src/api/exports.rs @@ -519,3 +519,187 @@ pub unsafe extern "C" fn SQLCancel(_statement_handle: SqlHandle) -> SqlReturn { crate::init_tracing(); SQL_SUCCESS } + +#[cfg(test)] +mod tests { + use std::ptr; + + use super::*; + use crate::api::odbc_types::{SQL_DROP, SQL_HANDLE_ENV, SQL_INVALID_HANDLE, SQL_NULL_HANDLE}; + + /// Every delegating export forwards a null handle to its impl, which + /// uniformly reports `SQL_INVALID_HANDLE`. + #[test] + fn delegating_exports_reject_null_handle() { + let sql: Vec = "SELECT 1".encode_utf16().chain(Some(0)).collect(); + unsafe { + assert_eq!( + SQLFreeHandle(SQL_HANDLE_ENV, SQL_NULL_HANDLE), + SQL_INVALID_HANDLE + ); + assert_eq!( + SQLSetEnvAttr(SQL_NULL_HANDLE, 0, ptr::null_mut(), 0), + SQL_INVALID_HANDLE + ); + assert_eq!( + SQLGetDiagRecW( + SQL_HANDLE_ENV, + SQL_NULL_HANDLE, + 1, + ptr::null_mut(), + ptr::null_mut(), + ptr::null_mut(), + 0, + ptr::null_mut(), + ), + SQL_INVALID_HANDLE + ); + assert_eq!( + SQLGetDiagFieldW( + SQL_HANDLE_ENV, + SQL_NULL_HANDLE, + 1, + 0, + ptr::null_mut(), + 0, + ptr::null_mut(), + ), + SQL_INVALID_HANDLE + ); + assert_eq!( + SQLDriverConnectW( + SQL_NULL_HANDLE, + ptr::null_mut(), + ptr::null(), + 0, + ptr::null_mut(), + 0, + ptr::null_mut(), + 0, + ), + SQL_INVALID_HANDLE + ); + assert_eq!(SQLDisconnect(SQL_NULL_HANDLE), SQL_INVALID_HANDLE); + assert_eq!( + SQLSetConnectAttrW(SQL_NULL_HANDLE, 0, ptr::null_mut(), 0), + SQL_INVALID_HANDLE + ); + assert_eq!(SQLCloseCursor(SQL_NULL_HANDLE), SQL_INVALID_HANDLE); + assert_eq!(SQLFreeStmt(SQL_NULL_HANDLE, SQL_CLOSE), SQL_INVALID_HANDLE); + assert_eq!( + SQLPrepareW(SQL_NULL_HANDLE, ptr::null(), 0), + SQL_INVALID_HANDLE + ); + assert_eq!( + SQLExecDirectW(SQL_NULL_HANDLE, sql.as_ptr(), 0), + SQL_INVALID_HANDLE + ); + assert_eq!(SQLFetch(SQL_NULL_HANDLE), SQL_INVALID_HANDLE); + assert_eq!( + SQLNumResultCols(SQL_NULL_HANDLE, ptr::null_mut()), + SQL_INVALID_HANDLE + ); + assert_eq!( + SQLDescribeColW( + SQL_NULL_HANDLE, + 1, + ptr::null_mut(), + 0, + ptr::null_mut(), + ptr::null_mut(), + ptr::null_mut(), + ptr::null_mut(), + ptr::null_mut(), + ), + SQL_INVALID_HANDLE + ); + assert_eq!( + SQLGetData(SQL_NULL_HANDLE, 1, 0, ptr::null_mut(), 0, ptr::null_mut()), + SQL_INVALID_HANDLE + ); + assert_eq!(SQLMoreResults(SQL_NULL_HANDLE), SQL_INVALID_HANDLE); + } + } + + /// `SQLAllocHandle` validates its output pointer before touching the parent + /// handle; a null output pointer is `SQL_INVALID_HANDLE`. + #[test] + fn alloc_handle_rejects_null_output() { + let ret = unsafe { SQLAllocHandle(SQL_HANDLE_ENV, SQL_NULL_HANDLE, ptr::null_mut()) }; + assert_eq!(ret, SQL_INVALID_HANDLE); + } + + /// Round-trips an ENV handle through the exported alloc/free wrappers. + #[test] + fn alloc_and_free_env_handle() { + let mut env: SqlHandle = SQL_NULL_HANDLE; + assert_eq!( + unsafe { SQLAllocHandle(SQL_HANDLE_ENV, SQL_NULL_HANDLE, &mut env) }, + SQL_SUCCESS + ); + assert!(!env.is_null()); + assert_eq!(unsafe { SQLFreeHandle(SQL_HANDLE_ENV, env) }, SQL_SUCCESS); + } + + /// Not-yet-implemented stubs succeed unconditionally regardless of handle. + #[test] + fn stub_exports_return_success() { + unsafe { + let mut row_count: i64 = -1; + assert_eq!(SQLRowCount(SQL_NULL_HANDLE, &mut row_count), SQL_SUCCESS); + assert_eq!(row_count, 0); + + assert_eq!( + SQLGetConnectAttrW(SQL_NULL_HANDLE, 0, ptr::null_mut(), 0, ptr::null_mut()), + SQL_SUCCESS + ); + assert_eq!( + SQLSetStmtAttrW(SQL_NULL_HANDLE, 0, ptr::null_mut(), 0), + SQL_SUCCESS + ); + assert_eq!( + SQLGetStmtAttrW(SQL_NULL_HANDLE, 0, ptr::null_mut(), 0, ptr::null_mut()), + SQL_SUCCESS + ); + assert_eq!( + SQLGetDescFieldW(SQL_NULL_HANDLE, 0, 0, ptr::null_mut(), 0, ptr::null_mut()), + SQL_SUCCESS + ); + assert_eq!( + SQLBindParameter( + SQL_NULL_HANDLE, + 1, + 0, + 0, + 0, + 0, + 0, + ptr::null_mut(), + 0, + ptr::null_mut(), + ), + SQL_SUCCESS + ); + assert_eq!(SQLCancel(SQL_NULL_HANDLE), SQL_SUCCESS); + } + } + + /// `SQLRowCount` skips the write when the output pointer is null. + #[test] + fn row_count_tolerates_null_out_pointer() { + assert_eq!( + unsafe { SQLRowCount(SQL_NULL_HANDLE, ptr::null_mut()) }, + SQL_SUCCESS + ); + } + + /// `SQLFreeStmt` only implements `SQL_CLOSE`; other options hit the default + /// arm and succeed without delegating. + #[test] + fn free_stmt_non_close_option_succeeds() { + assert_eq!( + unsafe { SQLFreeStmt(SQL_NULL_HANDLE, SQL_DROP) }, + SQL_SUCCESS + ); + } +} diff --git a/mssql-odbc/src/api/more_results.rs b/mssql-odbc/src/api/more_results.rs index fb49f2a4..d940e145 100644 --- a/mssql-odbc/src/api/more_results.rs +++ b/mssql-odbc/src/api/more_results.rs @@ -166,3 +166,63 @@ fn sql_more_results_safe(statement_handle: SqlHandle, stmt: &StmtHandle) -> SqlR } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::odbc_types::SQL_NULL_HANDLE; + use crate::handles::dbc::DbcHandle; + use crate::test_support::TestHandles; + + #[test] + fn null_handle_returns_invalid_handle() { + assert_eq!( + unsafe { sql_more_results(SQL_NULL_HANDLE) }, + SQL_INVALID_HANDLE + ); + } + + #[test] + fn no_cursor_open_returns_no_data() { + let h = TestHandles::with_env_dbc_stmt(); + assert_eq!(unsafe { sql_more_results(h.stmt) }, SQL_NO_DATA); + } + + #[test] + fn busy_with_other_statement_returns_error() { + let mut h = TestHandles::with_env_dbc_stmt(); + let other_stmt = h.alloc_extra_stmt(); + + let stmt = unsafe { handle_from_raw::(h.stmt) }; + stmt.inner.lock().unwrap().set_state(STMT_STATE_CURSOR_OPEN); + + let dbc = unsafe { handle_from_raw::(h.dbc) }; + dbc.inner.lock().unwrap().active_stmt = Some(other_stmt); + + assert_eq!(unsafe { sql_more_results(h.stmt) }, SQL_ERROR); + + let stmt_state = stmt.inner.lock().unwrap(); + assert_eq!(stmt_state.diag_records.len(), 1); + assert_eq!( + stmt_state.diag_records[0].sql_state, + ERR_CONNECTION_BUSY.state + ); + } + + #[test] + fn cursor_open_without_client_returns_error() { + let h = TestHandles::with_env_dbc_stmt(); + + let stmt = unsafe { handle_from_raw::(h.stmt) }; + stmt.inner.lock().unwrap().set_state(STMT_STATE_CURSOR_OPEN); + + assert_eq!(unsafe { sql_more_results(h.stmt) }, SQL_ERROR); + + let stmt_state = stmt.inner.lock().unwrap(); + assert_eq!(stmt_state.diag_records.len(), 1); + assert_eq!( + stmt_state.diag_records[0].sql_state, + ERR_NO_ACTIVE_TDS_CLIENT.state + ); + } +} diff --git a/mssql-py-core/src/row_writer.rs b/mssql-py-core/src/row_writer.rs index 78ac8f1a..bf78bda0 100644 --- a/mssql-py-core/src/row_writer.rs +++ b/mssql-py-core/src/row_writer.rs @@ -144,3 +144,78 @@ impl RowWriter for PyRowWriter { // No-op — caller takes the row after each decode cycle. } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn write_methods_accumulate_column_values() { + let mut w = PyRowWriter::new(0); + w.write_null(0); + w.write_bool(0, true); + w.write_u8(0, 7); + w.write_i16(0, -3); + w.write_i32(0, 42); + w.write_i64(0, 1 << 40); + w.write_f32(0, 1.5); + w.write_f64(0, 2.5); + w.write_string(0, SqlString::from_utf8_string("hi".into())); + w.write_bytes(0, vec![1, 2, 3]); + w.write_decimal(0, DecimalParts::from_string("1.5", 2, 1).unwrap()); + w.write_numeric(0, DecimalParts::from_string("2.5", 2, 1).unwrap()); + w.write_date(0, SqlDate::create(1).unwrap()); + let time = SqlTime { + time_nanoseconds: 1, + scale: 7, + }; + w.write_time(0, time.clone()); + w.write_datetime(0, SqlDateTime { days: 1, time: 2 }); + w.write_smalldatetime(0, SqlSmallDateTime { days: 1, time: 2 }); + let dt2 = SqlDateTime2 { + days: 1, + time: time.clone(), + }; + w.write_datetime2(0, dt2.clone()); + w.write_datetimeoffset( + 0, + SqlDateTimeOffset { + datetime2: dt2, + offset: 60, + }, + ); + w.write_money(0, SqlMoney::from(10_000)); + w.write_smallmoney(0, SqlSmallMoney::from(5_000)); + w.write_uuid(0, Uuid::from_u128(0)); + w.write_xml(0, SqlXml::from("".to_string())); + w.write_json(0, SqlJson::new(b"{}".to_vec())); + w.write_vector(0, SqlVector::try_from_f32(vec![1.0, 2.0]).unwrap()); + w.end_row(); + + assert!(matches!(w.row[0], ColumnValues::Null)); + assert!(matches!(w.row[1], ColumnValues::Bit(true))); + assert!(matches!(w.row[2], ColumnValues::TinyInt(7))); + assert!(matches!(w.row[3], ColumnValues::SmallInt(-3))); + assert!(matches!(w.row[4], ColumnValues::Int(42))); + assert!(matches!(w.row[5], ColumnValues::BigInt(_))); + assert!(matches!(w.row[6], ColumnValues::Real(_))); + assert!(matches!(w.row[7], ColumnValues::Float(_))); + assert!(matches!(w.row[8], ColumnValues::String(_))); + assert!(matches!(w.row[9], ColumnValues::Bytes(_))); + assert!(matches!(w.row[10], ColumnValues::Decimal(_))); + assert!(matches!(w.row[11], ColumnValues::Numeric(_))); + assert!(matches!(w.row[12], ColumnValues::Date(_))); + assert!(matches!(w.row[13], ColumnValues::Time(_))); + assert!(matches!(w.row[14], ColumnValues::DateTime(_))); + assert!(matches!(w.row[15], ColumnValues::SmallDateTime(_))); + assert!(matches!(w.row[16], ColumnValues::DateTime2(_))); + assert!(matches!(w.row[17], ColumnValues::DateTimeOffset(_))); + assert!(matches!(w.row[18], ColumnValues::Money(_))); + assert!(matches!(w.row[19], ColumnValues::SmallMoney(_))); + assert!(matches!(w.row[20], ColumnValues::Uuid(_))); + assert!(matches!(w.row[21], ColumnValues::Xml(_))); + assert!(matches!(w.row[22], ColumnValues::Json(_))); + assert!(matches!(w.row[23], ColumnValues::Vector(_))); + assert_eq!(w.row.len(), 24); + } +} diff --git a/mssql-py-core/src/types.rs b/mssql-py-core/src/types.rs index 0b7109bb..5d031a83 100644 --- a/mssql-py-core/src/types.rs +++ b/mssql-py-core/src/types.rs @@ -824,4 +824,56 @@ mod tests { assert_eq!((h, m, s), (16, 33, 33)); assert_eq!(us, 123_333); } + + fn meta(sql_type: SqlDbType) -> BulkCopyColumnMetadata { + BulkCopyColumnMetadata::new("c", sql_type, 0) + } + + #[test] + fn compatible_types_validate_ok() { + let cases = [ + (ColumnValues::TinyInt(1), SqlDbType::TinyInt), + (ColumnValues::SmallInt(1), SqlDbType::SmallInt), + (ColumnValues::Int(1), SqlDbType::Int), + (ColumnValues::BigInt(1), SqlDbType::BigInt), + (ColumnValues::Float(1.0), SqlDbType::Float), + (ColumnValues::Real(1.0), SqlDbType::Real), + ( + ColumnValues::Numeric(DecimalParts::from_string("1", 1, 0).unwrap()), + SqlDbType::Decimal, + ), + (ColumnValues::Bit(true), SqlDbType::Bit), + (ColumnValues::Bytes(vec![1]), SqlDbType::VarBinary), + ( + ColumnValues::String(SqlString::from_utf8_string("x".into())), + SqlDbType::NVarChar, + ), + ]; + for (value, sql_type) in cases { + assert!(validate_type_compatibility(&value, &meta(sql_type)).is_ok()); + } + } + + #[test] + fn null_is_compatible_with_any_type() { + assert!(validate_type_compatibility(&ColumnValues::Null, &meta(SqlDbType::Int)).is_ok()); + } + + #[test] + fn variant_accepts_scalar_but_rejects_xml() { + assert!( + validate_type_compatibility(&ColumnValues::Int(1), &meta(SqlDbType::Variant)).is_ok() + ); + let xml = ColumnValues::Xml(mssql_tds::datatypes::column_values::SqlXml::from( + "".to_string(), + )); + assert!(validate_type_compatibility(&xml, &meta(SqlDbType::Variant)).is_err()); + } + + #[test] + fn mismatched_types_return_usage_error() { + let err = validate_type_compatibility(&ColumnValues::Int(1), &meta(SqlDbType::VarChar)) + .unwrap_err(); + assert!(matches!(err, Error::UsageError(_))); + } }