diff --git a/Cargo.toml b/Cargo.toml index 0caaac815..db58d48c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,11 @@ path = "tests/named-instance-smol.rs" name = "named-instance-smol" required-features = ["sql-browser-smol"] +[[test]] +path = "tests/serde.rs" +name = "serde" +required-features = ["serde"] + [dependencies] enumflags2 = "0.7" byteorder = "1.0" @@ -105,6 +110,11 @@ version = "0.3" optional = true package = "bigdecimal" +[dependencies.serde] +version = "1.0" +optional = true +features = ["derive", "rc"] + [dependencies.async-io] version = "1.8" optional = true @@ -174,6 +184,7 @@ paste = "1.0" indicatif = "0.17" chrono = "0.4.38" indoc = "1.0.7" +serde_json = "1.0" [package.metadata.docs.rs] features = ["all", "docs"] @@ -190,6 +201,7 @@ all = [ "rust_decimal", "bigdecimal", "native-tls", + "serde", ] default = ["tds73", "winauth", "native-tls"] tds73 = [] @@ -202,3 +214,6 @@ bigdecimal = ["bigdecimal_"] rustls = ["tokio-rustls", "tokio-util", "rustls-pemfile", "rustls-native-certs"] native-tls = ["async-native-tls"] vendored-openssl = ["opentls"] +# Optional serde Serialize/Deserialize impls for query result types +# (Row, Column, ColumnData, Numeric, ColumnType and time/xml types). +serde = ["dep:serde", "uuid/serde"] diff --git a/src/row.rs b/src/row.rs index 5441be700..3ceff9b9c 100644 --- a/src/row.rs +++ b/src/row.rs @@ -7,6 +7,7 @@ use std::{fmt::Display, sync::Arc}; /// A column of data from a query. #[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct Column { pub(crate) name: String, pub(crate) column_type: ColumnType, @@ -30,6 +31,7 @@ impl Column { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] /// The type of the column. pub enum ColumnType { /// The column doesn't have a specified type. @@ -246,6 +248,7 @@ impl From<&TypeInfo> for ColumnType { /// [`try_get`]: #method.try_get /// [`IntoIterator`]: #impl-IntoIterator #[derive(Debug)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct Row { pub(crate) columns: Arc>, pub(crate) data: TokenRow<'static>, diff --git a/src/tds/codec/column_data.rs b/src/tds/codec/column_data.rs index fecd83f75..516e6f461 100644 --- a/src/tds/codec/column_data.rs +++ b/src/tds/codec/column_data.rs @@ -37,6 +37,7 @@ use uuid::Uuid; const MAX_NVARCHAR_SIZE: usize = 1 << 30; #[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] /// A container of a value that can be represented as a TDS value. pub enum ColumnData<'a> { /// 8-bit integer, unsigned. diff --git a/src/tds/codec/token/token_row.rs b/src/tds/codec/token/token_row.rs index b1ff16b6c..230992c57 100644 --- a/src/tds/codec/token/token_row.rs +++ b/src/tds/codec/token/token_row.rs @@ -9,6 +9,7 @@ pub use into_row::IntoRow; /// A row of data. #[derive(Debug, Default, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct TokenRow<'a> { data: Vec>, } diff --git a/src/tds/numeric.rs b/src/tds/numeric.rs index 4f856bebb..e4bb3fcb2 100644 --- a/src/tds/numeric.rs +++ b/src/tds/numeric.rs @@ -19,6 +19,7 @@ use std::fmt::{self, Debug, Display, Formatter}; /// A recommended way of dealing with numeric values is by enabling the /// `rust_decimal` feature and using its `Decimal` type instead. #[derive(Copy, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct Numeric { value: i128, scale: u8, diff --git a/src/tds/time.rs b/src/tds/time.rs index 05a1c053c..2074b0cf5 100644 --- a/src/tds/time.rs +++ b/src/tds/time.rs @@ -43,6 +43,7 @@ use futures_util::io::AsyncReadExt; /// It isn't recommended to use this type directly. For dealing with `datetime`, /// use the `time` feature of this crate and its `PrimitiveDateTime` type. #[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct DateTime { days: i32, seconds_fragments: u32, @@ -99,6 +100,7 @@ impl Encode for DateTime { /// `smalldatetime`, use the `time` feature of this crate and its /// `PrimitiveDateTime` type. #[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct SmallDateTime { days: u16, seconds_fragments: u16, @@ -152,6 +154,7 @@ impl Encode for SmallDateTime { /// It isn't recommended to use this type directly. If you want to deal with /// `date`, use the `time` feature of this crate and its `Date` type. #[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg(feature = "tds73")] #[cfg_attr(feature = "docs", doc(cfg(feature = "tds73")))] pub struct Date(u32); @@ -205,6 +208,7 @@ impl Encode for Date { /// It isn't recommended to use this type directly. If you want to deal with /// `time`, use the `time` feature of this crate and its `Time` type. #[derive(Copy, Clone, Debug)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg(feature = "tds73")] #[cfg_attr(feature = "docs", doc(cfg(feature = "tds73")))] pub struct Time { @@ -318,6 +322,7 @@ impl Encode for Time { } #[derive(Copy, Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg(feature = "tds73")] #[cfg_attr(feature = "docs", doc(cfg(feature = "tds73")))] /// A presentation of `datetime2` type in the server. @@ -380,6 +385,7 @@ impl Encode for DateTime2 { } #[derive(Copy, Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg(feature = "tds73")] #[cfg_attr(feature = "docs", doc(cfg(feature = "tds73")))] /// A presentation of `datetimeoffset` type in the server. diff --git a/src/tds/xml.rs b/src/tds/xml.rs index 242be05f3..8e0dc9f1e 100644 --- a/src/tds/xml.rs +++ b/src/tds/xml.rs @@ -6,6 +6,7 @@ use std::sync::Arc; /// Provides information of the location for the schema. #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct XmlSchema { db_name: String, owner: String, @@ -45,6 +46,7 @@ impl XmlSchema { /// A representation of XML data in TDS. Holds the data as a UTF-8 string and /// and optional information about the schema. #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct XmlData { data: String, schema: Option>, diff --git a/tests/serde.rs b/tests/serde.rs new file mode 100644 index 000000000..dd85d8297 --- /dev/null +++ b/tests/serde.rs @@ -0,0 +1,195 @@ +//! Tests that verify the optional serde Serialize/Deserialize impls +//! gated behind the `serde` feature. +//! +//! These tests do not require a live SQL Server. They round-trip the +//! exposed result-set types (Row pieces, ColumnData, Numeric, time +//! types, etc.) through `serde_json` and assert the values survive. + +#![cfg(feature = "serde")] + +use std::borrow::Cow; +use std::sync::Arc; + +use tiberius::numeric::Numeric; +use tiberius::time::DateTime; +use tiberius::xml::XmlData; +use tiberius::{Column, ColumnData, ColumnType, TokenRow}; +use uuid::Uuid; + +#[cfg(feature = "tds73")] +use tiberius::time::{Date, DateTime2, DateTimeOffset, Time}; + +fn json_round_trip(value: &T) -> T +where + T: serde::Serialize + serde::de::DeserializeOwned, +{ + let s = serde_json::to_string(value).expect("serialize"); + serde_json::from_str(&s).expect("deserialize") +} + +#[test] +fn column_type_round_trip() { + let value = ColumnType::Int4; + let back: ColumnType = json_round_trip(&value); + assert_eq!(value, back); +} + +#[test] +fn column_round_trip() { + let value = Column::new("hello".to_string(), ColumnType::NVarchar); + let back: Column = json_round_trip(&value); + assert_eq!(back.name(), "hello"); + assert_eq!(back.column_type(), ColumnType::NVarchar); +} + +#[test] +fn column_data_int_round_trip() { + let value = ColumnData::I32(Some(42)); + let back: ColumnData<'static> = json_round_trip(&value); + assert_eq!(value, back); +} + +#[test] +fn column_data_null_round_trip() { + let value: ColumnData<'static> = ColumnData::I64(None); + let back: ColumnData<'static> = json_round_trip(&value); + assert_eq!(value, back); +} + +#[test] +fn column_data_string_round_trip() { + let value = ColumnData::String(Some(Cow::Borrowed("héllo"))); + let back: ColumnData<'static> = json_round_trip(&value); + // Borrowed inputs deserialize as Cow::Owned but values match. + match back { + ColumnData::String(Some(s)) => assert_eq!(s.as_ref(), "héllo"), + other => panic!("unexpected: {:?}", other), + } +} + +#[test] +fn column_data_binary_round_trip() { + let bytes: &[u8] = &[0xde, 0xad, 0xbe, 0xef]; + let value = ColumnData::Binary(Some(Cow::Borrowed(bytes))); + let back: ColumnData<'static> = json_round_trip(&value); + match back { + ColumnData::Binary(Some(b)) => assert_eq!(b.as_ref(), bytes), + other => panic!("unexpected: {:?}", other), + } +} + +#[test] +fn column_data_guid_round_trip() { + let id = Uuid::from_u128(0xfeed_face_dead_beef_0000_1111_2222_3333u128); + let value = ColumnData::Guid(Some(id)); + let back: ColumnData<'static> = json_round_trip(&value); + assert_eq!(value, back); +} + +#[test] +fn column_data_bool_round_trip() { + let value = ColumnData::Bit(Some(true)); + let back: ColumnData<'static> = json_round_trip(&value); + assert_eq!(value, back); +} + +#[test] +fn column_data_float_round_trip() { + let value = ColumnData::F64(Some(std::f64::consts::PI)); + let back: ColumnData<'static> = json_round_trip(&value); + assert_eq!(value, back); +} + +#[test] +fn numeric_round_trip() { + let value = Numeric::new_with_scale(57705, 2); + let back: Numeric = json_round_trip(&value); + assert_eq!(value, back); + assert_eq!(back.value(), 57705); + assert_eq!(back.scale(), 2); +} + +#[test] +fn column_data_numeric_round_trip() { + let value = ColumnData::Numeric(Some(Numeric::new_with_scale(12345, 3))); + let back: ColumnData<'static> = json_round_trip(&value); + assert_eq!(value, back); +} + +#[test] +fn datetime_round_trip() { + let value = DateTime::new(200, 3000); + let back: DateTime = json_round_trip(&value); + assert_eq!(value, back); +} + +#[test] +fn column_data_datetime_round_trip() { + let value = ColumnData::DateTime(Some(DateTime::new(42, 84))); + let back: ColumnData<'static> = json_round_trip(&value); + assert_eq!(value, back); +} + +#[cfg(feature = "tds73")] +#[test] +fn time_types_round_trip() { + let date = Date::new(123); + let time = Time::new(7, 7); + let dt2 = DateTime2::new(date, time); + let dto = DateTimeOffset::new(dt2, -120); + + assert_eq!(date, json_round_trip(&date)); + assert_eq!(time, json_round_trip(&time)); + assert_eq!(dt2, json_round_trip(&dt2)); + assert_eq!(dto, json_round_trip(&dto)); +} + +#[test] +fn xml_data_round_trip() { + let value = XmlData::new("hi"); + let back: XmlData = json_round_trip(&value); + assert_eq!(value.as_ref(), back.as_ref()); +} + +#[test] +fn token_row_round_trip() { + let mut row: TokenRow<'static> = TokenRow::new(); + row.push(ColumnData::I32(Some(1))); + row.push(ColumnData::String(Some(Cow::Owned("hello".to_string())))); + row.push(ColumnData::Bit(Some(false))); + + let back: TokenRow<'static> = json_round_trip(&row); + assert_eq!(back.len(), 3); + assert_eq!(back.get(0), Some(&ColumnData::I32(Some(1)))); + match back.get(1).unwrap() { + ColumnData::String(Some(s)) => assert_eq!(s.as_ref(), "hello"), + other => panic!("unexpected: {:?}", other), + } + assert_eq!(back.get(2), Some(&ColumnData::Bit(Some(false)))); +} + +/// Mimic the "send query results across the network as JSON" flow from +/// the issue: build a row out of columns + data, then verify the whole +/// thing round-trips. +#[test] +fn row_shape_round_trip() { + // Build column metadata. + let columns = Arc::new(vec![ + Column::new("id".to_string(), ColumnType::Int4), + Column::new("name".to_string(), ColumnType::NVarchar), + ]); + let mut data: TokenRow<'static> = TokenRow::new(); + data.push(ColumnData::I32(Some(7))); + data.push(ColumnData::String(Some(Cow::Owned("ada".to_string())))); + + // Round-trip the column metadata. + let columns_back: Arc> = json_round_trip(&columns); + assert_eq!(columns_back.len(), 2); + assert_eq!(columns_back[0].name(), "id"); + assert_eq!(columns_back[1].column_type(), ColumnType::NVarchar); + + // Round-trip the row data. + let data_back: TokenRow<'static> = json_round_trip(&data); + assert_eq!(data_back.len(), 2); + assert_eq!(data_back.get(0), Some(&ColumnData::I32(Some(7)))); +}