Tank
Tank (Table Abstraction & Navigation Kit): the Rust data layer.
One platform. Any terrain.
📘 https://tankhq.github.io/tank
🖥️ https://github.com/TankHQ/tank
📦 https://crates.io/crates/tank
Mission Briefing
In plain terms, Tank is a thin layer over your database workflow, designed for the Rust operator who needs to deploy across multiple environments without changing the kit.
It doesn't matter if you are digging into a local SQLite trench, coordinating a distributed ScyllaDB offensive, or managing a Postgres stronghold, Tank provides a unified interface. You define your entities once. Tank handles the ballistics.
Known battlefields:
- Postgres
- SQLite
- MySQL/MariaDB
- DuckDB
- MongoDB
- ScyllaDB/Cassandra
- Valkey/Redis
Mission Objectives
Tank exists to implement the best possible design for a ORM written in Rust. A a clean-slate design focused on ergonomics, flexibility and broad database support.
- Async operations - Fire and forget.
- Designed to be extensible - Swap databases like changing magazines mid-battle.
- SQL and NoSQL support - One Tank, all terrains.
- Transactions abstraction - Commit on success or rollback and retreat.
- Rich type arsenal - Automatic conversions between Rust and database types.
- Optional appender API - High caliber bulk inserts.
- TLS - No open radios on this battlefield.
- Joins - Multi unit coordination.
- Raw SQL - You're never limited by the abstractions provided.
- Zero setup - Skip training. Go straight to live fire.
No-Fly Zone
- No schema migrations (just table creation and destroy for fast setup).
- No implicit joins (no entities as fields, joins are explicit, every alliance is signed).
Why Tank?
Intelligence Report: A quick recon of the battlefield revealed that while existing heavy weaponry is effective, there was a critical need for a more adaptable, cleaner design. Tank was designed from scratch to address these weaknesses.
1. Modular Architecture: Some systems rely on hardcoded enums for database support, which limits flexibility. If a backend isn't in the core list, it cannot be used. Tank uses an extensible design pattern: a driver can be implemented for any database (SQL or NoSQL) without touching the core library. If it can hold data, Tank can likely target it.
2. Zero Boilerplate: Field operations shouldn't require filling out forms in triplicate. Some tools force data definition twice: once in a complex DSL and again as a Rust struct. Tank keeps it simple: One struct. One definition. The macros handle table creation, selection, and insertion automatically based on standard Rust structs. You can set up tables and get database communication running in just a few lines of code, all through a unified API that works the same regardless of the backend. Perfect for spinning up tests and prototypes rapidly while still scaling to production backends.
Operational Guide
- Arm your cargo
cargo add tank
- Choose your battlefield
cargo add tank-duckdb
- Define unit schematics
use std::borrow::Cow;
use tank::{Entity, Executor, Result};
#[derive(Entity)]
#[tank(schema = "army")]
pub struct Tank {
#[tank(primary_key)]
pub name: String,
pub country: Cow<'static, str>,
#[tank(name = "caliber")]
pub caliber_mm: u16,
#[tank(name = "speed")]
pub speed_kmh: f32,
pub is_operational: bool,
pub units_produced: Option<u32>,
}
- Fire for effect
use std::{borrow::Cow, collections::HashSet, sync::LazyLock};
use tank::{Entity, Executor, Result, expr, stream::TryStreamExt};
use tank_duckdb::DuckDBDriver;
async fn data() -> Result<()> {
let driver = DuckDBDriver::new();
let connection = driver
.connect("duckdb://../target/debug/tests.duckdb?mode=rw".into())
.await?;
let my_tank = Tank {
name: "Tiger I".into(),
country: "Germany".into(),
caliber_mm: 88,
speed_kmh: 45.4,
is_operational: false,
units_produced: Some(1_347),
};
Tank::create_table(connection, true, true).await?;
my_tank.save(connection).await?;
Tank::insert_many(
connection,
&[
Tank {
name: "T-34/85".into(),
country: "Soviet Union".into(),
caliber_mm: 85,
speed_kmh: 53.0,
is_operational: false,
units_produced: Some(49_200),
},
Tank {
name: "M1 Abrams".into(),
country: "USA".into(),
caliber_mm: 120,
speed_kmh: 72.0,
is_operational: true,
units_produced: None,
},
],
)
.await?;
let tanks = Tank::find_many(connection, expr!(Tank::is_operational == false), Some(1000))
.try_collect::<Vec<_>>()
.await?;
assert_eq!(
tanks
.iter()
.map(|t| t.name.to_string())
.collect::<HashSet<_>>(),
HashSet::from_iter(["Tiger I".into(), "T-34/85".into()])
);
Ok(())
}
Examples
Books
#[derive(Entity, Clone, PartialEq, Debug)]
#[tank(schema = "testing", name = "authors")]
pub struct Author {
#[tank(primary_key, name = "author_id")]
pub id: Uuid,
pub name: String,
...
}
#[derive(Entity, Clone, PartialEq, Debug)]
#[tank(schema = "testing", name = "books", primary_key = (Self::title, Self::author))]
pub struct Book {
#[tank(column_type = (mysql = "VARCHAR(255)"))]
pub title: String,
#[tank(references = Author::id)]
pub author: Uuid,
...
}
#[derive(Entity, PartialEq, Debug)]
struct BookAuthorResult {
#[tank(name = "title")]
book: String,
#[tank(name = "name")]
author: String,
}
let result = executor
.fetch(
QueryBuilder::new()
.select(cols!(B.title, A.name))
.from(join!(Book B JOIN Author A ON B.author == A.author_id))
.where_expr(expr!(B.year < 2000))
.order_by(cols!(B.title DESC))
.build(&executor.driver()),
)
.map_ok(BookAuthorResult::from_row)
.map(Result::flatten)
.try_collect::<Vec<_>>()
.await
.expect("Failed to query books and authors joined");
assert_eq!(
result,
[
BookAuthorResult{
book: "The Hobbit".into(),
author: "J.R.R. Tolkien".into()
},
BookAuthorResult{
book: "Harry Potter and the Philosopher's Stone".into(),
author: "J.K. Rowling".into(),
},
]
);
Radio Logs
#[derive(Entity)]
#[tank(schema = "operations", name = "radio_operator")]
pub struct Operator {
#[tank(primary_key)]
pub id: Uuid,
pub callsign: String,
#[tank(name = "rank")]
pub service_rank: String,
#[tank(name = "enlistment_date")]
pub enlisted: Date,
pub is_certified: bool,
}
#[derive(Entity)]
#[tank(schema = "operations")]
pub struct RadioLog {
#[tank(primary_key)]
pub id: Uuid,
#[tank(references = Operator::id)]
pub operator: Uuid,
pub message: String,
pub unit_callsign: String,
#[tank(name = "tx_time")]
pub transmission_time: OffsetDateTime,
#[tank(name = "rssi")]
pub signal_strength: i8,
}
let operator = Operator {
id: Uuid::parse_str("21c90df5-00db-4062-9f5a-bcfa2e759e78").unwrap(),
callsign: "SteelHammer".into(),
service_rank: "Major".into(),
enlisted: date!(2015 - 06 - 20),
is_certified: true,
};
Operator::insert_one(executor, &operator).await?;
let op_id = operator.id;
let logs: Vec<RadioLog> = (0..5)
.map(|i| RadioLog {
id: Uuid::new_v4(),
operator: op_id,
message: format!("Ping #{i}"),
unit_callsign: "Alpha-1".into(),
transmission_time: OffsetDateTime::now_utc(),
signal_strength: 42,
})
.collect();
RadioLog::insert_many(executor, &logs).await?;
if let Some(radio_log) = RadioLog::find_one(
executor,
expr!(RadioLog::unit_callsign == "Alpha-%" as LIKE),
)
.await?
{
log::debug!("Found radio log: {:?}", radio_log.id);
}
let mut query =
RadioLog::prepare_find(executor, expr!(RadioLog::signal_strength > ?), None).await?;
query.bind(40)?;
let _messages: Vec<_> = executor
.fetch(query)
.map_ok(|row| row.values[0].clone())
.try_collect()
.await?;
Rustaceans don't hide behind ORMs, they drive Tanks.