tdsql
tdsql is an async Microsoft SQL Server client for Rust, built on the
tiberius-ng TDS driver.
You connect once and run statements on that connection. Queries return rows, and you read values out of them as ordinary Rust types:
use ;
async
Installation
[]
= "0.5.0"
= { = "1", = ["macros", "rt-multi-thread"] }
Connecting
Build a Config in code, or parse a connection string:
use ;
# async
Config::from_jdbc_string parses JDBC-style strings too. A config's Debug
output hides the password, so it's safe to log.
A Client is one connection, reused for every statement. The driver doesn't
support MARS, so a connection runs one request at a time. For concurrency, use a
pool such as bb8 or
deadpool and give each task its own
connection.
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 the value to
the type you ask for:
# use Row;
#
Integers can widen but never narrow. A tinyint reads fine as an i64, and
nothing is ever silently truncated to fit. Reading a NULL into a type that
isn't an Option is an error.
Columns keep the order the query lists them in. Even SELECT a, a gives you two
separate columns, and you can reach each one by position.
Streaming large results
query gives you nothing until the last row has arrived. query_stream hands
you each row as soon as the server sends it:
# use Client;
# async
You get the rows of the first result set, the same ones query would collect.
If the server rejects the statement, the .await on query_stream fails. If
something goes wrong partway through, the error comes out of the stream after
the rows that made it. RowStream is also a futures::Stream, if you'd rather
use combinators.
The stream borrows the client, so nothing else can run on the connection while you hold it. Rows are read only a little ahead of you. A slow reader slows the query down, and memory use stays flat however big the result is. The query stays open on the server until you're done with it, and keeps any locks it has taken.
You can drop the stream early. That cancels the query on the server, and the
connection is ready for the next statement as soon as the server has confirmed.
Statements after the SELECT in the same batch don't run.
In the blocking client, query_stream returns an Iterator.
Cancelling
Stop waiting for a request and it's cancelled on the server too. Drop its
future, let tokio::time::timeout or select! give up on it, abort the task
running it, or drop a stream early. The server stops working on it and lets go
of any locks it took, and the connection stays usable:
# use Duration;
# use Client;
# async
A request still waiting its turn behind another never runs. One that's running stops at the next row or result set to come back. Two things can't be stopped partway:
- A statement that hasn't sent anything back yet, because it's still sorting, aggregating or waiting on a lock, runs until it does and is cancelled then.
execute, andexecute_commandwith a text command, run to completion once sent, because the driver reads their whole response in one go.
Transaction control (begin, commit, rollback, savepoints) always runs, whatever became of its future. Cancelling stops a statement, not the transaction around it: work that finished before the cancel stays done, just as it would if the statement had failed. Put statements that must stand or fall together in a transaction.
Named parameters and stored procedures
Command takes named parameters. Stored procedures are called over RPC, which
sends each parameter by name, so nothing gets pasted into an EXEC string:
# use ;
# use Decimal;
# async
Text queries can use named parameters too. They're rewritten into the positional form the protocol expects:
# use ;
# async
To send SQL NULL, bind None:
use Command;
let cmd = query
.param
.param;
Transactions
client.transaction() starts a transaction, and everything you run through the
handle it returns is part of it. Call commit() to keep the work. If you drop
the handle without committing, the transaction rolls back.
# use Client;
# async
Nothing commits unless you ask it to, so returning early with ? is safe. Here
the second insert fails, and the first one is rolled back with it:
# use Client;
async
You can pass a &mut Transaction to other functions, so one unit of work can
span several of them:
use ;
async
# async
You can also choose an isolation level and use savepoints. A savepoint works like a nested transaction. Rolling it back undoes its own work and leaves the outer transaction open.
# use ;
# async
How rollback-on-drop works
Drop can't run async code, so it can't wait for a rollback to finish. It
queues one instead, which never blocks. The connection runs in a background task
that handles requests in order, so the rollback reaches the server before
anything else you send on that connection. If you need to know whether the
rollback itself failed, call rollback() yourself.
Writing helpers that work either way
A helper that takes &mut Client can't be called inside a transaction, and one
that takes &mut Transaction can't be called outside one. Take
&mut impl Executor instead, and it works with both:
# use ;
async
# async
Executor has the same statement methods as Client, so you can make an
existing helper generic by changing nothing but its signature.
It also has transaction(). On a Client that starts a transaction, and on a
Transaction it opens a savepoint. So a helper can open a scope of its own
without knowing whether it's already inside a transaction:
# use ;
/// Rolls back its own work without disturbing whatever it was called from.
async
# async
The blocking client has its own version of the trait, tdsql::blocking::Executor.
Executor is sealed, so Client and Transaction are the only types that
implement it. It isn't dyn-compatible either: write &mut impl Executor or
<E: Executor>, not &mut dyn Executor.
DDL and raw batches
Parameterised statements are sent as RPC calls. Some statements, like
CREATE PROCEDURE, CREATE VIEW and CREATE TRIGGER, have to come first in a
batch of their own, so they can't be sent that way. batch sends SQL exactly as
you wrote it:
# use Client;
# async
Since the SQL goes over untouched, never build it from untrusted input. Values belong in parameters.
Multiple result sets
Most statements return a single result set, which is why query returns a plain
Vec<Row>. When a batch or procedure returns several, use query_dataset.
They come back in the order the server sent them:
# use ;
# async
Errors
Everything that can fail returns an Error, so you can match on what went
wrong:
use ;
# async
When the server rejects a statement, you get Error::Query. Its message names
the statement and repeats what the server said, so printing it is usually all
you need:
query failed [SELECT id FROM nope]: Invalid object name 'nope'. [error 208, state 1, severity 16, line 1]
The same details are available as fields. Use those to handle a specific server error, so you never have to parse the message:
# use Client;
# async
Parameter values never appear in error messages, since they often hold personal
data. parameter_count() tells you how many were bound, and statement_kind()
tells you whether the failure came from a query, a batch, a stored procedure or
transaction control.
Error is Send + Sync + 'static, so ? works with anyhow, eyre and
similar crates.
Blocking client
If your program isn't async, turn on the blocking feature:
[]
= { = "0.5.0", = ["blocking"] }
tdsql::blocking::Client is the async client without the async and .await.
Typed rows, Command, transactions, savepoints and the Executor trait all work
the same way, and Row, DataSet and Error are shared between the two. The
only difference in shape is query_stream, which returns an Iterator instead
of a Stream.
use Client;
use Config;
Each blocking client runs its own small runtime, so don't use one from async
code. Starting a runtime inside another one panics. Client::connect checks for
this and returns Error::BlockingInAsync, so you find out when you connect, not
on some later call. In async code, use tdsql::Client.
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>, DateTime<Utc>, DateTime<Local> |
any NULL |
Null |
Option<T> |
You can also read any column as a DataValue and match on it yourself.
Getting at chrono, uuid and rust_decimal
Types from these three crates appear in the API, and they only line up with the
exact version tdsql was built with. If two copies of uuid end up in your
dependency tree, you get the baffling expected `Uuid`, found `Uuid` error.
To save you from that, tdsql re-exports all three. Import them through tdsql
and the versions always match:
use Utc;
use Uuid;
use Decimal;
You can still add them to your own Cargo.toml. Just keep the major versions in
step with the ones in Cargo.toml.
Time zones
You can bind a chrono::DateTime<Tz> in any time zone: Utc, Local, a
FixedOffset, or a named zone from chrono-tz. It's sent as a datetimeoffset
with the zone's offset at that moment, so you get back the same instant you
wrote, whatever the zone:
use ;
use ;
# async
Reading into DateTime<Utc> or DateTime<Local> converts from the offset the
server sent, and both name the same instant.
datetime2 and the other offset-free types work differently, in both
directions. They don't store an offset, so treating one as UTC would be a
guess. Those columns bind from and read as NaiveDateTime, and you handle the
zone yourself: .naive_utc() on the way out, .and_utc() on the way back in.
Known limitation: untyped NULLs
Binding DataValue::Null or None sends an untyped NULL, which goes over the
wire as a NULL int. That's true of None for any Option<T>, because the T
isn't sent. SQL Server converts a NULL int for most targets, but a column that
needs a specific type may reject it.
Examples
There are runnable examples in examples/.
The tests
directory has integration tests that run the full type mapping against a live
server.
License
MIT. See LICENSE.