Skip to content

Commit 71a3acd

Browse files
Addition: DataValue::VarChar, a string sent as varchar
A &str or String parameter goes as nvarchar, which against an indexed varchar column under a SQL_* collation makes the server scan rather than seek. DataValue::VarChar is sent as varchar in the server's collation, as varchar(max) past 8000 bytes; a character the code page lacks is an error before anything is sent. It reads and compares as text. A varchar column still reads as Text. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 8535840 commit 71a3acd

5 files changed

Lines changed: 105 additions & 11 deletions

File tree

‎docs/types.md‎

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -129,13 +129,30 @@ implement `ToSql::null_type`.
129129

130130
## Parameter types and query plans
131131

132-
A string parameter is always `nvarchar`. Compared with an indexed `varchar`
133-
column under one of the `SQL_*` collations, which is SQL Server's default on
134-
many installations, that makes the server convert the column rather than the
135-
parameter, and the index is scanned instead of sought. On 300,000 rows that
136-
was 20 ms a query instead of 0.4 ms. Cast the parameter in the SQL to get the
137-
seek back: `WHERE code = CAST(@P1 AS varchar(20))`. Windows collations such as
138-
`Latin1_General_CI_AS` are not affected.
132+
A `&str` or `String` parameter is sent as `nvarchar`. Compared with an indexed
133+
`varchar` column under one of the `SQL_*` collations, which is SQL Server's
134+
default on many installations, that makes the server convert the column rather
135+
than the parameter, and the index is scanned instead of sought. On 300,000
136+
rows that was 20 ms a query instead of 0.4 ms. Bind such a parameter as
137+
`DataValue::VarChar` and it is sent as `varchar` in the server's collation, so
138+
the index is sought:
139+
140+
```rust,no_run
141+
# use tdsql::{Client, DataValue};
142+
# async fn f(client: &mut Client, code: &str) -> tdsql::Result<()> {
143+
let rows = client
144+
.query(
145+
"SELECT id FROM items WHERE code = @P1",
146+
&[&DataValue::VarChar(code.into())],
147+
)
148+
.await?;
149+
# Ok(())
150+
# }
151+
```
152+
153+
A character the server's code page cannot hold is an error at bind time, so
154+
keep `VarChar` for columns that are `varchar` themselves. Windows collations
155+
such as `Latin1_General_CI_AS` are not affected.
139156

140157
A `Decimal` parameter is declared as `numeric(38, s)` with the value's own
141158
scale, so each distinct scale compiles its own plan for the same SQL, and the

‎src/client.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ impl tds_driver::ToSql for DataValue {
4848
DataValue::Decimal(v) => v.to_sql(),
4949
DataValue::Bool(v) => C::Bit(Some(*v)),
5050
DataValue::Text(v) => C::String(Some(Cow::Borrowed(v.as_str()))),
51+
DataValue::VarChar(v) => C::VarChar(Some(Cow::Borrowed(v.as_str()))),
5152
DataValue::Binary(v) => C::Binary(Some(Cow::Borrowed(v.as_slice()))),
5253
DataValue::Guid(v) => C::Guid(Some(*v)),
5354
DataValue::Date(v) => v.to_sql(),
@@ -104,6 +105,7 @@ impl<'a> tds_driver::IntoSql<'a> for DataValue {
104105
use tds_driver::ColumnData as C;
105106
match self {
106107
DataValue::Text(v) => C::String(Some(Cow::Owned(v))),
108+
DataValue::VarChar(v) => C::VarChar(Some(Cow::Owned(v))),
107109
DataValue::Binary(v) => C::Binary(Some(Cow::Owned(v))),
108110
// Every other variant borrows nothing.
109111
other => match tds_driver::ToSql::to_sql(&other) {

‎src/value.rs‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,15 @@ pub enum DataValue {
3030
Bool(bool),
3131
/// Any character or XML type.
3232
Text(String),
33+
/// A string sent as `varchar`, in the server's collation, where
34+
/// [`Text`](DataValue::Text) goes as `nvarchar`.
35+
///
36+
/// Against an indexed `varchar` column under a `SQL_*` collation an
37+
/// `nvarchar` parameter makes the server convert the column and scan the
38+
/// index; a `varchar` parameter seeks. A character the collation's code
39+
/// page lacks is an error at bind time. Only sent: a `varchar` column
40+
/// still reads as `Text`.
41+
VarChar(String),
3342
/// Any binary type.
3443
Binary(Vec<u8>),
3544
/// `uniqueidentifier`
@@ -69,6 +78,7 @@ impl DataValue {
6978
DataValue::Decimal(_) => "Decimal",
7079
DataValue::Bool(_) => "Bool",
7180
DataValue::Text(_) => "Text",
81+
DataValue::VarChar(_) => "VarChar",
7282
DataValue::Binary(_) => "Binary",
7383
DataValue::Guid(_) => "Guid",
7484
DataValue::Date(_) => "Date",
@@ -490,7 +500,7 @@ impl_from_sql!(Decimal, "Decimal", |v| match v {
490500
});
491501

492502
impl_from_sql!(String, "String", |v| match v {
493-
DataValue::Text(s) => Some(s.clone()),
503+
DataValue::Text(s) | DataValue::VarChar(s) => Some(s.clone()),
494504
_ => None,
495505
});
496506

@@ -581,13 +591,16 @@ impl_partial_eq!(f64, |s, o| matches!(s, DataValue::Float(v) if v == o));
581591
impl_partial_eq!(Decimal, |s, o| matches!(s, DataValue::Decimal(v) if v == o));
582592
impl_partial_eq!(
583593
str,
584-
|s, o| matches!(s, DataValue::Text(v) if v.as_str() == o)
594+
|s, o| matches!(s, DataValue::Text(v) | DataValue::VarChar(v) if v.as_str() == o)
585595
);
586596
impl_partial_eq!(
587597
&str,
588-
|s, o| matches!(s, DataValue::Text(v) if v.as_str() == *o)
598+
|s, o| matches!(s, DataValue::Text(v) | DataValue::VarChar(v) if v.as_str() == *o)
599+
);
600+
impl_partial_eq!(
601+
String,
602+
|s, o| matches!(s, DataValue::Text(v) | DataValue::VarChar(v) if v == o)
589603
);
590-
impl_partial_eq!(String, |s, o| matches!(s, DataValue::Text(v) if v == o));
591604
impl_partial_eq!(Uuid, |s, o| matches!(s, DataValue::Guid(v) if v == o));
592605
impl_partial_eq!(NaiveDate, |s, o| matches!(s, DataValue::Date(v) if v == o));
593606
impl_partial_eq!(NaiveTime, |s, o| matches!(s, DataValue::Time(v) if v == o));

‎src/value/tests.rs‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,3 +274,14 @@ fn compares_equal_across_zones() {
274274
assert_ne!(value, utc + chrono::Duration::hours(1));
275275
assert_ne!(DataValue::Null, utc);
276276
}
277+
278+
#[test]
279+
fn varchar_reads_and_compares_as_text() {
280+
let v = DataValue::VarChar("abc".into());
281+
assert_eq!(v.type_name(), "VarChar");
282+
assert_eq!(get::<String>(v.clone()).unwrap(), "abc");
283+
assert!(v == "abc");
284+
let owned = String::from("abc");
285+
assert!(v == owned);
286+
assert!(v != "abd");
287+
}

‎tests/integration_test.rs‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -847,3 +847,54 @@ async fn datetime_parameters_can_be_sent_as_datetime_for_datetime_columns() {
847847

848848
ddl(&mut client, "DROP TABLE dbo.tdsql_dt_legacy").await;
849849
}
850+
851+
/// The wire type the server saw for a parameter, as `sql_variant` reports it.
852+
async fn param_type(client: &mut Client, value: &dyn ToSql) -> String {
853+
client
854+
.query_scalar(
855+
"SELECT CAST(SQL_VARIANT_PROPERTY(ISNULL(@P1, ''), 'BaseType') AS nvarchar(30))",
856+
&[value],
857+
)
858+
.await
859+
.unwrap()
860+
}
861+
862+
#[tokio::test]
863+
#[ignore]
864+
async fn varchar_values_are_sent_as_varchar() {
865+
let mut client = connect().await;
866+
867+
assert_eq!(param_type(&mut client, &"abc").await, "nvarchar");
868+
assert_eq!(
869+
param_type(&mut client, &DataValue::VarChar("abc".into())).await,
870+
"varchar"
871+
);
872+
873+
// Round trip, by position and by name.
874+
let name = DataValue::VarChar("Väinämöinen".into());
875+
let back: String = client.query_scalar("SELECT @P1", &[&name]).await.unwrap();
876+
assert_eq!(back, "Väinämöinen");
877+
let cmd = Command::query("SELECT @name AS name").param("name", name);
878+
let ds = client.query_dataset(&cmd).await.unwrap();
879+
assert_eq!(ds[0][0].get::<String, _>("name"), "Väinämöinen");
880+
881+
// Over 8000 bytes it goes as varchar(max), whole.
882+
let long = DataValue::VarChar("x".repeat(8001));
883+
let len: i64 = client
884+
.query_scalar("SELECT DATALENGTH(@P1)", &[&long])
885+
.await
886+
.unwrap();
887+
assert_eq!(len, 8001);
888+
889+
// A character the server's code page lacks is an error before anything
890+
// is sent, and the connection is still usable. (The test server's
891+
// collation is SQL_Latin1_General_CP1_CI_AS, code page 1252.)
892+
let err = client
893+
.query_scalar::<String>("SELECT @P1", &[&DataValue::VarChar("日本".into())])
894+
.await
895+
.unwrap_err();
896+
assert!(!err.is_connection_lost(), "{err:?}");
897+
assert!(err.to_string().contains("SELECT @P1"), "{err}");
898+
let one: i32 = client.query_scalar("SELECT 1", &[]).await.unwrap();
899+
assert_eq!(one, 1);
900+
}

0 commit comments

Comments
 (0)