# floz — Design Document
> A lightweight, typesafe SQL library for Rust.
> Fast to compile. Explicit by design. Zero black-box magic.
---
## 1. Philosophy
**"Familiarity meets Rust's performance."**
Rust developers shouldn't have to learn a convoluted trait system just to query a database.
`floz` takes the beloved paradigms of Django ORM and Kotlin Exposed and adapts them to Rust's
strict memory model — with idiomatic Rust syntax throughout.
Today's Rust SQL ecosystem forces you to choose: raw SQL (SQLx), type-heavy DSL (Diesel),
or async ORM (SeaORM). None offers both a query builder AND an ORM from a single schema definition
with **proven zero-cost overhead** (routinely matching raw SQLx execution speeds under load).
`floz` uses a **single `#[model]` proc macro** per struct to generate everything:
- **One centralized schema** — generates both a plain-data DAO object and a typesafe DSL query builder
- **Builder-chain syntax** — `integer("id").auto_increment().primary()` reads like natural Rust
- **Explicit state tracking** — zero-allocation dirty checking via bitmasks
- **Beautiful compile errors** — `syn`-powered parsing catches typos with helpful messages
- **Single invocation** — the proc macro runs once for the entire schema, not per-struct
- **Pragmatic runtime metadata** — where it makes syntax cleaner, we use it
### Non-Goals (for now)
- Being a database engine
- Supporting every SQL dialect edge case
- Replacing raw SQL for ultra-complex analytics queries
### Explicit Trade-offs
- **No compile-time SQL verification.** Unlike `sqlx::query!()` which validates SQL against
a live database at compile time, `floz` generates SQL at runtime via composable builders.
You trade compile-time schema validation for composability, dynamic query construction,
and freedom from requiring a database connection during compilation. The type system still
prevents column typos and type mismatches — you just won't catch schema drift until runtime.
### Future Goals (not in v1)
- Migration engine with schema diffing (`floz::schema::sync`)
- Reverse-engineer schema from existing database tables (CLI)
- CLI tool for code generation
---
## 2. Quick Start — What It Looks Like
```rust
use floz::prelude::*;
const NAME_LENGTH: i32 = 100;
const TITLE_LENGTH: i32 = 255;
const LABEL_LENGTH: i32 = 50;
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// Step 1: Define your schema ONCE
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
```rust
#[model("users")]
pub struct User {
#[col(key, auto)]
pub id: i32,
#[col(max_length = 100)]
pub name: String,
#[col]
pub age: i16,
#[col(max_length = 100)]
pub email: Option<String>,
#[col]
pub bio: Option<String>,
#[col(default = "true")]
pub is_active: bool,
#[col(auto_now_add)]
pub created_at: chrono::DateTime<chrono::Utc>,
#[col(auto_now)]
pub updated_at: chrono::DateTime<chrono::Utc>,
}
#[model("posts")]
pub struct Post {
#[col(key, auto)]
pub id: i32,
#[col(max_length = 255)]
pub title: String,
#[col]
pub body: String,
#[col(references("users", "id"))]
pub author_id: i32,
#[col(auto_now_add)]
pub created_at: chrono::DateTime<chrono::Utc>,
}
#[model("tags")]
pub struct Tag {
#[col(key, auto)]
pub id: i32,
#[col(unique, max_length = 50)]
pub label: String,
}
#[model("post_tags", pks("post_id", "tag_id"))]
pub struct PostTag {
#[col(references("posts", "id"))]
pub post_id: i32,
#[col(references("tags", "id"))]
pub tag_id: i32,
}
```
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// Step 2: DAO Mode (Active Record Style)
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// Create
let alice = User::create()
.name("Alice")
.age(30)
.email(Some("alice@example.com".into()))
.execute( & db)
.await?;
// Read (by primary key)
let mut user = User::get(1, & db).await?;
// Update (explicit setters track state via bitmask)
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?;
// Find / filter
let admins = User::find(UserTable::name.eq("admin"), & db).await?;
let admin = User::find_one(UserTable::name.eq("admin"), & db).await?;
let everyone = User::all( & db).await?;
// Utility
let exists = User::exists(UserTable::email.eq("x@y.com"), & db).await?;
let count = User::count(UserTable::age.gt(25), & db).await?;
// Relationships (auto-generated from `array(User, "author_id")`)
let user = User::get(1, & db).await?;
let posts: Vec<Post> = user.fetch_posts( & db).await?;
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// Step 3: DSL Mode (Exposed Style)
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// Simple select
let young_users: Vec<User> = UserTable::select()
.where_(UserTable::age.gt(25))
.order_by(UserTable::name.asc())
.limit(10)
.execute( & db)
.await?;
// Select specific columns (returns tuples)
let names: Vec<(String, i16) > = UserTable::select_cols((
UserTable::name,
UserTable::age,
))
.where_(UserTable::age.between(18, 65))
.execute( & db)
.await?;
// Complex filter
let results: Vec<User> = UserTable::select()
.where_(
UserTable::age.between(18, 65)
.and(UserTable::email.is_not_null())
.or(UserTable::name.eq("admin"))
)
.execute( & db)
.await?;
// Joins
let user_posts: Vec<(String, String) > = UserTable::select_cols((
UserTable::name,
PostTable::title,
))
.join(PostTable::author_id.eq(UserTable::id))
.where_(UserTable::age.gt(20))
.execute( & db)
.await?;
// Aggregates
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?;
// Insert via DSL
UserTable::insert()
.set(UserTable::name, "Alice")
.set(UserTable::age, 30i16)
.execute( & db)
.await?;
// Bulk insert
UserTable::insert_many( & [
user_row! { name: "Alice", age: 30i16 },
user_row! { name: "Bob", age: 25i16 },
])
.execute( & db)
.await?;
// Update via DSL
UserTable::update()
.set(UserTable::age, 32i16)
.where_(UserTable::name.eq("Alice"))
.execute( & db)
.await?;
// Delete via DSL
UserTable::delete()
.where_(UserTable::id.eq(1))
.execute( & db)
.await?;
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// Step 4: Relationships
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
let user = User::get(1, & db).await?;
// Auto-generated from array(User, "author_id") on Post
let posts: Vec<Post> = user.fetch_posts( & db).await?;
// Reverse: Post → User
let post = Post::get(1, & db).await?;
let author: User = post.fetch_user( & db).await?;
// Many-to-many via junction table
let post = Post::get(1, & db).await?;
let tags: Vec<Tag> = post.fetch_tags( & db).await?;
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// Step 5: Transactions
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Post::create()
.title("New Post")
.author_id(user.id)
.execute( & tx)
.await ?;
user.delete( & tx).await ?;
Ok(())
}).await?;
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// Step 5: Schema Management
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
floz::schema::create::<User>( & db).await?;
floz::schema::drop::<User>( & db).await?;
let sql = floz::schema::create_sql::<User>();
```
---
## 3. Macro Strategy: Thin Proc Macro
### Why not `macro_rules!`
Declarative macros hit walls with this level of code generation:
- **Recursion limits** — schemas with many tables blow past default limits
- **Terrible error messages** — typos produce cryptic "no rules expected this token" errors
- **Complex parsing** — builder chains like `.auto_increment().primary()` are hard to parse
### Why not per-struct `#[derive]`
Spins up the proc macro engine per-struct, scatters schema across the codebase,
and can't cross-reference models for relationship generation.
### The thin proc macro approach
A **single attribute proc macro** — `#[model("table_name")]`:
- **One pass** — invoked once per struct
- **Beautiful errors** — `syn` catches typos with underlined, helpful compiler errors
- **Identical DX** — the user writes idiomatic Rust-style struct fields with `#[col]` attributes
---
## 4. The `#[model]` Macro — Single Source of Truth
### 4.1 Syntax
```rust
#[model("table_name")]
pub struct ModelName {
#[col(col_modifier, ...)]
pub field_name: FieldType,
}
```
Key: **Rust field name** is exactly as declared. Type mapping is inferred by the `FieldType`.
### 4.2 What It Generates
For each `#[model]` declaration:
```
┌──────────────────────────────────────────────────────────────┐
│ #[model("users")] pub struct User { ... } │
├──────────────────────────────────────────────────────────────┤
│ │
│ 1. struct User { id, name, age, email, ... } │
│ → DAO entity struct with derives │
│ → Hidden _dirty_flags: u64 │
│ │
│ 2. impl User { ... } │
│ → set_name(), set_age(), ... (dirty-tracking setters) │
│ → create() → UserBuilder │
│ → get(), find(), find_one(), all(), save(), delete() │
│ → exists(), count(), count_all() │
│ → fetch_posts() (from array declarations) │
│ │
│ 3. struct UserTable; │
│ → DSL namespace with typed Column constants │
│ → UserTable::id, UserTable::name, UserTable::age, ... │
│ → UserTable::select(), insert(), update(), delete() │
│ │
│ 4. user_row! { name: "x", age: 1 } │
│ → Shorthand for bulk inserts │
│ → Expands to Vec<(AnyColumn, Value)> │
│ │
│ 5. static USER_TABLE_META: &[ColumnMeta] │
│ → Column metadata for schema generation │
│ │
└──────────────────────────────────────────────────────────────┘
```
### 4.3 Generated Struct (DAO Entity)
```rust
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct User {
pub id: i32,
pub name: String,
pub age: i16,
pub email: Option<String>,
pub bio: Option<String>,
pub is_active: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
#[serde(skip)]
#[sqlx(default)] // Initializes to 0 when fetched from DB (column doesn't exist)
#[doc(hidden)]
pub _dirty_flags: u64,
}
```
### 4.4 Generated DSL Namespace
```rust
pub struct UserTable;
impl UserTable {
pub const TABLE_NAME: &'static str = "users";
pub const id: Column<i32> = Column::new("id", "users");
pub const name: Column<String> = Column::new("name", "users");
pub const age: Column<i16> = Column::new("age", "users");
pub const email: Column<Option<String>> = Column::new("email", "users");
pub const bio: Column<Option<String>> = Column::new("bio", "users");
pub const is_active: Column<bool> = Column::new("is_active", "users");
pub const created_at: Column<chrono::DateTime<chrono::Utc>> = Column::new("created_at", "users");
pub const updated_at: Column<chrono::DateTime<chrono::Utc>> = Column::new("updated_at", "users");
pub fn select() -> SelectQuery<User> { ... }
pub fn select_cols<C: ColumnSet>(cols: C) -> SelectColsQuery<C::Output> { ... }
pub fn insert() -> InsertQuery { ... }
pub fn insert_many(rows: &[PartialRow]) -> InsertManyQuery { ... }
pub fn update() -> UpdateQuery { ... }
pub fn delete() -> DeleteQuery { ... }
}
```
---
## 5. Type System
### 5.1 Column Type Functions
Each column is defined with a type function. The first argument is always the **DB column name**.
> **Hard limit: 64 columns per model.** Dirty tracking uses a single `u64` bitmask — zero
> allocations, always. If a model exceeds 64 columns, the proc macro emits a compile error:
>
> ```
> error: `users` has 65 fields, exceeding the 64-column limit.
> = help: Normalize your schema by splitting into related tables.
> ```
>
> This is a deliberate design constraint that enforces good database normalization.
#### Numeric
```rust
integer("col") // i32 → INTEGER
short("col") // i16 → SMALLINT
bigint("col") // i64 → BIGINT
real("col") // f32 → REAL
double("col") // f64 → DOUBLE PRECISION
decimal("col", precision, scale) // BigDecimal → NUMERIC(p, s)
```
#### String
```rust
varchar("col", max_length) // String → VARCHAR(N)
text("col") // String → TEXT
```
#### Boolean
```rust
bool("col") // bool → BOOLEAN
```
#### Date & Time
Everything is **naive by default**. `.tz()` upgrades to timezone-aware.
```rust
date("col") // NaiveDate → DATE
time("col") // NaiveTime → TIME
time("col").tz() // (tz time) → TIMETZ (rare)
datetime("col") // NaiveDateTime → TIMESTAMP
datetime("col").tz() // DateTime<Utc> → TIMESTAMPTZ
```
#### Identity & Binary
```rust
uuid("col") // Uuid → UUID
binary("col") // Vec<u8> → BYTEA
```
#### Generic Column (escape hatch)
For types without a dedicated function, or PostgreSQL-specific types:
```rust
col(Uuid, "col") // Uuid → UUID
col(String, "col") // String → TEXT
col(serde_json::Value, "col") // Value → JSONB (future)
```
#### Relationships
```rust
array(Model, "fk_column") // has_many relationship
```
`array(Post, "author_id")` on User means "User has many Posts linked via `posts.author_id`".
Generates `user.fetch_posts(&db)` and `post.fetch_user(&db)`.
> **`array()` is for relationships only.** For PostgreSQL native array columns (`TEXT[]`,
> `INT[]`, etc.), use the dedicated array type functions below.
#### PostgreSQL Native Array Columns
```rust
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[]
```
These generate **actual database columns** in the struct, unlike `array(Model, "fk")`
which generates relationship methods only.
#### Future Types
```rust
json("col") // serde_json::Value → JSON
jsonb("col") // serde_json::Value → JSONB
blob("col") // Vec<u8> → BYTEA (large)
enumeration("col", EnumType) // Rust enum → PostgreSQL ENUM
ltree("col") // String → LTREE (extension)
varchar_array("col") // Vec<String> → VARCHAR[]
short_array("col") // Vec<i16> → SMALLINT[]
```
### 5.2 Complete Type Mapping Table
| `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` | |
| `time("col").tz()` | *(tz time)* | `TIMETZ` | Rare |
| `datetime("col")` | `NaiveDateTime` | `TIMESTAMP` | Naive |
| `datetime("col").tz()` | `DateTime<Utc>` | `TIMESTAMPTZ` | With timezone |
| `uuid("col")` | `Uuid` | `UUID` | |
| `binary("col")` | `Vec<u8>` | `BYTEA` | |
| `col(T, "col")` | `T` | *(inferred)* | Generic escape hatch |
| `text_array("col")` | `Vec<String>` | `TEXT[]` | Native PG array |
| `int_array("col")` | `Vec<i32>` | `INTEGER[]` | Native PG array |
| `bigint_array("col")` | `Vec<i64>` | `BIGINT[]` | Native PG array |
| `uuid_array("col")` | `Vec<Uuid>` | `UUID[]` | Native PG array |
| `array(Model, "fk")` | *(relationship)* | *(no column)* | Generates fetch_ methods |
### 5.3 Modifiers (Chained)
```rust
.primary() // PRIMARY KEY
.auto_increment() // SERIAL / BIGSERIAL (based on integer type)
.nullable() // Wraps Rust type in Option<T>, allows NULL
.unique() // UNIQUE constraint
.default (value) // DEFAULT <value>
.default ("sql_expr") // DEFAULT <sql expression>
.now() // DEFAULT now() — shorthand for datetime
.tz() // WITH TIME ZONE — for datetime/time
.index() // CREATE INDEX on this column
```
#### Modifier examples
```rust
id: integer("id").auto_increment().primary(),
name: varchar("name", 100),
email: varchar("email", 255).nullable().unique(),
is_active: bool("is_active").default (true),
score: integer("score").default (0),
bio: text("bio_column").nullable(),
created_at: datetime("created_at").tz().now(),
```
### 5.4 Table-Level Constraints
```rust
@primary_key(post_id, tag_id) // Composite primary key
@ unique(col_a, col_b) // Composite unique constraint
@ index(col_a) // Index on column
@ index(col_a, col_b) // Composite index
```
### 5.5 Full Schema Example
```rust
const NAME_LEN: i32 = 100;
const TITLE_LEN: i32 = 255;
const LABEL_LEN: i32 = 50;
const RATING_DIGITS: i32 = 5;
const RATING_DECIMAL: i32 = 2;
floz::schema! {
model User("users") {
id: integer("id").auto_increment().primary(),
name: varchar("name", NAME_LEN),
bio: text("bio").nullable(),
age: short("age"),
email: varchar("email", NAME_LEN).nullable().unique(),
is_active: bool("is_active").default(true),
rating: decimal("rating", RATING_DIGITS, RATING_DECIMAL),
avatar_id: col(Uuid, "avatar_id").nullable(),
start_date: date("start_date"),
start_time: time("start_time"),
created_at: datetime("created_at").tz().now(),
updated_at: datetime("updated_at").tz().now(),
}
model Post("posts") {
id: integer("id").auto_increment().primary(),
title: varchar("title", TITLE_LEN),
body: text("body"),
author_id: integer("author_id"),
published: bool("published").default(false),
created_at: datetime("created_at").tz().now(),
authors: array(User, "author_id"),
}
model Tag("tags") {
id: integer("id").auto_increment().primary(),
label: varchar("label", LABEL_LEN).unique(),
}
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),
}
}
```
---
## 6. DAO API — Active Record Style
### 6.1 Create
```rust
let alice = User::create()
.name("Alice")
.age(30)
.email(Some("alice@example.com".into()))
.execute( & db)
.await?;
// alice.id is now populated from the DB
let new_id: i32 = User::create()
.name("Bob")
.age(25)
.execute_returning(UserTable::id, & db)
.await?;
```
**Builder Internals: Option\<T\> for Default Handling**
The generated `UserBuilder` stores every field as `Option<T>` internally. When `.execute()`
is called, only explicitly-set fields appear in the INSERT statement. This lets PostgreSQL
apply its native `DEFAULT` values (e.g., `now()`, `true`) for omitted columns:
```rust
// Internally generated:
struct UserBuilder {
name: Option<String>, // None until .name() is called
age: Option<i16>,
email: Option<Option<String>>, // Option<Option<T>> for nullable fields
is_active: Option<bool>,
// ...
}
// User::create().name("Alice").age(30).execute(&db)
// → INSERT INTO users (name, age) VALUES ($1, $2) RETURNING *
// PostgreSQL handles is_active DEFAULT true, created_at DEFAULT now()
```
### 6.2 Read
```rust
let user = User::get(1, & db).await?; // by PK (errors if not found)
let maybe = User::get_optional(999, & db).await?; // by PK (returns Option)
// Composite primary keys: macro generates multi-arg get()
let pt = PostTag::get(post_id, tag_id, & db).await?; // composite PK
let pt = PostTag::get_optional(post_id, tag_id, & db).await?; // composite PK (Option)
let young = User::find(UserTable::age.lt(25), & db).await?; // all matching
let admin = User::find_one(UserTable::name.eq("admin"), & db).await?; // first matching
let everyone = User::all( & db).await?; // all records
// Paginated
let page = User::paginate( & db)
.page(3)
.per_page(20)
.order_by(UserTable::created_at.desc())
.execute()
.await?;
// page.items, page.total, page.pages, page.has_next
```
### 6.3 Update (Dirty Tracking via Bitmask)
```rust
let mut user = User::get(1, & db).await?;
user.set_name("Alice Updated");
user.set_age(31);
user.save( & db).await?;
// → UPDATE users SET name = $1, age = $2 WHERE id = $3
// Only dirty fields. If nothing changed, save() is a no-op.
// Fields are pub for reading:
println!("Name: {}", user.name);
```
**Bitmask internals:**
```rust
impl User {
pub fn set_name(&mut self, val: impl Into<String>) {
self.name = val.into();
self._dirty_flags |= 1 << 1; // Bit 1 = name
}
pub fn set_age(&mut self, val: i16) {
self.age = val;
self._dirty_flags |= 1 << 2; // Bit 2 = age
}
pub async fn save(&mut self, db: &Db) -> Result<(), FlozError> {
// Guard: prevent accidental UPDATE WHERE id = 0
if self.id == 0 {
return Err(FlozError::UnsavedEntity);
// Use User::create() for new records instead.
}
if self._dirty_flags == 0 { return Ok(()); }
// Build UPDATE with only flagged columns, execute, reset
self._dirty_flags = 0;
Ok(())
}
}
```
> **Hard limit: 64 columns per model.** The proc macro enforces this at compile time.
> Tables exceeding 64 columns produce a compile error — normalize your schema instead.
> This guarantee means every entity uses a single `u64` — zero heap allocations, ever.
### 6.4 Testing & Struct Instantiation
The hidden `_dirty_flags` field creates friction for unit tests. The macro addresses this:
```rust
// 1. _dirty_flags is public but hidden from docs
#[doc(hidden)]
pub _dirty_flags: u64,
// 2. Default is auto-generated for all models
impl Default for User {
fn default() -> Self {
User {
id: 0,
name: String::new(),
age: 0,
email: None,
bio: None,
is_active: true, // uses declared default
created_at: Utc::now(),
updated_at: Utc::now(),
_dirty_flags: 0,
}
}
}
// 3. Users can construct test instances freely:
let test_user = User {
id: 1,
name: "Test".into(),
age: 25,
..User::default ()
};
// 4. The create() builder allows .id() for test seeding:
let seeded = User::create()
.id(99) // Normally auto-increment, but allowed for testing
.name("Seed")
.age(30)
.execute( & db)
.await?;
```
### 6.5 Soft Delete & Hard Delete
If your model schema contains a `deleted_at: datetime("deleted_at").nullable()` field, the macro engine automatically enables **Soft Deletes**. Calling `user.delete(&db)` will update `deleted_at` with the current time instead of wiping the record. All queries (`User::all()`, `User::find()`) are seamlessly scoped to ignore soft-deleted items unless explicitly bypassed.
```rust
let user = User::get(1, & db).await?;
// SOFT DELETE (if deleted_at exists) or HARD DELETE
user.delete( & db).await?;
// Bulk delete without loading
User::destroy(UserTable::age.lt(18), & db).await?;
```
### 6.6 Utility
```rust
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?;
```
---
## 7. DSL API — Typesafe Query Builder
### 7.1 SELECT
```rust
// Full row
let users: Vec<User> = UserTable::select()
.where_(UserTable::age.gt(25))
.execute( & db).await?;
// Single row
let user: User = UserTable::select()
.where_(UserTable::id.eq(1))
.execute_one( & db).await?;
// Optional
let user: Option<User> = UserTable::select()
.where_(UserTable::id.eq(1))
.execute_optional( & db).await?;
// Specific columns (tuple return — max 16 columns)
let names: Vec<(String, i16) > = UserTable::select_cols((
UserTable::name, UserTable::age,
))
.where_(UserTable::age.gt(25))
.execute( & db).await?;
// Single column
let emails: Vec<String> = UserTable::select_cols(UserTable::email)
.where_(UserTable::email.is_not_null())
.execute( & db).await?;
// Distinct / Pagination / Count / Exists
UserTable::select_cols(UserTable::name).distinct().execute( & db).await?;
UserTable::select().order_by(UserTable::created_at.desc()).limit(20).offset(40).execute( & db).await?;
UserTable::select().where_(UserTable::age.gt(25)).count( & db).await?;
UserTable::select().where_(UserTable::email.eq("x@y.com")).exists( & db).await?;
// NOTE: select_cols supports tuples up to 16 elements.
// This mirrors sqlx's FromRow tuple implementation limit.
// The ColumnSet trait is implemented via macro for (A,) through (A,...,P).
// For wider selections, use select() to fetch the full struct, or use raw SQL.
```
### 7.2 Filters & Operators
```rust
// Comparison
UserTable::age.eq(25) UserTable::age.ne(25)
UserTable::age.gt(25) UserTable::age.gte(25)
UserTable::age.lt(25) UserTable::age.lte(25)
// Range
UserTable::age.between(18, 65)
UserTable::id.in_list(vec![1, 2, 3])
UserTable::id.not_in(vec![4, 5])
// String (on varchar/text columns only)
UserTable::name.like("A%") UserTable::name.ilike("a%")
UserTable::name.starts_with("A") UserTable::name.ends_with("z")
UserTable::name.contains("foo")
// Null (on nullable columns only)
UserTable::email.is_null() UserTable::email.is_not_null()
// Logical
UserTable::age.gt(25).and(UserTable::name.eq("Alice"))
UserTable::age.lt(18).or(UserTable::age.gt(65))
UserTable::email.is_null().not()
// Nesting (automatic parentheses)
UserTable::age.gt(25).and(
UserTable::name.eq("Alice").or(UserTable::name.eq("Bob"))
)
// → age > 25 AND (name = 'Alice' OR name = 'Bob')
// Ordering
UserTable::name.asc() UserTable::name.desc()
UserTable::name.asc_nulls_last()
```
### 7.3 Aggregates & Grouping
```rust
UserTable::age.avg() UserTable::age.sum() UserTable::age.min() UserTable::age.max()
UserTable::id.count() UserTable::id.count_distinct()
UserTable::age.avg().alias("avg_age")
UserTable::age.plus(1) UserTable::age.minus(1)
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?;
```
### 7.4 INSERT
```rust
UserTable::insert()
.set(UserTable::name, "Alice")
.set(UserTable::age, 30i16)
.execute( & db).await?;
// Returning
let new_user: User = UserTable::insert()
.set(UserTable::name, "Alice").set(UserTable::age, 30i16)
.returning().execute( & db).await?;
let new_id: i32 = UserTable::insert()
.set(UserTable::name, "Alice").set(UserTable::age, 30i16)
.returning_col(UserTable::id).execute( & db).await?;
// Returning multiple columns (mirrors select_cols tuple logic)
let (new_id, created): (i32, DateTime<Utc>) = UserTable::insert()
.set(UserTable::name, "Alice").set(UserTable::age, 30i16)
.returning_cols((UserTable::id, UserTable::created_at))
.execute( & db).await?;
// Bulk
UserTable::insert_many( & [
user_row! { name: "Alice", age: 30i16 },
user_row! { name: "Bob", age: 25i16 },
]).execute( & db).await?;
// user_row! expands to a fixed-size ARRAY (zero heap allocation per row):
// user_row! { name: "Alice", age: 30i16 }
// → [
// (UserTable::name.into_any(), Value::String("Alice".into())),
// (UserTable::age.into_any(), Value::Short(30)),
// ]
// Type: [(AnyColumn, Value); 2] — size known at compile time.
// insert_many takes &[&[(AnyColumn, Value)]] — zero allocations for row structures.
// Upsert
UserTable::insert()
.set(UserTable::name, "Alice").set(UserTable::age, 31i16)
.on_conflict(UserTable::name)
.do_update(UserTable::age, 31i16)
.execute( & db).await?;
```
### 7.5 UPDATE
```rust
let affected: u64 = UserTable::update()
.set(UserTable::name, "Updated")
.set(UserTable::age, 31i16)
.where_(UserTable::id.eq(1))
.execute( & db).await?;
// Increment
UserTable::update()
.set(UserTable::age, UserTable::age.plus(1))
.where_(UserTable::age.lt(18))
.execute( & db).await?;
// Returning
let updated: User = UserTable::update()
.set(UserTable::age, UserTable::age.plus(1))
.where_(UserTable::id.eq(1))
.returning().execute( & db).await?;
```
### 7.6 DELETE
```rust
let affected: u64 = UserTable::delete()
.where_(UserTable::id.eq(1))
.execute( & db).await?;
let deleted: User = UserTable::delete()
.where_(UserTable::id.eq(1))
.returning().execute( & db).await?;
// Safety guard for delete-all
UserTable::delete().all().execute( & db).await?;
```
### 7.7 JOINs
```rust
// Inner
let rows: Vec<(String, String) > = UserTable::select_cols((
UserTable::name, PostTable::title,
))
.join(PostTable::author_id.eq(UserTable::id))
.execute( & db).await?;
// Left
let rows: Vec<(String, Option<String>) > = UserTable::select_cols((
UserTable::name, PostTable::title,
))
.left_join(PostTable::author_id.eq(UserTable::id))
.execute( & db).await?;
// Multiple joins + filters
UserTable::select_cols((UserTable::name, PostTable::title, CommentTable::body))
.join(PostTable::author_id.eq(UserTable::id))
.join(CommentTable::post_id.eq(PostTable::id))
.where_(UserTable::age.gt(25))
.execute( & db).await?;
```
### 7.8 Subqueries & Raw SQL
```rust
// Subquery
UserTable::select()
.where_(UserTable::id.in_subquery(
PostTable::select_cols(PostTable::author_id)
.where_(PostTable::title.contains("Rust"))
))
.execute( & db).await?;
// Raw SQL escape hatch
let users: Vec<User> = floz::raw(
"SELECT * FROM users WHERE age > $1 AND name ILIKE $2",
& [ & 25, & "%alice%"],
).execute( & db).await?;
// Raw expression inside DSL
UserTable::select()
.where_(floz::expr("age + 5 > $1", & [ & 30]))
.execute( & db).await?;
```
---
## 8. Relationships
### 8.1 Declaration
Relationships are declared via `array(Model, "fk_column")` in the schema:
```rust
model Post("posts") {
author_id: integer("author_id"),
authors: array(User, "author_id"), // Post.author_id → User.id
}
```
The proc macro inspects all `array` declarations and auto-generates `fetch_` methods.
### 8.2 Generated Methods
From `authors: array(User, "author_id")` on Post:
**On Post (the "many" side):**
```rust
impl Post {
pub async fn fetch_user(&self, db: &Db) -> Result<User, FlozError> {
User::get(self.author_id, db).await
}
}
```
**On User (the "one" side) — reverse lookup:**
```rust
impl User {
pub async fn fetch_posts(&self, db: &Db) -> Result<Vec<Post>, FlozError> {
Post::find(PostTable::author_id.eq(self.id), db).await
}
}
```
> **⚠ N+1 Warning:** `fetch_` methods are **lazy** — they execute one query per call.
> Do NOT use them in a loop over multiple entities. For batch loading, use eager loading
> (Phase 4) or the DSL join API:
>
> ```rust
> // ❌ Bad: N+1 queries
> for user in users {
> let posts = user.fetch_posts(&db).await?; // 1 query per user!
> }
>
> // ✅ Good: Single query via DSL join
> let results = UserTable::select_cols((UserTable::name, PostTable::title))
> .join(PostTable::author_id.eq(UserTable::id))
> .execute(&db).await?;
>
> // ✅ Future: Eager loading (Phase 4)
> let users = UserTable::select().with(PostTable).execute(&db).await?;
> ```
### 8.3 Many-to-Many (Junction Tables)
```rust
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),
}
```
Generates:
```rust
impl Post {
pub async fn fetch_tags(&self, db: &Db) -> Result<Vec<Tag>, FlozError> { ... }
}
impl Tag {
pub async fn fetch_posts(&self, db: &Db) -> Result<Vec<Post>, FlozError> { ... }
}
```
### 8.4 Relationship Write Methods
Alongside `fetch_` (read), the macro generates write methods for linking/unlinking:
**One-to-Many:**
```rust
// Set the author on a post (updates post.author_id and saves)
let mut post = Post::get(1, & db).await?;
post.set_author( & user, & db).await?;
// → UPDATE posts SET author_id = $1 WHERE id = $2
```
**Many-to-Many (junction table handled automatically):**
```rust
let post = Post::get(1, & db).await?;
let tag = Tag::get(5, & db).await?;
// Link: inserts into junction table
post.add_tag( & tag, & db).await?;
// → INSERT INTO post_tags (post_id, tag_id) VALUES ($1, $2)
// Unlink: removes from junction table
post.remove_tag( & tag, & db).await?;
// → DELETE FROM post_tags WHERE post_id = $1 AND tag_id = $2
// Replace all: clear + re-link
post.set_tags( & [tag1, tag2, tag3], & db).await?;
// → DELETE FROM post_tags WHERE post_id = $1
// → INSERT INTO post_tags (post_id, tag_id) VALUES ...
```
> **Transaction Safety:** Multi-statement relationship writes (`set_tags`, which does
> DELETE + INSERT) automatically wrap in a transaction if the executor is a pool.
> If already inside a `Tx`, they execute directly on the existing transaction.
> This prevents orphaned data from partial failures.
---
## 9. Transactions
The **primary API** uses explicit transaction boundaries. This avoids the notorious
Higher-Rank Trait Bound (HRTB) lifetime issues that plague closure-based async transaction
APIs in Rust.
### 9.1 Explicit Boundaries (Primary API)
```rust
// Begin → execute → commit/rollback
let mut tx = db.begin().await?;
User::create()
.name("Alice")
.age(30)
.execute( & mut tx)
.await?;
Post::create()
.title("New Post")
.author_id(1)
.execute( & mut tx)
.await?;
tx.commit().await?;
// If tx is dropped without commit(), it auto-rolls back.
```
### 9.2 Nested Savepoints
```rust
let mut tx = db.begin().await?;
User::create().name("Outer").age(25).execute( & mut tx).await?;
// Nested — uses SAVEPOINT internally
let mut sp = tx.savepoint().await?;
User::create().name("Inner").age(30).execute( & mut sp).await?;
sp.commit().await?;
tx.commit().await?;
```
### 9.3 Closure API (Convenience, may require nightly or boxing)
If Rust's async closure lifetime story stabilizes, we'll also offer:
```rust
// Sugar — auto-commits on Ok, rolls back on Err
user.delete(& tx).await ?;
Ok(())
}).await?;
```
> **Note:** This may require `Box::pin` internally on stable Rust due to HRTB limitations.
> The explicit `begin/commit` API always works without any boxing or lifetime gymnastics.
All DAO and DSL methods accept `&Db`, `&mut Tx`, or `&mut Savepoint` via the `Executor` trait.
---
## 10. Schema Management
```rust
floz::schema::create::<User>( & db).await?; // CREATE TABLE IF NOT EXISTS
floz::schema::drop::<User>( & db).await?; // DROP TABLE
let exists = floz::schema::exists::<User>( & db).await?;
let sql = floz::schema::create_sql::<User>(); // Dry-run review
// Future: auto-sync
floz::schema::sync( & db).await?;
```
---
## 11. Connection / Pool
```rust
use floz::Db;
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
```
### 11.1 Executor Trait (Mockable)
The `Executor` trait is the backbone — all floz methods accept any implementor:
```rust
pub trait Executor {
async fn execute_raw(&self, sql: &str, params: Vec<Value>) -> Result<u64, FlozError>;
async fn fetch_raw<T>(&self, sql: &str, params: Vec<Value>) -> Result<Vec<T>, FlozError>
where
T: Send + Unpin + for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>;
async fn fetch_one_raw<T>(&self, sql: &str, params: Vec<Value>) -> Result<T, FlozError>
where
T: Send + Unpin + for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>;
}
// Implemented for:
impl Executor for Db { ... } // Live connection pool
impl Executor for &mut Tx { ... } // Transaction
impl Executor for &mut Sp { ... } // Savepoint
```
#### Implementation Note: Value → sqlx Binding
The `params: &[Value]` signature hides a critical translation layer. Because `sqlx`
requires concrete types for `.bind()` (via `Encode<'q, Postgres>`), the `Executor`
implementation must pattern-match each `Value` variant and bind it explicitly:
```rust
// Inside Executor::fetch_raw() implementation
let mut query = sqlx::query(sql);
for param in params {
match param {
// Non-nullable types
Value::Int(v) => { query = query.bind(v); }
Value::Short(v) => { query = query.bind(v); }
Value::BigInt(v) => { query = query.bind(v); }
Value::Real(v) => { query = query.bind(v); }
Value::Double(v) => { query = query.bind(v); }
Value::String(v) => { query = query.bind(v); }
Value::Bool(v) => { query = query.bind(v); }
Value::Uuid(v) => { query = query.bind(v); }
Value::DateTime(v) => { query = query.bind(v); }
Value::NaiveDateTime(v) => { query = query.bind(v); }
Value::NaiveDate(v) => { query = query.bind(v); }
Value::NaiveTime(v) => { query = query.bind(v); }
Value::Bytes(v) => { query = query.bind(v); }
// Nullable types — preserves PostgreSQL type OID even for NULL
// (PostgreSQL rejects untyped NULLs with: "could not determine data type")
Value::OptionInt(v) => { query = query.bind(v); }
Value::OptionShort(v) => { query = query.bind(v); }
Value::OptionBigInt(v) => { query = query.bind(v); }
Value::OptionReal(v) => { query = query.bind(v); }
Value::OptionDouble(v) => { query = query.bind(v); }
Value::OptionString(v) => { query = query.bind(v); }
Value::OptionBool(v) => { query = query.bind(v); }
Value::OptionUuid(v) => { query = query.bind(v); }
Value::OptionDateTime(v) => { query = query.bind(v); }
Value::OptionNaiveDateTime(v) => { query = query.bind(v); }
Value::OptionNaiveDate(v) => { query = query.bind(v); }
Value::OptionNaiveTime(v) => { query = query.bind(v); }
Value::OptionBytes(v) => { query = query.bind(v); }
}
}
let rows = query.fetch_all(pool).await?;
```
> **Why typed nulls?** A generic `Value::Null` variant causes PostgreSQL to reject queries
> with `could not determine data type of parameter $N`. Each `Option*` variant preserves
> the PostgreSQL type OID so `.bind(None::<i32>)` sends INT4 and `.bind(None::<String>)`
> sends TEXT.
This is verbose but correct. No generic `Null` variant exists — every value carries its
PostgreSQL type information. The match is exhaustive and compile-time checked.
#### Lifetime Note: Owned Values
The `Executor` trait takes `params: Vec<Value>` (**owned**, not `&[Value]`). This is
deliberate: `sqlx` requires bound values to live for the duration of query execution `'q`.
If `Value` held references, the borrow checker would fight us across async boundaries.
Owned data ensures the values survive through the `.await` without lifetime gymnastics.
For testing, users can implement `Executor` on a mock struct or use the `Db::from_pool()`
interop to pass a test database. The `fetch_posts()` and other DAO methods only require
`&impl Executor`, so they work with any backend:
```rust
// Unit test with a test database
let test_db = Db::connect("postgres://localhost/test_db").await?;
let user = User::get(1, & test_db).await?;
let posts = user.fetch_posts( & test_db).await?;
```
---
## 12. Error Handling
```rust
#[derive(Debug, thiserror::Error)]
pub enum FlozError {
#[error("Record not found")]
NotFound,
#[error("Unique constraint violated: {0}")]
UniqueViolation(String),
#[error("Foreign key constraint violated: {0}")]
ForeignKeyViolation(String),
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
#[error("Serialization error: {0}")]
Serialization(String),
#[error("Cannot save — entity has no primary key (use create() for new records)")]
UnsavedEntity,
#[error("Nothing to save — no fields were modified")]
NothingToSave,
}
```
---
## 13. Architecture
`floz` is a **standalone Rust workspace** with two crates:
```
floz/ ← workspace root
├── Cargo.toml ← workspace manifest
│
├── floz/ ← main crate (runtime library)
│ ├── Cargo.toml
│ └── src/
│ ├── lib.rs ← public API, prelude, re-exports
│ ├── column.rs ← Column<T>, typed operators
│ ├── expr.rs ← Expression tree (Eq, Gt, And, Or, etc.)
│ ├── value.rs ← Value enum for dynamic SQL params
│ ├── query/
│ │ ├── mod.rs
│ │ ├── select.rs ← SelectQuery, SelectColsQuery
│ │ ├── insert.rs ← InsertQuery, InsertManyQuery
│ │ ├── update.rs ← UpdateQuery
│ │ └── delete.rs ← DeleteQuery
│ ├── dao.rs ← Entity trait, save/delete base logic
│ ├── db.rs ← Db, Tx, Executor trait, transaction()
│ ├── schema.rs ← create/drop/exists/sync
│ ├── error.rs ← FlozError
│ └── prelude.rs ← use floz::prelude::*
│
├── floz-macros/ ← proc macro crate (thin parser + codegen)
│ ├── Cargo.toml
│ └── src/
│ ├── lib.rs ← proc_macro entry point
│ ├── parse.rs ← Parse schema! DSL → internal AST
│ ├── ast.rs ← ModelDef, FieldDef, TypeInfo, Modifier
│ ├── gen_struct.rs ← Generate DAO struct + set_ + save/delete
│ ├── gen_table.rs ← Generate Table namespace + Column constants
│ ├── gen_builder.rs ← Generate create() builder + _row! macro
│ ├── gen_relations.rs ← Generate fetch_ methods from array()
│ └── gen_schema.rs ← Generate CREATE TABLE SQL metadata
│
└── tests/ ← integration tests
├── test_schema.rs
├── test_dao.rs
├── test_dsl.rs
├── test_joins.rs
└── test_transactions.rs
```
### Dependencies
```toml
# floz/Cargo.toml
[dependencies]
floz-macros = { path = "../floz-macros" }
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4", "serde"] }
# floz-macros/Cargo.toml
[dependencies]
syn = { version = "2", features = ["full"] }
quote = "1"
proc-macro2 = "1"
```
### What Lives Where
| Parsing `schema!` DSL | `floz-macros` | Needs proc macro API |
| Code generation (structs, impls) | `floz-macros` | Compile-time expansion |
| `Column<T>`, expressions, operators | `floz` | Runtime types |
| Query builders | `floz` | Runtime SQL construction |
| SQL rendering, param binding | `floz` | Runtime execution |
| `Db`, `Tx`, `Executor` | `floz` | Runtime connection |
| `FlozError` | `floz` | Runtime errors |
---
## 14. Proc Macro Error Examples
```
error: unknown modifier `primry_key`
--> src/schema.rs:5:32
|
|
= help: did you mean `primary()`?
error: `varchar` requires a max_length argument
--> src/schema.rs:6:15
|
|
= help: use `varchar("name", 100)`
error: unknown type function `intgr`
--> src/schema.rs:4:13
|
|
= help: available types: integer, short, bigint, varchar, text, bool,
date, time, datetime, uuid, decimal, real, double, binary, col, array
```
---
## 15. DAO vs DSL — When to Use Which
| Create a single record | `User::create().name("x").execute()` (DAO) | Builder-driven |
| Load → modify → save | `user.set_name("y"); user.save()` (DAO) | Dirty tracking |
| Complex SELECT with joins | `UserTable::select_cols(...)` (DSL) | Full control |
| Bulk UPDATE by condition | `UserTable::update().set(...).where_(...)` (DSL) | No loading |
| Delete by condition | `UserTable::delete().where_(...)` (DSL) | No loading |
| Simple find by filter | `User::find(filter)` (DAO) | Returns entities |
Both APIs work on the same schema. Mix freely.
---
## 16. Implementation Phases
Build order: **floz core first** (testable without a database), then **floz-macros**.
Each phase is small, self-contained, and includes its own tests.
---
### Phase 1 — Workspace + Skeleton
**Goal:** Compiles, does nothing useful yet.
- [✓] Create workspace `Cargo.toml`
- [✓] Create `floz/Cargo.toml` + `floz/src/lib.rs` (empty, re-exports)
- [✓] Create `floz-macros/Cargo.toml` + `floz-macros/src/lib.rs` (empty proc macro stub)
- [✓] Create `floz/src/prelude.rs`
- [✓] Verify `cargo build` passes
**Tests:** `cargo build` compiles cleanly.
---
### Phase 2 — Value Enum + FlozError
**Goal:** Core data types needed by everything else.
- [✓] `floz/src/value.rs` — `Value` enum with all variants (Int, Short, BigInt, String, Bool, etc.) + all `Option*`
variants for typed nulls
- [✓] `floz/src/error.rs` — `FlozError` enum (NotFound, UniqueViolation, UnsavedEntity, MismatchedBulkInsertColumns, etc.)
- [✓] `impl From<T>` conversions for `Value` (e.g., `From<i32>`, `From<String>`, `From<Option<i32>>`)
**Tests:**
```rust
#[test]
fn value_from_i32() { assert!(matches!(Value::from(42), Value::Int(42))); }
#[test]
fn value_from_none_string() { assert!(matches!(Value::from(None::<String>), Value::OptionString(None))); }
#[test]
fn value_from_some_string() { assert!(matches!(Value::from(Some("hi".into())), Value::OptionString(Some(_)))); }
```
---
### Phase 3 — Column + Expression Tree
**Goal:** Typed columns and composable filter expressions. No SQL yet.
- [✓] `floz/src/column.rs` — `Column<T>` struct with `name()`, `table()`, `into_any()`
- [✓] `floz/src/column.rs` — Comparison methods: `.eq()`, `.ne()`, `.gt()`, `.gte()`, `.lt()`, `.lte()`
- [✓] `floz/src/column.rs` — Range methods: `.between()`, `.in_list()`, `.not_in()`
- [✓] `floz/src/column.rs` — String methods: `.like()`, `.ilike()`, `.contains()`, `.starts_with()`, `.ends_with()`
- [✓] `floz/src/column.rs` — Null methods: `.is_null()`, `.is_not_null()`
- [✓] `floz/src/column.rs` — Order methods: `.asc()`, `.desc()`, `.asc_nulls_last()`
- [✓] `floz/src/expr.rs` — `Expr` enum (Eq, Gt, And, Or, Not, InList, Between, Like, IsNull, etc.)
- [✓] `floz/src/expr.rs` — `.and()`, `.or()`, `.not()` combinators on `Expr`
- [✓] `AnyColumn` type-erased column for dynamic use
**Tests:**
```rust
#[test]
fn column_eq_creates_expr() {
let col: Column<i32> = Column::new("age", "users");
let expr = col.eq(25);
assert!(matches!(expr, Expr::Eq(_, _)));
}
#[test]
fn expr_and_combines() {
let e = col_age.eq(25).and(col_name.eq("Alice"));
assert!(matches!(e, Expr::And(_, _)));
}
```
---
### Phase 4 — SQL Rendering (No DB)
**Goal:** Expression tree → SQL string + params. Tested via string assertions.
- [✓] `floz/src/expr.rs` — `Expr::to_sql(&self, sql, params, &mut idx)` renderer
- [✓] Handle: `$1, $2, $3` parameter numbering via shared counter
- [✓] Handle: empty `in_list` → `1=0`, empty `not_in` → `1=1`
- [✓] Handle: nested `And`/`Or` with automatic parentheses
- [✓] Handle: `BETWEEN $1 AND $2`
- [✓] Handle: `IS NULL`, `IS NOT NULL`
- [✓] Handle: `LIKE`, `ILIKE`
**Tests:**
```rust
#[test]
fn render_eq() {
let (sql, params) = render(col_age.eq(25));
assert_eq!(sql, "age = $1");
assert_eq!(params, vec![Value::Int(25)]);
}
#[test]
fn render_and_or() {
let (sql, _) = render(col_age.gt(18).and(col_name.eq("A").or(col_name.eq("B"))));
assert_eq!(sql, "(age > $1 AND (name = $2 OR name = $3))");
}
#[test]
fn render_empty_in_list() {
let (sql, params) = render(col_id.in_list(vec![]));
assert_eq!(sql, "1=0");
assert!(params.is_empty());
}
#[test]
fn render_between() {
let (sql, _) = render(col_age.between(18, 65));
assert_eq!(sql, "age BETWEEN $1 AND $2");
}
```
---
### Phase 5 — Query Builders + SQL Generation (No DB)
**Goal:** SelectQuery, InsertQuery, UpdateQuery, DeleteQuery → full SQL strings.
- [✓] `floz/src/query/select.rs` — `SelectQuery` with `.where_()`, `.order_by()`, `.limit()`, `.offset()`, `.distinct()`
- [✓] `floz/src/query/select.rs` — `SelectColsQuery` for column-specific selects
- [✓] `floz/src/query/insert.rs` — `InsertQuery` with `.set()`, `.returning()`, `.returning_col()`, `.returning_cols()`
- [✓] `floz/src/query/insert.rs` — `InsertManyQuery` with ragged-row validation
- [✓] `floz/src/query/insert.rs` — Upsert: `.on_conflict()`, `.do_update()`
- [✓] `floz/src/query/update.rs` — `UpdateQuery` with `.set()`, `.where_()`, `.returning()`
- [✓] `floz/src/query/delete.rs` — `DeleteQuery` with `.where_()`, `.all()`, `.returning()`
- [✓] Table name quoting for PostgreSQL schemas (`"auth"."users"`)
**Tests:**
```rust
#[test]
fn select_basic() {
let (sql, _) = SelectQuery::new("users").where_(col_age.gt(25)).to_sql();
assert_eq!(sql, "SELECT * FROM users WHERE age > $1");
}
#[test]
fn select_order_limit() {
let (sql, _) = SelectQuery::new("users")
.order_by(col_name.asc()).limit(10).to_sql();
assert_eq!(sql, "SELECT * FROM users ORDER BY name ASC LIMIT 10");
}
#[test]
fn insert_basic() {
let (sql, _) = InsertQuery::new("users")
.set_raw("name", Value::String("Alice".into()))
.set_raw("age", Value::Short(30))
.to_sql();
assert_eq!(sql, "INSERT INTO users (name, age) VALUES ($1, $2)");
}
#[test]
fn insert_returning() {
let (sql, _) = InsertQuery::new("users")
.set_raw("name", Value::String("A".into()))
.returning_all().to_sql();
assert!(sql.ends_with("RETURNING *"));
}
#[test]
fn update_basic() {
let (sql, _) = UpdateQuery::new("users")
.set_raw("age", Value::Short(31))
.where_(col_id.eq(1)).to_sql();
assert_eq!(sql, "UPDATE users SET age = $1 WHERE id = $2");
}
#[test]
fn delete_with_guard() {
// delete without .where_() or .all() should error
let result = DeleteQuery::new("users").to_sql();
assert!(result.is_err());
}
#[test]
fn schema_qualified_table() {
let (sql, _) = SelectQuery::new("auth.users").to_sql();
assert_eq!(sql, r#"SELECT * FROM "auth"."users""#);
}
```
---
### Phase 6 — Db + Executor + Transactions (Requires PostgreSQL)
**Goal:** Connect to real PostgreSQL, execute raw SQL.
- [✓] `floz/src/db.rs` — `Db` struct wrapping `sqlx::PgPool`
- [✓] `floz/src/db.rs` — `Db::connect()`, `Db::from_pool()`, `Db::pool()`
- [✓] `floz/src/db.rs` — `Tx` struct wrapping `sqlx::Transaction`
- [✓] `floz/src/db.rs` — `Executor` trait with `for<'r>` bounds
- [✓] `floz/src/db.rs` — `impl Executor for Db` with Value → `.bind()` pattern-match loop
- [✓] `floz/src/db.rs` — `impl Executor for &mut Tx` with `&&mut` reborrow
- [✓] `floz/src/db.rs` — `db.begin()`, `tx.commit()`, `tx.savepoint()`
**Tests:** (integration, requires running PostgreSQL)
```rust
#[tokio::test]
async fn connect_and_execute() {
let db = Db::connect(&test_url()).await.unwrap();
let result = db.execute_raw("SELECT 1", vec![]).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn transaction_commit() {
let db = Db::connect(&test_url()).await.unwrap();
let mut tx = db.begin().await.unwrap();
tx.execute_raw("CREATE TEMP TABLE test_tx (id INT)", vec![]).await.unwrap();
tx.commit().await.unwrap();
}
#[tokio::test]
async fn transaction_rollback_on_drop() {
let db = Db::connect(&test_url()).await.unwrap();
{
let mut tx = db.begin().await.unwrap();
tx.execute_raw("CREATE TEMP TABLE test_drop (id INT)", vec![]).await.unwrap();
// tx dropped here — should rollback
}
// table should not exist
}
```
---
### Phase 7 — Proc Macro: Schema Parsing
**Goal:** `schema!` macro parses the DSL into an internal AST. No code generation yet.
- [✓] `floz-macros/src/ast.rs` — `ModelDef`, `FieldDef`, `RelDef`, `TypeInfo`, `Modifier` structs
- [✓] `floz-macros/src/parse.rs` — Parse `model Name("table") { ... }` blocks
- [✓] `floz-macros/src/parse.rs` — Parse type functions: `integer("col")`, `varchar("col", N)`, etc.
- [✓] `floz-macros/src/parse.rs` — Parse modifier chains: `.auto_increment().primary().nullable()`
- [✓] `floz-macros/src/parse.rs` — Parse `array(Model, "fk")` → `RelDef` (separate bucket)
- [✓] `floz-macros/src/parse.rs` — Parse `@primary_key(a, b)`, `@unique(a, b)`, `@index(a)`
- [✓] `floz-macros/src/parse.rs` — Validate: unknown type → error, missing `max_length` for varchar → error
- [✓] `floz-macros/src/parse.rs` — Validate: >64 columns → compile error
- [✓] Split fields into `db_columns` vs `relationships`
**Tests:** (proc macro unit tests via `syn::parse_str`)
```rust
#[test]
fn parse_simple_model() {
let ast = parse_schema("model User(\"users\") { id: integer(\"id\").auto_increment().primary() }");
assert_eq!(ast.models[0].name, "User");
assert_eq!(ast.models[0].table_name, "users");
assert_eq!(ast.models[0].db_columns.len(), 1);
}
#[test]
fn parse_relationship_excluded_from_columns() {
let ast = parse_schema("model Post(\"posts\") { id: integer(\"id\"), authors: array(User, \"author_id\") }");
assert_eq!(ast.models[0].db_columns.len(), 1); // only id
assert_eq!(ast.models[0].relationships.len(), 1); // authors
}
#[test]
fn parse_rejects_65_columns() {
// Generate 65 fields, assert compile error
}
#[test]
fn parse_varchar_requires_length() {
let result = try_parse("model X(\"x\") { name: varchar(\"name\") }");
assert!(result.is_err());
}
```
---
### Phase 8 — Proc Macro: Code Generation (Struct + Table)
**Goal:** `schema!` generates the struct, Table namespace, and Column constants.
- [✓] `floz-macros/src/gen_struct.rs` — Generate `pub struct User { ... }` with derives
- [✓] `floz-macros/src/gen_struct.rs` — Generate `#[sqlx(default)] pub _dirty_flags: u64`
- [✓] `floz-macros/src/gen_struct.rs` — Generate `impl Default for User`
- [✓] `floz-macros/src/gen_table.rs` — Generate `pub struct UserTable;`
- [✓] `floz-macros/src/gen_table.rs` — Generate `UserTable::id`, `UserTable::name` Column constants
- [✓] `floz-macros/src/gen_table.rs` — Generate `UserTable::select()`, `insert()`, `update()`, `delete()`
- [✓] `floz-macros/src/lib.rs` — Wire it all together in `#[proc_macro] pub fn schema`
- [✓] Re-export `schema!` from `floz/src/lib.rs`
**Tests:** (compile-test + `cargo expand`)
```rust
floz::schema! {
model User("users") {
id: integer("id").auto_increment().primary(),
name: varchar("name", 100),
age: short("age"),
}
}
#[test]
fn generated_struct_has_fields() {
let u = User { id: 1, name: "A".into(), age: 20, _dirty_flags: 0 };
assert_eq!(u.name, "A");
}
#[test]
fn generated_table_has_columns() {
assert_eq!(UserTable::TABLE_NAME, "users");
assert_eq!(UserTable::id.name(), "id");
assert_eq!(UserTable::name.name(), "name");
}
#[test]
fn generated_default_works() {
let u = User::default();
assert_eq!(u.id, 0);
assert_eq!(u._dirty_flags, 0);
}
#[test]
fn struct_literal_with_default() {
let u = User { id: 99, name: "Test".into(), ..User::default() };
assert_eq!(u.id, 99);
}
```
---
### Phase 9 — Proc Macro: DAO Methods
**Goal:** Generate `set_`, `save()`, `get()`, `create()`, CRUD on entities.
- [✓] `floz-macros/src/gen_struct.rs` — Generate `set_name()`, `set_age()` with bitmask
- [✓] `floz-macros/src/gen_struct.rs` — Generate `save(&mut self, db)` — dirty-only UPDATE + UnsavedEntity guard
- [✓] `floz-macros/src/gen_struct.rs` — Generate `delete(&self, db)`
- [✓] `floz-macros/src/gen_struct.rs` — Generate `get(id, db)`, `get_optional(id, db)`
- [✓] `floz-macros/src/gen_struct.rs` — Generate `find(filter, db)`, `find_one(filter, db)`, `all(db)`
- [✓] `floz-macros/src/gen_struct.rs` — Generate `exists(filter, db)`, `count(filter, db)`, `count_all(db)`
- [✓] `floz-macros/src/gen_struct.rs` — Generate `destroy(filter, db)` — bulk delete
- [✓] `floz-macros/src/gen_builder.rs` — Generate `User::create()` builder with `Option<T>` internals
- [✓] `floz-macros/src/gen_builder.rs` — Generate `user_row!` macro (fixed-size array)
- [✓] Handle composite PKs: `PostTag::get(post_id, tag_id, db)`
- [✓] Handle PK-less models: skip `get/save/delete/set_`
**Tests:** (integration, requires PostgreSQL)
```rust
#[tokio::test]
async fn create_and_get() {
let db = test_db().await;
let user = User::create().name("Alice").age(30).execute(&db).await.unwrap();
assert!(user.id > 0);
let fetched = User::get(user.id, &db).await.unwrap();
assert_eq!(fetched.name, "Alice");
}
#[tokio::test]
async fn dirty_tracking() {
let db = test_db().await;
let mut user = User::get(1, &db).await.unwrap();
user.set_name("Updated");
assert!(user._dirty_flags != 0);
user.save(&db).await.unwrap();
assert_eq!(user._dirty_flags, 0);
}
#[tokio::test]
async fn save_unsaved_entity_errors() {
let db = test_db().await;
let mut user = User::default();
user.set_name("Ghost");
let result = user.save(&db).await;
assert!(matches!(result, Err(FlozError::UnsavedEntity)));
}
#[tokio::test]
async fn save_noop_when_clean() {
let db = test_db().await;
let mut user = User::get(1, &db).await.unwrap();
// No changes — save should be a no-op
user.save(&db).await.unwrap();
}
```
---
### Phase 10 — Advanced DSL + Relationships
**Goal:** Joins, aggregates, relationships, upsert.
- [✓] JOINs: `.join()`, `.left_join()` on SelectQuery
- [✓] Aggregates: `.avg()`, `.sum()`, `.count()`, `.min()`, `.max()` on Column
- [✓] `.alias()` for aggregate expressions
- [✓] `.group_by()`, `.having()` on SelectQuery
- [✓] Subqueries: `.in_subquery()` on Column
- [✓] UPSERT: `.on_conflict()`, `.do_update()` on InsertQuery
- [✓] `floz-macros/src/gen_relations.rs` — Generate `fetch_` methods from `array()`
- [✓] `floz-macros/src/gen_relations.rs` — Generate `add_`, `remove_`, `set_` for M2M
- [✓] Auto-transaction wrapping for multi-statement relationship writes
**Tests:**
```rust
#[test]
fn join_sql_rendering() {
let (sql, _) = SelectQuery::new("users")
.cols(&["users.name", "posts.title"])
.join("posts", col_author_id.eq(col_user_id))
.to_sql();
assert!(sql.contains("INNER JOIN posts ON"));
}
#[tokio::test]
async fn fetch_relationship() {
let db = test_db().await;
let user = User::get(1, &db).await.unwrap();
let posts = user.fetch_posts(&db).await.unwrap();
assert!(!posts.is_empty());
}
#[tokio::test]
async fn add_remove_m2m() {
let db = test_db().await;
let post = Post::get(1, &db).await.unwrap();
let tag = Tag::get(1, &db).await.unwrap();
post.add_tag(&tag, &db).await.unwrap();
let tags = post.fetch_tags(&db).await.unwrap();
assert!(tags.iter().any(|t| t.id == tag.id));
post.remove_tag(&tag, &db).await.unwrap();
}
```
---
### Phase 11, 12, 13, 14
- [✓] Eager loading: `.with(PostTable)`
- [✓] Hooks (before_save, after_create)
- [✓] Pagination: `User::paginate().page(3).per_page(20)`
- [✓] Additional types: `json()`, `jsonb()`, `enumeration()`, `ltree()`
### Future Phases
- [ ] Schema management: `create::<User>()`, `drop`, `exists`, `create_sql()`
- [ ] Migration engine
- [ ] Reverse-engineer models from DB (CLI)
- [ ] MySQL / SQLite backends
---
## 17. Implementation Traps (Reference During Build)
These are known technical traps to keep in mind during implementation.
### Trap 1: Bulk Insert Ragged Rows
`insert_many` generates `INSERT INTO users (name, age) VALUES ($1, $2), ($3, $4)`.
Every row **must** supply the same columns. If rows have mismatched columns:
```rust
UserTable::insert_many( & [
user_row! { name: "Alice", age: 30 },
user_row! { name: "Bob" }, // Missing age!
])
```
**Fix:** Grab the column list from the first row. For subsequent rows, if a column is
missing, return `FlozError::MismatchedBulkInsertColumns`. Fail fast — don't silently pad
with nulls. Add this error variant:
```rust
#[error("Bulk insert rows have mismatched columns: row {row} is missing `{column}`")]
MismatchedBulkInsertColumns { row: usize, column: String },
```
### Trap 2: Parameter Counter Across AST Nodes
PostgreSQL uses numbered params: `$1, $2, $3`. When rendering a nested expression tree
like `(age > $1 AND name = $2) OR (id = $3)`, the counter must be shared.
**Fix:** Thread a `&mut usize` counter through all `to_sql()` render methods:
```rust
impl Expr {
fn to_sql(&self, sql: &mut String, params: &mut Vec<Value>, idx: &mut usize) {
match self {
Expr::Eq(col, val) => {
*idx += 1;
write!(sql, "{} = ${}", col.name(), idx).unwrap();
params.push(val.clone());
}
Expr::And(left, right) => {
sql.push('(');
left.to_sql(sql, params, idx);
sql.push_str(" AND ");
right.to_sql(sql, params, idx);
sql.push(')');
}
// ...
}
}
}
```
### Trap 3: Relationship Fields Must NOT Enter the Struct
In the schema, `array()` declarations sit alongside real columns:
```rust
model Post("posts") {
title: varchar("title", 255), // Real column
authors: array(User, "author_id"), // Relationship — NOT a column
}
```
If the proc macro accidentally puts `pub authors: Vec<User>` in `struct Post`,
`sqlx::FromRow` will panic at runtime because there's no `authors` column in PostgreSQL.
**Fix:** In `floz-macros/src/parse.rs`, split parsed fields into two buckets:
```rust
struct ModelDef {
name: Ident,
table_name: String,
db_columns: Vec<FieldDef>, // → go into struct + Column constants
relationships: Vec<RelDef>, // → only generate fetch_/add_/remove_ methods
}
```
Only `db_columns` participate in struct generation, `FromRow`, dirty flags, and
`UserTable` Column constants. `relationships` only produce impl methods.
### Trap 4: Empty `IN ()` Clause
`UserTable::id.in_list(vec![1, 2, 3])` renders `id IN ($1, $2, $3)`.
But if the user passes an empty vec dynamically, a naive renderer produces
`id IN ()` — which PostgreSQL **rejects** with a syntax error.
**Fix:** Short-circuit to `1=0` (always false, which is logically correct):
```rust
Expr::InList(col, values) => {
if values.is_empty() {
sql.push_str("1=0"); // Empty IN is logically impossible
} else {
write ! (sql, "{} IN (", col.name()).unwrap();
for (i, val) in values.iter().enumerate() {
if i > 0 { sql.push_str(", "); }
* idx += 1;
write ! (sql, "${}", idx).unwrap();
params.push(val.clone());
}
sql.push(')');
}
}
```
Similarly, `not_in` with an empty list should render `1=1` (always true — nothing is excluded).
*Working name: `floz`*
*License: To be decided.*
---
## Appendix: Additional Implementation Notes
### Note A: Models Without Primary Keys
Append-only tables (audit logs, metrics) may intentionally lack a primary key.
When `floz-macros` detects no `.primary()` modifier and no `@primary_key(...)` constraint,
it must:
- **Skip generating:** `get()`, `get_optional()`, `save()`, `delete()`, `set_` methods, `_dirty_flags`
- **Still generate:** `create()`, `find()`, `find_one()`, `all()`, `count()`, `exists()`
- **Still generate:** full `ModelTable` DSL (select, insert, update, delete)
This is not an error — it's a valid use case. No compile error needed.
### Note B: FromRow Trait Bound
The `Executor` trait uses `for<'r> sqlx::FromRow<'r, PgRow>` for generic fetch methods.
This Higher-Rank Trait Bound (HRTB) tells Rust that `T` can be constructed from a row
with *any* lifetime, which is required for sqlx's stream-based row fetching.
All generated structs automatically satisfy this via `#[derive(sqlx::FromRow)]`.
### Note C: user_row! Zero-Allocation Design
`user_row!` expands to a **fixed-size array** `[(AnyColumn, Value); N]` instead of `Vec`.
For 10,000-row bulk inserts, this avoids 10,000 small heap allocations.
`insert_many` accepts `&[&[(AnyColumn, Value)]]` — a slice of slices, all stack-allocated.
### Note D: Executor `&&mut Tx` Reborrow
Because `sqlx::query().execute()` requires `&mut PgConnection`, but the `Executor` trait
takes `&self`, implementing `Executor` for `&mut Tx` means `self` resolves to `&&mut Tx`.
Inside the implementation, you'll need a double-deref reborrow:
```rust
impl Executor for &mut Tx {
async fn execute_raw(&self, sql: &str, params: Vec<Value>) -> Result<u64, FlozError> {
let conn: &mut PgConnection = &mut **self; // double-deref reborrow
// ... bind and execute on conn
}
}
```
This is standard Rust — don't let the compiler error confuse you.