floz-orm 0.1.7

A lightweight, typesafe Rust ORM — unifying DAO and DSL from a single schema
Documentation
#![cfg(feature = "postgres")]

use chrono::Utc;
use sqlx::{PgPool, postgres::PgPoolOptions};
use std::env;
extern crate floz_orm as floz;
use floz_orm::prelude::*;
use floz_orm::types::*;

#[model("authors")]
#[hooks]
pub struct Author {
    #[col(auto, key)]
    pub id: i32,
    #[col(max = 100)]
    pub name: Varchar,
    pub bio: Option<Text>,
    #[col(now)]
    pub created_at: TimestampTz,
    #[col(now)]
    pub updated_at: TimestampTz,

    // Relationship: Author -> Book
    #[rel(has_many(model = "Book", foreign_key = "author_id"))]
    pub books: Vec<Book>,
}

#[model("books")]
pub struct Book {
    #[col(auto, key)]
    pub id: i32,
    pub author_id: i32,
    #[col(max = 100)]
    pub title: Varchar,
}

// ── CUSTOM HOOKS FOR AUTHOR ──
impl floz_orm::FlozHooks for Author {
    fn before_save(&mut self) -> Result<(), floz_orm::FlozError> {
        // Automatically set updated_at on every save
        self.set_updated_at(Utc::now());
        
        // Prevent saving authors with empty names
        if self.name.trim().is_empty() {
            return Err(floz_orm::FlozError::ValidationError("Name cannot be empty".into()));
        }
        
        Ok(())
    }

    fn before_delete(&self) -> Result<(), floz_orm::FlozError> {
        // Prevent deleting admin author
        if self.name == "Admin" {
            return Err(floz_orm::FlozError::ValidationError("Cannot delete admin".into()));
        }
        Ok(())
    }
}

fn test_url() -> Option<String> {
    std::env::var("DATABASE_URL").ok()
}

async fn get_db() -> Option<Db> {
    let url = test_url()?;
    let pool = PgPoolOptions::new()
        .max_connections(2)
        .connect(&url)
        .await
        .expect("Failed to connect to PG");

    setup_schema(&pool).await;

    Some(floz_orm::Db::from_pg_pool(pool))
}

async fn setup_schema(pool: &PgPool) {
    sqlx::query("DROP TABLE IF EXISTS books;")
        .execute(pool).await.unwrap();
    sqlx::query("DROP TABLE IF EXISTS authors;")
        .execute(pool).await.unwrap();

    sqlx::query(
        r#"
        CREATE TABLE authors (
            id SERIAL PRIMARY KEY,
            name VARCHAR(100) NOT NULL,
            bio TEXT,
            created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
            updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
        );
        "#,
    )
    .execute(pool)
    .await
    .unwrap();

    sqlx::query(
        r#"
        CREATE TABLE books (
            id SERIAL PRIMARY KEY,
            author_id INT NOT NULL REFERENCES authors(id) ON DELETE CASCADE,
            title VARCHAR(100) NOT NULL
        );
        "#,
    )
    .execute(pool)
    .await
    .unwrap();
}

#[tokio::test]
async fn test_hooks_lifecycle() {
    let Some(db) = get_db().await else { return };

    // 1. Create ok
    let mut author = Author::default();
    author.name = "John Doe".into();
    let mut author = author.create(&db).await.unwrap();

    // 2. Save triggers before_save and updates updated_at
    let old_updated = author.updated_at;
    
    // Slight delay to ensure timestamp is different
    tokio::time::sleep(std::time::Duration::from_millis(10)).await;
    
    author.set_bio(Some("A writer".into()));
    author.save(&db).await.unwrap();
    
    // the hook updated `updated_at` internally
    let latest_author = Author::get(author.id, &db).await.unwrap();
    assert!(latest_author.updated_at > old_updated);

    // 3. Validation hook prevents save
    author.set_name("".into());
    let res = author.save(&db).await;
    assert!(res.is_err());
    
    // 4. Validation hook prevents delete
    let mut admin = Author::default();
    admin.name = "Admin".into();
    let admin = admin.create(&db).await.unwrap();
    
    let res = admin.delete(&db).await;
    assert!(res.is_err());
}

#[tokio::test]
async fn test_pagination() {
    let Some(db) = get_db().await else { return };

    // Insert 25 books for one author
    let mut author = Author::default();
    author.name = "Tolkien".into();
    let author = author.create(&db).await.unwrap();

    for i in 1..=25 {
        let mut b = Book::default();
        b.author_id = author.id;
        b.title = format!("Book {:02}", i);
        b.create(&db).await.unwrap();
    }

    // Paginate Page 1, size 10, ordered by title DESC
    let page1 = Book::paginate()
        .where_(BookTable::author_id.eq(author.id))
        .per_page(10)
        .page(1)
        .order_by(BookTable::title.desc())
        .execute(&db)
        .await
        .unwrap();

    assert_eq!(page1.total, 25);
    assert_eq!(page1.total_pages, 3);
    assert_eq!(page1.items.len(), 10);
    assert!(page1.has_next);
    assert!(!page1.has_prev);
    assert_eq!(page1.items[0].title, "Book 25");

    // Paginate Page 3 (last page)
    let page3 = Book::paginate()
        .where_(BookTable::author_id.eq(author.id))
        .per_page(10)
        .page(3)
        .order_by(BookTable::title.asc()) // simple test
        .execute(&db)
        .await
        .unwrap();

    assert_eq!(page3.total, 25);
    assert_eq!(page3.items.len(), 5);
    assert!(!page3.has_next);
    assert!(page3.has_prev);
    assert_eq!(page3.items[0].title, "Book 21");
}

#[tokio::test]
async fn test_relationships() {
    let Some(db) = get_db().await else { return };

    // Create 2 authors
    let mut a1 = Author::default();
    a1.name = "Author 1".into();
    let a1 = a1.create(&db).await.unwrap();

    let mut a2 = Author::default();
    a2.name = "Author 2".into();
    let a2 = a2.create(&db).await.unwrap();

    // Create 3 books for a1
    for i in 1..=3 {
        let mut b = Book::default();
        b.author_id = a1.id;
        b.title = format!("A1 Book {}", i);
        b.create(&db).await.unwrap();
    }

    // Create 2 books for a2
    for i in 1..=2 {
        let mut b = Book::default();
        b.author_id = a2.id;
        b.title = format!("A2 Book {}", i);
        b.create(&db).await.unwrap();
    }

    // 1. Lazy Fetch
    let a1_books = a1.fetch_books(&db).await.unwrap();
    assert_eq!(a1_books.len(), 3);
    assert_eq!(a1_books[0].title, "A1 Book 1");

    // 2. Preload Batch
    let mut authors = Author::all(&db).await.unwrap();
    
    // Sort just to be deterministic
    authors.sort_by_key(|a| a.id);
    assert_eq!(authors.len(), 2);
    
    // books is initially empty
    assert!(authors[0].books.is_empty());
    
    // Batch preload
    Author::preload_books(&mut authors, &db).await.unwrap();
    
    assert_eq!(authors[0].books.len(), 3);
    assert_eq!(authors[1].books.len(), 2);
    
    // ensure they are correctly mapped
    assert_eq!(authors[0].books[0].title, "A1 Book 1");
    assert_eq!(authors[1].books[0].title, "A2 Book 1");
}