One schema.
Two powerful APIs.
Zero magic.
floz-orm unifies DAO-style CRUD and DSL-style query building into a single, zero-allocation Rust ORM. Define your schema once — get typesafe structs, a query builder, dirty tracking, relationships, and a production-grade terminal SQL editor.
Zero-Alloc Dirty Tracking
u64 bitmask tracks modified fields. save() only UPDATEs what changed.
Compile-Time Safety
Proc macro catches schema errors, type mismatches, and >64 column limit at compile time.
Auto Relationships
One-to-many and many-to-many with auto-generated fetch, add, remove methods.
Safety Guards
DELETE and UPDATE without WHERE are compile-time errors. No accidental table wipes.
Terminal SQL Editor
floz-editor: production-grade TUI for PostgreSQL with CRUD, filtering, and hierarchical views.
Serde Integration
Generated structs derive Serialize/Deserialize. Dirty flags auto-excluded.
Quick Start
Define your schema once. Get a DAO struct, a typesafe DSL, dirty tracking, and relationships — all from one macro invocation.
Zero-Boilerplate Endpoints
Simply defining a model creates the REST endpoints dynamically via crud(tag = "..."):
#[model("notes", crud(tag = "Notes"))]
pub struct Note { /* ... */ }
Or Define Custom Handlers
Where #[route] is used explicitly alongside native database injection for full custom control over the endpoint response:
#[route(get: "/notes/custom", tag = "Notes", desc = "Fetch customized logic safely")]
async fn list_notes(db: web::types::State<Db>) -> HttpResponse {
let notes = Note::all(&db).await.unwrap();
res!(pp!(¬es).unwrap_or_default())
}
1. Define Your Schema
use floz::prelude::*;
#[model("users", crud(tag = "Users", path = "/users"))]
pub struct User {
#[col(key, auto)]
pub id: i32,
pub name: Text,
pub email: Option<Text>,
pub age: i16,
#[col(default = "true")]
pub is_active: bool,
#[col(now)]
pub created_at: TimestampTz,
}
#[model("posts", crud(tag = "Posts", path = "/posts"))]
pub struct Post {
#[col(key, auto)]
pub id: i32,
pub title: Text,
pub body: Text,
pub author_id: i32,
// Navigation to `User`
#[rel(has_many(model = "crate::app::user::User", foreign_key = "author_id"))]
pub authors: Vec<User>,
#[col(now)]
pub created_at: TimestampTz,
}
2. CRUD Operations (DAO)
// Connect
let db = Db::connect("postgres://localhost/mydb").await?;
// Create
let alice = User::create()
.name("Alice").age(30)
.email(Some("alice@example.com".into()))
.execute(&db).await?;
// Read
let mut user = User::get(1, &db).await?;
// Update (only dirty fields)
user.set_name("Alice Updated");
user.set_age(31);
user.save(&db).await?;
// → UPDATE users SET name = $1, age = $2 WHERE id = $3
// Delete
user.delete(&db).await?;
3. Query Builder (DSL)
// Typesafe queries
let active_users: Vec<User> = UserTable::select()
.where_(UserTable::age.gt(25).and(UserTable::is_active.eq(true)))
.order_by(UserTable::name.asc())
.limit(10)
.execute(&db).await?;
// Joins
let results: Vec<(String, String)> = UserTable::select_cols((
UserTable::name, PostTable::title,
))
.join(PostTable::author_id.eq(UserTable::id))
.execute(&db).await?;
The #[model] Macro
A single proc macro invocation defines your entire schema at the struct level. One source of truth.
What It Generates
┌──────────────────────────────────────────────────────────────┐
│ #[model("users")] struct User { ... } │
├──────────────────────────────────────────────────────────────┤
│ │
│ 1. struct User { id, name, age, ... } │
│ → DAO entity with _dirty_flags: u64 │
│ │
│ 2. impl User { set_name(), save(), get(), create(), ... } │
│ → Active Record methods + dirty-tracking setters │
│ │
│ 3. struct UserTable → DSL namespace │
│ → UserTable::id, ::name (typed Column constants) │
│ → UserTable::select(), insert(), update(), delete() │
│ │
│ 4. user_row! { name: "x", age: 1 } │
│ → Zero-alloc bulk insert helper │
│ │
│ 5. impl Default for User │
│ → Test-friendly struct instantiation │
└──────────────────────────────────────────────────────────────┘
Why not per-struct #[derive]? Per-struct macros spin up the proc macro engine once per model, scatter schema across the codebase, and can't cross-reference models for relationship generation. A single invocation sees everything.
PostgreSQL Schema Support
Tables in non-default schemas use qualified names: model User("auth.users") generates SELECT * FROM "auth"."users".
Column Types
Every column is defined with a type function. The first argument is always the database column name.
| floz | Rust Type | PostgreSQL |
|---|---|---|
integer("col") | i32 | INTEGER |
short("col") | i16 | SMALLINT |
bigint("col") | i64 | BIGINT |
real("col") | f32 | REAL |
double("col") | f64 | DOUBLE PRECISION |
decimal("col", p, s) | BigDecimal | NUMERIC(p,s) |
varchar("col", N) | String | VARCHAR(N) |
text("col") | String | TEXT |
bool("col") | bool | BOOLEAN |
date("col") | NaiveDate | DATE |
time("col") | NaiveTime | TIME |
datetime("col") | NaiveDateTime | TIMESTAMP |
datetime("col").tz() | DateTime<Utc> | TIMESTAMPTZ |
uuid("col") | Uuid | UUID |
binary("col") | Vec<u8> | BYTEA |
json("col") | serde_json::Value | JSON |
jsonb("col") | serde_json::Value | JSONB |
enumeration("col", E) | Rust enum | ENUM |
ltree("col") | String | LTREE |
col(T, "col") | T | inferred |
Array Types
| floz | Rust Type | PostgreSQL |
|---|---|---|
text_array("col") | Vec<String> | TEXT[] |
int_array("col") | Vec<i32> | INTEGER[] |
bigint_array("col") | Vec<i64> | BIGINT[] |
uuid_array("col") | Vec<Uuid> | UUID[] |
bool_array("col") | Vec<bool> | BOOLEAN[] |
real_array("col") | Vec<f32> | REAL[] |
double_array("col") | Vec<f64> | DOUBLE PRECISION[] |
Relationships
array(Model, "fk_column") declares a relationship, not a database column. It generates fetch_ methods on both sides. See Relationships.
Column & Table Modifiers
| Modifier | Effect |
|---|---|
.primary() | PRIMARY KEY |
.auto_increment() | SERIAL / BIGSERIAL — excluded from INSERT |
.nullable() | Wraps Rust type in Option<T> |
.unique() | UNIQUE constraint |
.default(value) | DEFAULT <value> |
.now() | DEFAULT now() — shorthand for datetime |
.tz() | WITH TIME ZONE — for datetime/time |
.index() | CREATE INDEX on this column |
Table-Level Constraints
@primary_key(post_id, tag_id) // Composite primary key
@unique(col_a, col_b) // Composite unique constraint
@index(col_a, col_b) // Composite index
DAO API
Active Record style — load an entity, mutate it, save. Only dirty fields go into the UPDATE.
Create
let alice = User::create()
.name("Alice").age(30)
.execute(&db).await?;
// alice.id is now populated from the DB
// Returning a specific column
let new_id: i32 = User::create()
.name("Bob").age(25)
.execute_returning(UserTable::id, &db).await?;
Read
let user = User::get(1, &db).await?; // by PK
let maybe = User::get_optional(999, &db).await?; // returns Option
let young = User::find(UserTable::age.lt(25), &db).await?;
let admin = User::find_one(UserTable::name.eq("admin"), &db).await?;
let all = User::all(&db).await?;
// Composite primary keys
let pt = PostTag::get(post_id, tag_id, &db).await?;
Update (Dirty Tracking)
let mut user = User::get(1, &db).await?;
user.set_name("Alice Updated"); // _dirty_flags |= 1 << 1
user.set_age(31); // _dirty_flags |= 1 << 2
user.save(&db).await?;
// → UPDATE users SET name = $1, age = $2 WHERE id = $3
// Only 2 fields. If nothing changed, save() is a no-op.
Hard limit: 64 columns per model. Dirty tracking uses a single u64 bitmask — zero allocations, ever. Models exceeding 64 columns produce a compile error.
Delete & Utility
user.delete(&db).await?;
User::destroy(UserTable::age.lt(18), &db).await?; // bulk
let count = User::count(UserTable::age.gt(25), &db).await?;
let total = User::count_all(&db).await?;
let exists = User::exists(UserTable::email.eq("x@y.com"), &db).await?;
Models Without Primary Keys
Append-only tables (audit logs, metrics) work without a primary key. They get create(), all(), and find() — but no get(), save(), or delete().
DSL API
Typesafe query builder for complex queries — JOINs, aggregates, subqueries, upserts. Mix freely with the DAO API.
SELECT
// Full rows
let users: Vec<User> = UserTable::select()
.where_(UserTable::age.gt(25))
.order_by(UserTable::name.asc()).limit(10)
.execute(&db).await?;
// Specific columns (tuple return, up to 16)
let names: Vec<(String, i16)> = UserTable::select_cols((
UserTable::name, UserTable::age,
)).where_(UserTable::age.between(18, 65))
.execute(&db).await?;
Filters & Operators
// Comparison
UserTable::age.eq(25) .ne(25) .gt(25) .gte(25) .lt(25) .lte(25)
// Range
UserTable::age.between(18, 65)
UserTable::id.in_list(vec![1, 2, 3])
// String
UserTable::name.like("A%") .ilike("a%") .contains("foo")
UserTable::name.starts_with("A") .ends_with("z")
// Null
UserTable::email.is_null() .is_not_null()
// Logical (auto-parenthesized)
UserTable::age.gt(25).and(
UserTable::name.eq("Alice").or(UserTable::name.eq("Bob"))
)
// → age > 25 AND (name = 'Alice' OR name = 'Bob')
INSERT & Upsert
UserTable::insert()
.set(UserTable::name, "Alice")
.set(UserTable::age, 30i16)
.execute(&db).await?;
// Bulk insert (zero-alloc via user_row! macro)
UserTable::insert_many(&[
user_row! { name: "Alice", age: 30i16 },
user_row! { name: "Bob", age: 25i16 },
]).execute(&db).await?;
// Upsert
UserTable::insert()
.set(UserTable::name, "Alice").set(UserTable::age, 31i16)
.on_conflict(UserTable::name)
.do_update(UserTable::age, 31i16)
.execute(&db).await?;
UPDATE & DELETE
// Bulk update
UserTable::update()
.set(UserTable::age, UserTable::age.plus(1))
.where_(UserTable::age.lt(18))
.execute(&db).await?;
// Delete (safety guard: requires .where_() or .all())
UserTable::delete()
.where_(UserTable::id.eq(1))
.execute(&db).await?;
JOINs & Aggregates
// Inner join
let rows = UserTable::select_cols((UserTable::name, PostTable::title))
.join(PostTable::author_id.eq(UserTable::id))
.where_(UserTable::age.gt(25))
.execute(&db).await?;
// Aggregates with GROUP BY
let stats = UserTable::select_cols((
UserTable::name,
UserTable::age.avg().alias("avg_age"),
UserTable::id.count().alias("total"),
)).group_by(UserTable::name)
.having(UserTable::age.avg().gt(30.0))
.execute(&db).await?;
// Subqueries
UserTable::select()
.where_(UserTable::id.in_subquery(
PostTable::select_cols(PostTable::author_id)
.where_(PostTable::title.contains("Rust"))
)).execute(&db).await?;
Relationships
Declare relationships via array(Model, "fk"). The macro inspects all declarations and generates fetch, add, and remove methods automatically.
One-to-Many
// Schema declaration
model Post("posts") {
author_id: integer("author_id"),
authors: array(User, "author_id"),
}
// Auto-generated: Post → User (many side)
let author: User = post.fetch_user(&db).await?;
// Auto-generated: User → Posts (one side, reverse)
let posts: Vec<Post> = user.fetch_posts(&db).await?;
Many-to-Many
// Junction table
model PostTag("post_tags") {
post_id: integer("post_id"),
tag_id: integer("tag_id"),
posts: array(Post, "post_id"),
tags: array(Tag, "tag_id"),
@primary_key(post_id, tag_id),
}
// Auto-generated write methods
post.add_tag(&tag, &db).await?; // INSERT into junction
post.remove_tag(&tag, &db).await?; // DELETE from junction
post.set_tags(&[t1, t2], &db).await?; // replace all
⚠ N+1 Warning: fetch_ methods execute one query per call. Don't use them in loops. For batch loading, use JOINs or eager loading: UserTable::select().with(PostTable).execute(&db)
Transactions
Explicit Boundaries (Primary API)
let mut tx = db.begin().await?;
User::create().name("Alice").age(30)
.execute(&mut tx).await?;
Post::create().title("Hello").author_id(1)
.execute(&mut tx).await?;
tx.commit().await?;
// If tx is dropped without commit(), auto-rollback.
Nested Savepoints
let mut sp = tx.savepoint().await?;
User::create().name("Inner").age(25).execute(&mut sp).await?;
sp.commit().await?;
All DAO and DSL methods accept &Db, &mut Tx, or &mut Savepoint via the Executor trait.
Lifecycle Hooks
impl floz::FlozHooks for User {
fn before_save(&mut self) -> Result<(), floz::FlozError> {
self.set_updated_at(chrono::Utc::now());
Ok(())
}
}
Available hooks: before_create, after_create, before_save, after_save, before_delete, after_delete. All before_* hooks return Result — return Err to abort.
Pagination
let page = User::paginate(&db)
.page(3)
.per_page(20)
.where_(UserTable::is_active.eq(true))
.order_by(UserTable::created_at.desc())
.execute().await?;
// page.items — Vec<User>
// page.total — total matching rows
// page.total_pages — computed
// page.has_next — bool
// page.has_prev — bool
Error Handling
pub enum FlozError {
NotFound,
UniqueViolation(String),
ForeignKeyViolation(String),
Database(sqlx::Error),
UnsavedEntity, // save() on id=0
NothingToSave, // no dirty fields
MismatchedBulkInsertColumns { row: usize, column: String },
}
Connection & Pool
let db = Db::connect("postgres://user:pass@localhost/mydb").await?;
let db = Db::from_pool(existing_sqlx_pool); // interop
let pool: &sqlx::PgPool = db.pool(); // escape hatch
The Executor trait is mockable for testing. All methods take &impl Executor, so they work with any backend — live pool, transaction, or test mock.
Database Migrations
The Structural Fingerprinting Engine removes external dependencies and automatically syncs abstract syntax trees to Postgres.
Generate & Migrate: Use floz db generate inside your terminal to statically parse your models into v0.json migration boundaries. Then run floz db migrate to execute a bidirectional schema diff directly against live infrastructure!
// Zero-hit compilation
embed_migrations!(); // Pushes all static JSON histories to Memory!
floz-editor
A production-grade terminal SQL editor for PostgreSQL, built with Ratatui. Full CRUD, hierarchical table views, advanced filtering — all from your terminal.
Dynamic Data Grid
Horizontal scrolling, column resizing, deterministic pagination with 100-row chunks.
Advanced Filtering
Global fuzzy search (/) and structured filter builder (F) with column-specific operators.
Schema Explorer
Collapsible schema tree with lazy-loaded table lists. Navigate between schemas and tables.
Composite Views
Expand parent rows to see related children in split-pane. Jump to child table with breadcrumb tracking.
Read-Only Mode
Launch with --read-only to disable all mutations. Safe for production auditing.
Configurable
TOML config for table aliases, hidden columns, PK overrides, and manual relationship definitions.
Architecture
floz/ ← workspace root
├── floz/ ← runtime library
│ └── src/
│ ├── column.rs ← Column<T>, typed operators
│ ├── expr.rs ← Expression tree
│ ├── value.rs ← Value enum for SQL params
│ ├── query/ ← SELECT, INSERT, UPDATE, DELETE
│ ├── db.rs ← Db, Tx, Executor trait
│ ├── paginate.rs ← Pagination
│ ├── hooks.rs ← Lifecycle hooks
│ └── error.rs ← FlozError
│
├── floz-macros-core/ ← proc macro crate
│ └── src/
│ ├── ast.rs ← ModelDef, FieldDef
│ ├── codegen.rs ← struct + table + builder gen
│
├── floz-editor/ ← TUI application
│
└── examples/ ← working examples
Dependencies
Why floz-orm?
vs Diesel
- No DSL learning curve
- Single macro, not schema.rs
- Async-first (Diesel is sync)
- DAO + DSL from one definition
vs SeaORM
- Lighter — no entity-per-file
- Single macro invocation
- Zero-alloc dirty tracking
- Built-in TUI editor
vs raw SQLx
- ORM layer with dirty tracking
- Auto-generated relationships
- Typesafe query builder
- No compile-time DB needed
| Feature | floz | Diesel | SeaORM | SQLx |
|---|---|---|---|---|
| Single schema definition | ✓ | — | — | — |
| DAO + DSL unified | ✓ | — | partial | — |
| Dirty tracking | ✓ (u64) | — | ActiveModel | — |
| Async-first | ✓ | — | ✓ | ✓ |
| No compile-time DB | ✓ | ✓ | ✓ | — |
| Auto relationships | ✓ | — | partial | — |
| Terminal SQL editor | ✓ | — | — | — |
| Safety guards | ✓ | ✓ | — | — |