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 Type | PostgreSQL | MySQL | SQLite | Notes |
|---|---|---|---|---|
i8, i16 | SMALLINT | SMALLINT | INTEGER | |
i32 | INTEGER | INT | INTEGER | |
i64 | BIGINT | BIGINT | INTEGER | Recommended for primary keys |
u8, u16 | SMALLINT | SMALLINT UNSIGNED | INTEGER | |
u32 | INTEGER | INT UNSIGNED | INTEGER | |
u64 | BIGINT | BIGINT UNSIGNED | INTEGER | |
f32 | REAL | FLOAT | REAL | |
f64 | DOUBLE PRECISION | DOUBLE | REAL | |
bool | BOOLEAN | TINYINT(1) | INTEGER | |
String | TEXT | TEXT | TEXT | |
Option<T> | (nullable) | (nullable) | (nullable) | Wraps any type to make it nullable |
uuid::Uuid | UUID | CHAR(36) | TEXT | |
rust_decimal::Decimal | DECIMAL | DECIMAL(65,30) | TEXT | |
serde_json::Value | JSONB | JSON | TEXT | |
Vec<u8> | BYTEA | BLOB | BLOB | Binary data |
chrono::NaiveDate | DATE | DATE | TEXT | Date only |
chrono::NaiveTime | TIME | TIME | TEXT | Time only |
chrono::NaiveDateTime | TIMESTAMP | DATETIME | TEXT | No timezone |
chrono::DateTime<Utc> | TIMESTAMPTZ | TIMESTAMP | TEXT | With timezone |
Date and Time Types
TideORM provides proper support for all common date/time scenarios:
DateTime with Timezone (Recommended for most cases)
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
Default Behavior (Recommended)
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/loggingClone- for cloning instancesDefault- for creating default instancesSerialize- for JSON serializationDeserialize- 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.
| Attribute | Description |
|---|---|
#[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
| Attribute | Description |
|---|---|
#[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 implicitidkey.
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
| Callback | When Called |
|---|---|
before_validation | Before validation runs |
after_validation | After validation passes |
before_save | Before create or update |
after_save | After create or update |
before_create | Before inserting new record |
after_create | After inserting new record |
before_update | Before updating existing record |
after_update | After updating existing record |
before_delete | Before deleting record |
after_delete | After 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:
| Method | Description |
|---|---|
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).await | Decode 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):
| Category | Methods |
|---|---|
| WHERE | where_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 WHERE | or_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 BY | order_by, order_asc, order_desc |
| GROUP BY | group_by |
| Aggregations | sum, avg, min, max, count_distinct |
| HAVING | having_sum_gt, having_avg_gt |
| Window | partition_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 = valueand_where_not(col, val)- AND column != valueand_where_gt(col, val)- AND column > valueand_where_gte(col, val)- AND column >= valueand_where_lt(col, val)- AND column < valueand_where_lte(col, val)- AND column <= valueand_where_like(col, pattern)- AND column LIKE patternand_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 NULLand_where_not_null(col)- AND column IS NOT NULLand_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?; }
Full-Text Search
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:
| Operation | PostgreSQL | MySQL | SQLite |
|---|---|---|---|
| JSON Contains | col @> '{"key":1}' | JSON_CONTAINS(col, '{"key":1}') | json_each(col) + subquery |
| Key Exists | col ? 'key' | JSON_CONTAINS_PATH(col, 'one', '$.key') | json_extract(col, '$.key') IS NOT NULL |
| Path Exists | col @? '$.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:
| Operation | PostgreSQL | MySQL/SQLite |
|---|---|---|
| Contains | col @> ARRAY['a','b'] | JSON_CONTAINS(col, '["a","b"]') |
| Contained By | col <@ ARRAY['a','b'] | JSON_CONTAINS('["a","b"]', col) |
| Overlaps | col && ARRAY['a','b'] | JSON_OVERLAPS(col, '["a","b"]') (MySQL 8+) |
Database-Specific Optimizations
applies optimizations based on your database:
| Feature | PostgreSQL | MySQL | SQLite |
|---|---|---|---|
| Optimal Batch Size | 1000 | 1000 | 500 |
| Parameter Style | $1, $2, ... | ?, ?, ... | ?, ?, ... |
| Identifier Quoting | "column" | `column` | "column" |
| Float Casting | FLOAT8 | DOUBLE | REAL |
Feature Compatibility Matrix
| Feature | PostgreSQL | MySQL | SQLite |
|---|---|---|---|
| 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:
GlobalProfilerfor lightweight application-wide query timing and slow-query counters.Profilerfor manual profiling sessions where you want to build a detailedProfileReportyourself.
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
Modelderive such asfind,save,update,delete, anddestroy - aggregate helpers such as
count_distinct,sum,avg,min, andmax - full-text search execution paths
- internal executor paths used by batch and model helpers
GlobalStats exposes:
total_queriestotal_time()avg_query_time()slow_queriesslow_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.
Related Features
- 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, andor_clause_benchmarks - Feature-gated library benchmarks:
attachments_translations_benchmarksandfulltext_benchmarks - Library-only benchmarks:
cache_benchmarks,relations_benchmarks,stability_benchmarks,tokenization_benchmarks, andvalidation_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-runchecks 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
| Type | Attribute | Description |
|---|---|---|
HasOne<T> | has_one | One-to-one relationship (e.g., User has one Profile) |
HasMany<T> | has_many | One-to-many relationship (e.g., User has many Posts) |
BelongsTo<T> | belongs_to | Inverse relationship (e.g., Post belongs to User) |
HasManyThrough<T, P> | has_many_through | Many-to-many via pivot table |
MorphOne<T> | - | Polymorphic one-to-one |
MorphMany<T> | - | Polymorphic one-to-many |
Relation Attributes
| Attribute | Description | Required |
|---|---|---|
foreign_key | Foreign key column on related table | Yes |
local_key | Local key (defaults to primary key) | No |
owner_key | Owner key for BelongsTo | No |
pivot | Pivot table name for HasManyThrough | For through relations |
related_key | Related key on pivot table | For 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
| Type | Description | Use Case |
|---|---|---|
has_one_file | Single file attachment | Avatar, thumbnail, profile picture |
has_many_files | Multiple file attachments | Gallery 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:
| Field | Type | Description |
|---|---|---|
key | String | File path/key (e.g., "uploads/2024/01/image.jpg") |
filename | String | Extracted filename |
created_at | String | ISO 8601 timestamp when attached |
original_filename | Option<String> | Original filename if different |
size | Option<u64> | File size in bytes |
mime_type | Option<String> | MIME type |
metadata | HashMap | Custom 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 fullFileAttachmentstruct with:key- Storage key/pathfilename- Extracted filenamecreated_at- Creation timestamporiginal_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:
- Model-specific generator - If the model overrides
file_url_generator() - Global custom generator - If set via
TideConfig::file_url_generator() - Global base URL - If set via
TideConfig::file_base_url() - 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 isNew,Managed,Removed, orDetached.
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>, andHasManyThrough<T, P>relations are synchronized with the saved aggregate. - New related models are inserted, changed related models are updated, removed
HasOne<T>orHasMany<T>children are deleted by the child model's own primary key, andHasManyThrough<T, P>pivot rows are attached or detached as needed. - Both
foreign_keyandlocal_keyare honored, so non-idrelation 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
EntityManageris explicit. It does not replace TideORM's global database APIs.EntityManager::save()andsave_with_entity_manager()are designed for aggregate workflows around loaded models plus loadedHasOne<T>,HasMany<T>, andHasManyThrough<T, P>relations.BelongsTo<T>participates in entity-manager-aware loads and identity reuse, but aggregate saves do not cascadeBelongsTo<T>updates.- Reuse the same
EntityManagerfor 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.