Statiq
Zero-overhead, compile-time MSSQL service library for Rust.
All SQL (SELECT, INSERT, UPDATE, DELETE, MERGE) is generated at compile time by the #[derive(SqlEntity)] proc-macro. There is no runtime string building, no reflection, no ORM magic. The library is async-first (Tokio), uses static dispatch throughout, and exposes CancellationToken on every operation for clean shutdown and request cancellation.
Targets: Axum REST APIs, Leptos SSR applications, and Tauri desktop apps.
Table of Contents
- Crates
- Requirements
- Cargo.toml Setup
- config.json
- Defining an Entity
- Building a Service
- CRUD Operations (SqlRepository)
- params! Macro
- Transactions
- Stored Procedures (SprocService)
- Multiple Result Sets from a Sproc
- Redis Cache
- Raw Queries
- Error Handling
- Testing with MockRepository
- Axum Integration — Full Example
- Leptos SSR Integration — Full Example
- Type Mappings (SQL Server ↔ Rust)
- Attribute Reference
- config.json Field Reference
1. Crates
The workspace has two published crates:
statiq— the main library (connection pool, services, transactions, cache, sproc, testing).statiq-macros— the proc-macro crate. You only need to add it explicitly if you use#[derive(SqlEntity)]without thestatiqre-export (very rare).
2. Requirements
- Rust 1.75+
- An ODBC driver for SQL Server installed on the machine.
- Windows: "ODBC Driver 17 for SQL Server" or "ODBC Driver 18 for SQL Server" (download from Microsoft).
- Linux/macOS: same drivers via Microsoft packages, plus
unixODBC.
- A reachable SQL Server instance (2016+ recommended).
3. Cargo.toml Setup
Minimal (no cache, no axum feature)
[]
= "0.2"
= { = "1", = ["full"] }
= { = "0.7", = ["full"] }
= { = "1", = ["derive"] }
With Axum
[]
= { = "0.2", = ["axum"] }
= "0.8"
= { = "1", = ["full"] }
= { = "0.7", = ["full"] }
= { = "1", = ["derive"] }
The axum feature makes SqlError implement axum::response::IntoResponse, so you can return Result<Json<T>, SqlError> directly from handlers.
With Tauri
[]
= { = "0.2", = ["tauri"] }
= { = "2", = ["devtools"] }
= { = "1", = ["full"] }
= { = "1", = ["derive"] }
= "1"
The tauri feature makes SqlError implement serde::Serialize, so Tauri commands can return Result<T, SqlError> and the error will reach the JavaScript frontend as a structured JSON object.
With Redis Cache
[]
= { = "0.2", = ["redis"] }
(Redis support is compiled in by default — no separate feature flag needed. RedisCache is always available, but only activated when you call build_with_cache and set "enabled": true in config.)
For Unit Tests
[]
= { = "0.2", = ["testing"] }
4. config.json
Place config.json at the working directory root (usually the workspace root when running cargo run).
You can also supply a programmatic AppConfig instead of a file path. See Building a Service.
5. Defining an Entity
An entity is a plain Rust struct that mirrors a database table. Add #[derive(SqlEntity)] and the proc-macro generates all SQL constants and the row-mapping code at compile time.
Basic Entity
use ;
use SqlEntity;
What the macro generates (you never write this yourself):
User::TABLE_NAME—"dbo.Users"User::SELECT_SQL—"SELECT Id, UserName, Email, Active FROM dbo.Users"User::INSERT_SQL—"INSERT INTO dbo.Users (UserName, Email, Active) OUTPUT INSERTED.Id VALUES (@UserName, @Email, @Active)"User::UPDATE_SQL—"UPDATE dbo.Users SET UserName = @UserName, Email = @Email, Active = @Active WHERE Id = @Id"User::DELETE_SQL—"DELETE FROM dbo.Users WHERE Id = @Id"User::MERGE_SQL— fullMERGEstatement for upsertUser::PK_COLUMN—"Id"User::PK_IS_IDENTITY—trueimpl User { fn from_row(row: &OdbcRow) -> Result<Self, SqlError> }— typed row mapperimpl User { fn to_params(&self) -> Vec<OdbcParam> }— parameter binderimpl User { fn pk_value(&self) -> PkValue }— primary key extractor
Entity with Optional Fields
Entity with Non-Identity Primary Key
Entity with UUID Primary Key
use Uuid;
6. Building a Service
SqlServiceFactory is the entry point. It's a builder — call .config_path(), .shutdown(), and .with_logging() as needed, then call one of the terminal .build*() methods.
No Cache (most common)
use ;
use CancellationToken;
let token = new;
let user_svc: = new
.config_path
.shutdown
.with_logging // don't touch global tracing subscriber
.
.await?;
With Redis Cache
use ;
let user_svc: = new
.config_path
.shutdown
.
.await?;
Cache is only active when "enabled": true in config.json. If disabled, RedisCache silently becomes a pass-through.
Programmatic Config (no config.json file)
use ;
let cfg = AppConfig ;
let svc = new
.config
.shutdown
.
.await?;
SprocService (for stored procedures)
use ;
let sproc: SprocService = new
.config_path
.shutdown
.build_sproc
.await?;
SprocService does not have a type parameter — it is entity-agnostic. You can share one instance across the entire application.
Graceful Shutdown
All services accept a CancellationToken. When the token is cancelled, in-flight operations are interrupted and the background pool validator stops. Wire this to your OS signal handler:
let token = new;
// spawn signal handler
let shutdown_token = token.clone;
spawn;
// all services share the same token
let svc = new
.config_path
.shutdown
.
.await?;
7. CRUD Operations (SqlRepository)
All operations are on the SqlRepository<T> trait, which SqlService<T, C> implements. Every method takes a &CancellationToken as the last argument.
Import the trait when you use a dyn SqlRepository or a generic bound:
use SqlRepository;
When working directly with SqlService<T, C> (not behind a trait object), no import is needed.
get_by_id
// Returns Option<T> — None when no row with that PK exists.
let user: = svc.get_by_id.await?;
get_all
// SELECT all rows.
let users: = svc.get_all.await?;
get_where
use params;
// Filter with a raw WHERE clause + typed parameters.
// params! creates a &[OdbcParam] on the stack — zero heap allocation.
let p = params! ;
let users: = svc.get_where.await?;
// Multiple parameters:
let p = params! ;
let users = svc.get_where.await?;
get_paged
// Page 1, 20 items per page. Pages are 1-indexed.
let page: = svc.get_paged.await?;
count
let n: i64 = svc.count.await?;
exists
let found: bool = svc.exists.await?;
insert
// Returns the new PK value as i64.
// For IDENTITY columns the DB assigns the value; for non-identity keys it echoes entity.pk_value().
let entity = User ;
let new_id: i64 = svc.insert.await?;
update
let updated = User ;
svc.update.await?;
delete
svc.delete.await?;
// Also accepts i64, String, Uuid depending on the PK type.
upsert
// MERGE INTO — inserts if PK not found, updates if found.
svc.upsert.await?;
batch_insert
// Inserts a slice; returns a Vec<i64> of new IDs.
let ids: = svc.batch_insert.await?;
batch_update
svc.batch_update.await?;
batch_delete
use PkValue;
let ids = vec!;
svc.batch_delete.await?;
8. params! Macro
params! creates a stack-allocated array of OdbcParam values. It is zero-cost — no Vec, no heap allocation.
use params;
// Single param
let p = params! ;
// Multiple params
let p = params! ;
// Using an expression
let p = params! ;
// Optional (NULL-safe)
let maybe_email: = Some;
let p = params! ; // sends NULL when None
// Passing to get_where
svc.get_where.await?;
The macro strips the @ prefix automatically when matching parameter names in the SQL string.
9. Transactions
Get a Transaction from the pool, do work, then call .commit(). If you drop the transaction without committing, it automatically rolls back.
use Pool;
// The pool is accessible via svc.pool() or store it separately.
// Here we show the pattern using a transaction obtained from the service's pool.
let conn = svc.pool.checkout.await?;
let mut tx = begin?;
tx..await?;
tx..await?;
tx.execute_raw.await?;
tx.commit.await?; // if this line is never reached, Drop auto-rolls back
Deadlock-Safe Retry
use with_retry;
let result = with_retry.await?;
with_retry detects SQL Server deadlock error 1205 and retries up to max_retries times with exponential back-off (50 ms, 100 ms, 200 ms, …).
10. Stored Procedures (SprocService)
SprocService executes stored procedures with compile-time generic return types. The dispatch is monomorphised at compile time — no virtual dispatch, no allocations beyond the final result.
Parameters
use SprocParams;
let params = new
.add
.add
.add
.add_nullable; // sends NULL
Single Result Set
// Returns Vec<T: SqlEntity>
let users: = sproc
.
.await?;
// Returns Option<T> — None if the proc returned no rows
use Single;
let Single = sproc
.
.await?;
// Returns T or error if no rows
use Required;
let Required = sproc
.
.await?;
// Returns a single scalar value
use Scalar;
let Scalar = sproc
.
.await?;
let total = count.unwrap_or;
execute (no result set)
// For procs that only perform DML and return no rows.
sproc.execute.await?;
11. Multiple Result Sets from a Sproc
When a stored procedure returns more than one result set, use query2, query3, query4, or query_multiple.
// Two result sets: total count + a page of rows
let = sproc
.
.await?;
let total_count = total.unwrap_or;
println!;
// Three result sets
let = sproc
.
.await?;
// Four result sets
let = sproc
.
.await?;
For more than four result sets, use query_multiple with a MultiReader:
use MultiReader;
let mut reader = sproc
.query_multiple
.await?;
let totals: = reader.read_list?;
let details: = reader.read_list?;
let Scalar = reader.?;
MultiReader methods:
| Method | Return type |
|---|---|
read_list::<T>() |
Result<Vec<T>, SqlError> |
read_single::<T>() |
Result<Single<T>, SqlError> |
read_required::<T>() |
Result<Required<T>, SqlError> |
read_scalar::<S>() |
Result<Scalar<S>, SqlError> |
read_raw() |
Result<Vec<OdbcRow>, SqlError> |
12. Redis Cache
Redis caching is transparent — the same SqlRepository trait is used. SqlService<T, RedisCache> automatically caches results under keys derived from the table name and primary key.
let svc: = new
.config_path
.shutdown
.
.await?;
// First call goes to SQL Server, populates Redis.
let user = svc.get_by_id.await?;
// Second call is served from Redis (TTL from config.json: default_ttl_secs).
let user = svc.get_by_id.await?;
When "enabled": false in config.json, RedisCache becomes a no-op pass-through — no Redis connection is established and all calls go directly to SQL Server.
Cache invalidation (insert/update/delete/upsert) automatically removes affected keys.
13. Raw Queries
For queries that don't map to a single entity, use the raw methods on SqlRepository.
query_raw — returns untyped rows
use OdbcRow;
let rows: = svc
.query_raw
.await?;
for row in &rows
execute_raw — DML without an entity
let affected: usize = svc
.execute_raw
.await?;
scalar — single value query
let max_price: Decimal = svc
.scalar
.await?;
OdbcRow field accessors
row.get_string?
row.get_i32?
row.get_i64?
row.get_bool?
row.get_f64?
row.get_decimal?
row.get_datetime?
row.get_naive_date?
row.get_bytes?
row.get_uuid?
// Optional variants (return None when the cell is NULL):
row.get_string_opt? // Option<String>
row.get_i32_opt? // Option<i32>
// ... and so on for every type
14. Error Handling
SqlError is the single error type. All methods return Result<_, SqlError>.
use SqlError;
match svc.get_by_id.await
Client-safe messages
Never expose internal error details (ODBC error text, connection strings) to clients. Use safe_message():
.map_err // returns a String safe to send to the frontend
Error codes for structured responses
let code = err.error_code; // "odbc_error", "pool_exhausted", "not_found", etc.
axum feature — automatic HTTP status mapping
With the axum feature, SqlError: IntoResponse:
// In an axum handler — no match needed, errors become proper HTTP responses
async
HTTP status mapping:
| Error | HTTP Status |
|---|---|
NotFound |
404 |
Cancelled |
499 |
PoolExhausted, DeadlockRetryExhausted |
503 |
QueryTimeout |
504 |
| everything else | 500 |
JSON body:
tauri feature — IPC-safe error serialization
With the tauri feature, SqlError: serde::Serialize:
async
Or, to send structured JSON to the JS frontend:
async
15. Testing with MockRepository
Enable the testing feature in [dev-dependencies]:
[]
= { = "0.2", = ["testing"] }
MockRepository<T> is an in-memory implementation of SqlRepository<T>. It requires no database.
use MockRepository;
use CancellationToken;
async
async
MockRepository API summary
| Method | Description |
|---|---|
MockRepository::new() |
Empty store |
MockRepository::with_data(iter) |
Pre-populated store |
repo.seed(item).await |
Add/replace an item (bypasses counters) |
repo.clear().await |
Remove all items |
repo.all_items().await |
Vec<T> snapshot |
repo.len().await |
Current item count |
repo.insert_call_count() |
How many times insert was called |
repo.update_call_count() |
How many times update was called |
repo.delete_call_count() |
How many times delete was called |
repo.upsert_call_count() |
How many times upsert was called |
Limitations:
get_whereandquery_rawreturn all items; the SQL filter string is not evaluated.get_pagedslices the full list; ordering is hash-map insertion order.scalaralways returns an error — supply a custom mock type for scalar tests.
Testing with trait objects
SqlRepository<T> is object-safe. You can inject either a real service or a mock through Arc<dyn SqlRepository<T>>:
// In production:
let repo: = new;
// In tests:
let repo: = new;
16. Axum Integration — Full Example
SQL
(
Id INT IDENTITY(1,1) PRIMARY KEY,
Name NVARCHAR(200) NOT NULL,
Price FLOAT NOT NULL,
Stock INT NOT NULL DEFAULT 0,
Active BIT NOT NULL DEFAULT 1
);
Cargo.toml
[]
= "my-api"
= "0.1.0"
= "2021"
[]
= { = "0.2", = ["axum"] }
= "0.8"
= { = "0.6", = ["cors", "trace"] }
= { = "1", = ["full"] }
= { = "0.7", = ["full"] }
= { = "1", = ["derive"] }
= "1"
= "0.1"
= "0.3"
main.rs
use Arc;
use ;
use ;
use ;
use CancellationToken;
use CorsLayer;
// ── Entity ────────────────────────────────────────────────────────────────────
// ── Application state ─────────────────────────────────────────────────────────
// ── DTOs ──────────────────────────────────────────────────────────────────────
// ── main ──────────────────────────────────────────────────────────────────────
async
// ── Handlers ──────────────────────────────────────────────────────────────────
// Every handler returns axum::response::Response.
// SqlError implements IntoResponse (axum feature) — no manual status mapping needed.
async
async
async
async
async
async
// Requires: CREATE PROCEDURE dbo.sp_GetTopProducts @TopN INT AS
// SELECT TOP (@TopN) * FROM dbo.Products ORDER BY Price DESC
async
async
Key points for Axum
- Store
SqlServiceandSprocServiceinArc<_>so they can be cloned intoAppState. AppStatemust implementClone— axum'sState<S>extractor clones it per request.- With
features = ["axum"], return type can beaxum::response::Responsefrom all handlers and call.into_response()on both the success and the error value. - Call
with_logging(false)— axum apps set up their own tracing subscriber before callingSqlServiceFactory::build. - Pass the same
CancellationTokento all services and to the signal handler.
17. Leptos SSR Integration — Full Example
Leptos SSR runs your components server-side (Rust) and hydrates them in the browser. DB access happens inside #[server] functions which are compiled for the server target only.
SQL (same table as above)
(
Id INT IDENTITY(1,1) PRIMARY KEY,
Name NVARCHAR(200) NOT NULL,
Price FLOAT NOT NULL,
Stock INT NOT NULL DEFAULT 0,
Active BIT NOT NULL DEFAULT 1
);
Cargo.toml
[]
= "my-leptos-app"
= "0.1.0"
= "2021"
[]
= { = "0.2" }
= { = "0.7", = ["ssr"] }
= { = "0.7" }
= { = "0.7", = ["ssr"] }
= { = "0.7", = ["macros"] } # leptos_axum 0.7 requires axum 0.7
= { = "1", = ["full"] }
= { = "0.7", = ["full"] }
= { = "1", = ["derive"] }
= "0.3"
Note: leptos_axum 0.7 depends on axum 0.7. Do not mix with axum 0.8 in the same binary.
src/main.rs
use OnceLock;
use ;
use get_configuration;
use ;
use ;
use ;
use CancellationToken;
use crate;
// ── Entity (defined at crate root so both main.rs and app.rs can use it) ──────
// ── Global singletons accessed by server functions ────────────────────────────
// In a larger application, prefer leptos_axum::extract with axum State.
// OnceLock works well for simple apps.
pub
// ── main ──────────────────────────────────────────────────────────────────────
async
src/app.rs
use *;
use ;
// SqlRepository must be in scope for .insert(), .get_where(), etc. to compile
use SqlRepository;
use crateProduct;
// ── Server Functions ───────────────────────────────────────────────────────────
// #[server] compiles the function body only for the server target.
// On the client, Leptos replaces it with an HTTP call automatically.
pub async
pub async
pub async
// ── Shell and root component ──────────────────────────────────────────────────
// ── Components ────────────────────────────────────────────────────────────────
Key points for Leptos SSR
- The entity struct must implement
PartialEq(LeptosResourceneeds it to detect changes) andSerialize + Deserialize(sent over the wire as JSON). - Inside a
#[server]function, bringuse statiq::SqlRepositoryinto scope — without it,.insert(),.get_where(), and the other methods are not in scope. db::products()returns&'static SqlService<...>viaOnceLock. This is appropriate for simple apps. For production apps with per-request context, useleptos_axum::extract::<State<AppState>>()inside server functions.leptos_axum0.7 usesaxum0.7 — do not upgrade to axum 0.8 in the same binary.- Leptos server functions are HTTP endpoints under the hood.
ServerFnErroris the only allowed error type. WrapSqlErrorwith.map_err(|e| ServerFnError::new(e.to_string())).
18. Type Mappings (SQL Server ↔ Rust)
| SQL Server type | Rust field type | params! sends |
OdbcRow getter |
|---|---|---|---|
bit |
bool / Option<bool> |
Bool |
get_bool / get_bool_opt |
tinyint |
u8 / Option<u8> |
U8 |
get_u8 / get_u8_opt |
smallint |
i16 / Option<i16> |
I16 |
get_i16 / get_i16_opt |
int |
i32 / Option<i32> |
I32 |
get_i32 / get_i32_opt |
bigint |
i64 / Option<i64> |
I64 |
get_i64 / get_i64_opt |
real |
f32 / Option<f32> |
F32 |
get_f32 / get_f32_opt |
float |
f64 / Option<f64> |
F64 |
get_f64 / get_f64_opt |
decimal, numeric, money, smallmoney |
rust_decimal::Decimal |
Decimal |
get_decimal |
char, varchar, nchar, nvarchar |
String / Option<String> |
Str |
get_string / get_string_opt |
text, ntext, xml, sql_variant |
String |
Str |
get_string |
binary, varbinary, image, rowversion |
Vec<u8> |
Bytes |
get_bytes |
date |
chrono::NaiveDate |
NaiveDate |
get_naive_date |
time |
chrono::NaiveTime |
NaiveTime |
get_naive_time |
datetime, datetime2, smalldatetime |
chrono::DateTime<chrono::Utc> |
DateTime(Utc) |
get_datetime |
datetimeoffset |
chrono::DateTime<chrono::FixedOffset> |
DateTimeOffset |
get_datetime_offset |
uniqueidentifier |
uuid::Uuid |
Guid |
get_uuid |
NULL (any) |
Option<T> |
Null |
get_*_opt |
19. Attribute Reference
Struct-level
#[sql_table("TableName", schema = "dbo")]
Required. Sets the SQL table name and schema. If schema is omitted, no schema prefix is emitted.
Field-level
| Attribute | Effect |
|---|---|
#[sql_primary_key] |
Marks this field as the PK. Required on exactly one field. Included in SELECT, UPDATE WHERE, DELETE WHERE, and MERGE match. |
#[sql_primary_key(identity)] |
Same as above, but also excludes the field from INSERT (SQL Server assigns the value). |
#[sql_column("ColName")] |
Overrides the SQL column name used in all generated SQL. The struct field name is used as the @param name in params!. |
#[sql_ignore] |
Completely excludes the field from all generated SQL. Not in SELECT, not in INSERT, not in params. |
#[sql_computed] |
DB-computed column. Included in SELECT only. Excluded from INSERT, UPDATE, MERGE, and to_params(). |
#[sql_default] |
Server-side DEFAULT. Included in SELECT and UPDATE. Excluded from INSERT and MERGE-insert clause. |
20. config.json Field Reference
mssql.connection_string
ODBC connection string. Common format:
Driver={ODBC Driver 17 for SQL Server};Server=HOST\INSTANCE;Database=DBNAME;UID=USER;PWD=PASS;
Driver={ODBC Driver 17 for SQL Server};Server=HOST,1433;Database=DBNAME;Trusted_Connection=yes;
mssql.pool
| Field | Type | Default | Description |
|---|---|---|---|
max_size |
u32 | 100 | Maximum total connections in the pool. |
min_size |
u32 | 5 | Minimum connections kept open (warm pool). |
idle_timeout_secs |
u64 | 300 | Close a connection that has been idle this long. |
max_lifetime_secs |
u64 | 1800 | Close and replace a connection older than this (0 = no limit). |
checkout_timeout_ms |
u64 | 5000 | How long a caller waits for an available connection before PoolExhausted. |
validation_interval_secs |
u64 | 60 | How often the background validator runs to cull idle/stale connections. |
max_deadlock_retries |
u8 | 3 | Used by with_retry when not overridden explicitly. |
reset_connection_on_reuse |
bool | false | Run EXEC sp_reset_connection before reusing a connection. Mirrors ADO.NET behaviour. |
mssql.query
| Field | Type | Default | Description |
|---|---|---|---|
default_command_timeout_secs |
u64 | 30 | ODBC statement timeout. |
slow_query_threshold_ms |
u64 | 1000 | Queries exceeding this are logged at WARN level. |
max_text_bytes |
usize | 65536 | Maximum byte size per text/binary cell. Increase for nvarchar(max) / xml / varbinary(max) columns. |
redis
| Field | Type | Default | Description |
|---|---|---|---|
url |
String | "redis://127.0.0.1:6379" |
Redis connection URL. |
pool_size |
u32 | 20 | Redis connection pool size. |
default_ttl_secs |
u64 | 300 | Default TTL for cached records. |
count_ttl_secs |
u64 | 60 | TTL for cached count() results. |
enabled |
bool | false | When false, RedisCache is a no-op pass-through. |
logging
| Field | Type | Default | Description |
|---|---|---|---|
level |
String | "INFO" |
Log level: "ERROR", "WARN", "INFO", "DEBUG", "TRACE". |
format |
String | "json" |
"json" for structured JSON, "text" for human-readable. |
Only used when SqlServiceFactory::with_logging(true) is set. In Axum/Leptos/Tauri applications, call with_logging(false) and set up your own subscriber.