๐๏ธ 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. |
| ๐ Full-text search | #[fts5_table] (or the Fts5Table builder) declares an SQLite FTS5 virtual table with UNINDEXED columns, a free-form tokenizer, and external content. Migrations emit CREATE VIRTUAL TABLE ... USING fts5(...). |
| ๐งฌ 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"] }
= { = "1", = ["rt-multi-thread", "macros"] }
That is the whole list. It re-exports toolu_orm::core, toolu_orm::query,
toolu_orm::connection and the proc macros, and the macros expand to absolute
paths resolved against your Cargo.toml, so #[table] and the derives work
with no other dependency and no import beyond the macro itself. toolu_orm::prelude
still exists as a convenience glob; nothing requires it.
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"] }
If you do name toolu-orm-core directly as well, keep it on the same version as
toolu-orm: 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.
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"] }
= { = "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 CommonOps;
use SchemaRegistry;
use TableSchema;
use ;
// The derive follows the drivers active on `toolu-orm-core`: one driver
// (libsql here) means a single `from_row`. See "Row mapping".
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. An Option<T> field decodes SQL NULL as None; anything else missing
or undecodable is a DbCoreError::RowMapping naming the column's index and
name, never a panic.
The derive expands to whichever shape the drivers on toolu-orm-core gave the
trait, so it compiles on every combination โ one driver means a single
from_row, two or more mean one method per driver:
| Drivers active | Methods the derive implements |
|---|---|
one of libsql / rusqlite / postgres |
from_row |
| any two | two of from_pg_row / from_libsql_row / from_rusqlite_row |
| all three | all three |
#[from_row(with = "f")] on a field routes the decoded value through f
(FieldTy -> Result<FieldTy, E>) to normalize or reject it.
Writing the impl by hand stays supported, and is the way out when a field type the active driver cannot decode needs a conversion:
use ;
Virtual tables (SQLite FTS5)
#[fts5_table] declares a full-text index. It generates the same items as
#[table] โ a TableSchema, typed Column<T> constants, builder factories โ
but the TableDef carries TableKind::Virtual { module: "fts5", args }:
use fts5_table;
CREATE VIRTUAL TABLE IF NOT EXISTS "memory_fts" USING fts5("memory_id" UNINDEXED,
"body", "tags", tokenize = 'porter unicode61 remove_diacritics 2');
name is required; tokenize, prefix, content, content_rowid,
columnsize and detail are passed through to the module verbatim, so a
tokenizer this crate has never heard of still works. #[column(unindexed)]
appends UNINDEXED: the value is stored and readable but not searchable.
Fts5Table is the same thing without the macro, for a TableDef built at
runtime.
SQLite cannot ALTER a virtual table, so run_generate refuses any in-place
change to one โ a new column, a different tokenizer, a switched module โ with
DbCoreError::VirtualTableChange and writes no migration; drop and recreate it
instead. Creating, dropping and renaming work as usual. On Postgres the table is
skipped with a comment naming it. Other modules (vec0, rtree) need no new
code: build the TableDef with TableKind::virtual_table(module, args).
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 ;
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
// โฆor, with the SQL compiled into the binary:
let applied = run_migrate_embedded.await?;
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. - Shipping a single binary with no migrations directory on the target machine?
Bake the SQL in with
include_str!and apply it withrun_migrate_embedded(&conn, MIGRATIONS, dialect), whereMIGRATIONSis a&[EmbeddedMigration]ofname/sql/hash. Same hashes, same one-transaction-per-migration, and a database is free to move between the two sources. - Adopting toolu-orm on a database that already has the schema? Baseline it with
mark_applied(&conn, "migrations", &["0001_init.sql"], dialect)โ ormark_applied_through(&conn, "migrations", "0016_add_tags.sql", dialect)โ to record those journal entries (with their journal hashes) without executing them, so the nextrun_migratestarts from the first one you did not baseline. - 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 natively (DbConnectionBlocking), wrapped in spawn_blocking for the async DbConnection |
RusqliteConnection::from_connection(conn) (no runtime) ยท ::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