From f152b48deaef87ed3eda08e9b63c87415440551b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Minh=20L=C3=AA?= <115204145+DucMinhNe@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:04:33 +0700 Subject: [PATCH 01/16] fix: correct 'occured' typo in I/O error message and doc comment (cherry picked from commit d5dabee36f632c0a955d554d438308d58c2a7e60) (cherry picked from commit 352dc9e824120e8e571e119be8baf83f206dc555) --- src/error.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/error.rs b/src/error.rs index 98bf01b58..504ca8c18 100644 --- a/src/error.rs +++ b/src/error.rs @@ -8,8 +8,8 @@ use thiserror::Error; /// the lifecycle of this driver #[derive(Debug, Clone, Error, PartialEq, Eq)] pub enum Error { - #[error("An error occured during the attempt of performing I/O: {}", message)] - /// An error occured when performing I/O to the server. + #[error("An error occurred during the attempt of performing I/O: {}", message)] + /// An error occurred when performing I/O to the server. Io { /// A list specifying general categories of I/O error. kind: IoErrorKind, From 0396167cdaaaf01ff1b14cfdec1f74224d66a9d6 Mon Sep 17 00:00:00 2001 From: ingrese1nombre <93015563+ingrese1nombre@users.noreply.github.com> Date: Wed, 2 Jul 2025 01:05:06 +0000 Subject: [PATCH 02/16] fix: improve QueryStream::into_results to better handle empty results. (#380) (cherry picked from commit a9eb0bf66ff387fd352ab76193a40661e0d8dd09) (cherry picked from commit dae6bdb4a290ae856bad7ecbc71eefe7cebe7b88) --- src/tds/stream/query.rs | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/src/tds/stream/query.rs b/src/tds/stream/query.rs index 0dc694749..8522cf4a0 100644 --- a/src/tds/stream/query.rs +++ b/src/tds/stream/query.rs @@ -222,28 +222,21 @@ impl<'a> QueryStream<'a> { /// of querying. pub async fn into_results(mut self) -> crate::Result>> { let mut results: Vec> = Vec::new(); - let mut result: Option> = None; + let mut result: Vec = if self.try_next().await?.is_some() { + Vec::new() + } else { + return Ok(results); + }; while let Some(item) = self.try_next().await? { - match (item, &mut result) { - (QueryItem::Row(row), None) => { - result = Some(vec![row]); - } - (QueryItem::Row(row), Some(ref mut result)) => result.push(row), - (QueryItem::Metadata(_), None) => { - result = Some(Vec::new()); - } - (QueryItem::Metadata(_), ref mut previous_result) => { - results.push(previous_result.take().unwrap()); - result = None; - } + if let QueryItem::Row(row) = item { + result.push(row); + } else { + results.push(result); + result = Vec::new(); } } - - if let Some(result) = result { - results.push(result); - } - + results.push(result); Ok(results) } From 977e8c0fc42b53abee0ab271e39e0991d0c4095a Mon Sep 17 00:00:00 2001 From: "Christopher H. Jordan" Date: Fri, 12 Sep 2025 17:04:14 +0800 Subject: [PATCH 03/16] fix: Allow column names like 'End' to be used This commit surrounds column names with square brackets so that any conflicts with SQL keywords don't cause errors. Looking at other PRs it looks like this also allows column names with spaces in them to be used. (cherry picked from commit b95ced7828e7bbb45fd3ae557120164f44d9548d) (cherry picked from commit ff65ebcbe4335dacecfac0f51f9cd8d6b5bd5e91) --- src/tds/codec/token/token_col_metadata.rs | 2 +- tests/bulk.rs | 34 +++++++++++++++++++++++ tests/query.rs | 27 ++++++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/tds/codec/token/token_col_metadata.rs b/src/tds/codec/token/token_col_metadata.rs index 53ffdf1c6..6d49938dd 100644 --- a/src/tds/codec/token/token_col_metadata.rs +++ b/src/tds/codec/token/token_col_metadata.rs @@ -25,7 +25,7 @@ pub struct MetaDataColumn<'a> { impl<'a> Display for MetaDataColumn<'a> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{} ", self.col_name)?; + write!(f, "[{}] ", self.col_name)?; match &self.base.ty { TypeInfo::FixedLen(fixed) => match fixed { diff --git a/tests/bulk.rs b/tests/bulk.rs index 33b90637a..08513b682 100644 --- a/tests/bulk.rs +++ b/tests/bulk.rs @@ -4,6 +4,7 @@ use once_cell::sync::Lazy; use std::cell::RefCell; use std::env; use std::sync::Once; +use tiberius::ColumnData; use tiberius::{IntoSql, Result, TokenRow}; #[cfg(all(feature = "tds73", feature = "chrono"))] @@ -218,3 +219,36 @@ test_bulk_type!(datetime2_7( 100, vec![DateTime::from_timestamp(1658524194, 123456789); 100].into_iter() )); + +#[test_on_runtimes] +async fn read_and_write_to_keyword_columns(mut conn: tiberius::Client) -> Result<()> +where + S: AsyncRead + AsyncWrite + Unpin + Send, +{ + let table = format!("##{}", random_table().await); + + conn.simple_query(format!("CREATE TABLE {} ([End] INT)", table)) + .await?; + + let mut req = conn.bulk_insert(&table).await.unwrap(); + for num in [6, 7, 8] { + let mut row = TokenRow::new(); + row.push(ColumnData::I32(Some(num))); + req.send(row).await.unwrap(); + } + let result = req.finalize().await.unwrap(); + assert_eq!(result.rows_affected(), &[3]); + + let rows = conn + .query(format!("SELECT [End] FROM {}", table), &[]) + .await? + .into_first_result() + .await?; + + assert_eq!(rows.len(), 3); + assert_eq!(Some(6), rows[0].get(0)); + assert_eq!(Some(7), rows[1].get(0)); + assert_eq!(Some(8), rows[2].get(0)); + + Ok(()) +} diff --git a/tests/query.rs b/tests/query.rs index 0a7b120e4..0cea71ebe 100644 --- a/tests/query.rs +++ b/tests/query.rs @@ -400,6 +400,33 @@ where Ok(()) } +#[test_on_runtimes] +async fn read_and_write_to_keyword_columns(mut conn: tiberius::Client) -> Result<()> +where + S: AsyncRead + AsyncWrite + Unpin + Send, +{ + let table = format!("##{}", random_table().await); + + conn.simple_query(format!("CREATE TABLE {} ([End] INT)", table)) + .await?; + + let res = conn + .execute(format!("INSERT INTO {} ([End]) VALUES (5)", table), &[]) + .await?; + + assert_eq!(1, res.total()); + + let rows = conn + .query(format!("SELECT [End] FROM {}", table), &[]) + .await? + .into_first_result() + .await?; + + assert_eq!(Some(5), rows[0].get(0)); + + Ok(()) +} + #[test_on_runtimes] async fn execute_insert_update_delete(mut conn: tiberius::Client) -> Result<()> where From 33ac4304267484e87729e605711aadb33d7bbd08 Mon Sep 17 00:00:00 2001 From: "Christian W. Zuckschwerdt" Date: Tue, 23 Sep 2025 10:51:11 +0200 Subject: [PATCH 04/16] Fix sign and padding in string format for negative Numeric (cherry picked from commit 895ae394471e6f22440cd7b0e30f6af5e25b4734) (cherry picked from commit abb378a7b3142d5a78e4d3b3afdb4ad22dd2bd20) --- src/tds/numeric.rs | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/tds/numeric.rs b/src/tds/numeric.rs index e4eff9ceb..109d040f8 100644 --- a/src/tds/numeric.rs +++ b/src/tds/numeric.rs @@ -186,9 +186,10 @@ impl Debug for Numeric { fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), fmt::Error> { write!( f, - "{}.{:0pad$}", - self.int_part(), - self.dec_part(), + "{}{}.{:0pad$}", + if self.value() < 0 { "-" } else { "" }, + self.int_part().abs(), + self.dec_part().abs(), pad = self.scale as usize ) } @@ -368,6 +369,24 @@ mod tests { assert_eq!(n.dec_part(), 5); } + #[test] + fn numeric_to_string() { + assert_eq!(Numeric::new_with_scale(123, 0).to_string(), "123.0"); + assert_eq!(Numeric::new_with_scale(123, 1).to_string(), "12.3"); + assert_eq!(Numeric::new_with_scale(123, 2).to_string(), "1.23"); + assert_eq!(Numeric::new_with_scale(123, 3).to_string(), "0.123"); + assert_eq!(Numeric::new_with_scale(123, 4).to_string(), "0.0123"); + assert_eq!(Numeric::new_with_scale(123, 36).to_string(), "0.000000000000000000000000000000000123"); + assert_eq!(Numeric::new_with_scale(123, 37).to_string(), "0.0000000000000000000000000000000000123"); + assert_eq!(Numeric::new_with_scale(-123, 0).to_string(), "-123.0"); + assert_eq!(Numeric::new_with_scale(-123, 1).to_string(), "-12.3"); + assert_eq!(Numeric::new_with_scale(-123, 2).to_string(), "-1.23"); + assert_eq!(Numeric::new_with_scale(-123, 3).to_string(), "-0.123"); + assert_eq!(Numeric::new_with_scale(-123, 4).to_string(), "-0.0123"); + assert_eq!(Numeric::new_with_scale(-123, 36).to_string(), "-0.000000000000000000000000000000000123"); + assert_eq!(Numeric::new_with_scale(-123, 37).to_string(), "-0.0000000000000000000000000000000000123"); + } + #[test] fn calculates_precision_correctly() { let n = Numeric::new_with_scale(57705, 2); From 660110cc87a0761052125ad197c99241401ccf0c Mon Sep 17 00:00:00 2001 From: Joel Parker Henderson Date: Mon, 24 Aug 2026 08:16:42 +0100 Subject: [PATCH 05/16] feat: helpers for IN lists and the 2100-parameter limit SQL Server has no array parameter, so an IN list must name one placeholder per value. Binding a comma-separated string to IN (@P1) matches nothing rather than failing, so every caller ends up writing the same format loop (#157). Adds four small, additive items to Query: Query::placeholders(first, count) -> String builds "@P1, @P2, @P3" Query::bind_iter(iter) binds each item in order Query::param_count() -> usize how many are bound Query::MAX_PARAMETERS: usize = 2100 the server's limit MAX_PARAMETERS matters most for exactly these runtime-sized statements: an IN list or a multi-row INSERT reaches the limit by data volume, on a batch that may be larger than any that was tested, and the server reports it only after the whole batch has been sent. No public API changes; nothing is renamed or removed. Eight unit tests and four doc tests, none of which need a server. (cherry picked from commit 9071362fbf98aebef9eb1226b632543450e5f557) (cherry picked from commit c90714abc70a4f374a39d968dbcf9f67e5c696e8) --- src/query.rs | 185 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) diff --git a/src/query.rs b/src/query.rs index 790052b4d..f31c54b1c 100644 --- a/src/query.rs +++ b/src/query.rs @@ -35,6 +35,119 @@ impl<'a> Query<'a> { self.params.push(param.into_sql()); } + /// Bind every item of an iterator, in order. + /// + /// Equivalent to calling [`bind`] once per item. Pairs with + /// [`placeholders`] to build an `IN` list, where the number of + /// parameters is only known at runtime. + /// + /// # Example + /// + /// ``` + /// # use tiberius::Query; + /// let ids = vec![1i32, 2, 3]; + /// + /// let sql = format!( + /// "SELECT name FROM users WHERE id IN ({})", + /// Query::placeholders(1, ids.len()), + /// ); + /// + /// let mut query = Query::new(sql); + /// query.bind_iter(ids); + /// + /// assert_eq!(query.param_count(), 3); + /// ``` + /// + /// [`bind`]: #method.bind + /// [`placeholders`]: #method.placeholders + pub fn bind_iter(&mut self, params: impl IntoIterator + 'a>) { + for param in params { + self.bind(param); + } + } + + /// How many parameters have been bound so far. + /// + /// Useful for checking against [`MAX_PARAMETERS`] before executing a + /// statement whose parameter count is decided at runtime. + /// + /// [`MAX_PARAMETERS`]: #associatedconstant.MAX_PARAMETERS + pub fn param_count(&self) -> usize { + self.params.len() + } + + /// The largest number of parameters SQL Server accepts in one statement. + /// + /// A statement carrying more is rejected by the server with + /// "The incoming request has too many parameters. The server supports a + /// maximum of 2100 parameters." — which arrives only after the whole + /// batch has been sent. + /// + /// This matters most for an `IN` list or a multi-row `INSERT`, where the + /// count comes from the length of a collection rather than from the SQL + /// text: the limit is reached by data volume, at run time, on a batch + /// that may be larger than any that was tested. Split such a batch into + /// chunks of at most `MAX_PARAMETERS / parameters_per_row` items. + /// + /// # Example + /// + /// ``` + /// # use tiberius::Query; + /// // A three-column INSERT: three parameters per row. + /// let rows_per_statement = Query::MAX_PARAMETERS / 3; + /// assert_eq!(rows_per_statement, 700); + /// ``` + pub const MAX_PARAMETERS: usize = 2100; + + /// Build a `@P1, @P2, …` placeholder list for `count` parameters, + /// numbered from `first`. + /// + /// SQL Server has no array parameter, so an `IN` list must name one + /// placeholder per value, and `IN (@P1)` bound to a comma-separated + /// string matches nothing rather than failing. Generating the list is + /// the only way to write such a query, and this does it without a + /// format loop at every call site. + /// + /// `first` is 1-based, matching the `@P1` numbering + /// [`Query::new`] documents. + /// + /// # Example + /// + /// ``` + /// # use tiberius::Query; + /// assert_eq!(Query::placeholders(1, 3), "@P1, @P2, @P3"); + /// + /// // Continuing after parameters that are already bound. + /// assert_eq!(Query::placeholders(4, 2), "@P4, @P5"); + /// ``` + /// + /// A count of zero yields an empty string. `IN ()` is a syntax error, so + /// a caller with nothing to match on should skip the query rather than + /// build one: + /// + /// ``` + /// # use tiberius::Query; + /// let ids: Vec = Vec::new(); + /// assert!(Query::placeholders(1, ids.len()).is_empty()); + /// ``` + /// + /// [`Query::new`]: #method.new + pub fn placeholders(first: usize, count: usize) -> String { + use std::fmt::Write; + + let mut out = String::with_capacity(count * 6); + + for index in 0..count { + if index > 0 { + out.push_str(", "); + } + // Writing into a String cannot fail. + let _ = write!(out, "@P{}", first + index); + } + + out + } + /// Executes SQL statements in the SQL Server, returning the number rows /// affected. Useful for `INSERT`, `UPDATE` and `DELETE` statements. See /// [`Client#execute`] for a simpler API if the parameters are statically @@ -136,3 +249,75 @@ impl<'a> Query<'a> { Ok(result) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn placeholders_are_numbered_from_one() { + assert_eq!(Query::placeholders(1, 1), "@P1"); + assert_eq!(Query::placeholders(1, 3), "@P1, @P2, @P3"); + } + + #[test] + fn placeholders_can_continue_from_an_offset() { + // For a query that already binds parameters before the list. + assert_eq!(Query::placeholders(4, 2), "@P4, @P5"); + assert_eq!(Query::placeholders(10, 1), "@P10"); + } + + #[test] + fn no_placeholders_is_an_empty_string() { + // `IN ()` is a syntax error, so a caller with nothing to match on + // must skip the query rather than build one. + assert_eq!(Query::placeholders(1, 0), ""); + assert_eq!(Query::placeholders(7, 0), ""); + } + + #[test] + fn placeholders_have_no_trailing_separator() { + let list = Query::placeholders(1, 5); + assert!(!list.ends_with(", ")); + assert_eq!(list.matches(',').count(), 4); + } + + #[test] + fn binding_an_iterator_counts_every_item() { + let mut query = Query::new("SELECT 1"); + assert_eq!(query.param_count(), 0); + + query.bind_iter(vec![1i32, 2, 3]); + assert_eq!(query.param_count(), 3); + + query.bind(4i32); + assert_eq!(query.param_count(), 4); + } + + #[test] + fn binding_an_empty_iterator_binds_nothing() { + let mut query = Query::new("SELECT 1"); + query.bind_iter(Vec::::new()); + assert_eq!(query.param_count(), 0); + } + + #[test] + fn a_generated_list_matches_the_number_of_bound_parameters() { + // The invariant that makes this pair usable: one placeholder per + // bound value, or the server rejects the statement. + let ids = vec![10i32, 20, 30, 40]; + let list = Query::placeholders(1, ids.len()); + + let mut query = Query::new(format!("SELECT * FROM t WHERE id IN ({list})")); + query.bind_iter(ids); + + assert_eq!(list.matches("@P").count(), query.param_count()); + } + + #[test] + fn the_parameter_limit_is_the_documented_tds_maximum() { + assert_eq!(Query::MAX_PARAMETERS, 2100); + // The chunking arithmetic the docs describe. + assert_eq!(Query::MAX_PARAMETERS / 3, 700); + } +} From c5151b4a9e251a877aa1d0e60a09114bea52b6c2 Mon Sep 17 00:00:00 2001 From: LazyDope Date: Mon, 17 Jul 2023 11:10:12 -0400 Subject: [PATCH 06/16] feat(row): get column data by idx (cherry picked from commit 7a201ca5ae42ac261c9de716107fafee90cd402c) (cherry picked from commit 2cb13e28d05996715dae528150ac76b7bb3bb3b2) --- src/row.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/row.rs b/src/row.rs index 7eaf79ff2..5b9fa1583 100644 --- a/src/row.rs +++ b/src/row.rs @@ -411,14 +411,23 @@ impl Row { where R: FromSql<'a>, I: QueryIdx, + { + let data = self.get_column_data(idx)?; + + R::from_sql(data) + } + + /// Retrieve a column's data for a given column index. + #[track_caller] + pub fn get_column_data<'a, I>(&'a self, idx: I) -> crate::Result> + where + I: QueryIdx, { let idx = idx.idx(self).ok_or_else(|| { Error::Conversion(format!("Could not find column with index {}", idx).into()) })?; - let data = self.data.get(idx).unwrap(); - - R::from_sql(data) + self.data.get(idx).unwrap() } } From 4bd457b3ca9748d996e246526cddfb37636fb8d5 Mon Sep 17 00:00:00 2001 From: LazyDope Date: Mon, 17 Jul 2023 11:17:33 -0400 Subject: [PATCH 07/16] fix(row): return result (cherry picked from commit 6c8d96b5edfa97a0960b7d0ec470e302c84ea849) (cherry picked from commit 9e65ac9d5fbc3b6d17a58b64b95d04c4a63e9a97) --- src/row.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/row.rs b/src/row.rs index 5b9fa1583..91263b0a4 100644 --- a/src/row.rs +++ b/src/row.rs @@ -419,7 +419,7 @@ impl Row { /// Retrieve a column's data for a given column index. #[track_caller] - pub fn get_column_data<'a, I>(&'a self, idx: I) -> crate::Result> + pub fn get_column_data<'a, I>(&'a self, idx: I) -> crate::Result<&'a ColumnData<'static>> where I: QueryIdx, { @@ -427,7 +427,7 @@ impl Row { Error::Conversion(format!("Could not find column with index {}", idx).into()) })?; - self.data.get(idx).unwrap() + Ok(self.data.get(idx).unwrap()) } } From 46765ab137b287a15887d4bde5fd2729d7932401 Mon Sep 17 00:00:00 2001 From: Alex Kasko Date: Wed, 31 Jul 2024 16:25:16 +0100 Subject: [PATCH 08/16] Fix header type for SSPI response message (cherry picked from commit 6dd26c0f7eb7e5a7defc3c3a63659f0b53a07766) (cherry picked from commit 50703ecba05b90aa2be80826cf66d0548c04278a) --- src/client/connection.rs | 2 +- src/tds/codec/header.rs | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/client/connection.rs b/src/client/connection.rs index 0038386f4..e9681159b 100644 --- a/src/client/connection.rs +++ b/src/client/connection.rs @@ -332,7 +332,7 @@ impl Connection { event!(Level::TRACE, sspi_response_len = sspi_response.len()); let id = self.context.next_packet_id(); - let header = PacketHeader::login(id); + let header = PacketHeader::sspi(id); let token = TokenSspi::new(sspi_response); self.send(header, token).await?; diff --git a/src/tds/codec/header.rs b/src/tds/codec/header.rs index cfabf2c55..a528d710a 100644 --- a/src/tds/codec/header.rs +++ b/src/tds/codec/header.rs @@ -92,6 +92,14 @@ impl PacketHeader { } } + pub fn sspi(id: u8) -> Self { + Self { + ty: PacketType::Sspi, + status: PacketStatus::EndOfMessage, + ..Self::new(0, id) + } + } + pub fn batch(id: u8) -> Self { Self { ty: PacketType::SQLBatch, From cf2526da17177cebde460ac4d567eb45b875071d Mon Sep 17 00:00:00 2001 From: John Dauphine Date: Fri, 2 Jan 2026 11:03:16 -0600 Subject: [PATCH 09/16] feat: add packet_size configuration for LOGIN7 message Add the ability to configure the TDS packet size in the LOGIN7 message. Larger packet sizes can significantly improve bulk insert performance by reducing network round-trips and protocol overhead. The default packet size remains 4096 bytes for backwards compatibility. Valid values are 512 to 32767 bytes. The server may negotiate a different size than requested. Example usage: ```rust let mut config = Config::new(); config.packet_size(32767); // Request 32KB packets ``` Performance testing showed that increasing packet size from 4KB to 16KB improved bulk insert throughput by ~40% (from 104K to 178K rows/sec for a 19.3M row dataset). (cherry picked from commit 23aedd621b72e8523dcced579c757a93c031c398) (cherry picked from commit 6110ce9f70425d6bfffd60c3aca92bb97ed2ea5d) --- src/client/config.rs | 18 ++++++++++++++++++ src/client/connection.rs | 6 ++++++ src/tds/codec/login.rs | 8 ++++++++ 3 files changed, 32 insertions(+) diff --git a/src/client/config.rs b/src/client/config.rs index 57374f8ce..3d6994f0f 100644 --- a/src/client/config.rs +++ b/src/client/config.rs @@ -32,6 +32,7 @@ pub struct Config { pub(crate) trust: TrustConfig, pub(crate) auth: AuthMethod, pub(crate) readonly: bool, + pub(crate) packet_size: Option, } #[derive(Clone, Debug)] @@ -65,6 +66,7 @@ impl Default for Config { trust: TrustConfig::Default, auth: AuthMethod::None, readonly: false, + packet_size: None, } } } @@ -115,6 +117,22 @@ impl Config { self.application_name = Some(name.to_string()); } + /// Sets the TDS packet size for the connection. + /// + /// Larger packet sizes can improve bulk insert performance by reducing + /// the number of network round-trips. Valid values are 512 to 32767. + /// The server may negotiate a different size. + /// + /// - Defaults to 4096 bytes. + pub fn packet_size(&mut self, size: u32) { + self.packet_size = Some(size); + } + + /// Gets the configured packet size, if set. + pub fn get_packet_size(&self) -> Option { + self.packet_size + } + /// Set the preferred encryption level. /// /// - With `tls` feature, defaults to `Required`. diff --git a/src/client/connection.rs b/src/client/connection.rs index e9681159b..2f90c0eb6 100644 --- a/src/client/connection.rs +++ b/src/client/connection.rs @@ -106,6 +106,7 @@ impl Connection { config.host, config.application_name, config.readonly, + config.packet_size, prelogin, ) .await?; @@ -293,6 +294,7 @@ impl Connection { server_name: Option, application_name: Option, readonly: bool, + packet_size: Option, prelogin: PreloginMessage, ) -> crate::Result { let mut login_message = LoginMessage::new(); @@ -311,6 +313,10 @@ impl Connection { login_message.readonly(readonly); + if let Some(size) = packet_size { + login_message.packet_size(size); + } + match auth { #[cfg(all(windows, feature = "winauth"))] AuthMethod::Integrated => { diff --git a/src/tds/codec/login.rs b/src/tds/codec/login.rs index bab14d736..73c6d6f2b 100644 --- a/src/tds/codec/login.rs +++ b/src/tds/codec/login.rs @@ -235,6 +235,14 @@ impl<'a> LoginMessage<'a> { self.type_flags.remove(LoginTypeFlag::ReadOnlyIntent); } } + + /// Sets the requested TDS packet size. + /// + /// Valid values are 512 to 32767. The server may negotiate a different size. + /// Larger packet sizes can improve bulk insert performance. + pub fn packet_size(&mut self, size: u32) { + self.packet_size = size; + } } impl<'a> Encode for LoginMessage<'a> { From e8fcaba0ec1309561cf7cad54b18970835bfaf29 Mon Sep 17 00:00:00 2001 From: Lukasz Sentkiewicz Date: Mon, 16 Mar 2026 09:37:44 +0100 Subject: [PATCH 10/16] Zeroize SQL auth password buffers (cherry picked from commit d191c1e964778c6d4c93750e77eac9c01db93092) (cherry picked from commit 01274b65248f600bfafb44d87b840ca743c24774) --- Cargo.toml | 1 + src/client/auth.rs | 35 ++++++++++++++++++++------ src/client/connection.rs | 53 +++++++++++++++++++++++++++++++++++++--- src/tds/codec/login.rs | 18 ++++++++++---- 4 files changed, 90 insertions(+), 17 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 42aa113c1..cd872a17f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,7 @@ async-trait = "0.1" connection-string = "0.2" num-traits = "0.2" uuid = "1.0" +zeroize = "1.8.2" [target.'cfg(windows)'.dependencies] winauth = { version = "0.0.4", optional = true } diff --git a/src/client/auth.rs b/src/client/auth.rs index 208d8d060..3abf42df8 100644 --- a/src/client/auth.rs +++ b/src/client/auth.rs @@ -1,18 +1,15 @@ use std::fmt::Debug; +use zeroize::Zeroizing; #[derive(Clone, PartialEq, Eq)] pub struct SqlServerAuth { user: String, - password: String, + password: Zeroizing, } impl SqlServerAuth { - pub(crate) fn user(&self) -> &str { - &self.user - } - - pub(crate) fn password(&self) -> &str { - &self.password + pub(crate) fn into_credentials(self) -> (String, Zeroizing) { + (self.user, self.password) } } @@ -79,7 +76,7 @@ impl AuthMethod { pub fn sql_server(user: impl ToString, password: impl ToString) -> Self { Self::SqlServer(SqlServerAuth { user: user.to_string(), - password: password.to_string(), + password: Zeroizing::new(password.to_string()), }) } @@ -104,3 +101,25 @@ impl AuthMethod { Self::AADToken(token.to_string()) } } + +#[cfg(test)] +mod tests { + use super::AuthMethod; + use zeroize::Zeroize; + + #[test] + fn sql_server_password_can_be_consumed_and_zeroized() { + let AuthMethod::SqlServer(auth) = AuthMethod::sql_server("sa", "secret") else { + unreachable!(); + }; + + let (user, mut password) = auth.into_credentials(); + + assert_eq!("sa", user); + assert_eq!("secret", password.as_str()); + + password.zeroize(); + + assert!(password.is_empty()); + } +} diff --git a/src/client/connection.rs b/src/client/connection.rs index 2f90c0eb6..dc73005ac 100644 --- a/src/client/connection.rs +++ b/src/client/connection.rs @@ -20,7 +20,7 @@ use asynchronous_codec::Framed; use bytes::BytesMut; #[cfg(any(windows, feature = "integrated-auth-gssapi"))] use codec::TokenSspi; -use futures_util::io::{AsyncRead, AsyncWrite}; +use futures_util::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; use futures_util::ready; use futures_util::sink::SinkExt; use futures_util::stream::{Stream, TryStream, TryStreamExt}; @@ -39,6 +39,7 @@ use task::Poll; use tracing::{event, Level}; #[cfg(all(windows, feature = "winauth"))] use winauth::{windows::NtlmSspiBuilder, NextBytes}; +use zeroize::{Zeroize, Zeroizing}; /// A `Connection` is an abstraction between the [`Client`] and the server. It /// can be used as a `Stream` to fetch [`Packet`]s from and to `send` packets @@ -197,6 +198,46 @@ impl Connection { Ok(()) } + async fn send_sensitive_login<'a>( + &mut self, + mut header: PacketHeader, + item: LoginMessage<'a>, + ) -> crate::Result<()> { + self.flushed = false; + let packet_size = (self.context.packet_size() as usize) - HEADER_BYTES; + let mut payload = item.encode_to_vec()?; + let mut offset = 0; + + while offset < payload.len() { + let end = cmp::min(payload.len(), offset + packet_size); + + if end == payload.len() { + header.set_status(PacketStatus::EndOfMessage); + } else { + header.set_status(PacketStatus::NormalMessage); + } + + let mut frame = Zeroizing::new(Vec::with_capacity(HEADER_BYTES + end - offset)); + header.encode(&mut *frame)?; + frame.extend_from_slice(&payload[offset..end]); + + let size = (frame.len() as u16).to_be_bytes(); + frame[2] = size[0]; + frame[3] = size[1]; + + event!(Level::TRACE, "Sending a packet ({} bytes)", frame.len(),); + + (&mut *self.transport).write_all(frame.as_slice()).await?; + frame.zeroize(); + payload[offset..end].zeroize(); + offset = end; + } + + (&mut *self.transport).flush().await?; + + Ok(()) + } + /// Sends a packet of data to the database. /// /// # Warning @@ -421,11 +462,15 @@ impl Connection { self = self.post_login_encryption(encryption); } AuthMethod::SqlServer(auth) => { - login_message.user_name(auth.user()); - login_message.password(auth.password()); + let (user, mut password) = auth.into_credentials(); + + login_message.user_name(user); + login_message.password(password.as_str()); let id = self.context.next_packet_id(); - self.send(PacketHeader::login(id), login_message).await?; + self.send_sensitive_login(PacketHeader::login(id), login_message) + .await?; + password.zeroize(); self = self.post_login_encryption(encryption); } AuthMethod::AADToken(token) => { diff --git a/src/tds/codec/login.rs b/src/tds/codec/login.rs index 73c6d6f2b..c92684f71 100644 --- a/src/tds/codec/login.rs +++ b/src/tds/codec/login.rs @@ -5,6 +5,7 @@ use enumflags2::{bitflags, BitFlags}; use io::{Cursor, Write}; use std::fmt::Debug; use std::{borrow::Cow, io}; +use zeroize::{Zeroize, Zeroizing}; uint_enum! { #[repr(u32)] @@ -243,10 +244,8 @@ impl<'a> LoginMessage<'a> { pub fn packet_size(&mut self, size: u32) { self.packet_size = size; } -} -impl<'a> Encode for LoginMessage<'a> { - fn encode(self, dst: &mut BytesMut) -> crate::Result<()> { + pub(crate) fn encode_to_vec(self) -> crate::Result>> { let mut cursor = Cursor::new(Vec::with_capacity(512)); // Space for the length @@ -369,7 +368,7 @@ impl<'a> Encode for LoginMessage<'a> { for codepoint in fed_auth_ext.fed_auth_token.encode_utf16() { token.write_u16::(codepoint)?; } - let token = token.into_inner(); + let mut token = token.into_inner(); // options (1) + TokenLength(4) + Token.length + nonce.length let feature_ext_length = @@ -386,6 +385,7 @@ impl<'a> Encode for LoginMessage<'a> { cursor.write_u32::(token.len() as u32)?; cursor.write_all(token.as_slice())?; + token.zeroize(); if let Some(nonce) = fed_auth_ext.nonce { cursor.write_all(nonce.as_ref())?; @@ -397,7 +397,15 @@ impl<'a> Encode for LoginMessage<'a> { cursor.set_position(0); cursor.write_u32::(cursor.get_ref().len() as u32)?; - dst.extend(cursor.into_inner()); + Ok(Zeroizing::new(cursor.into_inner())) + } +} + +impl<'a> Encode for LoginMessage<'a> { + fn encode(self, dst: &mut BytesMut) -> crate::Result<()> { + let mut encoded = self.encode_to_vec()?; + dst.extend_from_slice(encoded.as_slice()); + encoded.zeroize(); Ok(()) } From ca8ee0e0d22dbd4f3e37f1249eaecbc383fff08e Mon Sep 17 00:00:00 2001 From: Lukasz Sentkiewicz Date: Mon, 16 Mar 2026 13:33:47 +0100 Subject: [PATCH 11/16] Zeroize SQL password before async send (cherry picked from commit f49b35b4db2964337ba7b417031626568d2e6a5d) (cherry picked from commit 195ef3bf6ac4d58039c6f3cebe89b4d5c4eb82ff) --- src/client/connection.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/client/connection.rs b/src/client/connection.rs index dc73005ac..041a82007 100644 --- a/src/client/connection.rs +++ b/src/client/connection.rs @@ -198,14 +198,13 @@ impl Connection { Ok(()) } - async fn send_sensitive_login<'a>( + async fn send_sensitive_login( &mut self, mut header: PacketHeader, - item: LoginMessage<'a>, + mut payload: Zeroizing>, ) -> crate::Result<()> { self.flushed = false; let packet_size = (self.context.packet_size() as usize) - HEADER_BYTES; - let mut payload = item.encode_to_vec()?; let mut offset = 0; while offset < payload.len() { @@ -466,11 +465,12 @@ impl Connection { login_message.user_name(user); login_message.password(password.as_str()); + let payload = login_message.encode_to_vec()?; + password.zeroize(); let id = self.context.next_packet_id(); - self.send_sensitive_login(PacketHeader::login(id), login_message) + self.send_sensitive_login(PacketHeader::login(id), payload) .await?; - password.zeroize(); self = self.post_login_encryption(encryption); } AuthMethod::AADToken(token) => { From 24fb70331e161aaa9beab4917b285041cc74e7ba Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Sat, 29 Aug 2026 10:17:35 -0700 Subject: [PATCH 12/16] style: clippy/fmt cleanup after PR integration (#304, #351, #390, #411) - header.rs: allow(dead_code) on PacketHeader::sspi (platform/feature-gated use) - connection.rs: drop needless reborrow in the zeroizing send path - row.rs: elide lifetime on get_column_data - rustfmt negative-numeric formatting from #390 (cherry picked from commit 09079cabe5ff065155e3bf890df9195b5f63e223) --- src/client/connection.rs | 2 +- src/row.rs | 2 +- src/tds/codec/header.rs | 3 +++ src/tds/numeric.rs | 20 ++++++++++++++++---- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/client/connection.rs b/src/client/connection.rs index 041a82007..ae136fcc8 100644 --- a/src/client/connection.rs +++ b/src/client/connection.rs @@ -226,7 +226,7 @@ impl Connection { event!(Level::TRACE, "Sending a packet ({} bytes)", frame.len(),); - (&mut *self.transport).write_all(frame.as_slice()).await?; + self.transport.write_all(frame.as_slice()).await?; frame.zeroize(); payload[offset..end].zeroize(); offset = end; diff --git a/src/row.rs b/src/row.rs index 91263b0a4..3610b5818 100644 --- a/src/row.rs +++ b/src/row.rs @@ -419,7 +419,7 @@ impl Row { /// Retrieve a column's data for a given column index. #[track_caller] - pub fn get_column_data<'a, I>(&'a self, idx: I) -> crate::Result<&'a ColumnData<'static>> + pub fn get_column_data(&self, idx: I) -> crate::Result<&ColumnData<'static>> where I: QueryIdx, { diff --git a/src/tds/codec/header.rs b/src/tds/codec/header.rs index a528d710a..1ceda7556 100644 --- a/src/tds/codec/header.rs +++ b/src/tds/codec/header.rs @@ -92,6 +92,9 @@ impl PacketHeader { } } + // Only constructed on auth code paths gated behind platform/feature cfgs + // (Windows winauth / integrated-auth-gssapi), so it reads as dead on other builds. + #[allow(dead_code)] pub fn sspi(id: u8) -> Self { Self { ty: PacketType::Sspi, diff --git a/src/tds/numeric.rs b/src/tds/numeric.rs index 109d040f8..8866ebf27 100644 --- a/src/tds/numeric.rs +++ b/src/tds/numeric.rs @@ -376,15 +376,27 @@ mod tests { assert_eq!(Numeric::new_with_scale(123, 2).to_string(), "1.23"); assert_eq!(Numeric::new_with_scale(123, 3).to_string(), "0.123"); assert_eq!(Numeric::new_with_scale(123, 4).to_string(), "0.0123"); - assert_eq!(Numeric::new_with_scale(123, 36).to_string(), "0.000000000000000000000000000000000123"); - assert_eq!(Numeric::new_with_scale(123, 37).to_string(), "0.0000000000000000000000000000000000123"); + assert_eq!( + Numeric::new_with_scale(123, 36).to_string(), + "0.000000000000000000000000000000000123" + ); + assert_eq!( + Numeric::new_with_scale(123, 37).to_string(), + "0.0000000000000000000000000000000000123" + ); assert_eq!(Numeric::new_with_scale(-123, 0).to_string(), "-123.0"); assert_eq!(Numeric::new_with_scale(-123, 1).to_string(), "-12.3"); assert_eq!(Numeric::new_with_scale(-123, 2).to_string(), "-1.23"); assert_eq!(Numeric::new_with_scale(-123, 3).to_string(), "-0.123"); assert_eq!(Numeric::new_with_scale(-123, 4).to_string(), "-0.0123"); - assert_eq!(Numeric::new_with_scale(-123, 36).to_string(), "-0.000000000000000000000000000000000123"); - assert_eq!(Numeric::new_with_scale(-123, 37).to_string(), "-0.0000000000000000000000000000000000123"); + assert_eq!( + Numeric::new_with_scale(-123, 36).to_string(), + "-0.000000000000000000000000000000000123" + ); + assert_eq!( + Numeric::new_with_scale(-123, 37).to_string(), + "-0.0000000000000000000000000000000000123" + ); } #[test] From 1bf5b2ea62833e1ebfa7aff12c8117aae3eb1de7 Mon Sep 17 00:00:00 2001 From: Joel Parker Henderson Date: Sat, 29 Aug 2026 10:18:34 -0700 Subject: [PATCH 13/16] test: add docker/test-server.sh helper to spin up SQL Server locally Additive helper from upstream #430 (author Joel Parker Henderson): brings a SQL Server container up under podman or docker, defaults to arm64-friendly azure-sql-edge, and polls the log for readiness. (The cert-renewal part of #430 is already covered by #419.) (cherry picked from commit e22dbf9099e4ee0b0a39ad94b9120d4d62512eb8) --- docker/test-server.sh | 86 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100755 docker/test-server.sh diff --git a/docker/test-server.sh b/docker/test-server.sh new file mode 100755 index 000000000..a0239280c --- /dev/null +++ b/docker/test-server.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# +# Start a SQL Server for the test suite, with podman or docker. +# +# ./docker/test-server.sh up # build, start, wait until it accepts connections +# ./docker/test-server.sh down # stop and remove +# ./docker/test-server.sh logs # follow the server log +# +# Then: +# +# export TIBERIUS_TEST_CONNECTION_STRING='server=tcp:localhost,1433;user=SA;password=;IntegratedSecurity=true;TrustServerCertificate=true' +# cargo test +# +# IMAGE selects the flavour; the default works on both x86_64 and arm64. +# The full SQL Server images are x86_64 only, so on an arm64 machine +# (Apple silicon) they either refuse to run or run under emulation. + +set -euo pipefail + +ENGINE="${ENGINE:-$(command -v podman >/dev/null 2>&1 && echo podman || echo docker)}" +NAME="${NAME:-tiberius-test-mssql}" +PORT="${PORT:-1433}" +IMAGE="${IMAGE:-azure-sql-edge}" +PASSWORD='' +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +case "${1:-up}" in + up) + echo "engine: $ENGINE image: $IMAGE port: $PORT" + "$ENGINE" build -q -f "$HERE/docker-$IMAGE.dockerfile" -t "$NAME:local" "$HERE" + "$ENGINE" rm -f "$NAME" >/dev/null 2>&1 || true + "$ENGINE" run -d --name "$NAME" \ + -e ACCEPT_EULA=Y \ + -e "MSSQL_SA_PASSWORD=$PASSWORD" \ + -e "SA_PASSWORD=$PASSWORD" \ + -p "$PORT:1433" \ + "$NAME:local" >/dev/null + + # The port opens well before the server will answer, so poll the log + # rather than the socket. + # + # The log is captured into a variable and matched there, rather than + # piped into `grep -q`. Under `set -o pipefail`, `grep -q` exits on the + # first match, the writer upstream dies of SIGPIPE, and the pipeline + # reports failure even though the match succeeded — so the wait never + # ends. + echo -n "waiting for SQL Server" + for _ in $(seq 1 120); do + logs="$("$ENGINE" logs "$NAME" 2>&1 || true)" + + case "$logs" in + *"SQL Server is now ready for client connections"*) + echo " — ready" + exit 0 + ;; + esac + + running="$("$ENGINE" ps --format '{{.Names}}' || true)" + case "$running" in + *"$NAME"*) ;; + *) + echo " — container exited:" + "$ENGINE" logs --tail 30 "$NAME" || true + exit 1 + ;; + esac + + echo -n . + sleep 2 + done + echo " — gave up; last lines:" + "$ENGINE" logs --tail 30 "$NAME" + exit 1 + ;; + down) + "$ENGINE" rm -f "$NAME" >/dev/null 2>&1 || true + echo "removed $NAME" + ;; + logs) + "$ENGINE" logs -f "$NAME" + ;; + *) + echo "usage: $0 {up|down|logs}" >&2 + exit 2 + ;; +esac From c568fb9edf945c3bc4c29a71c85ccd2b3a749a8b Mon Sep 17 00:00:00 2001 From: Eric Sheppard Date: Sat, 29 Aug 2026 10:20:03 -0700 Subject: [PATCH 14/16] feat: implement IntoSql for rust_decimal Decimal (#401) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From upstream #376 (author Eric Sheppard) — the numeric.rs IntoSql impl only; the PR's stale edits to CI/TLS files (already covered by #419) are omitted. Lets a rust_decimal Decimal be bound directly in queries. (cherry picked from commit 54bf399b6c8de964d50cf558a70f9a6b24fb1433) --- src/tds/numeric.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/tds/numeric.rs b/src/tds/numeric.rs index 8866ebf27..f95e7cbad 100644 --- a/src/tds/numeric.rs +++ b/src/tds/numeric.rs @@ -264,6 +264,23 @@ mod decimal { Numeric::new_with_scale(value, self_.scale() as u8) }); ); + + #[cfg(feature = "tds73")] + into_sql!(self_, + Decimal: (ColumnData::Numeric, { + let unpacked = self_.unpack(); + + let mut value = (((unpacked.hi as u128) << 64) + + ((unpacked.mid as u128) << 32) + + unpacked.lo as u128) as i128; + + if self_.is_sign_negative() { + value = -value; + } + + Numeric::new_with_scale(value, self_.scale() as u8) + }); + ); } #[cfg(feature = "bigdecimal")] From bc2c4410fc9a20a4bddf80f498fad620f1b5da84 Mon Sep 17 00:00:00 2001 From: Thomas Johnson Date: Fri, 27 Sep 2024 02:57:43 +0200 Subject: [PATCH 15/16] Allow Bulk Insert for a specified list of columns (#311) Adds `bulk_insert_columns(self, table, columns)` and turns `bulk_insert(self, table)` into a compatibility shim that calls `self.bulk_insert_columns(table, &["*"])`, maintaining the existing behaviour. (cherry picked from commit 3ce3444eef3be5c95ef146dc0b0f7592748b2a3c) (cherry picked from commit 6ad9497960ba5897f2cc96c2dd8092bccfd3f11e) --- src/client.rs | 64 +++++++++++++++++++++++++++++++-- tests/bulk.rs | 98 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+), 3 deletions(-) diff --git a/src/client.rs b/src/client.rs index 688721d10..68ff32a73 100644 --- a/src/client.rs +++ b/src/client.rs @@ -251,10 +251,13 @@ impl Client { Ok(result) } - /// Execute a `BULK INSERT` statement, efficiantly storing a large number of + /// Execute a `BULK INSERT` statement, efficiently storing a large number of /// rows to a specified table. Note: make sure the input row follows the same /// schema as the table, otherwise calling `send()` will return an error. /// + /// This is equivalent to calling `bulk_insert("table_name", &["*"])` to merge + /// all of a tables columns. + /// /// # Example /// /// ``` @@ -299,12 +302,67 @@ impl Client { pub async fn bulk_insert<'a>( &'a mut self, table: &'a str, + ) -> crate::Result> { + self.bulk_insert_columns(table, &["*"]).await + } + + /// Execute a `BULK INSERT` statement, efficiently storing a large number of + /// rows to a specified table. Note: make sure the input row follows the same + /// schema as the column list, otherwise calling `send()` will return an error. + /// + /// # Example + /// + /// ``` + /// # use tiberius::{Config, IntoRow}; + /// # use tokio_util::compat::TokioAsyncWriteCompatExt; + /// # use std::env; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// # let c_str = env::var("TIBERIUS_TEST_CONNECTION_STRING").unwrap_or( + /// # "server=tcp:localhost,1433;integratedSecurity=true;TrustServerCertificate=true".to_owned(), + /// # ); + /// # let config = Config::from_ado_string(&c_str)?; + /// # let tcp = tokio::net::TcpStream::connect(config.get_addr()).await?; + /// # tcp.set_nodelay(true)?; + /// # let mut client = tiberius::Client::connect(config, tcp.compat_write()).await?; + /// let create_table = r#" + /// CREATE TABLE ##bulk_test ( + /// id INT IDENTITY PRIMARY KEY, + /// foo INT NOT NULL, + /// bar FLOAT NOT NULL + /// ) + /// "#; + /// + /// client.simple_query(create_table).await?; + /// + /// // Start the bulk insert with the client. + /// let mut req = client.bulk_insert_columns("##bulk_test", &["foo", "bar"]).await?; + /// + /// for (i, j) in [(0i32, 0f64), (1i32, 1f64), (2i32, 2f64)] { + /// let row = (i, j).into_row(); + /// + /// // The request will handle flushing to the wire in an optimal way, + /// // balancing between memory usage and IO performance. + /// req.send(row).await?; + /// } + /// + /// // The request must be finalized. + /// let res = req.finalize().await?; + /// assert_eq!(3, res.total()); + /// # Ok(()) + /// # } + /// ``` + pub async fn bulk_insert_columns<'a>( + &'a mut self, + table: &'a str, + columns: &'a [&'a str], ) -> crate::Result> { // Start the bulk request self.connection.flush_stream().await?; // retrieve column metadata from server - let query = format!("SELECT TOP 0 * FROM {}", table); + let columns = columns.join(", "); + let query = format!("SELECT TOP 0 {columns} FROM {table}"); let req = BatchRequest::new(query, self.connection.context().transaction_descriptor()); @@ -371,7 +429,7 @@ impl Client { &'a mut self, proc_id: RpcProcId, mut rpc_params: Vec>, - params: impl Iterator>, + params: impl Iterator>, ) -> crate::Result<()> where 'a: 'b, diff --git a/tests/bulk.rs b/tests/bulk.rs index 08513b682..bc1c2c8d3 100644 --- a/tests/bulk.rs +++ b/tests/bulk.rs @@ -252,3 +252,101 @@ where Ok(()) } + +macro_rules! test_bulk_columns { + ($name:ident($total_generated:literal $(, $sql_type:literal)+ $(, ($cols:expr, $generator:expr ))+ $(,)?)) => { + paste::item! { + #[test_on_runtimes] + async fn [< bulk_load_optional_ $name >](mut conn: tiberius::Client) -> Result<()> + where + S: AsyncRead + AsyncWrite + Unpin + Send, + { + use tiberius::IntoRow; + + let table = format!("##{}", random_table().await); + let column_defs = &[$($sql_type,)+]; + + conn.execute( + &format!( + "CREATE TABLE {} (id INT IDENTITY PRIMARY KEY, {})", + table, + column_defs.join(", "), + ), + &[], + ) + .await?; + + let mut count = 0; + + $( + let mut req = conn.bulk_insert_columns(&table, $cols).await?; + for i in $generator { + let row = i.into_row(); + req.send(row).await?; + } + + let res = req.finalize().await?; + count += res.total(); + )+ + assert_eq!($total_generated, count); + + Ok(()) + } + + #[test_on_runtimes] + async fn [< bulk_load_required_ $name >](mut conn: tiberius::Client) -> Result<()> + where + S: AsyncRead + AsyncWrite + Unpin + Send, + { + use tiberius::IntoRow; + let table = format!("##{}", random_table().await); + let column_defs = &[$(format!("{} NOT NULL", $sql_type),)+]; + + conn.execute( + &format!( + "CREATE TABLE {} (id INT IDENTITY PRIMARY KEY, {})", + table, + column_defs.join(", "), + ), + &[], + ) + .await?; + + let mut count = 0; + + $( + let mut req = conn.bulk_insert_columns(&table, $cols).await?; + for i in $generator { + let row = i.into_row(); + req.send(row).await?; + } + + let res = req.finalize().await?; + count += res.total(); + )+ + assert_eq!($total_generated, count); + + Ok(()) + } + + } + }; +} + +test_bulk_columns!(ab_ba_default_columns( + 200, + "a INT", + "b FLOAT", + "c INT DEFAULT 0", + (&["a", "b"], vec![(1i32, 1f64); 100]), + (&["b", "a"], vec![(2f64, 2i32); 100]), +)); + +test_bulk_columns!(ab_ba_override_default_columns( + 200, + "a INT", + "b FLOAT", + "c INT DEFAULT 0", + (&["a", "b", "c"], vec![(1i32, 1f64, 10i32); 100]), + (&["b", "c", "a"], vec![(2f64, 20i32, 2i32); 100]), +)); From b42bdfeb339d76e6daa232ef2a21b52ebbf75a8e Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Sat, 29 Aug 2026 10:21:02 -0700 Subject: [PATCH 16/16] style: rustfmt after #359 (cherry picked from commit b114498616affabce32ac58b3142b304099a1102) --- src/client.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client.rs b/src/client.rs index 68ff32a73..fd30a2dd0 100644 --- a/src/client.rs +++ b/src/client.rs @@ -429,7 +429,7 @@ impl Client { &'a mut self, proc_id: RpcProcId, mut rpc_params: Vec>, - params: impl Iterator>, + params: impl Iterator>, ) -> crate::Result<()> where 'a: 'b,