๐๏ธ 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.
Documentation ยท 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
The toolu-orm facade pulls in the whole stack behind one version and one
feature list:
[]
= { = "0.1", = ["libsql"] }
= { = "0.1", = false, = ["libsql"] }
= { = "1", = ["rt-multi-thread", "macros"] }
It re-exports toolu_orm::core, toolu_orm::query, toolu_orm::connection
and the proc macros. The macros expand to paths that name toolu_orm_core and
toolu_orm_query directly, so glob-import the prelude in every module that
uses #[table] or a derive:
use *;
toolu-orm-core is listed a second time because the prelude cannot cover one
case: #[table] also generates the companion column module, and the
toolu_orm_core paths inside that nested mod resolve against the crate's
extern prelude โ which Cargo fills from direct dependencies only, so a use in
the parent module never reaches them. Without it the expansion fails with
error[E0433]: cannot find module or crate toolu_orm_core. Everything else
resolves through the prelude. Emitting facade-relative paths
(proc-macro-crate) would remove both the glob and this extra dependency, and
is tracked in #15. crates/orm/tests/facade_test.rs does not catch it:
that package depends on the four crates directly.
toolu-orm-cli is not re-exported by the facade. Add it as a normal
dependency when you generate or apply migrations from your own binary โ it is a
library crate with no [[bin]] of its own:
= { = "0.1", = false, = ["libsql"] }
Keep toolu-orm and toolu-orm-core on the same version: they share one
workspace version, and a mismatch means two different toolu_orm_core crates in
the graph, whose types do not interoperate.
Depending on the crates directly
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.
[]
= { = "0.1", = ["libsql"] }
= { = "0.1", = false, = ["libsql"] }
= { = "0.1", = false, = ["libsql"] }
= { = "1", = ["rt-multi-thread", "macros"] }
toolu-orm-cli is a plain library crate despite the name โ it ships no
binary, so run_generate, run_migrate and get_status are called from your
own code (see Migrations for the usual bin/migrate.rs).
use Database;
use ;
use Dialect;
use DbCoreError;
use CommonOps;
use FromRow;
use SchemaRegistry;
use TableSchema;
use *; // required: the macros expand to
// toolu_orm_core / toolu_orm_query paths
// One driver is active (libsql), so `FromRow` asks for a single `from_row` โ
// `#[derive(FromRow)]` emits the postgres+libsql shape and does NOT compile
// here. See "Row mapping" for the derive and the other driver shapes.
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.
Two connection surfaces show up there. DbConnection โ what db.connect()
returns โ is what run_migrate and get_status take. Executor is what the
query builders run on, and it is implemented for the driver's own connection
type (libsql::Connection, rusqlite::Connection, tokio_postgres::Client,
PgTransaction), which conn.inner_conn() hands out.
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)] fills REQUIRED_COLUMNS from the field
names in declaration order and reads each field positionally at its own index, so
select_for::<T>() picks exactly the columns T needs, in the order it decodes
them. A hand-written impl is a few lines when you run a single driver:
use ;
That is the single-driver shape. With two drivers unified on toolu-orm-core
the trait asks for one method per driver instead โ from_pg_row,
from_libsql_row, from_rusqlite_row โ which is the shape
#[derive(FromRow)] emits.
Query builders
The snippets below name the crates directly (toolu_orm_core::โฆ,
toolu_orm_query::โฆ); through the facade the same items are
toolu_orm::core::โฆ and toolu_orm::query::โฆ.
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(exec) -> u64, where exec is
the driver connection (&libsql::Connection, &rusqlite::Connection,
&tokio_postgres::Client, or a transaction) โ not the DbConnection wrapper.
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?;
let any: bool = select.exists.await?;
On the rusqlite driver these are synchronous: same names, no .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` is a libsql::Connection โ TransactionExt is implemented on the driver type.
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