Open-Source Rust ORM + Terminal Editor

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.

$ cargo add floz
1 Macro
Schema Definition
2 APIs
DAO + DSL
0 Alloc
Dirty Tracking
64 Col
u64 Bitmask Limit

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!(&notes).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.

flozRust TypePostgreSQL
integer("col")i32INTEGER
short("col")i16SMALLINT
bigint("col")i64BIGINT
real("col")f32REAL
double("col")f64DOUBLE PRECISION
decimal("col", p, s)BigDecimalNUMERIC(p,s)
varchar("col", N)StringVARCHAR(N)
text("col")StringTEXT
bool("col")boolBOOLEAN
date("col")NaiveDateDATE
time("col")NaiveTimeTIME
datetime("col")NaiveDateTimeTIMESTAMP
datetime("col").tz()DateTime<Utc>TIMESTAMPTZ
uuid("col")UuidUUID
binary("col")Vec<u8>BYTEA
json("col")serde_json::ValueJSON
jsonb("col")serde_json::ValueJSONB
enumeration("col", E)Rust enumENUM
ltree("col")StringLTREE
col(T, "col")Tinferred

Array Types

flozRust TypePostgreSQL
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

ModifierEffect
.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.

★ View floz-editor on GitHub Full Documentation →

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

sqlx 0.8 chrono uuid serde serde_json thiserror 2 syn 2 quote proc-macro2 tokio

Why floz-orm?

vs Diesel

floz-orm advantages
  • No DSL learning curve
  • Single macro, not schema.rs
  • Async-first (Diesel is sync)
  • DAO + DSL from one definition

vs SeaORM

floz-orm advantages
  • Lighter — no entity-per-file
  • Single macro invocation
  • Zero-alloc dirty tracking
  • Built-in TUI editor

vs raw SQLx

floz-orm advantages
  • ORM layer with dirty tracking
  • Auto-generated relationships
  • Typesafe query builder
  • No compile-time DB needed
FeatureflozDieselSeaORMSQLx
Single schema definition
DAO + DSL unifiedpartial
Dirty tracking✓ (u64)ActiveModel
Async-first
No compile-time DB
Auto relationshipspartial
Terminal SQL editor
Safety guards