๐๏ธ toolu-orm
Schema-first Rust ORM โ one struct, three databases, migrations you can read.
Define a table once as a Rust struct. Get a typed query builder, a FromRow
mapper, and a schema snapshot back. Diff the snapshot into a plain-SQL
migration, apply it with a SHA-256-checked journal, and run the same code
against libsql (local file, in-memory, or Turso), rusqlite, or
Postgres. No runtime reflection, no macro-generated SQL you can't read.
Why ยท Features ยท How it works ยท Install ยท Quickstart ยท Tables ยท Queries ยท Relations ยท Migrations ยท Drivers ยท Contributing
Why toolu-orm?
Most Rust database layers make you pick a side:
- Query builders give you type-safe SQL but leave schema evolution to you. The migration folder drifts from the structs, and nobody notices until prod.
- Full ORMs own the schema but hide the SQL behind a runtime, a DSL, or a code generator you have to re-run and re-learn.
toolu-orm keeps the struct as the single source of truth and generates
everything else from it. #[table] produces a TableDef. A SchemaRegistry
of those defs is diffed against the last JSON snapshot to write the next
NNNN_name.sql migration โ plain SQL you can read in review. A journal records
each file's SHA-256 so a migration edited after it shipped fails loudly instead
of silently diverging.
The same struct also hands you typed Column<T> constants, select() /
insert() / update() / delete() builder factories, and an async executor
that speaks ?1 to SQLite and $1 to Postgres. Swap the driver by flipping a
Cargo feature; the application code does not change.
Extracted from a production backend where it drives Turso embedded replicas in the field and Postgres in the cloud, from one set of table structs.
Features
| ๐งฑ Schema as code | #[table] turns a struct into a TableDef with primary keys, defaults, foreign keys with on_delete / on_update, strict tables, and #[index] / #[unique_index]. |
| ๐ Diff-driven migrations | run_generate diffs your registry against the last *.snapshot.json and writes numbered SQL with a --> statement-breakpoint separator. run_migrate applies pending files in one transaction each; get_status lists applied and pending. |
| ๐ Tamper-evident journal | _journal.json stores a sha256: hash per migration. A file that changed after it was recorded stops the run with MigrateError::HashMismatch. |
| ๐งฎ Typed columns, typed expressions | Generated Column<T> constants (users::email) build Expr trees: eq / ne / in_list / not_in / is_null on every column, like on text, gt / lt / gte / lte / between on numbers, combined with .and() / .or(). Table-qualified, always quoted. |
| ๐๏ธ Four builders, one executor | SelectBuilder, InsertBuilder (with or_ignore / or_replace), UpdateBuilder (set / set_expr), DeleteBuilder. All share .execute(); select adds fetch_all, fetch_one, fetch_optional, count, exists. |
| ๐ Dialect-aware SQL | to_sql_for(Dialect::Sqlite) emits ?N placeholders; Dialect::Postgres emits $N, ON CONFLICT ... DO UPDATE SET ... = EXCLUDED, and LEFT JOIN LATERAL + json_agg for relations. |
| ๐ธ๏ธ Relational loads without N+1 | #[derive(Relational)] with #[has_many], #[belongs_to], #[many_to_many]; RelationalQuery fetches parent + children as JSON arrays in a single statement per dialect. |
| ๐งฌ Enums and views | #[derive(ColumnEnum)] stores a Rust enum as text; #[view(Name, pick(...))] / omit(...) generates subset structs from a table. |
| ๐ Transactions | `conn.run_transaction( |
| ๐ Three drivers, one trait | DbConnection over libsql (async, Turso embedded replica with sync retry), rusqlite (sync, wrapped in spawn_blocking), and Postgres (deadpool-postgres pool, rustls TLS). |
๐ก๏ธ Panic-free src/ |
Workspace-wide clippy::unwrap_used, expect_used, panic, indexing_slicing are deny. No #[allow] anywhere. |
How it works
Five crates. orm-core is the foundation; every other crate depends on it, and
only orm-cli depends on orm-connection.
โโโโโโโโโโโโโโโโโโโโโโโโโโ
โ toolu-orm-core โ TableDef ยท ColumnType ยท Value ยท Expr
โ schema ยท snapshot ยท โ Column<T> ยท Snapshot ยท Journal
โ diff ยท dialect ยท row โ Dialect { Sqlite, Postgres }
โโโโโโโโโโโโโฌโโโโโโโโโโโโโ
โโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโ
โผ โผ โผ
โโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโ
โ toolu-orm- โ โ toolu-orm- โ โ toolu-orm-connection โ
โ macros โ โ query โ โ Database ยท DbConnection
โ #[table] โ โ Select/Insert/ โ โ libsql ยท rusqlite ยท โ
โ FromRow โ โ Update/Delete โ โ PgDatabase (pool+TLS) โ
โ Relational โ โ RelationalQueryโ โโโโโโโโโโโโฌโโโโโโโโโโโโ
โ ColumnEnum โ โ Executor ยท tx โ โ
โโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโ โผ
โโโโโโโโโโโโโโโโโโโโโโโโ
โ toolu-orm-cli โ
โ run_generate โ
โ run_migrate โ
โ get_status โ
โโโโโโโโโโโโโโโโโโโโโโโโ
The migration loop in one line:
structs โโ#[table]โโโถ TableDef โโSchemaRegistryโโโถ diff vs last snapshot โโโถ NNNN_name.sql + snapshot.json + _journal.json
Install
Every crate exposes the same driver features (libsql, rusqlite, postgres)
and forwards them to toolu-orm-core. Enable the drivers you need on every
crate you depend on so Cargo unifies them into one shape.
[]
= { = "0.1", = false, = ["libsql"] }
= { = "0.1", = ["libsql"] }
= { = "0.1", = ["libsql"] }
= { = "0.1", = ["libsql"] }
= { = "0.1", = false, = ["libsql"] }
= { = "1", = ["rt-multi-thread", "macros"] }
For Postgres, replace "libsql" with "postgres". toolu-orm-core and
toolu-orm-cli default to libsql; the other crates have no default driver.
Heads-up on
#[derive(FromRow)]. The derive currently emits thepostgres+libsqldecoder shape, so it compiles only whentoolu-orm-corehas both features on. With a single driver, implementFromRowby hand (a few lines, see Defining tables). Making the derive follow the active driver set is tracked as a follow-up.
Quickstart
Define a table, generate and apply a migration, insert, and read back โ against an in-memory libsql database.
use Database;
use ;
use Dialect;
use CommonOps;
use SchemaRegistry;
use TableSchema;
use ;
async
#[table] generated everything used above: the users companion module with
one Column<T> per field, UsersTable::table_def(), and the select() /
select_for::<T>() / insert() / update() / delete() factories.
Defining tables
use ;
| Attribute | Effect |
|---|---|
#[table(name = "...", strict = true)] |
Table name; strict switches column SQL types to the SQLite / Turso STRICT set. |
#[column(primary_key)] |
Primary key. |
#[column(not_null)] |
NOT NULL; omit it for a nullable column. |
#[column(default = "...")] |
Raw SQL default, e.g. "unixepoch()", "'pending'", "uuid4_str()". |
#[column(references = "t(col)", on_delete = "cascade", on_update = "...")] |
Foreign key with referential actions. |
#[column(as_text)] |
Store an enum or custom type as TEXT. |
#[index("name", col, ...)] / #[unique_index("name", col)] |
Secondary indexes on the table; unique_index is how you express uniqueness. |
#[view(Name, pick(a, b))] / #[view(Name, omit(c))] |
Generate a subset struct from the table. |
Field types map to ColumnType: Text, Integer, Real, Blob, Uuid,
Boolean, Timestamp, Date, Time, Json, plus Postgres-flavoured
BigInt, SmallInt, Varchar(n), Serial, BigSerial, Jsonb, Numeric,
Char(n), Array.
Row mapping. #[derive(FromRow)] maps columns to fields by name and exposes
REQUIRED_COLUMNS, which select_for::<T>() uses to pick exactly the columns
T needs. A hand-written impl is a few lines when you run a single driver:
use ;
With postgres enabled the trait also asks for from_pg_row(&tokio_postgres::Row).
Query builders
Every builder renders with to_sql() (current dialect) or
to_sql_for(Dialect::โฆ) and returns (String, Vec<Value>). Column references
are always table-qualified and quoted. The comparison methods come from three
traits in toolu_orm_core::query_column: CommonOps (eq, ne, in_list,
not_in, is_null, is_not_null), TextOps (like), and NumericOps
(gt, lt, gte, lte, between).
use ;
use ;
// SELECT with filters, join, ordering, paging
let = new
.columns_raw
.filter
.filter
.join // INNER JOIN; left_join() too
.order_by
.limit
.offset
.to_sql_for;
// SELECT "id", "email" FROM "users"
// INNER JOIN "pipelines" ON "users"."id" = "pipelines"."user_id"
// WHERE "users"."org_id" = ?1 AND "users"."created_at" > ?2
// ORDER BY "users"."created_at" DESC LIMIT ?3 OFFSET ?4
// INSERT, with upsert flavours that render per dialect
new.or_ignore.set.to_sql_for;
// INSERT INTO "seeds" ("id") VALUES ($1) ON CONFLICT DO NOTHING
new.or_replace.set.set
.to_sql_for;
// ... ON CONFLICT ("run_id") DO UPDATE SET "status" = EXCLUDED."status"
// UPDATE with a bound value and a raw SQL expression
new.set.set_expr
.filter.to_sql_for;
// UPDATE "users" SET "email" = ?1, "updated_at" = unixepoch() WHERE "users"."id" = ?2
// DELETE
new.filter.to_sql_for;
// DELETE FROM "users" WHERE "users"."id" = $1
Executing. All four builders share .execute(&conn) -> u64. Select adds:
let users: = new.columns_raw.fetch_all.await?;
let one: User = .filter.fetch_one.await?; // QueryError::NotFound if empty
let maybe: = .filter.fetch_optional.await?;
let n: i64 = select.count.await?;
Also available: to_count_sql_for, to_exists_sql_for,
SelectBuilder::raw().column_expr(expr, alias), and
columns_typed(&[&dyn ColumnRef]).
Transactions. Anything that errors inside the closure rolls the whole block back.
use TransactionExt;
conn.run_transaction.await?;
Relations
Declare the shape you want back; the query is one statement per dialect.
use Relational;
// #[belongs_to(...)] โ field is Option<T>, same keys
// #[many_to_many(...)] โ adds through = "post_tags", local_key = "post_id"
The typed builder tracks the result tuple at compile time:
use RelationalQuery;
let q = new
.
.;
let _: = q;
- SQLite renders correlated subqueries with
json_group_array. - Postgres renders
LEFT JOIN LATERALwithjson_agg/json_build_array.
An untyped RelationalSelectBuilder exposes the same with_many / with_one
plus to_sql_sqlite() / to_sql_postgres() when you only want the SQL.
Migrations
use ;
let wrote = run_generate?;
// โ Some("0002_add_posts.sql"), or None when the schema did not change
let applied = run_migrate.await?; // u32 files applied
let status = get_status.await?;
println!;
What lands on disk:
migrations/
โโโ _journal.json # order + "sha256:โฆ" per file
โโโ 0001_init.sql
โโโ 0001_init.snapshot.json # schema state after this migration
โโโ 0002_add_posts.sql
โโโ 0002_add_posts.snapshot.json
- Files apply in name order; the applied set is tracked in a
_migrationstable on the target database. - A file holding several statements separates them with
--> statement-breakpoint. Each file runs insideBEGIN/COMMIT. - The journal hash is verified before a file runs. Edit a shipped migration
and
run_migratestops withMigrateError::HashMismatch. - Snapshots are plain JSON (
version,dialect,id,prev_id,tables,enums,meta), so a schema diff is reviewable in the PR alongside the SQL.
Drivers
| Feature | Backing crate | Mode | Open with |
|---|---|---|---|
libsql |
libsql | async; local file, :memory:, or Turso embedded replica |
Database::init_local(path) ยท Database::init_remote(RemoteConfig) |
rusqlite |
rusqlite (bundled) | sync, wrapped in spawn_blocking |
RusqliteConnection::open(path) ยท ::open_in_memory() |
postgres |
tokio-postgres + deadpool | async pool, rustls TLS | PgDatabase::init(&PgConfig) then .connect() |
// Turso embedded replica: local file kept in sync with the remote
let db = init_remote.await?;
// Postgres pool
let pg = init.await?;
let conn = pg.connect.await?;
All backends implement DbConnection (execute_sql, query_map<T: FromRow>,
execute_batch), which is what run_migrate and get_status accept.
Contributing
Read CLAUDE.md first: it holds the workspace map and the binding rules. The short version:
- No
.unwrap(),.expect(),panic!,unreachable!, or[]indexing insrc/. Propagate with?/ok_or. Tests may. - No
#[allow]/#[expect]. Fix the warning. - No
#[cfg(test)]insrc/; tests live in each crate'stests/. - โค 250 lines per file. Over that, split into a folder module whose
mod.rsholds onlymod,pub use, and//!docs. - One concern per file. No
utils.rs/helpers.rs/common.rs. cargo nextest run, nevercargo test.
The quality gate is what CI runs: four feature lanes plus a docs check. Every test executes against a real database (in-memory libsql, in-memory rusqlite, or a live Postgres), so start the test Postgres first:
# for_test() defaults to 5433
TEST_DB_HOST, TEST_DB_PORT, TEST_DB_USER, and TEST_DB_PASSWORD point the
Postgres suites at another server. Each feature scenario is documented in
docs/scenarios/ with the tests that prove it on
every driver; the docs check fails when a page and its tests drift apart, so
update the page with the test. Use a
Conventional Commits subject
(feat(query): add fetch_optional).
Releases
Releases are automated with release-plz. You do not bump versions or tag by hand.
- Merge Conventional Commits to
main. release-plz maintains one release PR that bumps the shared workspace version and rewritesCHANGELOG.md. - Merge that PR to cut the release: the five crates publish to crates.io in
dependency order, then a single
vX.Y.Ztag and GitHub Release are created.
License
MIT ยฉ Falconiere Barbosa