Skip to content

themixednuts/drizzle-rs

Repository files navigation

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.

Getting Started

1. Install

Add the library and install the CLI:

[dependencies]
drizzle = { git = "https://github.com/themixednuts/drizzle-rs", features = ["rusqlite"] }
# drivers: rusqlite | libsql | turso | postgres-sync | tokio-postgres
# optional: query (relational query API)
cargo install drizzle-cli --git https://github.com/themixednuts/drizzle-rs --locked --all-features

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

Write your schema in src/schema.rs. Each #[SQLiteTable] also generates typed helpers for selecting, inserting, updating, and partial queries (see Generated Models).

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,
}

#[derive(SQLiteSchema)]
pub struct Schema {
    pub users: Users,
    pub posts: Posts,
}

Alternatively, use drizzle new for an interactive schema builder, or drizzle introspect to reverse-engineer a schema from an existing database.

4. Migrate

drizzle generate              # diff schema → SQL migration files
drizzle generate --name init  # name the migration
drizzle migrate               # apply pending migrations
drizzle push                  # skip migration files, apply schema diff directly

push is useful during development. Use generate + migrate for production.

5. Connect & query

use drizzle::sqlite::rusqlite::Drizzle;

let conn = rusqlite::Connection::open("app.db")?;
let (db, Schema { users, posts }) = Drizzle::new(conn, Schema::new());

See examples/rusqlite.rs for a full runnable example.

CLI Reference

Command Description
drizzle init Create a new drizzle.config.toml
drizzle new Interactive schema builder (--json for JSON input)
drizzle generate Diff schema and emit SQL migration files (--custom for an empty migration)
drizzle migrate Apply pending migrations (--plan to preview, --safe to verify first)
drizzle push Apply schema diff directly without migration files (--explain for dry run)
drizzle introspect Reverse-engineer schema from a live database (--init to baseline)
drizzle status Show which migrations have been applied
drizzle check Validate your config file
drizzle export Print the schema as raw SQL (--sql file.sql to write to file)
drizzle up Upgrade migration snapshots to the latest format

drizzle pull is an alias for introspect. All commands accept -c <path> to use a custom config file and --db <name> for multi-database configs.

Generated Models

Each #[SQLiteTable] (or #[PostgresTable]) generates four helper types from your struct. Given:

#[SQLiteTable]
pub struct Users {
    #[column(primary, autoincrement)]
    pub id: i64,
    pub name: String,
    pub email: Option<String>,
    pub age: i64,
}
Model Purpose Fields
SelectUsers Query results Matches the table columns exactly
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 Selective columns All fields Option<T>; with_name() picks which columns to include

Insert models

new() takes only the required fields (columns without a default or autoincrement). Chain with_* methods for optional fields:

InsertUsers::new("Alex Smith", 26i64)
    .with_email("alex@example.com")

Update models

Start from default() and set only the fields you want to change. The query won't compile unless at least one field is set:

UpdateUsers::default()
    .with_age(27)
    .with_email("new@example.com")

Partial select models

Pick specific columns to return. Unselected fields come back as None:

let partial: Vec<PartialSelectUsers> = db
    .select(PartialSelectUsers::default().with_name().with_email())
    .from(users)
    .all()?;

CRUD

use drizzle::core::expr::*;

Select

// All rows
let all: Vec<SelectUsers> = db.select(()).from(users).all()?;

// Single row with filter
let user: SelectUsers = db
    .select(())
    .from(users)
    .r#where(eq(users.name, "Alex Smith"))
    .get()?;

// Specific columns
let names: Vec<(String,)> = db
    .select((users.name,))
    .from(users)
    .all()?;

SELECT builders are expressions — pass them directly into comparisons and set operators:

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()?;

Insert

// Single row
db.insert(users)
    .value(InsertUsers::new("Alex Smith", 26i64).with_email("alex@example.com"))
    .execute()?;

// Multiple rows — all rows must set the same optional fields
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()?;

Update

db.update(users)
    .set(UpdateUsers::default().with_age(27))
    .r#where(eq(users.id, 1))
    .execute()?;

Delete

db.delete(users)
    .r#where(eq(users.id, 1))
    .execute()?;

Order By / Limit / Offset

let rows: Vec<SelectUsers> = db
    .select(())
    .from(users)
    .order_by(asc(users.name))
    .limit(10)
    .offset(20)
    .all()?;

Multiple sort keys:

.order_by([asc(users.name), desc(users.age)])

Expressions

Aggregate functions and common SQL expressions:

// Aggregates
let total: (i64,) = db.select((count(users.id),)).from(users).get()?;
let oldest: (Option<i64>,) = db.select((max(users.age),)).from(users).get()?;

// Coalesce — first non-null value
let rows: Vec<(String,)> = db
    .select((coalesce(users.email, "unknown"),))
    .from(users)
    .all()?;

Available: count, sum, avg, min, max, coalesce, abs, upper, lower, length, and more in drizzle::core::expr.

Type casting

Each dialect provides cast target markers for use with cast():

use drizzle::core::expr::cast;

// SQLite
let age = cast(json_age, drizzle::sqlite::types::Integer);

// PostgreSQL
let age = cast(user.age, drizzle::postgres::types::Int4);

You can also pass a string to cast() when you need a custom SQL type name.

Group By

db.select((users.name, alias(count(users.id), "total")))
    .from(users)
    .group_by([users.name])
    .having(gt(count(users.id), 1))
    .all()?;

Joins

Use #[derive(SQLiteFromRow)] to map columns from multiple tables into a flat struct. #[from(Users)] sets the default source table for unannotated fields:

use drizzle::core::expr::eq;
use drizzle::sqlite::prelude::*;

#[derive(SQLiteFromRow, Debug)]
#[from(Users)]
struct UserWithPost {
    #[column(Users::id)]
    user_id: i64,
    name: String,
    #[column(Posts::id)]
    post_id: i64,
    #[column(Posts::content)]
    content: Option<String>,
}

// Explicit ON condition
let rows: Vec<UserWithPost> = db
    .select(UserWithPost::Select)
    .from(users)
    .left_join((posts, eq(users.id, posts.author_id)))
    .all()?;

// Auto-FK: derives the ON condition from #[column(references = ...)]
let rows: Vec<UserWithPost> = db
    .select(UserWithPost::Select)
    .from(users)
    .left_join(posts)
    .all()?;

Aliases

Use a Tag to alias a table for self-joins and CTEs:

use drizzle::sqlite::prelude::*;

tag!(U, "u");

let u = Users::alias::<U>();
let rows: Vec<(i64,)> = db.select((u.id,)).from(u).all()?;

Set Operations

Combine queries with union, union_all, intersect, and except:

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()?;

union removes duplicates. Use union_all to keep them.

Relational Queries

Requires the query feature. Fetches a table with its relations in a single query — no manual joins.

Relation methods are auto-generated from #[column(references = ...)] foreign keys. Given Posts.author_id → Users.id, calling users.posts() returns the reverse (one-to-many) relation and posts.author() returns the forward (many-to-one) relation.

// Users with their posts
let users = db.query(users)
    .with(users.posts())
    .find_many()?;

for user in &users {
    println!("{}: {} posts", user.name, user.posts().len());
}

.find_first() returns Option<QueryRow<...>> instead of Vec:

let user = db.query(users)
    .with(users.posts())
    .r#where(eq(users.name, "Alice"))
    .find_first()?;

Relations nest — fetch users with their posts and each post's comments:

let users = db.query(users)
    .with(users.posts().with(posts.comments()))
    .find_many()?;

let first_post = &users[0].posts()[0];
println!("{} comments", first_post.comments().len());

Multiple relations on the same table:

let users = db.query(users)
    .with(users.posts())
    .with(users.invited_by())
    .find_many()?;

Supports where, order_by, limit, and offset on the root query:

let users = db.query(users)
    .with(users.posts())
    .r#where(gt(users.age, 25))
    .order_by(asc(users.name))
    .limit(10)
    .find_many()?;

Each table generates convenient type aliases for use in function signatures:

use schema::{UsersQueryRow, UsersWithPosts, QueryUsersPosts};

fn print_user_posts(user: &UsersQueryRow<UsersWithPosts>) {
    println!("{} has {} posts", user.name, user.posts().len());
}

Transactions

Transactions auto-rollback on error or panic. Return Ok(value) to commit, Err(...) to rollback.

use drizzle::sqlite::connection::SQLiteTransactionType;

db.transaction(SQLiteTransactionType::Deferred, |tx| {
    tx.insert(users)
        .value(InsertUsers::new("Alice", 28i64))
        .execute()?;

    let all: Vec<SelectUsers> = tx.select(()).from(users).all()?;

    Ok(all.len())
})?;

Savepoints nest inside transactions — a failed savepoint rolls back without aborting the outer transaction:

use drizzle::sqlite::connection::SQLiteTransactionType;
use drizzle::core::error::DrizzleError;

let count = db.transaction(SQLiteTransactionType::Deferred, |tx| {
    tx.insert(users)
        .value(InsertUsers::new("Alice", 28i64))
        .execute()?;

    // This savepoint fails and rolls back, but the outer transaction continues
    let _ = tx.savepoint(|stx| {
        stx.insert(users)
            .value(InsertUsers::new("Bad Data", -1i64))
            .execute()?;
        Err(DrizzleError::Other("rollback this part".into()))
    });

    // Alice is still inserted
    tx.insert(users)
        .value(InsertUsers::new("Bob", 32i64))
        .execute()?;

    let all: Vec<SelectUsers> = tx.select(()).from(users).all()?;
    Ok(all.len())
})?;

PostgreSQL uses PostgresTransactionType (e.g. ReadCommitted, Serializable) instead.

Prepared Statements

Placeholders are created from columns — wrong bind types fail at compile time.

use drizzle::core::expr::eq;

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")])?;
// name.bind(42) — compile error: Integer is not compatible with Text

Placeholders work in update (and insert) models too:

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)])?;

Use .prepare().into_owned() to convert a prepared statement into a self-contained value that can be stored or moved freely.

PostgreSQL

Same pattern as SQLite, but with Postgres macros.

// schema.rs
use drizzle::postgres::prelude::*;

#[PostgresTable]
pub struct Accounts {
    #[column(serial, primary)]
    pub id: i32,
    pub name: String,
}

#[derive(PostgresSchema)]
pub struct Schema {
    pub accounts: Accounts,
}
// main.rs
mod schema;

use drizzle::postgres::sync::Drizzle;
use schema::*;

fn main() -> drizzle::Result<()> {
    let client = postgres::Client::connect(
        "host=localhost user=postgres password=postgres dbname=drizzle_test",
        postgres::NoTls,
    )?;
    let (mut db, Schema { accounts }) = Drizzle::new(client, Schema::new());
    db.create()?;

    db.insert(accounts)
        .value(InsertAccounts::new("Acme"))
        .execute()?;

    let rows: Vec<SelectAccounts> = db.select(()).from(accounts).all()?;
    println!("accounts: {rows:?}");
    Ok(())
}

For async, use drizzle::postgres::tokio::Drizzle with tokio_postgres::connect.

Runtime Migrations

For app startup, embed your generated ./drizzle folder at compile time and pass the resulting migrations into db.migrate(...).

use drizzle::migrations::Tracking;

let migrations = drizzle::include_migrations!("./drizzle");
db.migrate(&migrations, Tracking::SQLITE)?;

Use Tracking::POSTGRES for PostgreSQL, and override the tracking table or schema when needed:

db.migrate(
    &migrations,
    Tracking::POSTGRES
        .schema("drizzle")
        .table("__drizzle_migrations"),
)?;

migrate creates the tracking schema/table if needed and skips migrations that have already been applied.

Build.rs Migrations

If you want migration files generated during cargo build, you can keep ./drizzle in sync from build.rs instead of running drizzle generate manually.

Add the build-time crates:

[build-dependencies]
drizzle-migrations = { git = "https://github.com/themixednuts/drizzle-rs" }
drizzle-types = { git = "https://github.com/themixednuts/drizzle-rs" }

Create build.rs:

use drizzle_migrations::build::{Config, Output, run};
use drizzle_types::Dialect;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let cfg = Config::new(Dialect::SQLite)
        .file("src/schema.rs")
        .out("./drizzle");

    cfg.watch();

    match run(&cfg)? {
        Output::NoChanges => {}
        Output::Generated { tag, path, .. } => {
            println!("cargo:warning=generated migration {tag} at {}", path.display());
        }
    }

    Ok(())
}

If your schema is split across files, add each source file with another .file(...) call, for example .file("src/schema/users.rs").file("src/schema/posts.rs"). The build helper only uses the files you list; it does not follow Rust mod declarations automatically.

Push

For development, push skips migration files entirely — it introspects the live database, diffs it against your schema, and applies the changes directly.

let schema = Schema::new();
db.push(&schema)?;

push is intended for rapid iteration. Use migrate with versioned migration files for production deployments.

License

MIT — see LICENSE.

About

Drizzle clone in rust. WIP.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages