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.
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
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 — 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.