pub(crate) mod auth;
mod config;
mod connection;
mod tls;
#[cfg(any(
feature = "rustls",
feature = "native-tls",
feature = "vendored-openssl"
))]
mod tls_stream;
pub use auth::*;
pub use config::*;
pub(crate) use connection::*;
use crate::bulk_options::{ColumnOrderHint, SortOrder, SqlBulkCopyOption, SqlBulkCopyOptions};
use crate::tds::codec::RpcValue;
use crate::tds::stream::ReceivedToken;
use crate::{
result::ExecuteResult,
tds::{
codec::{self, IteratorJoin},
stream::{QueryStream, TokenStream},
},
BulkLoadRequest, MetaDataColumn, SqlReadBytes, ToSql,
};
use codec::{
BatchRequest, ColumnData, IsolationLevel, PacketHeader, RpcParam, RpcProcId, TokenRpcRequest,
TransactionManagerRequest,
};
use enumflags2::BitFlags;
use futures_util::io::{AsyncRead, AsyncWrite};
use futures_util::stream::TryStreamExt;
use std::{borrow::Cow, fmt::Debug};
#[derive(Debug)]
pub struct Client<S: AsyncRead + AsyncWrite + Unpin + Send> {
pub(crate) connection: Connection<S>,
}
impl<S: AsyncRead + AsyncWrite + Unpin + Send> Client<S> {
pub async fn connect(config: Config, tcp_stream: S) -> crate::Result<Client<S>> {
Ok(Client {
connection: Connection::connect(config, tcp_stream).await?,
})
}
pub async fn execute<'a>(
&mut self,
query: impl Into<Cow<'a, str>>,
params: &[&dyn ToSql],
) -> crate::Result<ExecuteResult> {
self.connection.flush_stream().await?;
let rpc_params = Self::rpc_params(query);
let params = params.iter().map(|s| s.to_sql());
self.rpc_perform_query(RpcProcId::ExecuteSQL, rpc_params, params)
.await?;
ExecuteResult::new(&mut self.connection).await
}
pub async fn query<'a, 'b>(
&'a mut self,
query: impl Into<Cow<'b, str>>,
params: &'b [&'b dyn ToSql],
) -> crate::Result<QueryStream<'a>>
where
'a: 'b,
{
self.connection.flush_stream().await?;
let rpc_params = Self::rpc_params(query);
let params = params.iter().map(|p| p.to_sql());
self.rpc_perform_query(RpcProcId::ExecuteSQL, rpc_params, params)
.await?;
let ts = TokenStream::new(&mut self.connection);
let mut result = QueryStream::new(ts.try_unfold());
result.forward_to_metadata().await?;
Ok(result)
}
pub async fn simple_query<'a, 'b>(
&'a mut self,
query: impl Into<Cow<'b, str>>,
) -> crate::Result<QueryStream<'a>>
where
'a: 'b,
{
self.connection.flush_stream().await?;
let req = BatchRequest::new(query, self.connection.context().transaction_descriptor());
let id = self.connection.context_mut().next_packet_id();
self.connection.send(PacketHeader::batch(id), req).await?;
let ts = TokenStream::new(&mut self.connection);
let mut result = QueryStream::new(ts.try_unfold());
result.forward_to_metadata().await?;
Ok(result)
}
pub async fn bulk_insert<'a>(
&'a mut self,
table: &'a str,
) -> crate::Result<BulkLoadRequest<'a, S>> {
self.bulk_insert_columns(table, &["*"]).await
}
pub async fn bulk_insert_columns<'a>(
&'a mut self,
table: &'a str,
columns: &'a [&'a str],
) -> crate::Result<BulkLoadRequest<'a, S>> {
self.bulk_insert_with_options(table, columns, SqlBulkCopyOptions::empty(), &[])
.await
}
pub async fn bulk_insert_with_options<'a>(
&'a mut self,
table: &'a str,
columns: &'a [&'a str],
options: SqlBulkCopyOptions,
order_hints: &'a [ColumnOrderHint<'a>],
) -> crate::Result<BulkLoadRequest<'a, S>> {
validate_bulk_table_identifier(table)?;
for column in columns {
validate_bulk_column_identifier(column)?;
}
for &(column, _) in order_hints {
validate_bulk_column_identifier(column)?;
}
let keep_identity = options.contains(SqlBulkCopyOption::KeepIdentity);
let mut columns: Vec<_> = self
.column_metadata(table, columns)
.await?
.into_iter()
.filter(|column| bulk_column_is_target(&column.base, keep_identity))
.collect();
for column in columns.iter_mut() {
column.base.table_name = Some(table.to_string());
}
self.connection.flush_stream().await?;
let col_data = columns.iter().map(|c| format!("{}", c)).join(", ");
let query = build_insert_bulk_sql(table, &col_data, options, order_hints);
let req = BatchRequest::new(query, self.connection.context().transaction_descriptor());
let id = self.connection.context_mut().next_packet_id();
self.connection.send(PacketHeader::batch(id), req).await?;
let ts = TokenStream::new(&mut self.connection);
ts.flush_done().await?;
BulkLoadRequest::new(&mut self.connection, columns)
}
pub async fn column_metadata(
&mut self,
table: &str,
columns: &[&str],
) -> crate::Result<Vec<MetaDataColumn<'static>>> {
validate_bulk_table_identifier(table)?;
for column in columns {
validate_bulk_column_identifier(column)?;
}
self.connection.flush_stream().await?;
let columns = columns.join(", ");
let query = format!("SELECT TOP 0 {columns} FROM {table}");
let req = BatchRequest::new(query, self.connection.context().transaction_descriptor());
let id = self.connection.context_mut().next_packet_id();
self.connection.send(PacketHeader::batch(id), req).await?;
let token_stream = TokenStream::new(&mut self.connection).try_unfold();
let columns = token_stream
.try_fold(None, |mut columns, token| async move {
if let ReceivedToken::NewResultset(metadata) = token {
columns = Some(metadata.columns.clone());
};
Ok(columns)
})
.await?;
let columns = columns.ok_or_else(|| {
crate::Error::Protocol("expecting column metadata from query but not found".into())
})?;
Ok(columns
.into_iter()
.map(|c| MetaDataColumn {
base: c.base,
col_name: std::borrow::Cow::Owned(c.col_name.into_owned()),
})
.collect())
}
pub async fn cancel_query(&mut self) -> crate::Result<()> {
self.connection.cancel_request().await?;
Ok(())
}
pub async fn close(self) -> crate::Result<()> {
self.connection.close().await
}
pub async fn begin_transaction(&mut self) -> crate::Result<()> {
self.begin_transaction_with_isolation(IsolationLevel::Unspecified)
.await
}
pub async fn begin_transaction_with_isolation(
&mut self,
isolation_level: IsolationLevel,
) -> crate::Result<()> {
let req = TransactionManagerRequest::begin(
self.connection.context().transaction_descriptor(),
isolation_level,
"",
);
self.send_transaction_manager_request(req).await
}
pub async fn commit_transaction(&mut self) -> crate::Result<()> {
let req = TransactionManagerRequest::commit(
self.connection.context().transaction_descriptor(),
"",
);
self.send_transaction_manager_request(req).await
}
pub async fn rollback_transaction(&mut self) -> crate::Result<()> {
let req = TransactionManagerRequest::rollback(
self.connection.context().transaction_descriptor(),
"",
);
self.send_transaction_manager_request(req).await
}
pub async fn save_transaction<'a>(
&mut self,
name: impl Into<Cow<'a, str>>,
) -> crate::Result<()> {
let req = TransactionManagerRequest::save(
self.connection.context().transaction_descriptor(),
name,
);
self.send_transaction_manager_request(req).await
}
async fn send_transaction_manager_request(
&mut self,
req: TransactionManagerRequest<'_>,
) -> crate::Result<()> {
self.connection.flush_stream().await?;
let id = self.connection.context_mut().next_packet_id();
self.connection
.send(PacketHeader::transaction_manager(id), req)
.await?;
TokenStream::new(&mut self.connection).flush_done().await?;
Ok(())
}
pub(crate) fn rpc_params<'a>(query: impl Into<Cow<'a, str>>) -> Vec<RpcParam<'a>> {
vec![
RpcParam {
name: Cow::Borrowed("stmt"),
flags: BitFlags::empty(),
value: RpcValue::Scalar(ColumnData::String(Some(query.into()))),
},
RpcParam {
name: Cow::Borrowed("params"),
flags: BitFlags::empty(),
value: RpcValue::Scalar(ColumnData::I32(Some(0))),
},
]
}
pub(crate) async fn rpc_perform_query<'a, 'b>(
&'a mut self,
proc_id: RpcProcId,
mut rpc_params: Vec<RpcParam<'b>>,
params: impl Iterator<Item = ColumnData<'b>>,
) -> crate::Result<()>
where
'a: 'b,
{
let mut param_str = String::new();
for (i, param) in params.enumerate() {
if i > 0 {
param_str.push(',')
}
param_str.push_str(&format!("@P{} ", i + 1));
param_str.push_str(¶m.type_name());
rpc_params.push(RpcParam {
name: Cow::Owned(format!("@P{}", i + 1)),
flags: BitFlags::empty(),
value: RpcValue::Scalar(param),
});
}
if let Some(params) = rpc_params.iter_mut().find(|x| x.name == "params") {
params.value = RpcValue::Scalar(ColumnData::String(Some(param_str.into())));
}
let req = TokenRpcRequest::new(
proc_id,
rpc_params,
self.connection.context().transaction_descriptor(),
);
let id = self.connection.context_mut().next_packet_id();
self.connection.send(PacketHeader::rpc(id), req).await?;
Ok(())
}
pub(crate) async fn rpc_run_command<'a, 'b>(
&'a mut self,
command_name: Cow<'b, str>,
rpc_params: Vec<RpcParam<'b>>,
) -> crate::Result<()>
where
'a: 'b,
{
let req = TokenRpcRequest::new(
command_name,
rpc_params,
self.connection.context().transaction_descriptor(),
);
let id = self.connection.context_mut().next_packet_id();
self.connection.send(PacketHeader::rpc(id), req).await?;
Ok(())
}
pub(crate) async fn query_run_for_metadata<'b>(
&mut self,
query: String,
) -> crate::Result<Option<Vec<MetaDataColumn<'b>>>> {
self.connection.flush_stream().await?;
let req = BatchRequest::new(query, self.connection.context().transaction_descriptor());
let id = self.connection.context_mut().next_packet_id();
self.connection.send(PacketHeader::batch(id), req).await?;
let token_stream = TokenStream::new(&mut self.connection).try_unfold();
let columns = token_stream
.try_fold(None, |mut columns, token| async move {
if let ReceivedToken::NewResultset(metadata) = token {
columns = Some(metadata.columns.clone());
};
Ok(columns)
})
.await?;
Ok(columns)
}
}
fn build_insert_bulk_sql(
table: &str,
col_data: &str,
options: SqlBulkCopyOptions,
order_hints: &[ColumnOrderHint<'_>],
) -> String {
let mut clauses: Vec<String> = Vec::new();
if options.contains(SqlBulkCopyOption::CheckConstraints) {
clauses.push("CHECK_CONSTRAINTS".to_owned());
}
if options.contains(SqlBulkCopyOption::TableLock) {
clauses.push("TABLOCK".to_owned());
}
if options.contains(SqlBulkCopyOption::KeepNulls) {
clauses.push("KEEP_NULLS".to_owned());
}
if options.contains(SqlBulkCopyOption::FireTriggers) {
clauses.push("FIRE_TRIGGERS".to_owned());
}
if !order_hints.is_empty() {
let hints = order_hints
.iter()
.map(|(col, order)| {
format!(
"{col} {}",
match order {
SortOrder::Ascending => "ASC",
SortOrder::Descending => "DESC",
}
)
})
.join(", ");
clauses.push(format!("ORDER({hints})"));
}
let mut query = format!("INSERT BULK {table} ({col_data})");
if !clauses.is_empty() {
query.push_str(" WITH (");
query.push_str(&clauses.join(", "));
query.push(')');
}
query
}
fn bulk_column_is_target(
base: &crate::tds::codec::BaseMetaDataColumn,
keep_identity: bool,
) -> bool {
base.is_updateable() || (keep_identity && base.is_identity())
}
fn validate_bulk_table_identifier(table: &str) -> crate::Result<()> {
validate_sql_identifier("bulk insert table", table)
}
fn validate_bulk_column_identifier(column: &str) -> crate::Result<()> {
validate_sql_identifier("bulk insert column", column)
}
pub(crate) fn validate_sql_identifier(what: &str, ident: &str) -> crate::Result<()> {
if ident.chars().any(|c| c.is_ascii_control()) {
return Err(crate::Error::BulkInput(
format!("{what} identifier must not contain NUL or control characters").into(),
));
}
let mut chars = ident.chars().peekable();
let mut in_bracket = false;
let mut paren_depth: u32 = 0;
while let Some(c) = chars.next() {
if in_bracket {
if c == ']' {
if chars.peek() == Some(&']') {
chars.next(); } else {
in_bracket = false;
match chars.peek() {
None | Some('.') => {}
Some(_) => {
return Err(crate::Error::BulkInput(
format!("{what} identifier has trailing characters after a bracket-quoted segment").into(),
));
}
}
}
}
continue;
}
match c {
'[' => in_bracket = true,
']' => {
return Err(crate::Error::BulkInput(
format!("{what} identifier contains an unbalanced `]` bracket").into(),
));
}
'(' => paren_depth += 1,
')' => {
if paren_depth == 0 {
return Err(crate::Error::BulkInput(
format!("{what} identifier contains an unbalanced `)`").into(),
));
}
paren_depth -= 1;
if paren_depth == 0 && chars.peek().is_some() {
return Err(crate::Error::BulkInput(
format!("{what} identifier has trailing characters after a closing `)`")
.into(),
));
}
}
' ' if paren_depth == 0 => {
return Err(crate::Error::BulkInput(
format!("{what} identifier contains a disallowed character").into(),
));
}
',' if paren_depth == 0 => {
return Err(crate::Error::BulkInput(
format!("{what} identifier contains a disallowed character").into(),
));
}
c if c.is_alphanumeric() => {}
'_' | '.' | '@' | '#' | ',' | ' ' | '*' => {}
_ => {
return Err(crate::Error::BulkInput(
format!("{what} identifier contains a disallowed character").into(),
));
}
}
}
if in_bracket {
return Err(crate::Error::BulkInput(
format!("{what} identifier has an unterminated `[` bracket-quoted segment").into(),
));
}
if paren_depth != 0 {
return Err(crate::Error::BulkInput(
format!("{what} identifier contains an unbalanced `(`").into(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{
build_insert_bulk_sql, bulk_column_is_target, validate_bulk_column_identifier,
validate_bulk_table_identifier,
};
use crate::tds::codec::{BaseMetaDataColumn, ColumnFlag, TypeInfo, VarLenContext, VarLenType};
use crate::{SortOrder, SqlBulkCopyOption, SqlBulkCopyOptions};
use enumflags2::BitFlags;
fn base_with_flags(flags: BitFlags<ColumnFlag>) -> BaseMetaDataColumn {
BaseMetaDataColumn {
flags,
ty: TypeInfo::VarLenSized(VarLenContext::new(VarLenType::Intn, 4, None)),
table_name: None,
}
}
#[test]
fn bulk_column_is_target_selects_writeable_and_keep_identity() {
let updateable: BitFlags<ColumnFlag> = ColumnFlag::Updateable.into();
let unknown: BitFlags<ColumnFlag> = ColumnFlag::UpdateableUnknown.into();
let read_only = BitFlags::<ColumnFlag>::empty();
let identity_ro: BitFlags<ColumnFlag> = ColumnFlag::Identity.into();
let identity_unknown = ColumnFlag::Identity | ColumnFlag::UpdateableUnknown;
for keep in [false, true] {
assert!(bulk_column_is_target(&base_with_flags(updateable), keep));
assert!(bulk_column_is_target(&base_with_flags(unknown), keep));
assert!(!bulk_column_is_target(&base_with_flags(read_only), keep));
}
assert!(!bulk_column_is_target(&base_with_flags(identity_ro), false));
assert!(bulk_column_is_target(&base_with_flags(identity_ro), true));
assert!(bulk_column_is_target(
&base_with_flags(identity_unknown),
false
));
assert!(bulk_column_is_target(
&base_with_flags(identity_unknown),
true
));
assert!(!bulk_column_is_target(&base_with_flags(read_only), true));
}
const COLS: &str = "[id] int, [val] int";
#[test]
fn build_sql_no_options_no_hints_emits_no_with_clause() {
assert_eq!(
build_insert_bulk_sql("t", COLS, SqlBulkCopyOptions::empty(), &[]),
"INSERT BULK t ([id] int, [val] int)"
);
}
#[test]
fn build_sql_each_flag_emits_expected_keyword() {
for (flag, keyword) in [
(SqlBulkCopyOption::CheckConstraints, "CHECK_CONSTRAINTS"),
(SqlBulkCopyOption::TableLock, "TABLOCK"),
(SqlBulkCopyOption::KeepNulls, "KEEP_NULLS"),
(SqlBulkCopyOption::FireTriggers, "FIRE_TRIGGERS"),
] {
assert_eq!(
build_insert_bulk_sql("t", COLS, flag.into(), &[]),
format!("INSERT BULK t ([id] int, [val] int) WITH ({keyword})"),
"flag {flag:?} did not emit {keyword}",
);
}
}
#[test]
fn build_sql_keep_identity_emits_no_with_keyword() {
let sql = build_insert_bulk_sql("t", COLS, SqlBulkCopyOption::KeepIdentity.into(), &[]);
assert_eq!(sql, "INSERT BULK t ([id] int, [val] int)", "got: {sql}");
assert!(!sql.contains("KEEP_IDENTITY"), "got: {sql}");
}
#[test]
fn build_sql_combined_flags_join_in_fixed_order() {
let opts = SqlBulkCopyOption::KeepIdentity
| SqlBulkCopyOption::TableLock
| SqlBulkCopyOption::FireTriggers;
assert_eq!(
build_insert_bulk_sql("t", COLS, opts, &[]),
"INSERT BULK t ([id] int, [val] int) WITH (TABLOCK, FIRE_TRIGGERS)"
);
}
#[test]
fn build_sql_all_flags() {
let opts = SqlBulkCopyOptions::all();
assert_eq!(
build_insert_bulk_sql("t", COLS, opts, &[]),
"INSERT BULK t ([id] int, [val] int) WITH (CHECK_CONSTRAINTS, TABLOCK, KEEP_NULLS, FIRE_TRIGGERS)"
);
}
#[test]
fn build_sql_order_hints_asc_and_desc() {
let hints = [("id", SortOrder::Ascending), ("val", SortOrder::Descending)];
assert_eq!(
build_insert_bulk_sql("t", COLS, SqlBulkCopyOptions::empty(), &hints),
"INSERT BULK t ([id] int, [val] int) WITH (ORDER(id ASC, val DESC))"
);
}
#[test]
fn build_sql_options_and_order_hints_combine() {
let hints = [("id", SortOrder::Ascending)];
assert_eq!(
build_insert_bulk_sql("t", COLS, SqlBulkCopyOption::TableLock.into(), &hints),
"INSERT BULK t ([id] int, [val] int) WITH (TABLOCK, ORDER(id ASC))"
);
}
#[test]
fn build_sql_order_hint_bracketed_name_not_double_escaped() {
let hints = [
("[id]", SortOrder::Ascending),
("[my]]col]", SortOrder::Descending),
];
assert_eq!(
build_insert_bulk_sql("t", COLS, SqlBulkCopyOptions::empty(), &hints),
"INSERT BULK t ([id] int, [val] int) WITH (ORDER([id] ASC, [my]]col] DESC))"
);
}
#[test]
fn build_sql_empty_hints_emits_no_order_clause() {
let sql = build_insert_bulk_sql("t", COLS, SqlBulkCopyOption::TableLock.into(), &[]);
assert!(!sql.contains("ORDER"), "got: {sql}");
assert_eq!(sql, "INSERT BULK t ([id] int, [val] int) WITH (TABLOCK)");
}
#[test]
fn build_sql_order_hint_column_names_are_validated_like_columns() {
assert!(validate_bulk_column_identifier("my]col").is_err());
assert!(validate_bulk_column_identifier("[my]]col]").is_ok());
}
#[test]
fn accepts_normal_column_identifiers() {
for column in ["foo", "bar", "*", "[my col]", "[weird]]col]"] {
assert!(
validate_bulk_column_identifier(column).is_ok(),
"expected column {column:?} to be accepted",
);
}
}
#[test]
fn rejects_bad_column_identifiers() {
assert!(validate_bulk_column_identifier("foo\0bar").is_err());
assert!(validate_bulk_column_identifier("foo\nbar").is_err());
assert!(validate_bulk_column_identifier("foo]").is_err());
assert!(validate_bulk_column_identifier("a]b").is_err());
}
#[test]
fn accepts_normal_identifiers() {
for table in [
"Foo",
"dbo.Foo",
"##bulk_test",
"#temp",
"[my table]",
"[dbo].[my table]",
"[weird]]name]", ] {
assert!(
validate_bulk_table_identifier(table).is_ok(),
"expected {table:?} to be accepted",
);
}
}
#[test]
fn rejects_control_characters() {
assert!(validate_bulk_table_identifier("Foo\0bar").is_err());
assert!(validate_bulk_table_identifier("Foo\nbar").is_err());
assert!(validate_bulk_table_identifier("Foo\tbar").is_err());
}
#[test]
fn rejects_unbalanced_closing_bracket() {
assert!(validate_bulk_table_identifier("Foo]").is_err());
assert!(validate_bulk_table_identifier("[my] table]").is_err());
assert!(validate_bulk_table_identifier("a]b").is_err());
}
#[test]
fn column_metadata_guards_reject_bad_identifiers() {
assert!(validate_bulk_table_identifier("Foo]").is_err());
assert!(validate_bulk_table_identifier("Foo\nbar").is_err());
assert!(validate_bulk_column_identifier("id]").is_err());
assert!(validate_bulk_column_identifier("col\0").is_err());
}
#[test]
fn rejects_statement_breaking_identifiers() {
for bad in [
"1; DROP TABLE Users--",
"id = 1",
"foo'bar",
"foo\"bar",
"foo--bar",
"foo;bar",
] {
assert!(
validate_bulk_column_identifier(bad).is_err(),
"expected column {bad:?} to be rejected",
);
assert!(
validate_bulk_table_identifier(bad).is_err(),
"expected table {bad:?} to be rejected",
);
}
}
#[test]
fn accepts_star_and_bracketed_statement_breakers() {
assert!(validate_bulk_column_identifier("*").is_ok());
assert!(validate_bulk_column_identifier("[weird;name]").is_ok());
assert!(validate_bulk_table_identifier("[dbo].[my;table]").is_ok());
}
#[test]
fn rejects_top_level_space_but_allows_it_inside_parens_and_brackets() {
assert!(validate_bulk_table_identifier("t UNION SELECT name FROM sysobjects").is_err());
assert!(validate_bulk_table_identifier("t WHERE 1=1").is_err());
assert!(validate_bulk_column_identifier("a b").is_err());
assert!(super::validate_sql_identifier("db_type", "decimal(18, 4)").is_ok());
assert!(validate_bulk_table_identifier("[my table]").is_ok());
assert!(super::validate_sql_identifier("db_type", "decimal(10,2)").is_ok());
assert!(super::validate_sql_identifier("db_type", "varchar(max)").is_ok());
}
#[test]
fn rejects_delimiter_adjacent_token_splicing() {
assert!(
validate_bulk_table_identifier("[RealTable]UNION(SELECT secret FROM users)").is_err()
);
assert!(validate_bulk_table_identifier("[t]UNION").is_err());
assert!(validate_bulk_table_identifier("[t](1)").is_err());
assert!(super::validate_sql_identifier("t", "foo(1)UNION(SELECT x)").is_err());
assert!(super::validate_sql_identifier("t", "decimal(10,2)x").is_err());
assert!(validate_bulk_table_identifier("[dbo].[my table]").is_ok());
assert!(validate_bulk_table_identifier("[db].[schema].[tbl]").is_ok());
assert!(validate_bulk_table_identifier("[weird]]name]").is_ok());
assert!(super::validate_sql_identifier("db_type", "decimal(18,4)").is_ok());
assert!(super::validate_sql_identifier("db_type", "numeric(38, 38)").is_ok());
assert!(super::validate_sql_identifier("db_type", "varchar(max)").is_ok());
}
#[test]
fn rejects_unbalanced_closing_paren_and_top_level_comma() {
assert!(validate_bulk_column_identifier("col)").is_err());
assert!(validate_bulk_column_identifier(")").is_err());
assert!(validate_bulk_column_identifier("a,b").is_err());
assert!(validate_bulk_table_identifier("t)").is_err());
assert!(super::validate_sql_identifier("db_type", "decimal(10,2))").is_err());
assert!(validate_bulk_column_identifier("a(b").is_err());
assert!(validate_bulk_column_identifier("(").is_err());
assert!(validate_bulk_column_identifier("[abc").is_err());
assert!(validate_bulk_table_identifier("dbo.[my table").is_err());
assert!(super::validate_sql_identifier("db_type", "decimal(10,2").is_err());
assert!(super::validate_sql_identifier("db_type", "decimal(10,2)").is_ok());
assert!(super::validate_sql_identifier("db_type", "numeric(18, 4)").is_ok());
assert!(validate_bulk_column_identifier("[weird,name)]").is_ok());
}
}