TideORM Book

TideORM is a Rust ORM built around macro-generated models, a fluent query builder, and field-declared relations.

Use the chapter list on the left to move through the main parts of the library:

  • Getting Started
  • Models
  • Queries
  • Profiling
  • Relations
  • Entity Manager
  • Migrations

If you were previously using DOCUMENTATION.md directly, it now points here as a lightweight index.

Getting Started

Configuration

Basic Connection

#![allow(unused)]
fn main() {
// Simple connection
Database::init("postgres://localhost/mydb").await?;

// With TideConfig (recommended)
TideConfig::init()
    .database("postgres://localhost/mydb")
    .connect()
    .await?;
}

Pool Configuration

#![allow(unused)]
fn main() {
TideConfig::init()
    .database("postgres://localhost/mydb")
    .max_connections(20)        // Maximum pool size
    .min_connections(5)         // Minimum idle connections
    .connect_timeout(Duration::from_secs(10))
    .idle_timeout(Duration::from_secs(300))
    .max_lifetime(Duration::from_secs(3600))
    .connect()
    .await?;
}

Database Types

#![allow(unused)]
fn main() {
TideConfig::init()
    .database_type(DatabaseType::Postgres)  // or MySQL, SQLite
    .database("postgres://localhost/mydb")
    .connect()
    .await?;
}

Resetting Global State

TideORM keeps its active database handle, global configuration, and tokenization settings in process-global state so models can run without threading a connection through every call. That state is now intentionally resettable, which is useful for tests, benchmarks, and applications that need to reconfigure TideORM during the same process lifetime.

#![allow(unused)]
fn main() {
use tideorm::prelude::*;

Database::reset_global();
TideConfig::reset();
TokenConfig::reset();

TideConfig::init()
    .database("sqlite::memory:")
    .database_type(DatabaseType::SQLite)
    .connect()
    .await?;
}

TideConfig::apply() and TideConfig::connect() overwrite previously applied TideORM configuration. If a new configuration omits the database type, the stored type is cleared instead of leaving the old backend classification behind.


Data Type Mappings

Rust to SQL Type Reference

Rust TypePostgreSQLMySQLSQLiteNotes
i8, i16SMALLINTSMALLINTINTEGER
i32INTEGERINTINTEGER
i64BIGINTBIGINTINTEGERRecommended for primary keys
u8, u16SMALLINTSMALLINT UNSIGNEDINTEGER
u32INTEGERINT UNSIGNEDINTEGER
u64BIGINTBIGINT UNSIGNEDINTEGER
f32REALFLOATREAL
f64DOUBLE PRECISIONDOUBLEREAL
boolBOOLEANTINYINT(1)INTEGER
StringTEXTTEXTTEXT
Option<T>(nullable)(nullable)(nullable)Wraps any type to make it nullable
uuid::UuidUUIDCHAR(36)TEXT
rust_decimal::DecimalDECIMALDECIMAL(65,30)TEXT
serde_json::ValueJSONBJSONTEXT
Vec<u8>BYTEABLOBBLOBBinary data
chrono::NaiveDateDATEDATETEXTDate only
chrono::NaiveTimeTIMETIMETEXTTime only
chrono::NaiveDateTimeTIMESTAMPDATETIMETEXTNo timezone
chrono::DateTime<Utc>TIMESTAMPTZTIMESTAMPTEXTWith timezone

Date and Time Types

TideORM provides proper support for all common date/time scenarios:

Use chrono::DateTime<Utc> for timestamps that should include timezone information:

#![allow(unused)]
fn main() {
use chrono::{DateTime, Utc};

#[tideorm::model(table = "sessions")]
pub struct Session {
    #[tideorm(primary_key, auto_increment)]
    pub id: i64,
    pub user_id: i64,
    pub token: String,
    pub expires_at: DateTime<Utc>,        // Maps to TIMESTAMPTZ in PostgreSQL
    pub created_at: DateTime<Utc>,        // Maps to TIMESTAMPTZ in PostgreSQL
    pub updated_at: DateTime<Utc>,        // Maps to TIMESTAMPTZ in PostgreSQL
}
}

In migrations, use timestamptz():

#![allow(unused)]
fn main() {
schema.create_table("sessions", |t| {
    t.id();
    t.big_integer("user_id").not_null();
    t.string("token").not_null();
    t.timestamptz("expires_at").not_null();
    t.timestamps();  // Uses TIMESTAMPTZ by default
}).await?;
}

DateTime without Timezone

Use chrono::NaiveDateTime when you don't need timezone information:

#![allow(unused)]
fn main() {
use chrono::NaiveDateTime;

#[tideorm::model(table = "logs")]
pub struct Log {
    #[tideorm(primary_key, auto_increment)]
    pub id: i64,
    pub message: String,
    pub logged_at: NaiveDateTime,         // Maps to TIMESTAMP (no timezone)
}
}

In migrations, use timestamp():

#![allow(unused)]
fn main() {
schema.create_table("logs", |t| {
    t.id();
    t.text("message").not_null();
    t.timestamp("logged_at").default_now();
}).await?;
}

Date Only

Use chrono::NaiveDate for date-only fields:

#![allow(unused)]
fn main() {
use chrono::NaiveDate;

#[tideorm::model(table = "events")]
pub struct Event {
    #[tideorm(primary_key, auto_increment)]
    pub id: i64,
    pub name: String,
    pub event_date: NaiveDate,            // Maps to DATE
}
}

In migrations, use date():

#![allow(unused)]
fn main() {
schema.create_table("events", |t| {
    t.id();
    t.string("name").not_null();
    t.date("event_date").not_null();
}).await?;
}

Time Only

Use chrono::NaiveTime for time-only fields:

#![allow(unused)]
fn main() {
use chrono::NaiveTime;

#[tideorm::model(table = "schedules")]
pub struct Schedule {
    #[tideorm(primary_key, auto_increment)]
    pub id: i64,
    pub name: String,
    pub start_time: NaiveTime,            // Maps to TIME
    pub end_time: NaiveTime,
}
}

In migrations, use time():

#![allow(unused)]
fn main() {
schema.create_table("schedules", |t| {
    t.id();
    t.string("name").not_null();
    t.time("start_time").not_null();
    t.time("end_time").not_null();
}).await?;
}

Examples

See the TideORM Examples repository for complete working examples.


Testing

Use the smallest command that covers your change.

# Fast library validation
cargo test --lib

# Broad compatibility pass
cargo test --all-features

# Default backend suite
cargo test --features postgres

# SQLite integration suite
cargo test --test sqlite_integration_tests --features "sqlite runtime-tokio" --no-default-features

# PostgreSQL integration suite
cargo test --test postgres_integration_tests

# MySQL integration suite
cargo test --test mysql_integration_tests --features mysql

# SQLite smoke test
cargo test --test sqlite_ci_smoke_test --features "sqlite runtime-tokio" --no-default-features

# Rebuild the book after documentation changes
mdbook build

Integration suites may require local database servers and environment variables. When a test needs a fresh TideORM setup, clear global state first with Database::reset_global(), TideConfig::reset(), and TokenConfig::reset().

Models

Model Definition

Define most models with #[tideorm::model(table = "...")]:

#![allow(unused)]
fn main() {
#[tideorm::model(table = "products")]
pub struct Product {
    #[tideorm(primary_key, auto_increment)]
    pub id: i64,
    pub name: String,
    pub price: f64,
}
}

The #[tideorm::model] macro automatically implements:

  • Debug - for printing/logging
  • Clone - for cloning instances
  • Default - for creating default instances
  • Serialize - for JSON serialization
  • Deserialize - for JSON deserialization

User-defined #[derive(...)] attributes are preserved. TideORM only adds the generated derives that are still missing unless you opt out with the skip_* attributes.

Reserved Attribute Names

params is reserved for presenter payloads.

When TideORM builds to_hash_map() output and the serialized params value is an object or array, it is omitted from the resulting map. Avoid using params for presenter-facing structured model attributes if you need that data to appear in to_hash_map() output.

Custom Implementations (When Needed)

If you need full control over generated derives, use skip_derives and provide your own:

#![allow(unused)]
fn main() {
#[tideorm::model(table = "products", skip_derives)]
#[index("category")]
#[index("active")]
#[index(name = "idx_price_category", columns = "price,category")]
#[unique_index("sku")]
pub struct Product {
    #[tideorm(primary_key, auto_increment)]
    pub id: i64,
    
    pub name: String,
    pub sku: String,
    pub category: String,
    pub price: i64,
    
    #[tideorm(nullable)]
    pub description: Option<String>,
    
    pub active: bool,
}

// Provide your own implementations
impl Debug for Product { /* custom impl */ }
impl Clone for Product { /* custom impl */ }
}

Model Attributes

Struct-Level Attributes

Use these either inline in #[tideorm::model(...)] or in a separate #[tideorm(...)] attribute.

AttributeDescription
#[tideorm(table = "name")]Custom table name
#[tideorm(skip_derives)]Skip auto-generated Debug, Clone, Default, Serialize, Deserialize
#[tideorm(skip_debug)]Skip auto-generated Debug impl only
#[tideorm(skip_clone)]Skip auto-generated Clone impl only
#[tideorm(skip_default)]Skip auto-generated Default impl only
#[tideorm(skip_serialize)]Skip auto-generated Serialize impl only
#[tideorm(skip_deserialize)]Skip auto-generated Deserialize impl only
#[index("col")]Create an index
#[unique_index("col")]Create a unique index
#[index(name = "idx", columns = "a,b")]Named composite index

Field-Level Attributes

AttributeDescription
#[tideorm(primary_key)]Mark as primary key
#[tideorm(auto_increment)]Auto-increment field for a single-column primary key
#[tideorm(nullable)]Optional/nullable field
#[tideorm(column = "name")]Custom column name
#[tideorm(default = "value")]Default value
#[tideorm(skip)]Skip field in queries

Composite Primary Keys

TideORM supports composite primary keys by declaring #[tideorm(primary_key)] on multiple fields:

#![allow(unused)]
fn main() {
#[tideorm::model(table = "user_roles")]
pub struct UserRole {
    #[tideorm(primary_key)]
    pub user_id: i64,
    #[tideorm(primary_key)]
    pub role_id: i64,
    pub granted_by: String,
}

let role = UserRole::find((1_i64, 2_i64)).await?;
}

Composite primary key notes:

  • CRUD methods use tuples in the same order as the key fields are declared.
  • #[tideorm(auto_increment)] only works with a single primary key field.
  • #[tideorm(tokenize)] requires exactly one primary key field.
  • When defining relations on a composite-key model, set local_key = "..." explicitly if the relation would otherwise rely on the implicit id key.

CRUD Operations

Create

#![allow(unused)]
fn main() {
let user = User {
    email: "john@example.com".to_string(),
    name: "John Doe".to_string(),
    active: true,
    ..Default::default()
};
let user = user.save().await?;
println!("Created user with id: {}", user.id);
}

For auto-increment primary keys, TideORM treats 0 as an unsaved record marker internally. You usually do not need to assign it yourself when constructing a new model. Natural keys, composite keys, and non-auto-increment primary keys are considered persisted unless the primary key value is empty.

Read

#![allow(unused)]
fn main() {
// Get all
let users = User::all().await?;

// Find by primary key
let user = User::find(1).await?;  // Option<User>

// Composite primary key example
let membership = UserRole::find((1_i64, 2_i64)).await?;

// Query builder (see above)
let users = User::query().where_eq("active", true).get().await?;
}

Update

#![allow(unused)]
fn main() {
let mut user = User::find(1).await?.unwrap();
user.name = "Jane Doe".to_string();
let user = user.update().await?;
}

Dirty Tracking

Requires the dirty-tracking feature.

Persisted models loaded or saved through TideORM keep a baseline of their last known persisted column values. Use changed_fields() to inspect which persisted fields differ from that baseline, and original_value() to inspect the previous value before saving.

#![allow(unused)]
fn main() {
let mut user = User::find(1).await?.unwrap();
user.name = "Jane Doe".to_string();

assert_eq!(user.changed_fields()?, vec!["name"]);
assert_eq!(
    user.original_value("name")?,
    Some(serde_json::json!("John Doe"))
);
}

changed_fields() only reports persisted model fields, not runtime relation wrappers. The tracked baseline is refreshed by TideORM loads such as find(), query results, reload(), save(), and update(). Bulk mutation helpers such as update_all() and query-builder deletes invalidate the baseline for that model type.

Because TideORM models are plain Rust structs without hidden instance-local tracking state, dirty tracking follows the latest persisted snapshot TideORM knows for a primary key. If you keep multiple in-memory copies of the same row and one of them saves first, reload the stale copies before relying on their original values.

Delete

#![allow(unused)]
fn main() {
// Delete instance
let user = User::find(1).await?.unwrap();
user.delete().await?;

// Delete by ID
User::destroy(1).await?;

// Bulk delete
User::query()
    .where_eq("active", false)
    .delete()
    .await?;
}

Schema Synchronization (Development Only)

TideORM can automatically sync your database schema with your models during development:

#![allow(unused)]
fn main() {
TideConfig::init()
    .database("postgres://localhost/mydb")
    .models_matching("src/models/*.model.rs")
    .sync(true)  // Enable auto-sync (development only!)
    .connect()
    .await?;
}

models_matching(...) filters compiled #[tideorm::model] types by their source file path, so patterns like src/models/*, src/models/*.model.rs, and src/models/**/*.rs work as long as those modules are still included through normal Rust mod declarations.

Or export schema to a file:

#![allow(unused)]
fn main() {
TideConfig::init()
    .database("postgres://localhost/mydb")
    .schema_file("schema.sql")  // Generate SQL file
    .connect()
    .await?;
}

⚠️ Warning: Do NOT use sync(true) in production! Use proper migrations instead.


Soft Deletes

TideORM supports soft deletes for models that have a deleted_at column:

#![allow(unused)]
fn main() {
#[tideorm::model(table = "posts", soft_delete)]
pub struct Post {
    #[tideorm(primary_key, auto_increment)]
    pub id: i64,
    pub title: String,
    pub deleted_at: Option<DateTime<Utc>>,
}
}

The SoftDelete impl is generated automatically. If your field or column uses a different name, declare it on the model:

#![allow(unused)]
fn main() {
#[tideorm::model(table = "posts", soft_delete, deleted_at_column = "archived_on")]
pub struct Post {
    #[tideorm(primary_key, auto_increment)]
    pub id: i64,
    pub title: String,
    pub archived_on: Option<DateTime<Utc>>,
}
}

Querying Soft-Deleted Records

#![allow(unused)]
fn main() {
// By default, soft-deleted records are excluded
let active_posts = Post::query().get().await?;

// Include soft-deleted records
let all_posts = Post::query()
    .with_trashed()
    .get()
    .await?;

// Only get soft-deleted records (trash bin)
let trashed_posts = Post::query()
    .only_trashed()
    .get()
    .await?;
}

Soft Delete Operations

#![allow(unused)]
fn main() {
use tideorm::SoftDelete;

// Soft delete (sets deleted_at to now)
let post = post.soft_delete().await?;

// Restore a soft-deleted record
let post = post.restore().await?;

// Permanently delete
post.force_delete().await?;
}

Scopes (Reusable Query Fragments)

For model-local, chainable scopes, declare a dedicated scope impl block with #[tideorm::scopes]:

#![allow(unused)]
fn main() {
#[tideorm::scopes]
impl User {
    pub fn active(query: QueryBuilder<Self>) -> QueryBuilder<Self> {
        query.where_eq(User::columns.active, true)
    }

    pub fn verified(query: QueryBuilder<Self>) -> QueryBuilder<Self> {
        query.where_not_null(User::columns.verified_at)
    }

    pub fn role(query: QueryBuilder<Self>, role: &str) -> QueryBuilder<Self> {
        query.where_eq(User::columns.role, role)
    }
}

let users = User::query()
    .active()
    .verified()
    .role("admin")
    .get()
    .await?;
}

If you call a model's named scopes from a different module than the #[tideorm::scopes] block, bring the generated extension trait into scope first:

#![allow(unused)]
fn main() {
use crate::models::UserQueryScopes as _;
}

The lower-level .scope(...) helper still works when you want ad hoc reusable fragments that are not tied to one model type:

#![allow(unused)]
fn main() {
// Define scope functions
fn active<M: Model>(q: QueryBuilder<M>) -> QueryBuilder<M> {
    q.where_eq("active", true)
}

fn recent<M: Model>(q: QueryBuilder<M>) -> QueryBuilder<M> {
    q.order_desc("created_at").limit(10)
}

// Apply scopes
let users = User::query()
    .scope(active)
    .scope(recent)
    .get()
    .await?;
}

Conditional Scopes

#![allow(unused)]
fn main() {
// Apply scope conditionally
let include_inactive = false;
let users = User::query()
    .when(include_inactive, |q| q.with_trashed())
    .get()
    .await?;

// Apply scope based on Option value
let status_filter: Option<&str> = Some("active");
let users = User::query()
    .when_some(status_filter, |q, status| q.where_eq("status", status))
    .get()
    .await?;
}

Transactions

TideORM provides clean transaction support:

#![allow(unused)]
fn main() {
// Model-centric transactions
User::transaction(|tx| async move {
    // All operations in here are in a single transaction
    let user = User::create(User { ... }).await?;
    let profile = Profile::create(Profile { user_id: user.id, ... }).await?;
    
    // Return Ok to commit, Err to rollback
    Ok((user, profile))
}).await?;

// Database-level transactions
db.transaction(|tx| async move {
    // ... operations ...
    Ok(result)
}).await?;
}

If the closure returns Ok, the transaction is committed. If it returns Err or panics, the transaction is rolled back.


Auto-Timestamps

TideORM automatically manages created_at and updated_at fields:

#![allow(unused)]
fn main() {
#[tideorm::model(table = "posts")]
pub struct Post {
    #[tideorm(primary_key, auto_increment)]
    pub id: i64,
    pub title: String,
    pub content: String,
    pub created_at: DateTime<Utc>,  // Auto-set on save()
    pub updated_at: DateTime<Utc>,  // Auto-set on save() and update()
}

// No need to set timestamps manually
let post = Post {
    title: "Hello".into(),
    content: "World".into(),
    ..Default::default()
};
let post = post.save().await?;
// created_at and updated_at are now set to the current time

post.title = "Updated Title".into();
let post = post.update().await?;
// updated_at is refreshed, created_at remains unchanged
}

Callbacks / Hooks

Implement lifecycle callbacks for your models:

#![allow(unused)]
fn main() {
use tideorm::callbacks::Callbacks;

#[tideorm::model(table = "users")]
pub struct User {
    #[tideorm(primary_key, auto_increment)]
    pub id: i64,
    pub email: String,
    pub password_hash: String,
}

impl Callbacks for User {
    fn before_save(&mut self) -> tideorm::Result<()> {
        // Normalize email before saving
        self.email = self.email.to_lowercase().trim().to_string();
        Ok(())
    }
    
    fn after_create(&self) -> tideorm::Result<()> {
        println!("User {} created with id {}", self.email, self.id);
        // Could send welcome email, create audit log, etc.
        Ok(())
    }
    
    fn before_delete(&self) -> tideorm::Result<()> {
        // Prevent deletion of important accounts
        if self.email == "admin@example.com" {
            return Err(tideorm::Error::validation("Cannot delete admin account"));
        }
        Ok(())
    }
}
}

Available Callbacks

CallbackWhen Called
before_validationBefore validation runs
after_validationAfter validation passes
before_saveBefore create or update
after_saveAfter create or update
before_createBefore inserting new record
after_createAfter inserting new record
before_updateBefore updating existing record
after_updateAfter updating existing record
before_deleteBefore deleting record
after_deleteAfter deleting record

Batch Operations

For efficient bulk operations:

#![allow(unused)]
fn main() {
// Insert multiple records at once
let users = vec![
    User { name: "John".into(), email: "john@example.com".into(), ..Default::default() },
    User { name: "Jane".into(), email: "jane@example.com".into(), ..Default::default() },
    User { name: "Bob".into(), email: "bob@example.com".into(), ..Default::default() },
];
let inserted = User::insert_all(users).await?;

// Bulk update with conditions
let affected = User::update_all()
    .set("active", false)
    .set("updated_at", Utc::now())
    .where_eq("last_login_before", "2024-01-01")
    .execute()
    .await?;
}

Model Validation

TideORM includes built-in validation rules and validation helpers for model data.

Built-in Validation Rules

#![allow(unused)]
fn main() {
use tideorm::validation::{ValidationRule, Validator, ValidationBuilder};

// Available validation rules
ValidationRule::Required           // Field must not be empty
ValidationRule::Email              // Valid email format
ValidationRule::Url                // Valid URL format
ValidationRule::MinLength(n)       // Minimum string length
ValidationRule::MaxLength(n)       // Maximum string length
ValidationRule::Min(n)             // Minimum numeric value
ValidationRule::Max(n)             // Maximum numeric value
ValidationRule::Range(min, max)    // Numeric range
ValidationRule::Regex(pattern)     // Custom regex pattern
ValidationRule::Alpha              // Only alphabetic characters
ValidationRule::Alphanumeric       // Only alphanumeric characters
ValidationRule::Numeric            // Only numeric characters
ValidationRule::Uuid               // Valid UUID format
ValidationRule::In(values)         // Value must be in list
ValidationRule::NotIn(values)      // Value must not be in list
}

Using the Validator

#![allow(unused)]
fn main() {
use tideorm::validation::{Validator, ValidationRule};
use std::collections::HashMap;

// Create a validator with rules
let validator = Validator::new()
    .field("email", vec![ValidationRule::Required, ValidationRule::Email])
    .field("username", vec![
        ValidationRule::Required,
        ValidationRule::MinLength(3),
        ValidationRule::MaxLength(20),
        ValidationRule::Alphanumeric,
    ])
    .field("age", vec![ValidationRule::Range(18.0, 120.0)]);

// Validate data
let mut data = HashMap::new();
data.insert("email".to_string(), "user@example.com".to_string());
data.insert("username".to_string(), "johndoe123".to_string());
data.insert("age".to_string(), "25".to_string());

match validator.validate_map(&data) {
    Ok(_) => println!("Validation passed!"),
    Err(errors) => {
        for (field, message) in errors.errors() {
            println!("{}: {}", field, message);
        }
    }
}
}

ValidationBuilder with Custom Rules

#![allow(unused)]
fn main() {
use tideorm::validation::ValidationBuilder;

let validator = ValidationBuilder::new()
    .add("email", ValidationRule::Required)
    .add("email", ValidationRule::Email)
    .add("username", ValidationRule::Required)
    .add("username", ValidationRule::MinLength(3))
    // Add custom validation logic
    .custom("username", |value| {
        let reserved = ["admin", "root", "system"];
        if reserved.contains(&value.to_lowercase().as_str()) {
            Err(format!("Username '{}' is reserved", value))
        } else {
            Ok(())
        }
    })
    .build();
}

Handling Validation Errors

#![allow(unused)]
fn main() {
use tideorm::validation::ValidationErrors;

let mut errors = ValidationErrors::new();
errors.add("email", "Email is required");
errors.add("email", "Email format is invalid");
errors.add("password", "Password must be at least 8 characters");

// Check if there are errors
if errors.has_errors() {
    // Get all errors for a specific field
    let email_errors = errors.field_errors("email");
    for msg in email_errors {
        println!("Email error: {}", msg);
    }
    
    // Display all errors
    println!("{}", errors);
}

// Convert to TideORM Error
let tide_error: tideorm::error::Error = errors.into();
}

Record Tokenization

TideORM provides secure tokenization for record IDs, converting them to encrypted, URL-safe tokens. This prevents exposing internal database IDs in URLs and APIs.

Tokenization Quick Start

Enable tokenization with the #[tideorm(tokenize)] attribute:

#![allow(unused)]
fn main() {
use tideorm::prelude::*;

#[tideorm::model(table = "users", tokenize)]  // Enable tokenization
pub struct User {
    #[tideorm(primary_key, auto_increment)]
    pub id: i64,
    pub email: String,
    pub name: String,
}

// Configure encryption key once at startup
TokenConfig::set_encryption_key("your-32-byte-secret-key-here-xx");

// Tokenize a record
let user = User::find(1).await?.unwrap();
let token = user.tokenize()?;  // "iIBmdKYhJh4_vSKFlBTP..."

// Decode token to the model's primary key type (doesn't hit database)
let id = User::detokenize(&token)?;  // 1

// Fetch record directly from token
let same_user = User::from_token(&token).await?;
assert_eq!(user.id, same_user.id);
}

If no encryption key is configured, tokenization now returns a configuration error instead of panicking at runtime. In most applications, the simplest setup is to provide the key through TideConfig during startup:

#![allow(unused)]
fn main() {
let encryption_key = std::env::var("ENCRYPTION_KEY")?;

TideConfig::init()
    .database("postgres://localhost/mydb")
    .encryption_key(&encryption_key)
    .connect()
    .await?;
}

Tokenization Methods

When a model has #[tideorm(tokenize)], these methods are available:

MethodDescription
user.tokenize()Convert record to token (instance method)
user.to_token()Alias for tokenize()
User::tokenize_id(42)Tokenize an ID without having the record
User::detokenize(&token)Decode token to the model's primary key type
User::decode_token(&token)Alias for detokenize()
User::from_token(&token).awaitDecode token and fetch record from DB
user.regenerate_token()Generate a fresh token; the default encoder uses a new random nonce each time

Model-Specific Tokens

Tokens are bound to their model type. A User token cannot decode a Product:

#![allow(unused)]
fn main() {
#[tideorm::model(table = "users", tokenize)]
pub struct User { /* ... */ }

#[tideorm::model(table = "products", tokenize)]
pub struct Product { /* ... */ }

// Same ID, different tokens
let user_token = User::tokenize_id(1)?;
let product_token = Product::tokenize_id(1)?;
assert_ne!(user_token, product_token);  // Different!

// Cross-model decoding fails
assert!(User::detokenize(&product_token).is_err());  // Error!
}

Using Tokens in APIs

Tokens are URL-safe and perfect for REST APIs:

#![allow(unused)]
fn main() {
// In your API handler
async fn get_user(token: String) -> Result<Json<User>> {
    let user = User::from_token(&token).await?;
    Ok(Json(user))
}

// Example URLs:
// GET /api/users/iIBmdKYhJh4_vSKFlBTPgWRlbW8tZW5isZqLo_EU4YI
// GET /api/products/1NhY5XxAm_D53flvEc-5JmRlbW8tZW5iShKwXZjCb9s
}

Custom Encoders

For custom tokenization logic, implement the Tokenizable trait manually:

#![allow(unused)]
fn main() {
use tideorm::tokenization::{Tokenizable, TokenEncoder, TokenDecoder};

#[tideorm::model(table = "documents")]
pub struct Document {
    #[tideorm(primary_key)]
    pub id: i64,
    pub title: String,
}

#[async_trait::async_trait]
impl Tokenizable for Document {
    type TokenPrimaryKey = i64;

    fn token_model_name() -> &'static str { "Document" }
    fn token_primary_key(&self) -> Self::TokenPrimaryKey { self.id }
    
    // Custom encoder - prefix with "DOC-"
    fn token_encoder() -> Option<TokenEncoder> {
        Some(|id, _model| Ok(format!("DOC-{}", id)))
    }
    
    // Custom decoder
    fn token_decoder() -> Option<TokenDecoder> {
        Some(|token, _model| {
            Ok(token.strip_prefix("DOC-").map(ToOwned::to_owned))
        })
    }
    
    async fn from_token(token: &str) -> tideorm::Result<Self> {
        let id = Self::decode_token(token)?;
        Self::find(id).await?.ok_or_else(|| 
            tideorm::Error::not_found("Document not found")
        )
    }
}
}

Global Custom Encoder

Set a custom encoder for all models:

#![allow(unused)]
fn main() {
// Set global custom encoder
TokenConfig::set_encoder(|id, model| {
    Ok(format!("{}-{}", model.to_lowercase(), id))
});

TokenConfig::set_decoder(|token, model| {
    let prefix = format!("{}-", model.to_lowercase());
    Ok(token.strip_prefix(&prefix).map(ToOwned::to_owned))
});
}

Calling TokenConfig::set_encryption_key, TokenConfig::set_encoder, or TokenConfig::set_decoder again replaces the previous global override. Use TokenConfig::reset() to clear all tokenization overrides and return to the default encoder/decoder configuration.

Tokenization Security

Features:

  • Authenticated encryption: Default tokens use XChaCha20-Poly1305
  • Model binding: Model name is authenticated as associated data, preventing cross-model reuse
  • Tamper detection: Modified tokens fail authentication and are rejected
  • Randomized output: The default encoder uses a fresh nonce, so the same record can produce different valid tokens
  • URL-safe: Base64-URL encoding (A-Za-z0-9-_), no escaping needed

Best Practices:

  • Use a high-entropy secret from the environment; 32+ characters is a good baseline
  • Store keys in environment variables, never in code
  • Changing the key invalidates all existing tokens
  • If you override the encoder/decoder, you are responsible for preserving equivalent security guarantees
  • Consider token rotation for high-security applications
#![allow(unused)]
fn main() {
// Configure from environment variable
TokenConfig::set_encryption_key(
    &std::env::var("ENCRYPTION_KEY").expect("ENCRYPTION_KEY must be set")
);
}


Advanced ORM Features

TideORM includes a broad set of advanced model and query helpers through its own API surface:

Strongly-Typed Columns

Compile-time type safety for column operations. The compiler catches type mismatches before runtime.

Auto-Generated Columns

When you define a model with #[tideorm::model], typed columns are automatically generated as an attribute on the model:

#![allow(unused)]
fn main() {
#[tideorm::model(table = "users")]
pub struct User {
    #[tideorm(primary_key, auto_increment)]
    pub id: i64,
    pub name: String,
    pub age: Option<i32>,
    pub active: bool,
}

// A `UserColumns` struct is automatically generated with typed column accessors.
// Access columns via `User::columns`:
// User::columns.id, User::columns.name, User::columns.age, User::columns.active
}

Unified Type-Safe Queries

All query methods accept BOTH string column names AND typed columns. Use User::columns.field_name for compile-time safety:

#![allow(unused)]
fn main() {
// SAME method works with both strings AND typed columns:
User::query().where_eq("name", "Alice")                    // String-based (runtime checked)
User::query().where_eq(User::columns.name, "Alice")        // Typed column (compile-time checked)

// Type-safe query - compiler catches typos!
let users = User::query()
    .where_eq(User::columns.name, "Alice")     // ✓ Type-safe
    .where_gt(User::columns.age, 18)           // ✓ Type-safe
    .where_eq(User::columns.active, true)      // ✓ Type-safe
    .get()
    .await?;

// All query methods support typed columns:
User::query().where_eq(User::columns.name, "Alice")              // =
User::query().where_not(User::columns.role, "admin")             // <>
User::query().where_gt(User::columns.age, 18)                    // >
User::query().where_gte(User::columns.age, 18)                   // >=
User::query().where_lt(User::columns.age, 65)                    // <
User::query().where_lte(User::columns.age, 65)                   // <=
User::query().where_like(User::columns.email, "%@test.com")      // LIKE
User::query().where_not_like(User::columns.email, "%spam%")      // NOT LIKE
User::query().where_in(User::columns.role, vec!["admin", "mod"]) // IN
User::query().where_not_in(User::columns.status, vec!["banned"]) // NOT IN
User::query().where_null(User::columns.deleted_at)               // IS NULL
User::query().where_not_null(User::columns.email)                // IS NOT NULL
User::query().where_between(User::columns.age, 18, 65)           // BETWEEN

// Ordering and grouping also support typed columns:
User::query()
    .order_by(User::columns.created_at, Order::Desc)
    .order_asc(User::columns.name)
    .group_by(User::columns.role)
    .get()
    .await?;

// Aggregations with typed columns:
let total = Order::query().sum(Order::columns.amount).await?;
let average = Product::query().avg(Product::columns.price).await?;
let max_age = User::query().max(User::columns.age).await?;

// OR conditions with typed columns:
User::query()
    .or_where_eq(User::columns.role, "admin")
    .or_where_eq(User::columns.role, "moderator")
    .get()
    .await?;
}

Why Use Typed Columns?

  • Compile-time safety: Wrong column names won't compile
  • IDE autocomplete: User::columns. shows all available columns with their types
  • Refactoring-friendly: Rename a field and the compiler tells you everywhere to update
  • No conflicts: Columns are accessed via .columns, won't override other struct attributes
  • Backward compatible: String column names still work for quick prototyping

Manual Column Definitions (Advanced)

If you need custom behavior or computed columns, you can define columns manually:

#![allow(unused)]
fn main() {
use tideorm::columns::Column;

// Custom columns that map to different DB column names
pub const FULL_NAME: Column<String> = Column::new("full_name");
pub const COMPUTED_FIELD: Column<i32> = Column::new("computed_field");

// Use in queries
User::query().where_eq(FULL_NAME, "John Doe").get().await?;
}

Typed Column Support Summary:

All these methods accept both "column_name" (string) and Model::columns.field (typed):

CategoryMethods
WHEREwhere_eq, where_not, where_gt, where_gte, where_lt, where_lte, where_like, where_not_like, where_in, where_not_in, where_null, where_not_null, where_between
OR WHEREor_where_eq, or_where_not, or_where_gt, or_where_gte, or_where_lt, or_where_lte, or_where_like, or_where_in, or_where_not_in, or_where_null, or_where_not_null, or_where_between
ORDER BYorder_by, order_asc, order_desc
GROUP BYgroup_by
Aggregationssum, avg, min, max, count_distinct
HAVINGhaving_sum_gt, having_avg_gt
Windowpartition_by, order_by (in WindowFunctionBuilder)

Self-Referencing Relations

Support for hierarchical data like org charts, categories, or comment threads:

#![allow(unused)]
fn main() {
#[tideorm::model(table = "employees")]
pub struct Employee {
    #[tideorm(primary_key)]
    pub id: i64,
    pub name: String,
    pub manager_id: Option<i64>,

    #[tideorm(foreign_key = "manager_id")]
    pub manager: SelfRef<Employee>,

    #[tideorm(foreign_key = "manager_id")]
    pub reports: SelfRefMany<Employee>,
}

// Usage:
let emp = Employee::find(5).await?.unwrap();

let manager_rel = emp.manager.clone();
let reports_rel = emp.reports.clone();

// Load parent (manager)
let manager = manager_rel.load().await?;
let has_manager = manager_rel.exists().await?;

// Load children (direct reports)
let reports = reports_rel.load().await?;
let count = reports_rel.count().await?;

// Load entire subtree recursively in one recursive CTE query
let tree = reports_rel.load_tree(3).await?;  // 3 levels deep
}

SelfRef and SelfRefMany fields are wired automatically when you provide the self-referencing foreign_key. local_key defaults to id and can be overridden explicitly when needed.

SelfRefMany::load_tree() respects the configured local_key and fetches the tree in one query, which avoids one SELECT per node on large hierarchies.

Nested Save (Cascade Operations)

Save parent and related models together with automatic foreign key handling:

#![allow(unused)]
fn main() {
// Save parent with single related model
let (user, profile) = user.save_with_one(profile, "user_id").await?;
// profile.user_id is automatically set to user.id

// Save parent with multiple related models
let posts = vec![post1, post2, post3];
let (user, posts) = user.save_with_many(posts, "user_id").await?;
// All posts have user_id set to user.id

// Cascade updates
let (user, profile) = user.update_with_one(profile).await?;
let (user, posts) = user.update_with_many(posts).await?;

// Cascade delete (children first for referential integrity)
let deleted_count = user.delete_with_many(posts).await?;

// Builder API for complex nested saves
let (user, related_json) = NestedSaveBuilder::new(user)
    .with_one(profile, "user_id")
    .with_many(posts, "user_id")
    .with_many(comments, "author_id")
    .save()
    .await?;
}

save_with_many batches related inserts through TideORM's bulk insert path, and delete_with_many removes related rows with a single WHERE IN delete. update_with_many batches existing related rows through an upsert-style write and then reloads them once. If any related model still looks new, update_with_many falls back to per-row updates so create-vs-update semantics stay unchanged.

NestedSaveBuilder is Send, so you can hold it across await points or move it into task executors such as tokio::spawn before calling .save().

Join Result Consolidation

Transform flat JOIN results into nested structures:

#![allow(unused)]
fn main() {
use tideorm::prelude::JoinResultConsolidator;

// Flat JOIN results: Vec<(Order, LineItem)>
let flat = Order::query()
    .find_also_related::<LineItem>()
    .get()
    .await?;
// [(order1, item1), (order1, item2), (order2, item3)]

// Consolidate into nested: Vec<(Order, Vec<LineItem>)>
let nested = JoinResultConsolidator::consolidate_two(flat, |o| o.id);
// [(order1, [item1, item2]), (order2, [item3])]

// For LEFT JOINs with Option<B>
let nested = JoinResultConsolidator::consolidate_two_optional(flat, |o| o.id);

// Three-level nesting
let flat3: Vec<(Order, LineItem, Product)> = /* ... */;
let nested3 = JoinResultConsolidator::consolidate_three(flat3, |o| o.id, |i| i.id);
// Vec<(Order, Vec<(LineItem, Vec<Product>)>)>
}

Linked Partial Select

Select specific columns from related tables with automatic JOINs:

#![allow(unused)]
fn main() {
// Select specific columns from both tables
let results = User::query()
    .select_with_linked::<Profile>(
        &["id", "name"],           // Local columns
        &["bio", "avatar_url"],    // Linked columns
        "user_id"                  // Foreign key for join
    )
    .get::<(i64, String, String, Option<String>)>()
    .await?;

// All local columns + specific linked columns
let results = User::query()
    .select_also_linked::<Profile>(
        &["bio"],                  // Just the linked columns
        "user_id"
    )
    .get::<(User, String)>()
    .await?;
}

Additional Advanced Features

#![allow(unused)]
fn main() {
// has_related() - EXISTS subqueries
let cakes = Cake::query()
    .has_related("fruits", "cake_id", "id", "name", "Mango")
    .get().await?;

// eq_any() / ne_all() - PostgreSQL array optimizations
let users = User::query()
    .eq_any("id", vec![1, 2, 3, 4, 5])    // "id" = ANY(ARRAY[...])
    .ne_all("role", vec!["banned"])        // "role" <> ALL(ARRAY[...])
    .get().await?;

// Unix timestamps
use tideorm::types::{UnixTimestamp, UnixTimestampMillis};
let ts = UnixTimestamp::now();
let dt = ts.to_datetime();

// Batch insert
let users: Vec<User> = User::insert_all(vec![u1, u2]).await?;

// consolidate() - Reusable query fragments
let active_scope = User::query()
    .where_eq("status", "active")
    .consolidate();
let admins = User::query().apply(&active_scope).where_eq("role", "admin").get().await?;

// Multi-column unique constraints (migrations)
builder.unique(&["user_id", "role_id"]);
builder.unique_named("uq_email_tenant", &["email", "tenant_id"]);

// CHECK constraints (migrations)
builder.string("email").check("email LIKE '%@%'");
}

Queries

For execution metrics and slow-query counters, see Profiling.

Query Builder

Query execution paths are aligned on parameterized SQL generation for reads, JOIN clauses are validated before execution, and destructive mutations reject incompatible SELECT, JOIN, ORDER BY, GROUP BY, UNION, CTE, and window-function modifiers instead of silently ignoring them.

WHERE Conditions

Most where_* methods accept either string column names or typed columns:

#![allow(unused)]
fn main() {
// Every model gets a `columns` constant with typed column accessors
// User::columns.id, User::columns.name, User::columns.active, etc.

// The same method accepts either form:
User::query().where_eq("active", true)                    // String-based (runtime checked)
User::query().where_eq(User::columns.active, true)        // Typed column (compile-time checked)

// Typed columns are usually the better default
User::query()
    .where_eq(User::columns.status, "active")
    .where_gt(User::columns.age, 18)
    .where_not_null(User::columns.email)
    .get()
    .await?;
}

Other helpers follow the same pattern:

#![allow(unused)]
fn main() {
User::query().where_eq("status", "active")
User::query().where_eq(User::columns.status, "active")
User::query().where_not("role", "admin")
User::query().where_not(User::columns.role, "admin")

User::query().where_gt("age", 18)
User::query().where_gt(User::columns.age, 18)
User::query().where_gte("age", 18)
User::query().where_lt("age", 65)
User::query().where_lte("age", 65)

User::query().where_like("name", "%John%")
User::query().where_like(User::columns.name, "%John%")
User::query().where_not_like("email", "%spam%")

User::query().where_in("role", vec!["admin", "moderator"])
User::query().where_in(User::columns.role, vec!["admin", "moderator"])
User::query().where_not_in("status", vec!["banned", "suspended"])

User::query().where_null("deleted_at")
User::query().where_null(User::columns.deleted_at)
User::query().where_not_null("email_verified_at")

User::query().where_between("age", 18, 65)
User::query().where_between(User::columns.age, 18, 65)
}

OR Conditions

OR clauses are available as simple query-level helpers or grouped begin_or() / end_or() blocks. Both accept string column names and typed columns.

Simple OR Methods

#![allow(unused)]
fn main() {
// Basic OR conditions (applied at query level)
// Works with both strings and typed columns:
User::query()
    .or_where_eq("role", "admin")                    // String-based
    .or_where_eq(User::columns.role, "moderator")   // Typed column
    .get()
    .await?;

// OR with comparison operators
Product::query()
    .or_where_gt(Product::columns.price, 1000.0)   // price > 1000
    .or_where_lt(Product::columns.price, 50.0)     // OR price < 50
    .get()
    .await?;  // Gets premium OR budget products

// OR with pattern matching
User::query()
    .or_where_like(User::columns.name, "John%")    // name LIKE 'John%'
    .or_where_like(User::columns.name, "Jane%")    // OR name LIKE 'Jane%'
    .get()
    .await?;

// OR with IN clause
Product::query()
    .or_where_in(Product::columns.category, vec!["Electronics", "Books"])
    .or_where_eq(Product::columns.featured, true)
    .get()
    .await?;

// OR with NULL checks
User::query()
    .or_where_null(User::columns.deleted_at)
    .or_where_gt(User::columns.reactivated_at, some_date)
    .get()
    .await?;

// OR with BETWEEN
Product::query()
    .or_where_between(Product::columns.price, 10.0, 50.0)    // Budget range
    .or_where_between(Product::columns.price, 500.0, 1000.0) // Premium range
    .get()
    .await?;
}

Fluent OR API (begin_or / end_or)

For complex queries with grouped OR conditions combined with AND, use the fluent OR API:

#![allow(unused)]
fn main() {
// Basic OR group: (category = 'Electronics' OR category = 'Home')
Product::query()
    .begin_or()
        .or_where_eq(Product::columns.category, "Electronics")
        .or_where_eq(Product::columns.category, "Home")
    .end_or()
    .get()
    .await?;

// OR with AND chains: (Apple AND active) OR (Samsung AND featured)
Product::query()
    .begin_or()
        .or_where_eq("brand", "Apple").and_where_eq("active", true)
        .or_where_eq("brand", "Samsung").and_where_eq("featured", true)
    .end_or()
    .get()
    .await?;

// Complex: active AND rating >= 4.0 AND ((Electronics AND price < 1000) OR (Home AND featured))
Product::query()
    .where_eq("active", true)
    .where_gte("rating", 4.0)
    .begin_or()
        .or_where_eq("category", "Electronics").and_where_lt("price", 1000.0)
        .or_where_eq("category", "Home").and_where_eq("featured", true)
    .end_or()
    .get()
    .await?;

// Multiple sequential OR groups
Product::query()
    .where_eq("active", true)
    .begin_or()
        .or_where_eq("category", "Electronics")
        .or_where_eq("category", "Home")
    .end_or()
    .begin_or()
        .or_where_eq("brand", "Apple")
        .or_where_eq("brand", "Samsung")
    .end_or()
    .get()
    .await?;
// SQL: WHERE active = true 
//      AND (category = 'Electronics' OR category = 'Home') 
//      AND (brand = 'Apple' OR brand = 'Samsung')
}

AND Methods within OR Groups

Use and_where_* methods to chain AND conditions within an OR branch:

#![allow(unused)]
fn main() {
Product::query()
    .begin_or()
        // First OR branch: Electronics with price > 500 and good rating
        .or_where_eq("category", "Electronics")
            .and_where_gt("price", 500.0)
            .and_where_gte("rating", 4.5)
        // Second OR branch: Home items that are featured
        .or_where_eq("category", "Home")
            .and_where_eq("featured", true)
        // Third OR branch: Any discounted item
        .or_where_not_null("discount_percent")
    .end_or()
    .get()
    .await?;
}

Available and_where_* methods:

  • and_where_eq(col, val) - AND column = value
  • and_where_not(col, val) - AND column != value
  • and_where_gt(col, val) - AND column > value
  • and_where_gte(col, val) - AND column >= value
  • and_where_lt(col, val) - AND column < value
  • and_where_lte(col, val) - AND column <= value
  • and_where_like(col, pattern) - AND column LIKE pattern
  • and_where_in(col, values) - AND column IN (values)
  • and_where_not_in(col, values) - AND column NOT IN (values)
  • and_where_null(col) - AND column IS NULL
  • and_where_not_null(col) - AND column IS NOT NULL
  • and_where_between(col, min, max) - AND column BETWEEN min AND max

Real-World Examples

#![allow(unused)]
fn main() {
// E-commerce: Flash sale eligibility
let flash_sale_products = Product::query()
    .where_eq("active", true)
    .where_gt("stock", 100)
    .where_gte("rating", 4.3)
    .where_null("discount_percent")  // Not already discounted
    .get()
    .await?;

// Inventory: Reorder alerts
let reorder_needed = Product::query()
    .where_eq("active", true)
    .begin_or()
        .or_where_lt("stock", 50).and_where_gt("rating", 4.5)  // Popular items low
        .or_where_lt("stock", 30)  // Any item critically low
    .end_or()
    .order_by("stock", Order::Asc)
    .get()
    .await?;

// Marketing: Cross-sell recommendations
let recommendations = Product::query()
    .where_eq("active", true)
    .begin_or()
        .or_where_eq("brand", "Apple")
        .or_where_eq("brand", "Samsung").and_where_gt("price", 500.0)
        .or_where_eq("featured", true).and_where_not("category", "Electronics")
    .end_or()
    .order_by("rating", Order::Desc)
    .limit(10)
    .get()
    .await?;

// Search: Multi-pattern name matching
let search_results = Product::query()
    .begin_or()
        .or_where_like("name", "iPhone%")
        .or_where_like("name", "Galaxy%")
        .or_where_like("name", "%Pro%")
    .end_or()
    .where_eq("active", true)
    .get()
    .await?;

// Analytics: Price segmentation
let segmented = Product::query()
    .begin_or()
        .or_where_eq("category", "Electronics")
        .or_where_eq("category", "Books")
    .end_or()
    .begin_or()
        .or_where_gt("price", 1000.0)   // Premium
        .or_where_lt("price", 50.0)     // Budget
    .end_or()
    .order_by("price", Order::Desc)
    .get()
    .await?;
}

Ordering

#![allow(unused)]
fn main() {
// Basic ordering - works with both strings and typed columns
User::query()
    .order_by("created_at", Order::Desc)           // String-based
    .order_by(User::columns.name, Order::Asc)      // Typed column
    .get()
    .await?;

// Convenience methods - also work with typed columns
User::query().order_asc(User::columns.name)        // ORDER BY name ASC
User::query().order_desc(User::columns.created_at) // ORDER BY created_at DESC
User::query().latest()                              // ORDER BY created_at DESC
User::query().oldest()                              // ORDER BY created_at ASC
}

Pagination

#![allow(unused)]
fn main() {
// Limit and offset
User::query()
    .limit(10)
    .offset(20)
    .get()
    .await?;

// Page-based pagination
User::query()
    .page(3, 25)  // Page 3, 25 per page
    .get()
    .await?;

// Aliases
User::query().take(10).skip(20)  // Same as limit(10).offset(20)
}

Chunked Processing

#![allow(unused)]
fn main() {
async fn process(_user: User) -> tideorm::Result<()> {
    Ok(())
}

User::query()
    .chunk(500, |batch| async move {
        for user in batch {
            process(user).await?;
        }
        Ok(())
    })
    .await?;
}

chunk() walks the current query by a single-column primary-key cursor instead of loading the full result set into memory at once. That means callbacks may safely update or delete already-processed rows without later batches skipping records. Existing filters, limit(), and cache settings remain in effect. If you want descending traversal, order explicitly by the primary key before calling chunk(). chunk() rejects offset() and other custom ordering because those conflict with stable cursor traversal.

Execution Methods

#![allow(unused)]
fn main() {
// Get all matching records
let users = User::query()
    .where_eq("active", true)
    .get()
    .await?;  // Vec<User>

// Get first record
let user = User::query()
    .where_eq("email", "admin@example.com")
    .first()
    .await?;  // Option<User>

// Get first or fail
let user = User::query()
    .where_eq("id", 1)
    .first_or_fail()
    .await?;  // Result<User>

// Count (efficient SQL COUNT)
let count = User::query()
    .where_eq("active", true)
    .count()
    .await?;  // u64

// Check existence
let exists = User::query()
    .where_eq("email", "admin@example.com")
    .exists()
    .await?;  // bool

// Bulk delete (efficient single DELETE statement)
let deleted = User::query()
    .where_eq("status", "inactive")
    .delete()
    .await?;  // u64 (rows affected)
}

UNION Queries

Combine results from multiple queries:

#![allow(unused)]
fn main() {
// UNION - combines results and removes duplicates
let users = User::query()
    .where_eq("active", true)
    .union(User::query().where_eq("role", "admin"))
    .get()
    .await?;

// UNION ALL - includes all results (faster, keeps duplicates)
let orders = Order::query()
    .where_eq("status", "pending")
    .union_all(Order::query().where_eq("status", "processing"))
    .union_all(Order::query().where_eq("status", "shipped"))
    .order_by("created_at", Order::Desc)
    .get()
    .await?;

// Raw UNION for complex queries
let results = User::query()
    .union_raw("SELECT * FROM archived_users WHERE year = 2023")
    .get()
    .await?;
}

Window Functions

Perform calculations across sets of rows:

#![allow(unused)]
fn main() {
use tideorm::prelude::*;

// ROW_NUMBER - assign sequential numbers
let products = Product::query()
    .row_number("row_num", Some("category"), "price", Order::Desc)
    .get_raw()
    .await?;
// SQL: ROW_NUMBER() OVER (PARTITION BY "category" ORDER BY "price" DESC) AS "row_num"

// RANK - rank with gaps for ties
let employees = Employee::query()
    .rank("salary_rank", Some("department_id"), "salary", Order::Desc)
    .get_raw()
    .await?;

// DENSE_RANK - rank without gaps
let students = Student::query()
    .dense_rank("score_rank", None, "score", Order::Desc)
    .get_raw()
    .await?;

// Running totals with SUM window
let sales = Sale::query()
    .running_sum("running_total", "amount", "date", Order::Asc)
    .get_raw()
    .await?;

// LAG - access previous row value
let orders = Order::query()
    .lag("prev_total", "total", 1, Some("0"), "user_id", "created_at", Order::Asc)
    .get_raw()
    .await?;

// LEAD - access next row value
let appointments = Appointment::query()
    .lead("next_date", "date", 1, None, "patient_id", "date", Order::Asc)
    .get_raw()
    .await?;

// NTILE - distribute into buckets
let products = Product::query()
    .ntile("price_quartile", 4, "price", Order::Asc)
    .get_raw()
    .await?;

// Custom window function with full control
let results = Order::query()
    .window(
        WindowFunction::new(WindowFunctionType::Sum("amount".to_string()), "total_sales")
            .partition_by("region")
            .order_by("month", Order::Asc)
            .frame(FrameType::Rows, FrameBound::UnboundedPreceding, FrameBound::CurrentRow)
    )
    .get_raw()
    .await?;
}

Common Table Expressions (CTEs)

Define temporary named result sets:

#![allow(unused)]
fn main() {
use tideorm::prelude::*;

// Simple CTE
let orders = Order::query()
    .with_cte(CTE::new(
        "high_value_orders",
        "SELECT * FROM orders WHERE total > 1000".to_string()
    ))
    .where_raw("id IN (SELECT id FROM high_value_orders)")
    .get()
    .await?;

// CTE from another query builder
let active_users = User::query()
    .where_eq("active", true)
    .select(vec!["id", "name", "email"]);

let posts = Post::query()
    .with_query("active_users", active_users)
    .inner_join("active_users", "posts.user_id", "active_users.id")
    .get()
    .await?;

// CTE with column aliases
let stats = Sale::query()
    .with_cte_columns(
        "daily_stats",
        vec!["sale_date", "total_sales", "order_count"],
        "SELECT DATE(created_at), SUM(amount), COUNT(*) FROM sales GROUP BY DATE(created_at)"
    )
    .where_raw("date IN (SELECT sale_date FROM daily_stats WHERE total_sales > 10000)")
    .get()
    .await?;

// Recursive CTE for hierarchical data
let employees = Employee::query()
    .with_recursive_cte(
        "org_tree",
        vec!["id", "name", "manager_id", "level"],
        // Base case: top-level managers
        "SELECT id, name, manager_id, 0 FROM employees WHERE manager_id IS NULL",
        // Recursive: employees under managers
        "SELECT e.id, e.name, e.manager_id, t.level + 1 
         FROM employees e 
         INNER JOIN org_tree t ON e.manager_id = t.id"
    )
    .where_raw("id IN (SELECT id FROM org_tree)")
    .get()
    .await?;
}


TideORM provides full-text search capabilities across PostgreSQL (tsvector/tsquery), MySQL (FULLTEXT), and SQLite (FTS5).

Enable the feature explicitly when you need the full-text search API:

tideorm = { version = "0.9.13", features = ["postgres", "fulltext"] }

Search Basics

#![allow(unused)]
fn main() {
use tideorm::prelude::*;

// Simple full-text search
let results = Article::search(&["title", "content"], "rust programming")
    .await?;

// Search with ranking (ordered by relevance)
let ranked = Article::search_ranked(&["title", "content"], "rust async")
    .limit(10)
    .get_ranked()
    .await?;

for result in ranked {
    println!("{}: {} (rank: {:.2})", 
        result.record.id, 
        result.record.title, 
        result.rank
    );
}

// Count matching results
let count = Article::search(&["title", "content"], "rust")
    .count()
    .await?;

// Get first matching result
let first = Article::search(&["title"], "rust")
    .first()
    .await?;
}

Search Modes

#![allow(unused)]
fn main() {
use tideorm::fulltext::{SearchMode, FullTextConfig};

// Natural language search (default)
Article::search(&["content"], "learn rust programming").await?;

// Boolean search with operators
Article::search(&["content"], "+rust +async -javascript")
    .mode(SearchMode::Boolean)
    .get()
    .await?;

// Phrase search (exact phrase matching)
Article::search(&["content"], "async await")
    .mode(SearchMode::Phrase)
    .get()
    .await?;

// Prefix search (for autocomplete)
Article::search(&["title"], "prog")
    .mode(SearchMode::Prefix)
    .get()
    .await?;
}

Search Configuration

#![allow(unused)]
fn main() {
use tideorm::fulltext::{FullTextConfig, SearchMode, SearchWeights};

let config = FullTextConfig::new()
    .language("english")        // Text analysis language
    .mode(SearchMode::Boolean)  // Search mode
    .min_word_length(3)         // Minimum word length to index
    .max_word_length(50)        // Maximum word length
    // Custom weights for ranking (title > summary > content)
    .weights(SearchWeights::new(1.0, 0.5, 0.3, 0.1));

let results = Article::search_with_config(
    &["title", "summary", "content"],
    "rust programming",
    config
).get().await?;
}

Text Highlighting

#![allow(unused)]
fn main() {
use tideorm::fulltext::{highlight_text, generate_snippet};

let text = "The quick brown fox jumps over the lazy dog.";

// Highlight search terms
let highlighted = highlight_text(text, "fox lazy", "<mark>", "</mark>");
// Result: "The quick brown <mark>fox</mark> jumps over the <mark>lazy</mark> dog."

// Generate snippet with context
let long_text = "Lorem ipsum... The fox jumped... More text here...";
let snippet = generate_snippet(long_text, "fox", 5, "<b>", "</b>");
// Result: "...dolor sit amet. The <b>fox</b> jumped over the..."
}

Creating Full-Text Indexes

#![allow(unused)]
fn main() {
use tideorm::fulltext::{FullTextIndex, PgFullTextIndexType};
use tideorm::config::DatabaseType;

// Create index definition
let index = FullTextIndex::new(
    "idx_articles_search",
    "articles",
    vec!["title".to_string(), "content".to_string()]
)
.language("english")
.pg_index_type(PgFullTextIndexType::GIN);

// Generate SQL for your database
let sql = index.to_sql(DatabaseType::Postgres);
// PostgreSQL: CREATE INDEX "idx_articles_search" ON "articles" 
//             USING GIN ((to_tsvector('english', ...)))

let sql = index.to_sql(DatabaseType::MySQL);
// MySQL: CREATE FULLTEXT INDEX `idx_articles_search` ON `articles`(`title`, `content`)

let sql = index.to_sql(DatabaseType::SQLite);
// SQLite: Creates FTS5 virtual table + sync triggers
}

PostgreSQL-Specific Features

#![allow(unused)]
fn main() {
use tideorm::fulltext::pg_headline_sql;

// Generate ts_headline SQL for server-side highlighting
let headline_sql = pg_headline_sql(
    "content",           // column
    "search query",      // search terms
    "english",           // language
    "<b>", "</b>"        // highlight tags
);
// Result: ts_headline('english', "content", plainto_tsquery(...), ...)
}

Multi-Database Support

TideORM automatically detects your database type and generates appropriate SQL syntax. The same code works seamlessly across PostgreSQL, MySQL, and SQLite.

Connecting to Different Databases

#![allow(unused)]
fn main() {
// PostgreSQL
TideConfig::init()
    .database("postgres://user:pass@localhost/mydb")
    .connect()
    .await?;

// MySQL / MariaDB
TideConfig::init()
    .database("mysql://user:pass@localhost/mydb")
    .connect()
    .await?;

// SQLite
TideConfig::init()
    .database("sqlite://./data.db?mode=rwc")
    .connect()
    .await?;
}

Explicit Database Type

#![allow(unused)]
fn main() {
TideConfig::init()
    .database_type(DatabaseType::MySQL)
    .database("mysql://localhost/mydb")
    .connect()
    .await?;
}

Database Feature Detection

Check which features are supported by the current database:

#![allow(unused)]
fn main() {
let db_type = require_db()?.backend();

// Feature checks
if db_type.supports_json() {
    // JSON/JSONB operations available
}

if db_type.supports_arrays() {
    // Native array operations (PostgreSQL only)
}

if db_type.supports_returning() {
    // RETURNING clause for INSERT/UPDATE
}

if db_type.supports_upsert() {
    // ON CONFLICT / ON DUPLICATE KEY support
}

if db_type.supports_window_functions() {
    // OVER(), ROW_NUMBER(), etc.
}

if db_type.supports_cte() {
    // WITH ... AS (Common Table Expressions)
}

if db_type.supports_fulltext_search() {
    // Full-text search capabilities
}
}

Database-Specific JSON Operations

automatically translates JSON queries to the appropriate syntax:

#![allow(unused)]
fn main() {
// This query works on all databases with JSON support
Product::query()
    .where_json_contains("metadata", serde_json::json!({"featured": true}))
    .get()
    .await?;
}

Generated SQL by database:

OperationPostgreSQLMySQLSQLite
JSON Containscol @> '{"key":1}'JSON_CONTAINS(col, '{"key":1}')json_each(col) + subquery
Key Existscol ? 'key'JSON_CONTAINS_PATH(col, 'one', '$.key')json_extract(col, '$.key') IS NOT NULL
Path Existscol @? '$.path'JSON_CONTAINS_PATH(col, 'one', '$.path')json_extract(col, '$.path') IS NOT NULL

Database-Specific Array Operations

Array operations are fully supported on PostgreSQL. On MySQL/SQLite, arrays are stored as JSON:

#![allow(unused)]
fn main() {
// PostgreSQL native arrays
Product::query()
    .where_array_contains("tags", vec!["sale", "featured"])
    .get()
    .await?;
}

Generated SQL:

OperationPostgreSQLMySQL/SQLite
Containscol @> ARRAY['a','b']JSON_CONTAINS(col, '["a","b"]')
Contained Bycol <@ ARRAY['a','b']JSON_CONTAINS('["a","b"]', col)
Overlapscol && ARRAY['a','b']JSON_OVERLAPS(col, '["a","b"]') (MySQL 8+)

Database-Specific Optimizations

applies optimizations based on your database:

FeaturePostgreSQLMySQLSQLite
Optimal Batch Size10001000500
Parameter Style$1, $2, ...?, ?, ...?, ?, ...
Identifier Quoting"column"`column`"column"
Float CastingFLOAT8DOUBLEREAL

Feature Compatibility Matrix

FeaturePostgreSQLMySQLSQLite
JSON/JSONB✅ (JSON1)
Native JSON Operators
Native Arrays
RETURNING Clause✅ (3.35+)
Upsert
Window Functions✅ (8.0+)✅ (3.25+)
CTEs✅ (8.0+)✅ (3.8+)
Full-Text Search✅ (FTS5)
Schemas

Raw SQL Queries

For complex queries that can't be expressed with the query builder:

#![allow(unused)]
fn main() {
// Execute raw SQL and return model instances
let users: Vec<User> = Database::raw::<User>(
    "SELECT * FROM users WHERE age > 18"
).await?;

// With parameters (use $1, $2 for PostgreSQL, ? for MySQL/SQLite)
let users: Vec<User> = Database::raw_with_params::<User>(
    "SELECT * FROM users WHERE age > $1 AND status = $2",
    vec![18.into(), "active".into()]
).await?;

// Execute raw SQL statement (INSERT, UPDATE, DELETE)
let affected = Database::execute(
    "UPDATE users SET active = false WHERE last_login < NOW() - INTERVAL '1 year'"
).await?;

// Execute with parameters
let affected = Database::execute_with_params(
    "DELETE FROM users WHERE status = $1",
    vec!["banned".into()]
).await?;
}

Query Logging

Enable SQL query logging for development/debugging:

# Set environment variable
TIDE_LOG_QUERIES=true cargo run

When enabled, all SQL queries will be logged to stderr.


Error Handling

TideORM provides rich error types with optional context:

#![allow(unused)]
fn main() {
// Get context from errors
if let Err(e) = User::find_or_fail(999).await {
    if let Some(ctx) = e.context() {
        println!("Error in table: {:?}", ctx.table);
        println!("Column: {:?}", ctx.column);
        println!("Query: {:?}", ctx.query);
    }
}

// Create errors with context
use tideorm::error::{Error, ErrorContext};

let ctx = ErrorContext::new()
    .table("users")
    .column("email")
    .query("SELECT * FROM users WHERE email = $1");

return Err(Error::not_found("User not found").with_context(ctx));
}

Profiling

TideORM exposes two different profiling tools:

  • GlobalProfiler for lightweight application-wide query timing and slow-query counters.
  • Profiler for manual profiling sessions where you want to build a detailed ProfileReport yourself.

Use GlobalProfiler when you want to observe real query execution with minimal setup. Use Profiler when you want to capture custom SQL strings, table names, row counts, and run QueryAnalyzer over a curated set of queries.

Global Profiling

GlobalProfiler records aggregate timing data for real executed queries when it is enabled.

#![allow(unused)]
fn main() {
use tideorm::prelude::*;
use tideorm::profiling::GlobalProfiler;

GlobalProfiler::enable();
GlobalProfiler::reset();
GlobalProfiler::set_slow_threshold(200);

let users = User::query()
    .where_eq("active", true)
    .limit(25)
    .get()
    .await?;

let stats = GlobalProfiler::stats();
println!("{}", stats);

GlobalProfiler::disable();
}

When enabled, TideORM records query timings from the main execution paths:

  • query-builder reads and counts routed through the database helpers
  • raw SQL helpers such as Database::raw, Database::execute, and JSON/raw parameterized helpers
  • direct CRUD methods generated by the Model derive such as find, save, update, delete, and destroy
  • aggregate helpers such as count_distinct, sum, avg, min, and max
  • full-text search execution paths
  • internal executor paths used by batch and model helpers

GlobalStats exposes:

  • total_queries
  • total_time()
  • avg_query_time()
  • slow_queries
  • slow_percentage()

What Global Profiling Does Not Capture

GlobalProfiler is intentionally lightweight. It tracks counts and durations, but it does not retain SQL text or per-query metadata.

If you need a detailed report with SQL strings, operation types, slowest queries, table counts, or custom annotations, build that report manually with Profiler and ProfiledQuery.

Manual Profile Reports

Profiler is a report builder. It does not automatically subscribe to all TideORM queries by itself.

#![allow(unused)]
fn main() {
use std::time::Duration;
use tideorm::profiling::{ProfiledQuery, Profiler};

let mut profiler = Profiler::start();

profiler.record("SELECT * FROM users WHERE active = true", Duration::from_millis(12));

profiler.record_full(
    ProfiledQuery::new("SELECT * FROM posts WHERE user_id = 42", Duration::from_millis(37))
        .with_table("posts")
        .with_rows(8),
);

let report = profiler.stop();
println!("{}", report);
}

This is useful when you are instrumenting a benchmark, comparing alternative query shapes, or building a focused performance report in tests.

Query Analysis

QueryAnalyzer can inspect SQL and return optimization suggestions.

#![allow(unused)]
fn main() {
use tideorm::profiling::QueryAnalyzer;

let suggestions = QueryAnalyzer::analyze("SELECT * FROM users WHERE email = 'john@example.com'");

for suggestion in suggestions {
    println!("{}", suggestion);
}
}

Use this alongside Profiler when you want human-readable guidance about indexing, query shape, and complexity.

  • Query logging is covered in Queries.
  • Slow-query thresholds for logs and profiler statistics are separate settings.
  • The global profiler is process-wide, so reset it between tests when you need deterministic assertions.

Benchmarking

TideORM uses Criterion benchmarks under benches/ for both database-backed workloads and pure library workloads.

Benchmark Groups

The current benchmark targets fall into three categories:

  • PostgreSQL-backed benchmarks: crud_benchmarks, query_benchmarks, and or_clause_benchmarks
  • Feature-gated library benchmarks: attachments_translations_benchmarks and fulltext_benchmarks
  • Library-only benchmarks: cache_benchmarks, relations_benchmarks, stability_benchmarks, tokenization_benchmarks, and validation_benchmarks

cargo bench runs the default-enabled benchmark targets. Benches with required-features are skipped unless you enable the matching features explicitly.

Prerequisites

For PostgreSQL-backed benches, TideORM uses the same fallback URL as the test suite unless you override it:

POSTGRESQL_DATABASE_URL=postgres://postgres:postgres@localhost:5432/test_tide_orm

The benchmark support layer loads .env through dotenvy, so a local .env file works too.

Criterion can render plots with plotters out of the box. Installing gnuplot is optional.

Quick Start

Run the default benchmark set:

cargo bench

Prefer a focused target when you are investigating a bottleneck:

cargo bench --bench query_benchmarks
cargo bench --bench crud_benchmarks
cargo bench --bench or_clause_benchmarks

Run library-only benchmarks that do not require a database:

cargo bench --bench cache_benchmarks
cargo bench --bench relations_benchmarks
cargo bench --bench stability_benchmarks
cargo bench --bench tokenization_benchmarks
cargo bench --bench validation_benchmarks

Run feature-gated benchmarks explicitly:

cargo bench --bench attachments_translations_benchmarks --features "attachments translations"
cargo bench --bench fulltext_benchmarks --features fulltext

Compile all benchmark targets without executing them:

cargo bench --no-run --features "attachments translations fulltext"

Comparing Bottlenecks

Criterion supports saved baselines. Use them when you are working on a performance change so the comparison stays on the same target and workload.

Save a baseline:

cargo bench --bench query_benchmarks -- --save-baseline before-change

Run the modified code against that baseline:

cargo bench --bench query_benchmarks -- --baseline before-change

The HTML and raw report output lives under target/criterion/.

Workflow Notes

  • Benchmark the smallest relevant target first. Avoid running unrelated benches while narrowing a regression.
  • Keep the PostgreSQL instance idle when measuring database-backed benches; noisy local activity will swamp small improvements.
  • Use compile-only cargo bench --no-run checks when changing shared benchmark support or feature wiring.
  • If a change affects query shape or SQL generation, pair the benchmark run with the profiling tools described in docs/profiling.md.

Relations

Model Relations

Relations are declared as struct fields. You can load them on demand from an individual model, or batch-load them eagerly from a query to avoid N+1 lookups.

Defining Relations

#![allow(unused)]
fn main() {
use tideorm::prelude::*;

#[tideorm::model(table = "users")]
pub struct User {
    #[tideorm(primary_key, auto_increment)]
    pub id: i64,
    pub name: String,
    pub email: String,
    
    // One-to-one: User has one Profile
    #[tideorm(has_one = "Profile", foreign_key = "user_id")]
    pub profile: HasOne<Profile>,
    
    // One-to-many: User has many Posts
    #[tideorm(has_many = "Post", foreign_key = "user_id")]
    pub posts: HasMany<Post>,
}

#[tideorm::model(table = "profiles")]
pub struct Profile {
    #[tideorm(primary_key, auto_increment)]
    pub id: i64,
    pub user_id: i64,
    pub bio: String,
    
    // Inverse: Profile belongs to User
    #[tideorm(belongs_to = "User", foreign_key = "user_id")]
    pub user: BelongsTo<User>,
}

#[tideorm::model(table = "posts")]
pub struct Post {
    #[tideorm(primary_key, auto_increment)]
    pub id: i64,
    pub user_id: i64,
    pub title: String,
    pub content: String,
    
    // Inverse: Post belongs to User
    #[tideorm(belongs_to = "User", foreign_key = "user_id")]
    pub author: BelongsTo<User>,
    
    // One-to-many: Post has many Comments
    #[tideorm(has_many = "Comment", foreign_key = "post_id")]
    pub comments: HasMany<Comment>,
}
}

Relation Types

TypeAttributeDescription
HasOne<T>has_oneOne-to-one relationship (e.g., User has one Profile)
HasMany<T>has_manyOne-to-many relationship (e.g., User has many Posts)
BelongsTo<T>belongs_toInverse relationship (e.g., Post belongs to User)
HasManyThrough<T, P>has_many_throughMany-to-many via pivot table
MorphOne<T>-Polymorphic one-to-one
MorphMany<T>-Polymorphic one-to-many

Relation Attributes

AttributeDescriptionRequired
foreign_keyForeign key column on related tableYes
local_keyLocal key (defaults to primary key)No
owner_keyOwner key for BelongsToNo
pivotPivot table name for HasManyThroughFor through relations
related_keyRelated key on pivot tableFor through relations

Loading Relations

Relation helper fields such as HasOne<T>, HasMany<T>, and BelongsTo<T> are runtime helpers, not persisted columns. TideORM's generated serde implementation serializes cached relation payloads when they are loaded and rebuilds the wrappers with runtime context during deserialization, so JSON round trips can preserve loaded relations without treating the wrappers as stored schema fields.

Runtime relation helpers operate on a single local or foreign key value per query. For composite-key models, define local_key explicitly when needed and use custom queries when the relation requires matching multiple columns.

For has_many_through, TideORM requires all three relation options to be declared explicitly: pivot, foreign_key, and related_key. Missing any of them is now a compile-time error.

#![allow(unused)]
fn main() {
// Load a HasOne relation
let user = User::find(1).await?.unwrap();
let profile: Option<Profile> = user.profile.load().await?;

// Load a HasMany relation
let posts: Vec<Post> = user.posts.load().await?;

// Load a BelongsTo relation
let post = Post::find(1).await?.unwrap();
let author: Option<User> = post.author.load().await?;

// Check if relation exists
let has_profile = user.profile.exists().await?;  // bool
let has_posts = user.posts.exists().await?;      // bool

// Count related records
let post_count = user.posts.count().await?;      // u64
}

Eager Loading (N+1 Prevention)

When you already know which relations you need, promote the query builder into an eager-loading query with with() or with_many(). TideORM batches each requested relation path with set-based loader queries, so you avoid issuing one relation query per parent row.

#![allow(unused)]
fn main() {
// Preserve existing query filters, ordering, pagination, and database handle.
let users = User::query()
    .where_eq("active", true)
    .with("profile")
    .with("posts")
    .get()
    .await?;

for user in users {
    let profile = user.profile.get_cached();
    let posts = user.posts.get_cached().unwrap_or_default();
    println!("{} has {} cached posts", user.name, posts.len());
}

// You can also start directly from the eager-loading builder.
let users = User::eager()
    .with_many(&["profile", "posts", "posts.comments"])
    .get()
    .await?;
}

Eager queries return WithRelations<User> wrappers that dereference to User, so the normal relation helper fields remain available and expose their cached payloads through get_cached().

Loading with Constraints

#![allow(unused)]
fn main() {
// Load posts with custom conditions
let recent_posts = user.posts.load_with(|query| {
    query
        .where_eq("published", true)
        .where_gt("views", 100)
        .order_desc("created_at")
        .limit(10)
}).await?;

// Load profile with constraints
let profile = user.profile.load_with(|query| {
    query.where_not_null("avatar")
}).await?;
}

Entity Manager Workflows

If you enable the entity-manager feature, TideORM adds an explicit persistence context plus entity-manager-aware relation loading and aggregate synchronization.

Use entity_manager.find::<Model>(...) to load the root model, entity_manager.load(&mut relation) to load HasOne<T>, HasMany<T>, BelongsTo<T>, or HasManyThrough<T, P> relations inside the same context, and entity_manager.save(&model) or entity_manager.flush() to persist loaded aggregate-side changes.

If the root model itself came from the entity manager, plain relation read helpers such as load(), load_with(...), count(), and exists() stay on that same database handle even when no global database is configured. Use entity_manager.load(&mut relation) when the relation should also become tracked for aggregate synchronization.

find_in_entity_manager(...), load_in_entity_manager(...), and save_with_entity_manager(...) remain available as lower-level compatibility entry points.

See Entity Manager for the full workflow and primary-key support details.

Many-to-Many Relations

#![allow(unused)]
fn main() {
#[tideorm::model(table = "users")]
pub struct User {
    #[tideorm(primary_key, auto_increment)]
    pub id: i64,
    pub name: String,
    
    // Many-to-many: User has many Roles through user_roles pivot table
    #[tideorm(has_many_through = "Role", pivot = "user_roles", foreign_key = "user_id", related_key = "role_id")]
    pub roles: HasManyThrough<Role, UserRole>,
}

#[tideorm::model(table = "roles")]
pub struct Role {
    #[tideorm(primary_key, auto_increment)]
    pub id: i64,
    pub name: String,
}

#[tideorm::model(table = "user_roles")]
pub struct UserRole {
    #[tideorm(primary_key, auto_increment)]
    pub id: i64,
    pub user_id: i64,
    pub role_id: i64,
}

// Usage
let user = User::find(1).await?.unwrap();

// Load all roles
let roles = user.roles.load().await?;

// Attach a role
user.roles.attach(role_id).await?;

// Detach a role
user.roles.detach(role_id).await?;

// Sync roles (replace all with new set)
user.roles.sync(vec![
    serde_json::json!(1),
    serde_json::json!(2),
    serde_json::json!(3),
]).await?;
}

Polymorphic Relations

#![allow(unused)]
fn main() {
use tideorm::prelude::*;

// Images can belong to Posts or Videos (polymorphic)
#[tideorm::model(table = "images")]
pub struct Image {
    #[tideorm(primary_key, auto_increment)]
    pub id: i64,
    pub path: String,
    pub imageable_type: String,  // "posts" or "videos"
    pub imageable_id: i64,
}

#[tideorm::model(table = "posts")]
pub struct Post {
    #[tideorm(primary_key, auto_increment)]
    pub id: i64,
    pub title: String,
    
    // Polymorphic: Post has many Images
    #[tideorm(morph_name = "imageable")]
    pub images: MorphMany<Image>,
}

// MorphOne/MorphMany fields are wired automatically when morph_name is provided.
// On the child side, use #[tideorm(morph_name = "imageable")] on MorphTo<T> too.
}


File Attachments

TideORM provides a file attachment system for managing file relationships. Attachments are stored in a JSONB column with metadata.

Enable the feature first:

[dependencies]
tideorm = { version = "0.9.13", features = ["postgres", "attachments"] }

Model Setup

#![allow(unused)]
fn main() {
#[tideorm::model(table = "products")]
#[tideorm(has_one_file = "thumbnail")]
#[tideorm(has_many_files = "images,documents")]
pub struct Product {
    #[tideorm(primary_key, auto_increment)]
    pub id: i64,
    pub name: String,
    pub files: Option<Json>,  // JSONB column storing attachments
}
}

Relation Types

TypeDescriptionUse Case
has_one_fileSingle file attachmentAvatar, thumbnail, profile picture
has_many_filesMultiple file attachmentsGallery images, documents, media

Attaching Files

#![allow(unused)]
fn main() {
use tideorm::prelude::*;

// Attach a single file (hasOne) - replaces any existing
product.attach("thumbnail", "uploads/thumb.jpg")?;

// Attach multiple files (hasMany) - accumulates
product.attach("images", "uploads/img1.jpg")?;
product.attach("images", "uploads/img2.jpg")?;

// Attach multiple at once
product.attach_many("images", vec![
    "uploads/img3.jpg",
    "uploads/img4.jpg",
])?;

// Attach with metadata
let attachment = FileAttachment::with_metadata(
    "uploads/document.pdf",
    Some("My Document.pdf"),  // Original filename
    Some(1024 * 1024),        // File size (1MB)
    Some("application/pdf"),  // MIME type
);
product.attach_with_metadata("documents", attachment)?;

// Add custom metadata
let attachment = FileAttachment::new("uploads/photo.jpg")
    .add_metadata("width", 1920)
    .add_metadata("height", 1080)
    .add_metadata("photographer", "John Doe");
product.attach_with_metadata("images", attachment)?;

// Save to persist changes
product.update().await?;
}

Detaching Files

#![allow(unused)]
fn main() {
// Remove thumbnail (hasOne)
product.detach("thumbnail", None)?;

// Remove specific file (hasMany)
product.detach("images", Some("uploads/img1.jpg"))?;

// Remove all files from relation (hasMany)
product.detach("images", None)?;

// Remove multiple specific files
product.detach_many("images", vec!["img2.jpg", "img3.jpg"])?;

product.update().await?;
}

Syncing Files (Replace All)

#![allow(unused)]
fn main() {
// Replace all images with new ones
product.sync("images", vec![
    "uploads/new1.jpg",
    "uploads/new2.jpg",
])?;

// Clear all images
product.sync("images", vec![])?;

// Sync with metadata
let attachments = vec![
    FileAttachment::with_metadata("img1.jpg", Some("Photo 1"), Some(1024), Some("image/jpeg")),
    FileAttachment::with_metadata("img2.jpg", Some("Photo 2"), Some(2048), Some("image/jpeg")),
];
product.sync_with_metadata("images", attachments)?;

product.update().await?;
}

Getting Files

#![allow(unused)]
fn main() {
// Get single file (hasOne)
if let Some(thumb) = product.get_file("thumbnail")? {
    println!("Thumbnail: {}", thumb.key);
    println!("Filename: {}", thumb.filename);
    println!("Created: {}", thumb.created_at);
    if let Some(size) = thumb.size {
        println!("Size: {} bytes", size);
    }
}

// Get multiple files (hasMany)
let images = product.get_files("images")?;
for img in images {
    println!("Image: {} ({})", img.filename, img.key);
}

// Check if has files
if product.has_files("images")? {
    let count = product.count_files("images")?;
    println!("Product has {} images", count);
}
}

FileAttachment Structure

Each attachment stores:

FieldTypeDescription
keyStringFile path/key (e.g., "uploads/2024/01/image.jpg")
filenameStringExtracted filename
created_atStringISO 8601 timestamp when attached
original_filenameOption<String>Original filename if different
sizeOption<u64>File size in bytes
mime_typeOption<String>MIME type
metadataHashMapCustom metadata fields

JSON Storage Format

Attachments are stored in JSONB with this structure:

{
  "thumbnail": {
    "key": "uploads/thumb.jpg",
    "filename": "thumb.jpg",
    "created_at": "2024-01-15T10:30:00Z"
  },
  "images": [
    {
      "key": "uploads/img1.jpg",
      "filename": "img1.jpg",
      "created_at": "2024-01-15T10:30:00Z",
      "size": 1048576,
      "mime_type": "image/jpeg"
    },
    {
      "key": "uploads/img2.jpg",
      "filename": "img2.jpg",
      "created_at": "2024-01-15T10:31:00Z"
    }
  ]
}

File URL Generation

TideORM can automatically generate full URLs for file attachments. This is useful when you store file keys/paths in the database but need to serve them from a CDN or storage service.

Global Base URL

Configure a base URL that will be prepended to all file keys:

#![allow(unused)]
fn main() {
TideConfig::init()
    .database("postgres://localhost/mydb")
    .file_base_url("https://cdn.example.com/uploads")
    .connect()
    .await?;
}

Now when you call to_json(), file attachments will include a url field:

{
  "thumbnail": {
    "key": "products/123/thumb.jpg",
    "filename": "thumb.jpg",
    "url": "https://cdn.example.com/uploads/products/123/thumb.jpg"
  }
}

Custom URL Generator

For more complex URL generation (signed URLs, image transformations, etc.), use a custom generator that receives both the field name and the full FileAttachment:

#![allow(unused)]
fn main() {
use tideorm::attachments::FileAttachment;

// Define a custom URL generator function with field name and full metadata access
fn smart_url_generator(field_name: &str, file: &FileAttachment) -> String {
    // Route based on field name first
    match field_name {
        "thumbnail" => {
            let quality = if file.size.unwrap_or(0) > 500_000 { "60" } else { "auto" };
            return format!("https://thumbs.example.com/q_{}/{}", quality, file.key);
        }
        "avatar" => {
            return format!("https://avatars.example.com/w_200,h_200/{}", file.key);
        }
        _ => {}
    }
    
    // Fall back to mime_type routing
    match file.mime_type.as_deref() {
        Some(m) if m.starts_with("video/") => {
            format!("https://stream.example.com/{}", file.key)
        }
        Some(m) if m.starts_with("image/") => {
            let quality = if file.size.unwrap_or(0) > 1_000_000 { "80" } else { "auto" };
            format!("https://images.example.com/q_{}/{}", quality, file.key)
        }
        _ => format!("https://cdn.example.com/{}", file.key),
    }
}

// Use it globally
TideConfig::init()
    .database("postgres://localhost/mydb")
    .file_url_generator(smart_url_generator)
    .connect()
    .await?;
}

Parameters available to URL generators:

  • field_name - The attachment field name (e.g., "thumbnail", "avatar", "documents")
  • file - The full FileAttachment struct with:
    • key - Storage key/path
    • filename - Extracted filename
    • created_at - Creation timestamp
    • original_filename - Original upload name (if available)
    • size - File size in bytes (if available)
    • mime_type - MIME type (if available)
    • metadata - Custom HashMap for additional data

Model-Specific URL Generator

Override the URL generator for specific models:

#![allow(unused)]
fn main() {
#[tideorm::model(table = "products")]
#[tideorm(has_one_file = "thumbnail")]
pub struct Product {
    #[tideorm(primary_key, auto_increment)]
    pub id: i64,
    pub name: String,
    pub files: Option<Json>,
}

impl ModelMeta for Product {
    // ... other required methods ...
    
    fn file_url_generator() -> FileUrlGenerator {
        |field_name, file| {
            match field_name {
                "thumbnail" => format!("https://products-cdn.example.com/thumb/{}", file.key),
                "gallery" => format!("https://products-cdn.example.com/gallery/{}", file.key),
                _ => format!("https://products-cdn.example.com/assets/{}", file.key),
            }
        }
    }
}
}

Manual URL Generation

Generate URLs programmatically:

#![allow(unused)]
fn main() {
use tideorm::prelude::*;
use tideorm::attachments::FileAttachment;

// Create a FileAttachment for URL generation
let file = FileAttachment::new("uploads/image.jpg");
let url = Config::generate_file_url("thumbnail", &file);

// With metadata for smarter URL generation
let file = FileAttachment::with_metadata(
    "uploads/video.mp4",
    Some("My Video.mp4"),
    Some(50_000_000),
    Some("video/mp4"),
);
let url = Config::generate_file_url("video", &file);

// Using model-specific generator
let url = Product::generate_file_url("thumbnail", &file);

// Using FileAttachment method directly
let attachment = product.get_file("thumbnail")?;
if let Some(thumb) = attachment {
    let url = thumb.url("thumbnail");  // Uses global generator with field name
    
    // Or with custom generator
    let url = thumb.url_with_generator("thumbnail", |field_name, file| {
        format!("https://custom-cdn.com/{}/{}", field_name, file.key)
    });
}
}

URL Generator Priority

URL generators are resolved in this order:

  1. Model-specific generator - If the model overrides file_url_generator()
  2. Global custom generator - If set via TideConfig::file_url_generator()
  3. Global base URL - If set via TideConfig::file_base_url()
  4. Key as-is - If no configuration, returns the key unchanged

Translations (i18n)

TideORM provides a translation system for multilingual content. Translations are stored in a JSONB column.

Enable the feature first:

[dependencies]
tideorm = { version = "0.9.13", features = ["postgres", "translations"] }

Model Setup

#![allow(unused)]
fn main() {
#[tideorm::model(table = "products")]
#[tideorm(translatable = "name,description")]
pub struct Product {
    #[tideorm(primary_key, auto_increment)]
    pub id: i64,
    
    // Default/fallback values
    pub name: String,
    pub description: String,
    
    pub price: f64,
    
    // JSONB column for translations
    pub translations: Option<Json>,
}
}

Setting Translations

#![allow(unused)]
fn main() {
use tideorm::prelude::*;

// Set individual translation
product.set_translation("name", "ar", "اسم المنتج")?;
product.set_translation("name", "fr", "Nom du produit")?;
product.set_translation("description", "ar", "وصف المنتج")?;

// Set multiple translations at once
let mut names = HashMap::new();
names.insert("en", "Product Name");
names.insert("ar", "اسم المنتج");
names.insert("fr", "Nom du produit");
product.set_translations("name", names)?;

// Sync translations (replace all for a field)
let mut new_names = HashMap::new();
new_names.insert("en", "New Product Name");
new_names.insert("de", "Neuer Produktname");
product.sync_translations("name", new_names)?;

// Save to persist
product.update().await?;
}

Getting Translations

#![allow(unused)]
fn main() {
// Get specific translation
if let Some(name) = product.get_translation("name", "ar")? {
    println!("Arabic name: {}", name);
}

// Get with fallback chain: requested -> fallback language -> default field value
let name = product.get_translated("name", "ar")?;

// Get all translations for a field
let all_names = product.get_all_translations("name")?;
for (lang, value) in all_names {
    println!("{}: {}", lang, value);
}

// Get all translations for a language
let arabic = product.get_translations_for_language("ar")?;
// Returns: {"name": "اسم المنتج", "description": "وصف المنتج"}
}

Model::load_all_translations() is no longer available because applying every translation directly onto scalar model fields was misleading and lossy. Use get_all_translations() for field-level access, get_translations_for_language() for one language at a time, or to_json_with_all_translations() when you need a full JSON payload.

Checking Translations

#![allow(unused)]
fn main() {
// Check if specific translation exists
if product.has_translation("name", "ar")? {
    println!("Arabic name available");
}

// Check if field has any translations
if product.has_any_translation("name")? {
    println!("Name has translations");
}

// Get available languages for a field
let languages = product.available_languages("name")?;
println!("Name available in: {:?}", languages);
}

Removing Translations

#![allow(unused)]
fn main() {
// Remove specific translation
product.remove_translation("name", "fr")?;

// Remove all translations for a field
product.remove_field_translations("name")?;

// Clear all translations
product.clear_translations()?;

product.update().await?;
}

JSON Output with Translations

#![allow(unused)]
fn main() {
// Get JSON with translated fields (removes raw translations column)
let mut opts = HashMap::new();
opts.insert("language".to_string(), "ar".to_string());
let json = product.to_translated_json(Some(opts));
// Result: {"id": 1, "name": "اسم المنتج", "description": "وصف المنتج", "price": 99.99}

// Get JSON with fallback (if Arabic not available, uses fallback language)
let json = product.to_translated_json(Some(opts));

// Get JSON including all translations (for admin interfaces)
let json = product.to_json_with_all_translations();
// Result includes raw translations field
}

Translation Configuration

When implementing HasTranslations manually:

#![allow(unused)]
fn main() {
impl HasTranslations for Product {
    fn translatable_fields() -> Vec<&'static str> {
        vec!["name", "description"]
    }
    
    fn allowed_languages() -> Vec<String> {
        vec!["en".to_string(), "ar".to_string(), "fr".to_string(), "de".to_string()]
    }
    
    fn fallback_language() -> String {
        "en".to_string()
    }
    
    fn get_translations_data(&self) -> Result<TranslationsData, TranslationError> {
        match &self.translations {
            Some(json) => Ok(TranslationsData::from_json(json)),
            None => Ok(TranslationsData::new()),
        }
    }
    
    fn set_translations_data(&mut self, data: TranslationsData) -> Result<(), TranslationError> {
        self.translations = Some(data.to_json());
        Ok(())
    }
    
    fn get_default_value(&self, field: &str) -> Result<serde_json::Value, TranslationError> {
        match field {
            "name" => Ok(serde_json::json!(self.name)),
            "description" => Ok(serde_json::json!(self.description)),
            _ => Err(TranslationError::InvalidField(format!("Unknown field: {}", field))),
        }
    }
}
}

JSON Storage Format

Translations are stored in JSONB with this structure:

{
  "name": {
    "en": "Wireless Headphones",
    "ar": "سماعات لاسلكية",
    "fr": "Écouteurs sans fil"
  },
  "description": {
    "en": "High-quality wireless headphones",
    "ar": "سماعات لاسلكية عالية الجودة",
    "fr": "Écouteurs sans fil de haute qualité"
  }
}

Combining Attachments and Translations

Models can use both features together:

#![allow(unused)]
fn main() {
#[tideorm::model(table = "products")]
#[tideorm(translatable = "name,description")]
#[tideorm(has_one_file = "thumbnail")]
#[tideorm(has_many_files = "images")]
pub struct Product {
    #[tideorm(primary_key, auto_increment)]
    pub id: i64,
    pub name: String,
    pub description: String,
    pub price: f64,
    pub translations: Option<Json>,
    pub files: Option<Json>,
}

// Use both features
product.set_translation("name", "ar", "اسم المنتج")?;
product.attach("thumbnail", "uploads/thumb.jpg")?;
product.attach_many("images", vec!["img1.jpg", "img2.jpg"])?;
product.update().await?;
}

Entity Manager

The optional entity-manager feature adds an explicit persistence context for aggregate workflows.

An EntityManager owns a database handle, caches loaded models by primary key, tracks loaded aggregate-side relations for aggregate saves, and also supports managed lifecycles through persist, merge, remove, detach, and flush.

Enabling the Feature

[dependencies]
tideorm = { version = "0.9.13", features = ["postgres", "entity-manager"] }

Use the backend feature you need (postgres, mysql, or sqlite) alongside entity-manager.

Aggregate Workflow

#![allow(unused)]
fn main() {
use std::sync::Arc;

use tideorm::prelude::*;

#[tideorm::model(table = "users")]
struct User {
    #[tideorm(primary_key, auto_increment)]
    id: i64,
    name: String,

    #[tideorm(has_many = "Post", foreign_key = "user_id")]
    posts: HasMany<Post>,
}

#[tideorm::model(table = "posts")]
struct Post {
    #[tideorm(primary_key, auto_increment)]
    id: i64,
    user_id: i64,
    title: String,
}

async fn aggregate_example(db: Arc<Database>) -> tideorm::Result<()> {
    let entity_manager = EntityManager::new(db);

    let mut user = entity_manager
        .find::<User>(1)
        .await?
        .expect("user should exist");

    entity_manager.load(&mut user.posts).await?;

    user.posts
        .as_mut()
        .expect("posts should be loaded")
        .push(Post {
            id: 0,
            user_id: 0,
            title: "Draft".to_string(),
        });

    let user = entity_manager.save(&user).await?;

    assert!(user.posts.get_cached().is_some());
    Ok(())
}
}

Use this facade when you want to load a root aggregate, explicitly load its relations, mutate the graph, then persist the loaded graph back through the same context.

Managed Lifecycle Workflow

#![allow(unused)]
fn main() {
async fn managed_example(db: Arc<Database>) -> tideorm::Result<()> {
    let entity_manager = EntityManager::new(db);

    let user = entity_manager
        .find_managed::<User>(1)
        .await?
        .expect("user should exist");

    user.edit(|user| user.name = "Updated".to_string());

    entity_manager.flush().await?;

    let inserted = entity_manager.persist(User {
        id: 0,
        name: "New User".to_string(),
        posts: Default::default(),
    });
    entity_manager.flush().await?;
    assert!(inserted.get().id > 0);

    entity_manager.remove(&user);
    entity_manager.flush().await?;

    Ok(())
}
}

Managed entities expose:

  • managed.get() to clone the current managed value.
  • managed.edit(...) to mutate the managed value in place.
  • managed.replace(...) to replace the current managed value wholesale.
  • managed.state() to inspect whether the entity is New, Managed, Removed, or Detached.

merge(...) attaches a detached instance into the current context. detach(...) keeps the in-memory value but removes it from future flushes. clear() detaches the whole context.

Compatibility Helpers

The EntityManager facade is the recommended API, but the generated compatibility entry points remain available:

  • Model::find_in_entity_manager(pk, &entity_manager)
  • relation.load_in_entity_manager(&entity_manager)
  • save_with_entity_manager(&model, &entity_manager)

The explicit tracked-collection helper also remains available when needed:

#![allow(unused)]
fn main() {
tideorm::entity_manager::TrackedHasManyEntityManagerExt::load(&mut user.posts, &entity_manager)
    .await?;
}

When a model itself was loaded through EntityManager::find(...) or find_in_entity_manager(...), plain relation read helpers such as load(), load_with(...), count(), and exists() continue to query through that same entity-manager database handle even if no global database is configured.

Use entity_manager.load(&mut relation) or relation.load_in_entity_manager(&entity_manager) when the relation should become tracked for aggregate synchronization on save() or flush().

What Aggregate Saves Synchronize

  • Root saves use the entity manager's database handle.
  • Loaded HasOne<T>, HasMany<T>, and HasManyThrough<T, P> relations are synchronized with the saved aggregate.
  • New related models are inserted, changed related models are updated, removed HasOne<T> or HasMany<T> children are deleted by the child model's own primary key, and HasManyThrough<T, P> pivot rows are attached or detached as needed.
  • Both foreign_key and local_key are honored, so non-id relation keys work.
  • Nested loaded child graphs continue through the same context.
  • Unloaded relations remain untouched.

Primary Key Support

Entity-manager identity tracking works with the same primary-key shapes as the generated model APIs:

  • Auto-increment numeric keys, such as User::find_in_entity_manager(1, &entity_manager).
  • Natural keys, such as ApiKey::find_in_entity_manager("api-key-1".to_string(), &entity_manager).
  • Composite keys, such as Membership::find_in_entity_manager((team_id, member_id), &entity_manager).

Tracked HasOne<T> and HasMany<T> synchronization uses the related model's actual primary key for updates and deletes, so natural-key and composite-key children work the same way as numeric-key children.

Notes

  • EntityManager is explicit. It does not replace TideORM's global database APIs.
  • EntityManager::save() and save_with_entity_manager() are designed for aggregate workflows around loaded models plus loaded HasOne<T>, HasMany<T>, and HasManyThrough<T, P> relations.
  • BelongsTo<T> participates in entity-manager-aware loads and identity reuse, but aggregate saves do not cascade BelongsTo<T> updates.
  • Reuse the same EntityManager for aggregate loads, managed edits, and flushes when you want one consistent persistence context.

Migrations

Use migrations for schema changes in deployed environments. The schema builder reference below covers the column helpers TideORM provides.

Schema Builder Column Types

The migration schema builder provides convenience methods for all common column types:

Numeric Types

#![allow(unused)]
fn main() {
t.small_integer("count");              // SMALLINT
t.integer("quantity");                 // INTEGER  
t.big_integer("total");                // BIGINT
t.float("rate");                       // REAL/FLOAT
t.double("precise_rate");              // DOUBLE PRECISION
t.decimal("price");                    // DECIMAL(10,2)
t.decimal_with("amount", 16, 4);       // DECIMAL(16,4)
}

String Types

#![allow(unused)]
fn main() {
t.string("name");                      // VARCHAR(255)
t.text("description");                 // TEXT (unlimited)
}

Boolean

#![allow(unused)]
fn main() {
t.boolean("active");                   // BOOLEAN
}

Date/Time Types

#![allow(unused)]
fn main() {
t.date("birth_date");                  // DATE
t.time("start_time");                  // TIME
t.datetime("logged_at");               // DATETIME (MySQL) / TIMESTAMP (Postgres)
t.timestamp("created_at");             // TIMESTAMP (without timezone)
t.timestamptz("expires_at");           // TIMESTAMPTZ (with timezone) - PostgreSQL
}

Special Types

#![allow(unused)]
fn main() {
t.uuid("external_id");                 // UUID (Postgres) / CHAR(36) (MySQL)
t.json("metadata");                    // JSON
t.jsonb("data");                       // JSONB (PostgreSQL only)
t.binary("file_data");                 // BYTEA/BLOB
t.integer_array("tag_ids");            // INTEGER[] (PostgreSQL only)
t.text_array("tags");                  // TEXT[] (PostgreSQL only)
}

Convenience Methods

#![allow(unused)]
fn main() {
t.id();                                // BIGSERIAL PRIMARY KEY (auto-increment)
t.big_increments("id");                // Same as id()
t.increments("id");                    // INTEGER PRIMARY KEY (auto-increment)
t.foreign_id("user_id");               // BIGINT (for foreign keys)
t.timestamps();                        // created_at + updated_at (TIMESTAMPTZ)
t.timestamps_naive();                  // created_at + updated_at (TIMESTAMP, no tz)
t.soft_deletes();                      // deleted_at (nullable TIMESTAMPTZ)
}

Complete Migration Example

#![allow(unused)]
fn main() {
use tideorm::prelude::*;
use tideorm::migration::*;

struct CreateUsersTable;

#[async_trait]
impl Migration for CreateUsersTable {
    fn version(&self) -> &str { "20260115_001" }
    fn name(&self) -> &str { "create_users_table" }

    async fn up(&self, schema: &mut Schema) -> Result<()> {
        schema.create_table("users", |t| {
            t.id();                                    // id BIGSERIAL PRIMARY KEY
            t.string("email").unique().not_null();    // email VARCHAR(255) UNIQUE NOT NULL
            t.string("name").not_null();              // name VARCHAR(255) NOT NULL
            t.text("bio").nullable();                 // bio TEXT NULL
            t.boolean("active").default(true);        // active BOOLEAN DEFAULT true
            t.date("birth_date").nullable();          // birth_date DATE NULL
            t.decimal_with("balance", 12, 2)          // balance DECIMAL(12,2) DEFAULT 0.00
                .default("0.00");
            t.jsonb("preferences").nullable();        // preferences JSONB NULL
            t.timestamptz("email_verified_at")        // email_verified_at TIMESTAMPTZ NULL
                .nullable();
            t.timestamps();                           // created_at, updated_at TIMESTAMPTZ
            t.soft_deletes();                         // deleted_at TIMESTAMPTZ NULL
        }).await?;

        // Add custom index
        schema.create_index("users", "idx_users_email_active", &["email", "active"], false).await?;

        Ok(())
    }

    async fn down(&self, schema: &mut Schema) -> Result<()> {
        schema.drop_table("users").await
    }
}
}

Matching Model Definition

#![allow(unused)]
fn main() {
use chrono::{DateTime, NaiveDate, Utc};
use rust_decimal::Decimal;

#[tideorm::model(table = "users", soft_delete)]
pub struct User {
    #[tideorm(primary_key, auto_increment)]
    pub id: i64,
    pub email: String,
    pub name: String,
    pub bio: Option<String>,
    pub active: bool,
    pub birth_date: Option<NaiveDate>,
    pub balance: Decimal,
    pub preferences: Option<serde_json::Value>,
    pub email_verified_at: Option<DateTime<Utc>>,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
    pub deleted_at: Option<DateTime<Utc>>,
}
}

Schema Synchronization (Development Only)

TideORM can automatically sync your database schema with your models during development:

#![allow(unused)]
fn main() {
TideConfig::init()
    .database("postgres://localhost/mydb")
    .models_matching("src/models/*")
    .sync(true)  // Enable auto-sync (development only!)
    .connect()
    .await?;
}

models_matching(...) uses glob-style source path matching against compiled TideORM models. It is useful when your project keeps models under folders like src/models/, but it does not replace normal Rust module wiring.

Or export schema to a file:

#![allow(unused)]
fn main() {
TideConfig::init()
    .database("postgres://localhost/mydb")
    .schema_file("schema.sql")  // Generate SQL file
    .connect()
    .await?;
}

⚠️ Warning: Do NOT use sync(true) in production! Use proper migrations instead.