tdsql
tdsql is an ergonomic async Microsoft SQL Server client for Rust, built on the
tiberius-ng TDS driver.
Connect once, then run statements against the connection. Queries hand back rows directly, and values come out typed:
use ;
async
Installation
[]
= "0.1"
= { = "1", = ["macros", "rt-multi-thread"] }
Connecting
Build the configuration fluently, or parse a connection string:
use ;
# async
Config also accepts a JDBC-style string via Config::from_jdbc_string. The
password is redacted from the Debug output, so logging a config is safe.
One Client is one connection, and it is reused for every statement. Because
TDS has no MARS, a connection carries one in-flight request at a time — for
concurrency, use a pool such as bb8 or
deadpool and check out a connection per
task.
Queries
Parameters are positional: @P1 is the first, @P2 the second.
# use Client;
# async
Reading values
get takes a column name or a zero-based position, and converts to the type you
ask for:
# use Row;
#
Integers widen (a tinyint reads fine as i64) but never narrow, so a value is
never silently truncated. Reading a NULL into a non-optional type is an error
rather than a default value.
Columns keep their order, and SELECT a, a stays addressable by position.
Named parameters and stored procedures
Command carries named parameters. A stored procedure is sent as a real RPC, so
its parameters are genuinely named on the wire rather than pasted into an EXEC
string:
# use ;
# use Decimal;
# async
A text batch may also use named parameters; they are rewritten to the positional form the protocol expects:
# use ;
# async
Binding None sends SQL NULL:
use Command;
let cmd = query
.param
.param;
Transactions
client.transaction() returns a handle that scopes every statement run through
it. Commit to keep the work; dropping without committing rolls back.
# use Client;
# async
A transaction never commits implicitly, so an early ? return is safe — the
insert below is discarded, not half-applied:
# use Client;
async
A &mut Transaction can be passed around, so one unit of work can span several
functions:
use ;
async
# async
Isolation levels and savepoints are both available. A savepoint is a nested transaction: rolling it back undoes only its own work and leaves the outer transaction open.
# use ;
# async
How rollback-on-drop works
Drop cannot run async code, so the rollback is enqueued rather than
awaited — the send is synchronous and non-blocking. The connection lives in a
background task that serves requests in order, so the rollback is guaranteed to
reach the server before any later statement on that connection. Use
rollback() explicitly when you want to observe an error from it.
Writing helpers that work either way
A helper written against &mut Client cannot be called inside a transaction,
and one written against &mut Transaction cannot be called without one. The
Executor trait removes the choice: take &mut impl Executor and the caller
decides.
# use ;
async
# async
Executor carries the statement methods — query, query_one, query_opt,
query_scalar, execute, batch, batch_dataset, query_dataset,
execute_command — so an existing helper becomes generic by changing only its
signature.
It also carries transaction(), which is the interesting one: on a Client it
begins a real transaction, and on a Transaction it opens a savepoint. A
helper that wants its own atomic scope therefore nests correctly instead of
trying to begin a second transaction on a connection that already has one:
# use ;
/// Rolls back its own work without disturbing whatever it was called from.
async
# async
The blocking client has the same trait as tdsql::blocking::Executor, with the
async/await removed.
The trait is sealed — it describes the two types this crate provides rather
than an extension point — and it is generics-only, not dyn-compatible, so use
&mut impl Executor or <E: Executor> rather than &mut dyn Executor.
DDL and raw batches
Parameterised statements are sent as an RPC, and some statements — CREATE
PROCEDURE, CREATE VIEW, CREATE TRIGGER — must be the first statement of
their own batch, so they cannot go that route. batch sends SQL verbatim:
# use Client;
# async
Because the SQL is sent verbatim, never interpolate untrusted input into it — use parameters for values.
Multiple result sets
Most commands return one result set, which is why query hands back a plain
Vec<Row>. When a batch or procedure genuinely returns several, use
query_dataset and index them in the order the server sent them:
# use ;
# async
Errors
Every fallible call returns Error, so failures can be matched rather than
string-compared:
use ;
# async
A statement that the server rejects fails with Error::Query, which names what
was running and repeats what the server said, so printing the error alone is
enough to diagnose it:
query failed [SELECT id FROM nope]: Invalid object name 'nope'. [error 208, state 1, severity 16, line 1]
The same details are readable field by field, which is how to branch on a specific server error rather than on its message text:
# use Client;
# async
Parameter values are never captured into the message — they routinely hold
personal data — but parameter_count() records how many were bound, and
statement_kind() says whether the failure came from a query, a batch, a stored
procedure, or transaction control.
Error is Send + Sync + 'static, so it also works with anyhow, eyre and
friends via ?.
Blocking client
If you are not in an async program, enable the blocking feature:
[]
= { = "0.1", = ["blocking"] }
tdsql::blocking::Client mirrors the async client with the async/await
removed. Everything else — typed rows, Command, transactions, savepoints, the
Executor trait — is the same, and Row, DataSet and Error are the very
same types.
use Client;
use Config;
Each blocking client owns a small runtime, so it must not be used from inside
an async runtime — driving a runtime from within another one panics.
Client::connect detects that case and returns Error::BlockingInAsync up
front rather than letting a later call blow up. In async code, use
tdsql::Client directly.
Type mapping
| SQL Server | DataValue |
Reads as |
|---|---|---|
tinyint |
TinyInt |
u8, i16, i32, i64 |
smallint |
SmallInt |
i16, i32, i64 |
int |
Int |
i32, i64 |
bigint |
BigInt |
i64 |
real, float |
Float |
f64 |
decimal, numeric, money |
Decimal |
rust_decimal::Decimal |
bit |
Bool |
bool |
char, varchar, nchar, nvarchar, text, xml |
Text |
String |
binary, varbinary, image |
Binary |
Vec<u8> |
uniqueidentifier |
Guid |
uuid::Uuid |
date |
Date |
chrono::NaiveDate |
time |
Time |
chrono::NaiveTime |
datetime, datetime2, smalldatetime |
DateTime |
chrono::NaiveDateTime |
datetimeoffset |
DateTimeOffset |
chrono::DateTime<FixedOffset> |
any NULL |
Null |
Option<T> |
Any column reads as DataValue if you would rather match on it yourself.
Known limitation: untyped NULLs
Binding DataValue::Null (or None) sends an untyped NULL, which travels as a
NULL int. SQL Server coerces that for most targets, but a column that needs a
specific type may reject it. Bind a typed Option<T> when that matters.
Examples
Runnable examples live in examples/. The
tests directory holds integration tests that exercise the full type
mapping against a live server.
License
MIT — see LICENSE.