Drizzle RS
A type-safe SQL query builder and ORM for Rust, inspired by Drizzle ORM.
[!WARNING]
This project is still evolving. Expect breaking changes.
Contents
Getting Started
1. Install
[dependencies]
drizzle = { version = "0.2", features = ["rusqlite"] }
rusqlite = { version = "0.39", features = ["bundled"] }
Pick the driver feature that matches the client your application already
uses. You create and own the connection; drizzle wraps it.
Install the CLI with the drivers it should connect through:
cargo install drizzle-cli --locked --features sqlite-all
Individual driver features (rusqlite, postgres-sync, mysql-async, ...)
work too. Without a driver, generate still works, but migrate, push, and
introspect stop with a "No driver available" error.
See Feature Flags for every driver and optional column type.
2. Initialize
drizzle init --dialect sqlite
This creates drizzle.config.toml. Point it at your schema and database:
dialect = "sqlite"
schema = "src/schema.rs"
out = "./drizzle"
[dbCredentials]
url = "./dev.db"
3. Define Your Schema
# #[cfg(feature = "rusqlite")]
# fn main() {
use drizzle::sqlite::prelude::*;
#[SQLiteTable]
pub struct Users {
#[column(primary, autoincrement)]
pub id: i64,
pub name: String,
pub email: Option<String>,
pub age: i64,
}
#[SQLiteTable]
pub struct Posts {
#[column(primary, autoincrement)]
pub id: i64,
pub title: String,
pub content: Option<String>,
#[column(references = Users::id)]
pub author_id: i64,
}
#[SQLiteTable]
pub struct Comments {
#[column(primary, autoincrement)]
pub id: i64,
pub body: String,
#[column(references = Posts::id)]
pub post_id: i64,
}
#[derive(SQLiteSchema)]
pub struct Schema {
pub users: Users,
pub posts: Posts,
pub comments: Comments,
}
# }
# #[cfg(not(feature = "rusqlite"))]
# fn main() {}
Each table also gets a module of column types named after it: users::Name is the type of Users::name, should you need to name it. The module keeps these types apart from your own, so a User table can have a role: UserRole column.
If you already have a database, run drizzle introspect to reverse-engineer the schema instead of writing it by hand.
4. Connect & Query
# #[cfg(feature = "rusqlite")]
# fn main() -> drizzle::Result<()> {
# mod readme {
# include!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/readme/sqlite.rs"));
# }
# use readme::Schema;
use drizzle::sqlite::rusqlite::Drizzle;
let conn = rusqlite::Connection::open("app.db")?;
let (db, Schema { users, posts, comments }) = Drizzle::new(conn);
# Ok(())
# }
# #[cfg(not(feature = "rusqlite"))]
# fn main() {}
Drizzle::new builds the schema value itself, and the pattern that
destructures it names its type. When nothing else names it, put the type on
the call: Drizzle::<Schema>::new(conn) (MySQL: Drizzle::<_, Schema>::new(conn)).
Use let (db, ()) = Drizzle::new(conn) to run queries with no schema.
[!NOTE]
See examples/rusqlite.rs for a full runnable example.
Feature Flags
| Feature |
What it enables |
rusqlite, libsql, turso |
SQLite drivers |
d1, durable |
Cloudflare D1 and Durable Object SQLite (wasm32 only) |
postgres-sync, tokio-postgres |
PostgreSQL drivers |
hyperdrive |
Cloudflare Hyperdrive over tokio-postgres (wasm32 only) |
aws-data-api |
AWS Aurora Serverless Data API (PostgreSQL over HTTP) |
mysql-sync, mysql-async |
MySQL drivers (mysql / mysql_async) |
query |
Relational queries (db.query(...)) |
serde |
JSON columns |
uuid, chrono, time, jiff, rust-decimal |
Column types from those crates |
arrayvec, compact-str, bytes, smallvec-types |
Inline and zero-copy string/byte column types |
cidr, geo-types, bit-vec |
PostgreSQL network, geometric, and bit-string types |
math |
SQLite math functions (see Expressions) |
tracing, profiling |
Query spans and puffin profiling scopes |
Migrations
You have two workflows for keeping migration files in sync with your schema. Pick one — both produce the same committed SQL; the difference is whether you regenerate by hand or let cargo do it.
| Workflow |
Generate migrations |
Best for |
| Manual |
Run drizzle generate yourself |
Teams that want explicit control over when migrations are produced |
| Automatic |
Regenerated when watched schema/config inputs change during cargo build |
Solo dev or small teams who want schema and migrations to stay in lockstep |
Both workflows apply migrations the same way — either with the CLI at deploy time, or from your app at startup. For local iteration without committed files at all, see Push (Dev Only).
Manual: Generate with the CLI
Run drizzle generate whenever you change your schema, then commit the resulting SQL files:
drizzle generate drizzle generate --name init
Automatic: Generate from build.rs
Add drizzle-migrations as a build dependency, then point it at your existing drizzle.config.toml. Migration files regenerate themselves whenever your schema changes — you commit them the same way as the manual workflow, you just never run drizzle generate by hand.
[build-dependencies]
drizzle = { version = "0.2", features = ["rusqlite"] }
drizzle-migrations = "0.2"
rusqlite = { version = "0.39", features = ["bundled"] }
use drizzle_migrations::build::{Config, Output, run};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cfg = Config::from_toml("drizzle.config.toml")?;
cfg.watch();
if let Output::Generated { tag, .. } = run(&cfg)? {
println!("cargo:warning=generated migration {tag}");
}
Ok(())
}
cfg.watch() tells cargo to rerun build.rs whenever a schema file, drizzle.config.toml, or a referenced env var changes.
Applying Migrations
Once migration files exist, apply them one of three ways. They all use the same SQL files and tracking table — pick whichever fits your environment.
At deploy time, with the CLI:
drizzle migrate
At app startup, from your code:
use drizzle::migrations::Tracking;
let migrations = drizzle::include_migrations!("./drizzle");
db.migrate(&migrations, Tracking::SQLITE)?;
Use Tracking::POSTGRES for PostgreSQL and Tracking::MYSQL for MySQL. All
three record applied migrations in a __drizzle_migrations table, which
PostgreSQL keeps in a drizzle schema. Override the tracking table or schema
when you need to:
db.migrate(
&migrations,
Tracking::POSTGRES
.schema("ops")
.table("schema_migrations"),
)?;
MySQL DDL implicitly commits, so MySQL migrations do not pretend to be
transactional. The CLI takes a database-scoped advisory lock, writes a durable
dirty marker before the first statement, and marks the migration complete only
after every statement succeeds. If a migration fails or is interrupted, inspect
the partially applied DDL before retrying; automatic MySQL repair is deliberately
unsupported because the server may already have committed some statements.
During cargo build, by extending the build.rs from above. Set DRIZZLE_MIGRATE=1 in your dev environment and your local database stays in lockstep with the schema:
use drizzle::sqlite::rusqlite::Drizzle;
use drizzle_migrations::{MigrateOutcome, MigrationDir};
// `cfg.watch()` does not watch this flag; without this line, cargo would not
// rerun build.rs when you set or unset it.
println!("cargo:rerun-if-env-changed=DRIZZLE_MIGRATE");
if std::env::var("DRIZZLE_MIGRATE").is_ok() {
let conn = rusqlite::Connection::open(cfg.url()?)?;
let (db, ()) = Drizzle::new(conn);
let migrations = MigrationDir::new(cfg.out_dir()).discover()?;
if let MigrateOutcome::Applied { tags } = db.migrate(&migrations, cfg.tracking())? {
println!("cargo:warning=applied {} migration(s)", tags.len());
}
}
cfg.tracking() returns the same Tracking value the runtime path uses — just sourced from drizzle.config.toml instead of hardcoded.
migrate creates the tracking schema/table if needed and skips migrations that have already been applied. Without DRIZZLE_MIGRATE, cargo build only generates files and never touches the database.
Push (Dev Only)
# #[cfg(feature = "rusqlite")]
# fn main() -> drizzle::Result<()> {
# mod readme {
# include!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/readme/sqlite.rs"));
# }
# use readme::*;
# let (mut db, _) = readme::database()?;
let schema = Schema::new();
db.push(&schema)?;
# Ok(())
# }
# #[cfg(not(feature = "rusqlite"))]
# fn main() {}
push skips migration files entirely and applies the live schema diff directly.
[!CAUTION]
push is for local iteration only. It bypasses the migration tracking table and offers no audit trail. Never run it against a production database.
Generated Models
Given the schema above, each #[SQLiteTable], #[PostgresTable], or
#[MySQLTable] generates four helper types:
| Model |
Purpose |
Fields |
SelectUsers |
Full-row query results |
One field per column, with the declared type |
InsertUsers |
Insert rows |
new(name, age) requires non-default fields; with_email(...) for optional ones |
UpdateUsers |
Update rows |
default() starts empty; with_age(27) sets fields to update |
PartialSelectUsers |
Partial-column query results |
All fields Option<T>; populated by db.query(users).columns(...) (see Relational Queries) |
Insert
new() takes only the required fields (columns without a default or autoincrement). Chain with_* for optional fields:
# #[cfg(feature = "rusqlite")]
# fn main() {
# mod readme {
# include!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/readme/sqlite.rs"));
# }
# use readme::InsertUsers;
InsertUsers::new("Alex Smith", 26i64)
.with_email("alex@example.com");
# }
# #[cfg(not(feature = "rusqlite"))]
# fn main() {}
Update
Start from default() and set only the fields you want to change. The query won't compile unless at least one field is set:
# #[cfg(feature = "rusqlite")]
# fn main() {
# mod readme {
# include!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/readme/sqlite.rs"));
# }
# use readme::UpdateUsers;
UpdateUsers::default()
.with_age(27)
.with_email("new@example.com");
# }
# #[cfg(not(feature = "rusqlite"))]
# fn main() {}
JSON Columns
With the serde feature, any Serialize + Deserialize type can be stored in a
JSON column (json; json or jsonb on PostgreSQL; JSON on MySQL). The
field keeps its own type in the generated models, and one payload type can back
columns in several tables. The macro implements nothing on the payload type,
and your crate does not need a serde_json dependency. Generated models
implement Debug, Clone, PartialEq and Default whenever every field type
does, so a payload type only needs the traits you actually use.
# #[cfg(all(feature = "rusqlite", feature = "serde"))]
# fn main() -> drizzle::Result<()> {
use drizzle::core::Json;
use drizzle::core::expr::eq;
use drizzle::sqlite::prelude::*;
# use drizzle::sqlite::rusqlite::Drizzle;
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)]
pub struct Settings {
pub theme: String,
}
#[SQLiteTable]
pub struct Profiles {
#[column(primary)]
pub id: i64,
#[column(json)]
pub settings: Settings,
#[column(json)]
pub tags: Vec<String>,
}
#[derive(SQLiteSchema)]
pub struct Schema {
pub profiles: Profiles,
}
# let conn = rusqlite::Connection::open_in_memory()?;
# let (db, Schema { profiles }) = Drizzle::new(conn);
# db.create()?;
let dark = Settings { theme: "dark".into() };
db.insert(profiles)
.value(InsertProfiles::new(dark.clone(), vec!["admin".into()]))
.execute()?;
let rows: Vec<SelectProfiles> = db
.select(())
.from(profiles)
.r#where(eq(profiles.settings, Json(dark.clone())))
.all()?;
assert_eq!(rows[0].settings, dark);
# Ok(())
# }
# #[cfg(not(all(feature = "rusqlite", feature = "serde")))]
# fn main() {}
Selecting a JSON column on its own (db.select(profiles.settings)) yields
Json<Settings>; use .0 or .into_inner() to get the payload.
Querying
Comparison and expression functions such as eq, gt, and, and count live
in drizzle::core::expr. Ordering helpers such as asc and desc live in
drizzle::core.
Select
# #[cfg(feature = "rusqlite")]
# fn main() -> drizzle::Result<()> {
# mod readme {
# include!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/readme/sqlite.rs"));
# }
# use drizzle::core::expr::*;
# use readme::*;
# let (db, Schema { users, .. }) = readme::database()?;
let all: Vec<SelectUsers> = db.select(()).from(users).all()?;
let user: SelectUsers = db
.select(())
.from(users)
.r#where(eq(users.name, "Alex Smith"))
.get()?;
let names: Vec<(i64, String)> = db
.select((users.id, users.name))
.from(users)
.all()?;
let active_adults: Vec<SelectUsers> = db
.select(())
.from(users)
.r#where((gt(users.age, 18), eq(users.name, "Alex Smith")))
.all()?;
let rows: Vec<SelectUsers> = db
.select(())
.from(users)
.r#where(eq(users.name, "Alice") | eq(users.name, "Bob"))
.all()?;
# Ok(())
# }
# #[cfg(not(feature = "rusqlite"))]
# fn main() {}
Combining Conditions
A tuple of conditions is a condition, so lists stay flat instead of nesting
and(a, and(b, c)). all and any combine the same lists explicitly.
# #[cfg(feature = "rusqlite")]
# fn main() -> drizzle::Result<()> {
# mod readme {
# include!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/readme/sqlite.rs"));
# }
use drizzle::core::expr::{all, any, eq, gt, is_not_null};
# use readme::*;
# let (db, Schema { users, posts, .. }) = readme::database()?;
let adults: Vec<SelectUsers> = db
.select(())
.from(users)
.r#where((gt(users.age, 18), eq(users.name, "Alex"), is_not_null(users.email)))
.all()?;
let staff: Vec<SelectUsers> = db
.select(())
.from(users)
.r#where(any((eq(users.name, "Alice"), eq(users.name, "Bob"))))
.all()?;
let posts: Vec<(i64, i64)> = db
.select((users.id, posts.id))
.from(users)
.inner_join((posts, all((eq(posts.author_id, users.id), is_not_null(posts.content)))))
.all()?;
# Ok(())
# }
# #[cfg(not(feature = "rusqlite"))]
# fn main() {}
Elements may be Options — None contributes nothing, which makes dynamic
filters composable:
# #[cfg(feature = "rusqlite")]
# fn main() -> drizzle::Result<()> {
# mod readme {
# include!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/readme/sqlite.rs"));
# }
# use drizzle::core::expr::{eq, gt};
# use readme::*;
# let (db, Schema { users, .. }) = readme::database()?;
# let name = Some("Alex");
let by_name = name.map(|n| eq(users.name, n));
let rows: Vec<SelectUsers> = db
.select(())
.from(users)
.r#where((gt(users.age, 18), by_name))
.all()?;
# Ok(())
# }
# #[cfg(not(feature = "rusqlite"))]
# fn main() {}
When every element is absent the list renders as its operator's identity:
TRUE for a tuple or all (matches everything, like an absent WHERE) and
FALSE for any (matches nothing, so a fully-optional any fails closed).
Bare tuples hold up to 8 conditions; all/any and nesting cover longer lists.
Ordering, Limiting, Pagination
# #[cfg(feature = "rusqlite")]
# fn main() -> drizzle::Result<()> {
# mod readme {
# include!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/readme/sqlite.rs"));
# }
# use drizzle::core::{asc, desc};
# use readme::*;
# let (db, Schema { users, .. }) = readme::database()?;
let rows: Vec<SelectUsers> = db
.select(())
.from(users)
.order_by((asc(users.name), desc(users.age)))
.limit(10)
.offset(20)
.all()?;
# Ok(())
# }
# #[cfg(not(feature = "rusqlite"))]
# fn main() {}
Group By
# #[cfg(feature = "rusqlite")]
# fn main() -> drizzle::Result<()> {
# mod readme {
# include!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/readme/sqlite.rs"));
# }
# use drizzle::core::expr::{alias, count, gt};
# use readme::*;
# let (db, Schema { users, .. }) = readme::database()?;
let totals: Vec<(String, i64)> = db
.select((users.name, alias(count(users.id), "total")))
.from(users)
.group_by(users.name)
.having(gt(count(users.id), 1))
.all()?;
let totals_by_age: Vec<(String, i64, i64)> = db
.select((users.name, users.age, alias(count(users.id), "total")))
.from(users)
.group_by((users.name, users.age))
.all()?;
# Ok(())
# }
# #[cfg(not(feature = "rusqlite"))]
# fn main() {}
Insert
# #[cfg(feature = "rusqlite")]
# fn main() -> drizzle::Result<()> {
# mod readme {
# include!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/readme/sqlite.rs"));
# }
# use readme::*;
# let (db, Schema { users, .. }) = readme::database()?;
db.insert(users)
.value(InsertUsers::new("Alex Smith", 26i64).with_email("alex@example.com"))
.execute()?;
db.insert(users)
.values([
InsertUsers::new("Alex Smith", 26i64).with_email("alex@example.com"),
InsertUsers::new("Jordan Lee", 30i64).with_email("jordan@example.com"),
])
.execute()?;
# Ok(())
# }
# #[cfg(not(feature = "rusqlite"))]
# fn main() {}
[!IMPORTANT]
In a multi-row insert, every row must set the same set of optional fields. Mixing with_email(...) on some rows but not others is a compile error.
Update
# #[cfg(feature = "rusqlite")]
# fn main() -> drizzle::Result<()> {
# mod readme {
# include!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/readme/sqlite.rs"));
# }
# use drizzle::core::expr::eq;
# use readme::*;
# let (db, Schema { users, .. }) = readme::database()?;
db.update(users)
.set(UpdateUsers::default().with_age(27))
.r#where(eq(users.id, 1))
.execute()?;
# Ok(())
# }
# #[cfg(not(feature = "rusqlite"))]
# fn main() {}
Delete
# #[cfg(feature = "rusqlite")]
# fn main() -> drizzle::Result<()> {
# mod readme {
# include!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/readme/sqlite.rs"));
# }
# use drizzle::core::expr::eq;
# use readme::*;
# let (db, Schema { users, .. }) = readme::database()?;
db.delete(users)
.r#where(eq(users.id, 3))
.execute()?;
# Ok(())
# }
# #[cfg(not(feature = "rusqlite"))]
# fn main() {}
Joins
Use #[derive(SQLiteFromRow)] to map columns from multiple tables into a flat struct. #[from(Users)] sets the default source table for unannotated fields:
# #[cfg(feature = "rusqlite")]
# fn main() -> drizzle::Result<()> {
# mod readme {
# include!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/readme/sqlite.rs"));
# }
use drizzle::core::expr::eq;
use drizzle::sqlite::prelude::*;
# use readme::*;
#[derive(SQLiteFromRow, Debug)]
#[from(Users)]
struct UserWithPost {
#[column(Users::id)]
user_id: i64,
name: String,
#[column(Posts::id)]
post_id: Option<i64>,
#[column(Posts::content)]
content: Option<String>,
}
# let (db, Schema { users, posts, .. }) = readme::database()?;
let rows: Vec<UserWithPost> = db
.select(UserWithPost::Select)
.from(users)
.left_join((posts, eq(users.id, posts.author_id)))
.all()?;
let rows: Vec<UserWithPost> = db
.select(UserWithPost::Select)
.from(users)
.left_join(posts)
.all()?;
# Ok(())
# }
# #[cfg(not(feature = "rusqlite"))]
# fn main() {}
Subqueries & Set Operations
SELECT builders are expressions — pass them directly into comparisons or IN:
# #[cfg(feature = "rusqlite")]
# fn main() -> drizzle::Result<()> {
# mod readme {
# include!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/readme/sqlite.rs"));
# }
# use drizzle::core::expr::{eq, gt, in_subquery, min};
# use readme::*;
# let (db, Schema { users, .. }) = readme::database()?;
let min_id = db.select(min(users.id)).from(users);
let newer: Vec<SelectUsers> = db
.select(())
.from(users)
.r#where(gt(users.id, min_id))
.all()?;
let exact_rows = db
.select((users.id, users.name))
.from(users)
.r#where(eq(users.name, "Alex Smith"));
let matched: Vec<SelectUsers> = db
.select(())
.from(users)
.r#where(in_subquery((users.id, users.name), exact_rows))
.all()?;
# Ok(())
# }
# #[cfg(not(feature = "rusqlite"))]
# fn main() {}
Combine queries with union, union_all, intersect, and except. union removes duplicates; union_all keeps them:
# #[cfg(feature = "rusqlite")]
# fn main() -> drizzle::Result<()> {
# mod readme {
# include!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/readme/sqlite.rs"));
# }
# use drizzle::core::{asc, desc};
# use drizzle::core::expr::{gte, lte};
# use readme::*;
# let (db, Schema { users, .. }) = readme::database()?;
let results: Vec<(String,)> = db
.select((users.name,))
.from(users)
.r#where(lte(users.age, 25))
.union(
db.select((users.name,))
.from(users)
.r#where(gte(users.age, 30))
)
.order_by(asc(users.name))
.all()?;
# Ok(())
# }
# #[cfg(not(feature = "rusqlite"))]
# fn main() {}
Aliases
Use a Tag to alias a table for self-joins:
# #[cfg(feature = "rusqlite")]
# fn main() -> drizzle::Result<()> {
# mod readme {
# include!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/readme/sqlite.rs"));
# }
use drizzle::sqlite::prelude::*;
# use readme::*;
tag!(U, "u");
# let (db, _) = readme::database()?;
let u = Users::alias::<U>();
let rows: Vec<(i64,)> = db.select((u.id,)).from(u).all()?;
# Ok(())
# }
# #[cfg(not(feature = "rusqlite"))]
# fn main() {}
Expressions
Aggregate functions and common SQL expressions:
# #[cfg(feature = "rusqlite")]
# fn main() -> drizzle::Result<()> {
# mod readme {
# include!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/readme/sqlite.rs"));
# }
# use drizzle::core::expr::{coalesce, count, max};
# use readme::*;
# let (db, Schema { users, .. }) = readme::database()?;
let total: (i64,) = db.select((count(users.id),)).from(users).get()?;
let oldest: (Option<i64>,) = db.select((max(users.age),)).from(users).get()?;
let rows: Vec<(String,)> = db
.select((coalesce(users.email, "unknown"),))
.from(users)
.all()?;
# Ok(())
# }
# #[cfg(not(feature = "rusqlite"))]
# fn main() {}
Available in drizzle::core::expr:
- Comparisons —
eq, neq, gt, gte, lt, lte
- Boolean —
and, or, not, all, any (and tuples, which mean AND)
- Aggregates —
count, sum, avg, min, max
- Null handling —
coalesce, is_null, is_not_null
- Strings —
upper, lower, length
- Math —
abs, round, sign, mod_; ceil, floor, trunc, sqrt, power, exp, ln, log, log10, log2, pi (see the SQLite note below)
The ordering helpers asc and desc are in drizzle::core, not
drizzle::core::expr.
SQLite only has ceil through pi when it is compiled with
SQLITE_ENABLE_MATH_FUNCTIONS, so on SQLite those functions compile only with
drizzle's math feature. Enabling math is a promise about the SQLite you link:
- rusqlite (with
bundled) and libsql compile their own SQLite and read
LIBSQLITE3_FLAGS while doing so. Set
LIBSQLITE3_FLAGS="-DSQLITE_ENABLE_MATH_FUNCTIONS" in the build environment,
for example under [env] in .cargo/config.toml.
- turso implements these functions itself and needs no flag.
If the linked SQLite lacks them, the calls still compile under math, and the
query fails at runtime with no such function.
Type Casting
cast(expr, target) takes a type marker from the dialect's types module
(drizzle::sqlite::types, drizzle::postgres::types, or
drizzle::mysql::types). The marker supplies both the SQL type name and the
result type. You can pass a SQL type name as a string instead, but a string
carries no result type, so name the marker with a turbofish:
cast::<_, _, Real>(expr, "DOUBLE"). SQLite and PostgreSQL only allow casts
between compatible types, such as integer to real.
# #[cfg(feature = "rusqlite")]
# fn main() -> drizzle::Result<()> {
# mod readme {
# include!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/readme/sqlite.rs"));
# }
# use readme::*;
use drizzle::core::expr::cast;
use drizzle::sqlite::types::Real;
# let (db, Schema { users, .. }) = readme::database()?;
let ages: Vec<(f64,)> = db.select((cast(users.age, Real),)).from(users).all()?;
let ages: Vec<(f64,)> = db
.select((cast::<_, _, Real>(users.age, "DOUBLE"),))
.from(users)
.all()?;
# Ok(())
# }
# #[cfg(not(feature = "rusqlite"))]
# fn main() {}
Relational Queries
Requires the query feature. Fetches a table with its relations in a single query — no manual joins.
Relation methods are generated from #[column(references = ...)]. Given Posts.author_id → Users.id, users.posts() is the reverse (one-to-many) and posts.author() is the forward (many-to-one). Relation Names explains how the names are chosen.
# #[cfg(all(feature = "rusqlite", feature = "query"))]
# fn main() -> drizzle::Result<()> {
# mod readme {
# include!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/readme/sqlite.rs"));
# }
# use readme::*;
# let (db, Schema { users, .. }) = readme::database()?;
let users = db.query(users)
.with(users.posts())
.find_many()?;
for user in &users {
println!("{}: {} posts", user.name, user.posts.len());
}
# Ok(())
# }
# #[cfg(not(all(feature = "rusqlite", feature = "query")))]
# fn main() {}
.find_first() returns Option<...>:
# #[cfg(all(feature = "rusqlite", feature = "query"))]
# fn main() -> drizzle::Result<()> {
# mod readme {
# include!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/readme/sqlite.rs"));
# }
# use drizzle::core::expr::eq;
# use readme::*;
# let (db, Schema { users, .. }) = readme::database()?;
let user = db.query(users)
.with(users.posts())
.r#where(eq(users.name, "Alice"))
.find_first()?;
# Ok(())
# }
# #[cfg(not(all(feature = "rusqlite", feature = "query")))]
# fn main() {}
Nest relations:
# #[cfg(all(feature = "rusqlite", feature = "query"))]
# fn main() -> drizzle::Result<()> {
# mod readme {
# include!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/readme/sqlite.rs"));
# }
# use readme::*;
# let (db, Schema { users, posts, .. }) = readme::database()?;
let users = db.query(users)
.with(users.posts().with(posts.comments()))
.find_many()?;
println!("{} comments", users[0].posts[0].comments.len());
# Ok(())
# }
# #[cfg(not(all(feature = "rusqlite", feature = "query")))]
# fn main() {}
Filter and paginate the root query:
# #[cfg(all(feature = "rusqlite", feature = "query"))]
# fn main() -> drizzle::Result<()> {
# mod readme {
# include!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/readme/sqlite.rs"));
# }
# use drizzle::core::asc;
# use drizzle::core::expr::gt;
# use readme::*;
# let (db, Schema { users, .. }) = readme::database()?;
let users = db.query(users)
.with(users.posts())
.r#where(gt(users.age, 25))
.order_by(asc(users.name))
.limit(10)
.find_many()?;
# Ok(())
# }
# #[cfg(not(all(feature = "rusqlite", feature = "query")))]
# fn main() {}
Relation Names
Each #[column(references = Table::column)] generates two accessors:
- Forward (many-to-one), on the table that holds the foreign key: the
column name without its
_id suffix. Posts.author_id gives
posts.author(). A column without the suffix keeps its name, so
invited_by gives invited_by(). A nullable foreign key loads an Option.
- Reverse (one-to-many), on the referenced table: the plural
snake_case
form of the referencing struct's name, so Posts gives users.posts() and
a Category struct would give categories(). The Rust struct name counts,
not the SQL table name.
When a table references itself, or has two or more foreign keys to the same
table, each of those reverse accessors is named {forward}_{plural} instead,
for example users.author_posts() and users.editor_posts().
relation = "..." sets a reverse accessor's name and leaves the forward name
alone. You only need it when two accessors on the referenced table would still
share a name. If both come from one table, the macro's compile error asks for
relation. If they come from different tables, for example a direct foreign
key and a junction table that both give tags.posts(), rustc reports a
duplicate definition instead, and relation on the direct foreign key
resolves it.
A table with exactly two foreign keys that point at two different tables,
neither of them the table itself, also works as a junction table: each side
gets a many-to-many accessor named after the plural of the other side, so
PostTags gives posts.tags() and tags.posts(). The junction keeps its own
accessors as well (post_tags.post(), posts.post_tags()), and relation
does not rename the many-to-many pair.
# #[cfg(all(feature = "rusqlite", feature = "query"))]
# fn main() -> drizzle::Result<()> {
use drizzle::sqlite::prelude::*;
# use drizzle::sqlite::rusqlite::Drizzle;
#[SQLiteTable]
pub struct Users {
#[column(primary)]
pub id: i64,
#[column(references = Users::id)]
pub invited_by: Option<i64>,
}
#[SQLiteTable]
pub struct Posts {
#[column(primary)]
pub id: i64,
#[column(references = Users::id)]
pub author_id: i64,
#[column(references = Users::id, relation = "edited_posts")]
pub editor_id: Option<i64>,
}
#[SQLiteTable]
pub struct Tags {
#[column(primary)]
pub id: i64,
}
#[SQLiteTable]
pub struct PostTags {
#[column(references = Posts::id)]
pub post_id: i64,
#[column(references = Tags::id)]
pub tag_id: i64,
}
#[derive(SQLiteSchema)]
pub struct Schema {
pub users: Users,
pub posts: Posts,
pub tags: Tags,
pub post_tags: PostTags,
}
# let conn = rusqlite::Connection::open_in_memory()?;
# let (db, Schema { users, posts, tags, post_tags }) = Drizzle::new(conn);
# db.create()?;
let authors = db
.query(users)
.with(users.author_posts())
.with(users.edited_posts())
.with(users.invited_by_users())
.find_many()?;
let tagged = db.query(posts).with(posts.author()).with(posts.tags()).find_many()?;
# let _ = db.query(users).with(users.invited_by()).find_many()?;
# let _ = db.query(posts).with(posts.editor()).with(posts.post_tags()).find_many()?;
# let _ = db.query(tags).with(tags.posts()).find_many()?;
# let _ = db.query(post_tags).with(post_tags.post()).with(post_tags.tag()).find_many()?;
# Ok(())
# }
# #[cfg(not(all(feature = "rusqlite", feature = "query")))]
# fn main() {}
Selecting Specific Columns
.columns(...) / .omit(...) return PartialSelectUsers — same shape as SelectUsers, but every field is Option<T>:
# #[cfg(all(feature = "rusqlite", feature = "query"))]
# fn main() -> drizzle::Result<()> {
# mod readme {
# include!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/readme/sqlite.rs"));
# }
# use readme::*;
# let (db, Schema { users, .. }) = readme::database()?;
let users = db.query(users)
.columns(users.columns().name().email())
.find_many()?;
for u in &users {
assert!(u.name.is_some());
assert!(u.id.is_none()); }
# Ok(())
# }
# #[cfg(not(all(feature = "rusqlite", feature = "query")))]
# fn main() {}
Result Types
.with(users.posts()) returns UsersWithPosts — base columns via deref, relation data on fields like user.posts:
# #[cfg(all(feature = "rusqlite", feature = "query"))]
# fn main() {
# mod readme {
# include!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/readme/sqlite.rs"));
# }
# use readme::UsersWithPosts;
fn print_user_posts(user: &UsersWithPosts) {
println!("{} has {} posts", user.name, user.posts.len());
}
# }
# #[cfg(not(all(feature = "rusqlite", feature = "query")))]
# fn main() {}
Transactions
[!TIP]
Transactions auto-rollback on error or panic. Return Ok(value) to commit, Err(...) to rollback. No manual cleanup needed.
# #[cfg(feature = "rusqlite")]
# fn main() -> drizzle::Result<()> {
# mod readme {
# include!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/readme/sqlite.rs"));
# }
# use readme::*;
use drizzle::sqlite::TransactionConfig;
# let (mut db, Schema { users, .. }) = readme::database()?;
db.transaction(TransactionConfig::Deferred, |tx| {
tx.insert(users)
.value(InsertUsers::new("Alice", 28i64))
.execute()?;
let all: Vec<SelectUsers> = tx.select(()).from(users).all()?;
Ok(all.len())
})?;
# Ok(())
# }
# #[cfg(not(feature = "rusqlite"))]
# fn main() {}
Savepoints nest inside transactions — a failed savepoint rolls back without aborting the outer transaction:
# #[cfg(feature = "rusqlite")]
# fn main() -> drizzle::Result<()> {
# mod readme {
# include!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/readme/sqlite.rs"));
# }
# use readme::*;
use drizzle::sqlite::TransactionConfig;
use drizzle::error::DrizzleError;
# let (mut db, Schema { users, .. }) = readme::database()?;
let count = db.transaction(TransactionConfig::Deferred, |tx| {
tx.insert(users)
.value(InsertUsers::new("Alice", 28i64))
.execute()?;
let _ = tx.savepoint(|stx| {
stx.insert(users)
.value(InsertUsers::new("Bad Data", -1i64))
.execute()?;
Err::<(), _>(DrizzleError::Other("rollback this part".into()))
});
tx.insert(users)
.value(InsertUsers::new("Bob", 32i64))
.execute()?;
let all: Vec<SelectUsers> = tx.select(()).from(users).all()?;
Ok(all.len())
})?;
# Ok(())
# }
# #[cfg(not(feature = "rusqlite"))]
# fn main() {}
Cloudflare D1 is the exception: the platform exposes no transaction handles, so
the D1 driver has no transaction method. Its batch method submits several
statements that D1 applies atomically.
Prepared Statements
[!TIP]
Placeholders are typed by the column they came from. Binding the wrong type fails at compile time, not at runtime.
# #[cfg(feature = "rusqlite")]
# fn main() -> drizzle::Result<()> {
# mod readme {
# include!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/readme/sqlite.rs"));
# }
# use readme::*;
use drizzle::core::expr::eq;
use drizzle::core::SQLColumn;
# let (db, Schema { users, .. }) = readme::database()?;
let name = users.name.placeholder("name");
let find = db
.select(())
.from(users)
.r#where(eq(users.name, name))
.prepare();
let alice: Vec<SelectUsers> = find.all(db.conn(), [name.bind("Alice")])?;
let bob: Vec<SelectUsers> = find.all(db.conn(), [name.bind("Bob")])?;
# Ok(())
# }
# #[cfg(not(feature = "rusqlite"))]
# fn main() {}
Placeholders work in update (and insert) models too:
# #[cfg(feature = "rusqlite")]
# fn main() -> drizzle::Result<()> {
# mod readme {
# include!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/readme/sqlite.rs"));
# }
# use drizzle::core::{SQLColumn, expr::eq};
# use readme::*;
# let (db, Schema { users, .. }) = readme::database()?;
let new_name = users.name.placeholder("new_name");
let target = users.id.placeholder("target");
let stmt = db
.update(users)
.set(UpdateUsers::default().with_name(new_name))
.r#where(eq(users.id, target))
.prepare();
stmt.execute(db.conn(), [new_name.bind("New Name"), target.bind(1)])?;
# Ok(())
# }
# #[cfg(not(feature = "rusqlite"))]
# fn main() {}
Use .prepare().into_owned() to convert a prepared statement into a self-contained value that can be stored or moved freely.
PostgreSQL
The query API above works the same way with #[PostgresTable],
#[derive(PostgresSchema)], #[derive(PostgresFromRow)], and
drizzle::postgres::{sync,tokio}::Drizzle. The tokio driver's calls are
async, and the blocking driver runs prepared statements on db.conn_mut().
Two things in the SQLite examples do not carry over: autoincrement (use
serial, bigserial, smallserial, or identity(...) columns) and
TransactionConfig::Deferred, which is a SQLite transaction mode.
PostgreSQL's TransactionConfig::default() uses server defaults. Its
typestated builder keeps DEFERRABLE on the combination where it has meaning:
# #[cfg(feature = "postgres")]
# fn main() {
use drizzle::postgres::TransactionConfig;
let config = TransactionConfig::builder()
.serializable()
.read_only()
.deferrable()
.build();
# }
# #[cfg(not(feature = "postgres"))]
# fn main() {}
# #[cfg(feature = "postgres-sync")]
# fn main() -> Result<(), Box<dyn std::error::Error>> {
use drizzle::postgres::prelude::*;
use drizzle::postgres::sync::Drizzle;
#[PostgresTable]
pub struct Accounts {
#[column(serial, primary)]
pub id: i32,
pub name: String,
}
#[derive(PostgresSchema)]
pub struct Schema {
pub accounts: Accounts,
}
let client = postgres::Client::connect(
"host=localhost user=postgres password=postgres dbname=drizzle_test",
postgres::NoTls,
)?;
let (mut db, Schema { accounts }) = Drizzle::new(client);
# Ok(())
# }
# #[cfg(not(feature = "postgres-sync"))]
# fn main() {}
MySQL
MySQL uses the same select, insert, update, delete, prepared-statement,
relational-query, transaction, migration, and seed workflows as the other
dialects. Migration generation, push, and apply are available through the
Drizzle CLI, and both adapters expose migrate(&migrations, Tracking::MYSQL).
MySQL applies each statement in autocommit mode because its DDL can commit
implicitly. A failed migration stays marked as interrupted until you reconcile
the partial schema and its tracking row manually. Enable
mysql-sync for the blocking mysql
client or mysql-async for mysql_async.
The driver crates remain explicit dependencies because your application creates
and owns the connection or pool.
[dependencies]
drizzle = { version = "0.2", features = ["mysql-sync"] }
mysql = "28"
# #[cfg(feature = "mysql-sync")]
# fn main() -> Result<(), Box<dyn std::error::Error>> {
use drizzle::mysql::{mysql_sync::Drizzle, prelude::*};
#[MySQLTable]
struct User {
#[column(PRIMARY, AUTO_INCREMENT)]
id: u64,
#[column(VARCHAR(255))]
name: String,
}
#[derive(MySQLSchema)]
struct Schema {
users: User,
}
let options = mysql::Opts::from_url(
"mysql://drizzle:drizzle@127.0.0.1:3307/drizzle_test",
)?;
let connection = mysql::Conn::new(options)?;
let (mut db, Schema { users, .. }) = Drizzle::new(connection);
# Ok(())
# }
# #[cfg(not(feature = "mysql-sync"))]
# fn main() {}
The async adapter accepts either an owned mysql_async::Conn or a lazy pool:
[dependencies]
drizzle = { version = "0.2", features = ["mysql-async"] }
mysql_async = "0.37"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
# #[cfg(feature = "mysql-async")]
# #[tokio::main]
# async fn main() -> Result<(), Box<dyn std::error::Error>> {
use drizzle::mysql::{mysql_async::Drizzle, prelude::*};
let options = mysql_async::Opts::from_url(
"mysql://drizzle:drizzle@127.0.0.1:3307/drizzle_test",
)?;
let pool = mysql_async::Pool::new(options);
let (db, ()) = Drizzle::new(pool);
db.disconnect().await?;
# Ok(())
# }
# #[cfg(not(feature = "mysql-async"))]
# fn main() {}
Start the documented MySQL 8.4 container and run the complete adapter matrix or
the runnable blocking example:
just mysql-up
just test-mysql
cargo run --example mysql --features mysql-sync
DRIZZLE_MYSQL_URL overrides the example and test URL. First-class support
targets Oracle MySQL 8.0.31 or newer; CI runs both MySQL 8.0.31 and 8.4. MariaDB
and SingleStore compatibility are not promised. TLS configuration belongs to
the upstream mysql/mysql_async options supplied to Drizzle. The workspace
enables their native-TLS backends, and Drizzle neither disables certificate
validation nor silently changes the caller's transport policy.
Before its first typed query on a connection, the adapter sets the session time
zone to UTC and removes NO_UNSIGNED_SUBTRACTION and REAL_AS_FLOAT from the
session SQL mode. Those invariants keep temporal decoding, unsigned arithmetic,
and REAL columns consistent with the Rust types; using conn_mut() causes
them to be restored before the next Drizzle query.
Transactions use TransactionConfig for isolation level, access mode, and
consistent snapshots. The typestated builder only exposes .snapshot() after
.repeatable_read(). Runtime-derived values can use the enum setters instead.
The blocking adapter takes a closure synchronously; the async connection and
pool adapters expose the same transaction configuration on their async methods.
MySQL upserts use the native
.on_duplicate_key_update(...) builder (or .ignore()), not PostgreSQL's
.on_conflict(...) spelling.
# #[cfg(feature = "mysql-sync")]
# fn main() -> Result<(), Box<dyn std::error::Error>> {
# mod readme {
# include!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/readme/mysql.rs"));
# }
# use readme::*;
use drizzle::mysql::TransactionConfig;
# let (mut db, Schema { users }) = readme::database()?;
let config = TransactionConfig::builder()
.repeatable_read()
.read_write()
.snapshot()
.build();
db.transaction(config, |tx| {
tx.insert(users).value(InsertUser::new("Alice")).execute()?;
Ok(())
})?;
# Ok(())
# }
# #[cfg(not(feature = "mysql-sync"))]
# fn main() {}
Transactions stay scoped to the callback. Returning Ok commits; returning
Err rolls back. Dropping or cancelling an async transaction future also
prevents the active transaction from being reused without rollback.
The type surface deliberately leaves unsupported SQL unavailable:
- MySQL mutations return
MySQLMutationResult metadata, not SQL RETURNING rows.
- Full joins and partial-index predicates are rejected; MySQL does not support them.
.offset(n) without an explicit limit renders a LIMIT of i64::MAX
first, because MySQL has no standalone OFFSET syntax. (MySQL's manual
suggests 18446744073709551615, but after a UNION that value overflows and
the query returns no rows.)
- String concatenation uses
concat(...); the builder never emits ||, whose
default MySQL meaning is logical OR.
See examples/mysql.rs for the complete blocking example.
CLI Reference
Most projects only need these:
| Command |
Description |
drizzle init |
Create drizzle.config.toml |
drizzle generate |
Diff schema and emit SQL migration files |
drizzle migrate |
Apply pending migrations |
drizzle push |
Apply schema diff directly without migration files |
drizzle introspect |
Reverse-engineer schema from a live database |
Other useful commands:
| Command |
Description |
drizzle new |
Interactive schema builder |
drizzle status |
List local migration folders and whether each has a snapshot (it does not read the database; use drizzle migrate --plan for that) |
drizzle check |
Validate config |
drizzle export |
Print schema as raw SQL |
drizzle up |
Upgrade migration snapshots to the latest format |
drizzle pull is an alias for introspect. Commands that read the config accept -c <path> for a custom config file and --db <name> for multi-database configs.
License
MIT. See LICENSE.