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?;
}

The driver has one timeout for handing out a connection, opening a new one when the pool has room, so connect_timeout and acquire_timeout set the same limit, and the longer of the two applies.

Database Types

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

Resetting Global State

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

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

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

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

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


Data Type Mappings

Rust to SQL Type Reference

Rust TypePostgreSQLMySQLSQLiteNotes
i8—SMALLINTINTEGERThe PostgreSQL driver has no binding for i8; use i16 there
i16SMALLINTSMALLINTINTEGER
i32INTEGERINTINTEGER
i64BIGINTBIGINTINTEGERRecommended for primary keys
u8—TINYINT UNSIGNEDINTEGERNot supported on PostgreSQL
u16—SMALLINT UNSIGNEDINTEGERNot supported on PostgreSQL
u32INTEGERINT UNSIGNEDINTEGERPostgreSQL stores only up to i32::MAX
u64—BIGINT UNSIGNED—MySQL only
f32REALFLOATREAL
f64DOUBLE PRECISIONDOUBLEREAL
boolBOOLEANTINYINT(1)INTEGER
StringTEXTLONGTEXTTEXTSchema sync creates a MySQL key or indexed column as VARCHAR(255), because MySQL cannot index TEXT without a prefix length
Option<T>(nullable)(nullable)(nullable)Wraps any type to make it nullable
uuid::UuidUUIDBINARY(16)TEXTMySQL binds a UUID as 16 raw bytes, so the column must be BINARY(16)
rust_decimal::DecimalDECIMALDECIMAL(65,30)REALSQLite reads decimals back through f64, so REAL is forced; store a string or minor units if you need exactness
serde_json::ValueJSONBJSONTEXT
Vec<u8>BYTEALONGBLOBBLOBBinary data
chrono::NaiveDateDATEDATETEXTDate only
chrono::NaiveTimeTIMETIME(6)TEXTTime only
chrono::NaiveDateTimeTIMESTAMPDATETIME(6)TEXTNo timezone
chrono::DateTime<Utc>TIMESTAMPTZDATETIME(6)TEXTWith timezone

On PostgreSQL a field can also be an array: Vec<i32>, Vec<i64>, Vec<f64>, Vec<bool>, Vec<String> or Vec<serde_json::Value>. A field of any other type is a compile error on that field: store an enum as a String or an integer, and mark a field that is not a column #[tideorm(skip)].

On MySQL and MariaDB every timestamp is a DATETIME(6): MySQL's TIMESTAMP only spans 1970–2038 and a column without fractional digits drops microseconds. The connection runs in UTC, so a DateTime<Utc> is stored as its UTC wall time. Text and binary columns are LONGTEXT/LONGBLOB because TEXT and BLOB stop at 64 KB, and tables are created DEFAULT CHARSET=utf8mb4 whatever the database default is, so any Rust String fits.

Date and Time Types

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

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

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

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

In migrations, use timestamptz():

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

DateTime without Timezone

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

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

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

In migrations, use timestamp():

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

Date Only

Use chrono::NaiveDate for date-only fields:

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

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

In migrations, use date():

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

Time Only

Use chrono::NaiveTime for time-only fields:

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

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

In migrations, use time():

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

Examples

See the TideORM Examples repository for complete working examples.


Testing

Use the smallest command that covers your change.

# Fast library validation
cargo test --lib

# Broad compatibility pass
cargo test --all-features

# Default backend suite
cargo test --features postgres

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

# PostgreSQL integration suite
cargo test --test postgres_integration_tests

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

# MariaDB integration suite
cargo test --test mariadb_integration_tests --features mysql

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

# Rebuild the book after documentation changes
mdbook build

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

Models

Model Definition

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

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

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

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

You can derive any of these yourself, and TideORM then skips its own, as long as your derive is a separate #[derive(...)] attribute below #[tideorm::model(...)] (or below #[derive(Model)]). A derive above it, or in the same list as Model (#[derive(Model, Debug)]), is never shown to TideORM, and the two implementations fail with "conflicting implementations" (E0119): move it below, or opt out of TideORM's with the skip_* attributes.

#![allow(unused)]
fn main() {
#[tideorm::model(table = "users")]
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct User {
    #[tideorm(primary_key, auto_increment)]
    pub id: i64,
    pub display_name: String, // serialized as "displayName"
}
}

Your derive then decides the JSON: to_json() and hidden attributes follow its renames, but relation fields serialize as null until loaded, and a body that leaves out a relation or a managed timestamp only deserializes if the field is #[serde(default)].

Reserved Attribute Names

params is reserved.

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 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 inline in #[tideorm::model(...)], or in a #[tideorm(...)] attribute on a #[derive(Model)] struct — not both on one struct.

AttributeDescription
#[tideorm(table = "name")]Custom table name
#[tideorm(schema = "name")]The schema the table lives in — a database, on MySQL. Every statement names the table as schema.table, and joins accept "schema.table" too
#[tideorm(soft_delete)]Soft deletes through a deleted_at column (see Soft Deletes)
#[tideorm(deleted_at_column = "name")]The soft-delete column, when it is not deleted_at
#[tideorm(timestamps)]Mark the model as timestamped; created_at/updated_at fields are managed either way
#[tideorm(hidden = "field_a,field_b")]Leave these fields out of to_json(): fields, columns (which hide their field) or attachment relations; any other name is a compile error. Without it the soft-delete field is hidden. Plain serde serialization and query().get_json() still include them
#[tideorm(tokenize)]Enable record tokenization
#[tideorm(translatable = "..", languages = "..", fallback_language = "..")]Translated fields; requires the translations feature and a manual HasTranslations impl (see Relations)
#[tideorm(has_one_files = "..", has_many_files = "..")]File attachment slots; requires the attachments feature and a manual HasAttachments impl
#[tideorm(searchable = "..")]Columns for full-text search
#[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
#[tideorm(encrypted = "field_a,field_b")]Encrypt selected persisted string columns on write and decrypt them on load. Requires the encrypted-fields feature.
#[index("col")]Create an index
#[unique_index("col")]Create a unique index
#[index(name = "idx", columns = "a,b")]Named composite index
#[index] / #[unique_index] on a fieldIndex that field's column

Field-Level Attributes

AttributeDescription
#[tideorm(primary_key)]Mark as primary key
#[tideorm(auto_increment)]Auto-increment field for a single-column primary key
#[tideorm(nullable)]Optional/nullable field
#[tideorm(column = "name")]Custom column name
#[tideorm(default = "value")]The column's DEFAULT in the tables schema sync creates. Inserts send every field's value, so a model built with ..Default::default() stores the field's Rust default, not this one
#[tideorm(skip)]Not a column: never read or written, so it holds its Default on every model a query or write returns, and deserializing may leave it out

A Uuid primary key still at the nil UUID when the row is inserted gets a random (v4) key, whether through save(), create(), insert_all() or an upsert. Set the key yourself, in before_create for instance, to use another UUID version.


Encrypted Fields

Enable the encrypted-fields Cargo feature before using encrypted = "...".

[dependencies]
tideorm = { version = "0.14.1", features = ["postgres", "encrypted-fields"] }

Use encrypted = "..." on the model when specific persisted string columns should be stored encrypted in the database but remain plain strings in your Rust model.

#![allow(unused)]
fn main() {
#[tideorm::model(
    table = "customers",
    encrypted = "customer_phone_number, backup_phone"
)]
pub struct Customer {
    #[tideorm(primary_key, auto_increment)]
    pub id: i64,
    pub name: String,
    #[tideorm(column = "customer_phone_number")]
    pub phone_number: String,
    pub backup_phone: Option<String>,
}
}

Encrypted field behavior:

  • TideORM encrypts these fields before create(), save(), update(), insert_all(), nested saves, and batch update_all().set(...) writes.
  • TideORM decrypts them automatically on find(), query-builder reads, eager loads, and raw model hydration.
  • This feature is separate from #[tideorm(tokenize)]. It uses the same configured encryption key, but it protects regular columns rather than primary-key tokens.
  • Each encrypted field uses a scoped key derived from the configured encryption secret plus the model table and column name, so ciphertext from one attribute does not decrypt under another attribute's context.
  • Encrypted columns must contain TideORM encrypted payloads or NULL. Plaintext legacy rows and older global-scope payloads are rejected on load; migrate that data explicitly before enabling the feature.
  • Query predicates are not rewritten yet. Filters such as where_eq("customer_phone_number", "...") still compare against the stored database value, so plaintext lookups on encrypted columns are not currently transparent.
  • Configure the encryption key once during startup with TideConfig::init().encryption_key("...") or TokenConfig::set_encryption_key("...").
  • Supported encrypted field types are String, Text, Option<String>, and Option<Text>. A batch set() of an encrypted field takes a string or None.
  • pluck, value, get_json and get_as return an encrypted column decrypted when they read it under its own name.
  • A primary key cannot be encrypted: every write stores a new ciphertext, which no lookup by the key could match, so #[tideorm(encrypted = "..")] on one is a compile error.

Composite Primary Keys

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

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

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

Composite primary key notes:

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

CRUD Operations

Create

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

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

Read

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

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

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

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

Update

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

Dirty Tracking

Requires the dirty-tracking feature.

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

Both return an outer Option that distinguishes "no baseline is known" (None) from "a baseline exists and nothing changed" (Some(vec![])). Treat None as unknown rather than unchanged — otherwise a model with no snapshot silently skips its write.

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

assert_eq!(user.changed_fields()?, Some(vec!["name"]));

// The inner `Option` is the column's own value, which may itself be NULL.
assert_eq!(
    user.original_value("name")?,
    Some(Some(serde_json::json!("John Doe")))
);
}

The usual gate reads:

#![allow(unused)]
fn main() {
match user.changed_fields()? {
    // Nothing to compare against: save rather than guess.
    None => user.save().await?,
    Some(changed) if !changed.is_empty() => user.save().await?,
    Some(_) => user,
};
}

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. A snapshot taken inside a transaction that then rolls back is withdrawn, and the one before it comes back, unless the row got a newer snapshot from outside the transaction in the meantime. 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.

Each database keeps its own snapshots, since two databases can hold different rows under one key (query_with(db), find_with(id, db)). A model does not record which database it came from, so when TideORM has read different rows for a key from two databases, both methods return None for that key rather than compare the model with the wrong row, and they keep doing so after one database's snapshot is forgotten, until the row is read again.

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?;
}

On a soft-delete model delete(), destroy() and query().delete() mark the row deleted instead of removing it; force_delete() removes it for good.


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.

Sync also creates the indexes a model declares with #[index] and #[unique_index]. On an existing table it adds the ones that are missing, and a failure there is logged as a warning rather than stopping startup.

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.

force_sync(true) goes further: on every connect it drops and recreates the table of each registered model, deleting all of its rows. It is for throwaway development and test databases only.


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?;
}

The scope applies everywhere: find(id), exists(id), every query terminal, update_all(), relation loads and full-text search leave trashed rows out. To reach them:

  • Post::query().with_trashed().find(id) reads a trashed row by key; reload() refreshes the record in hand even when it is trashed.
  • Post::update_all().with_trashed() updates the trash too; Post::query().only_trashed().restore() restores it.

Deleting marks: delete(), destroy(id), query().delete() and delete_all() set deleted_at instead of removing the row. force_delete() — on a model, or on a query such as only_trashed().force_delete(), which empties the trash — removes rows for good. Deleting only_trashed() rows with delete() is an error, since they are deleted already.

Soft Delete Operations

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

// Soft delete (sets deleted_at to now). This is `delete()`: the delete
// callbacks run, and a row already deleted keeps its stamp.
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?;
}

A model takes one #[tideorm::scopes] block, which generates its UserQueryScopes trait; put every scope in it. A scope's parameters after the query can be mut and can name the model as Self.

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 _;
}

Plain functions over a QueryBuilder compose too, for ad hoc fragments not tied to one model type: call them with the query.

#![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 them
let users = recent(active(User::query())).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| Box::pin(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| Box::pin(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.

Concurrent Updates

A transaction does not stop two requests from reading the same row at the same time. update() writes every column of the model it was given, so in a read-check-write the last writer wins and silently undoes the other: two orders of 3 against a stock of 4 both pass the check, and both ship.

Lock the rows you are about to change with lock_for_update() (SELECT ... FOR UPDATE). A second transaction that locks the same row waits until the first commits, then reads what it wrote:

#![allow(unused)]
fn main() {
let shipped = Item::transaction(|_tx| Box::pin(async move {
    let mut item = Item::query()
        .where_eq("id", id)
        .lock_for_update()
        .first_or_fail()
        .await?;
    if item.stock < quantity {
        return Ok(false);
    }
    item.stock -= quantity;
    item.update().await?;
    Ok(true)
})).await?;
}

For a plain counter, a single conditional statement needs no lock: Item::update_all().decrement("stock", quantity).where_eq("id", id).where_gte("stock", quantity).execute() returns 0 when the stock ran out.

SQLite has no row locks, so lock_for_update() adds nothing there: the first write of a transaction locks the whole database, and the second of two competing transactions fails with a retryable LockNotAvailable error instead.


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 mut 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
}

A field named — or mapped with column = ".." to — created_at or updated_at is managed when it holds a DateTime<Utc> or a NaiveDateTime (set to the current UTC time, matching timestamps_naive() columns), optional or not. The value you set on such a field is replaced; a field of any other type keeps it.

Every write keeps the creation time: update() never writes created_at, and an upsert that finds the row already there refreshes updated_at but leaves created_at alone unless you name it in update_columns. A request body can leave both fields out, so a model deserialized from {"email": ".."} saves with fresh timestamps, and a body that sends created_at cannot rewrite it. update_all() writes only what it is told to, so set updated_at there yourself if you need it.


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("email", "Cannot delete admin account"));
        }
        Ok(())
    }
}
}

Available Callbacks

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

An Err from a before_* hook stops the operation before anything is written. An after_* hook runs once the statement has succeeded: its Err is returned from save(), update() or delete(), but the row is already written. Inside Database::transaction, propagating that error with ? rolls the write back with the rest of the transaction; outside one, it stays. Callbacks run for single-model writes only — insert_all(), update_all(), upserts and query().delete() skip them.


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?;
// Every model is validated before any is written; callbacks do not run.

// 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?;

// Or update the rows a query already selects, scopes included
let affected = User::query()
    .inactive()
    .update_all()
    .set("status", "dormant")
    .execute()
    .await?;
}

Like a query, update_all() leaves soft-deleted rows out; .with_trashed() reaches them. query().update_all() keeps the query's own scope, filters and the database query_with names. A query that joins, groups, pages or unions cannot become an UPDATE, and running one fails.


Model Validation

Declare rules on fields with #[validate(..)]. create(), update(), save(), insert_all() and upserts check every rule before anything reaches the database and fail with a validation error — for insert_all(), naming the failing model's position in the batch. Call validate() yourself to collect all failures at once:

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

#[tideorm::model(table = "users")]
pub struct User {
    #[tideorm(primary_key, auto_increment)]
    pub id: i64,
    #[validate(required, email)]
    pub email: String,
    #[validate(min_length = 3, max_length = 20, alphanumeric)]
    pub username: String,
    #[validate(range(18, 120))]
    pub age: i32,
}

let user = User {
    email: "not-an-email".into(),
    username: "x".into(),
    age: 12,
    ..Default::default()
};

if let Err(errors) = user.validate() {
    for (field, message) in errors.errors() {
        println!("{}: {}", field, message);
    }
}
}

Supported rules: required, email, url, alpha, alphanumeric, numeric, uuid, min_length = n, max_length = n, length = n, min = n, max = n, range(min, max) (or range(min..=max), or range = "min..max", all including max), and regex = "pattern". The string rules apply to String and Text fields, min, max and range to numbers, Decimal and numeric strings, and required to an Option of any common type (a Uuid, a date, a bool, JSON, a Vec). Lengths count characters, not bytes. min, max and range reject NaN, compare an f32 at its own precision (so min = 0.7 takes 0.7_f32), and take finite bounds only; numeric refuses NaN, inf and a value past f64. Errors come out in the order the struct declares its fields. A regex pattern that does not compile fails validation for every value, with an error naming the pattern.

Rules on a #[tideorm(skip)] field run too, for a value that is checked but never stored, such as a password confirmation. A skip field holds its Default on every model a query returns, so make it an Option: None passes every rule except required. #[validate] on a relation field is a compile error; the rules belong on the related model's fields.

Applying Rules by Hand

Validator::validate_rule checks one value against one ValidationRule and returns the failure message, and ValidationBuilder::new(field) collects the rules for one field. ValidationRule also has In and NotIn for allow/deny lists:

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

let email = "user@example.com".to_string();
assert!(Validator::validate_rule(&email, &ValidationRule::Email, "email").is_none());

let (field, rules) = ValidationBuilder::new("username")
    .required()
    .min_length(3)
    .max_length(20)
    .alphanumeric()
    .build();

let username = "ab".to_string();
for rule in &rules {
    if let Some(message) = Validator::validate_rule(&username, rule, &field) {
        println!("{}", message);
    }
}
}

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.is_empty() {
    // 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.to_token()?;  // "iIBmdKYhJh4_vSKFlBTP..."

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

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

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

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

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

Tokenization Methods

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

MethodDescription
user.to_token()Convert record to token; the default encoder uses a new random nonce each call, so every token differs
User::tokenize_id(42)Tokenize an ID without having the record
User::decode_token(&token)Decode token to the model's primary key type
User::from_token(&token).awaitDecode token and fetch record from DB

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::decode_token(&product_token).is_err());  // Error!
}

Using Tokens in APIs

Tokens are URL-safe and perfect for REST APIs. The model itself still carries its primary key, so returning it as it is (Json(user), or to_json() without hiding the key) puts the raw id back into the response. Hide the key and send the token in its place:

#![allow(unused)]
fn main() {
#[tideorm::model(table = "users", tokenize, hidden = "id")]
pub struct User {
    #[tideorm(primary_key, auto_increment)]
    pub id: i64,
    pub email: String,
}

// In your API handler
async fn get_user(token: String) -> tideorm::Result<Json<serde_json::Value>> {
    let user = User::from_token(&token).await?;
    let mut body = user.to_json(None);          // `hidden` drops the raw id
    body["token"] = serde_json::json!(user.to_token()?);
    Ok(Json(body))
}

// 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. The name is the struct's, so two tokenized models of one struct name in different modules accept each other's tokens; name them apart
  • 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, ["admin", "mod"])     // IN, from any list
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, read as the type you ask for:
let total: Decimal = Order::query().sum(Order::columns.amount).await?;
let average: Option<f64> = Product::query().avg(Product::columns.price).await?;
let max_age: Option<i32> = User::query().max(User::columns.age).await?;

// OR conditions with typed columns. All or_where_* calls are combined into one
// OR group, ANDed with the rest of the query:
User::query()
    .or_where_eq(User::columns.role, "admin")
    .or_where_eq(User::columns.role, "moderator")
    .get()
    .await?;
}

Why Use Typed Columns?

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

Manual Column Definitions (Advanced)

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

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

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

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

Typed Column Support Summary:

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

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

Self-Referencing Relations

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

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

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

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

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

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

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

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

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

SelfRef and SelfRefMany fields are wired automatically when you provide the self-referencing foreign_key. local_key defaults to the primary key, whatever its column is called, 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; a new one is inserted and a
// stored one updated, as `save()` does

// 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, saved) = NestedSaveBuilder::new(user)
    .with_one(profile, "user_id")
    .with_many(posts, "user_id")
    .with_many(comments, "author_id")
    .save()
    .await?;
let profile: Profile = saved[0].clone().into_one()?;
let posts: Vec<Post> = saved[1].clone().into_many()?;
}

save() returns one SavedRelation per with_one/with_many, in the order they were added, holding the children as they were stored.

Each nested operation runs in one transaction — a savepoint when you are already inside one — so a child that fails also rolls back the parent's write. Related rows are written one at a time through each model's own create, update, or delete, so their callbacks and validation run as usual. The foreign key may be given as the Rust field name or the database column name.

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

JoinResultConsolidator turns flat pairs — such as rows of a join read into (Order, LineItem) tuples — into nested structures:

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

// Flat pairs: Vec<(Order, LineItem)>
let flat: Vec<(Order, LineItem)> = orders_with_items;
// [(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_with_linked left-joins another table and selects columns from both; select_also_linked selects every model column plus the linked ones. Read the rows into a struct of your own with get_as(), whose fields are the column names:

#![allow(unused)]
fn main() {
#[derive(serde::Deserialize)]
struct UserBio {
    id: i64,
    name: String,
    bio: Option<String>,
}

// users.id = profiles.user_id
let rows: Vec<UserBio> = User::query()
    .select_with_linked(vec!["id", "name"], "profiles", "id", "user_id", vec!["bio"])
    .get_as()
    .await?;

// Every user column, plus the profile's bio
let rows: Vec<serde_json::Value> = User::query()
    .select_also_linked("profiles", "id", "user_id", vec!["bio"])
    .get_json()
    .await?;
}

A linked column that shares a name with a selected one must be aliased: two outputs cannot share a name.

Additional Advanced Features

#![allow(unused)]
fn main() {
// where_has() - the related model's own query: its scope, types and any filter
let authors = User::query()
    .where_has::<Post>(Post::columns.user_id, User::columns.id, |posts| {
        posts.where_eq(Post::columns.published, true)
    })
    .get().await?;

// where_doesnt_have() - no related row passes the closure
let idle = User::query()
    .where_doesnt_have::<Post>(Post::columns.user_id, User::columns.id, |posts| posts)
    .get().await?;

// has_related() - EXISTS subqueries over a table, soft-deleted rows included
let cakes = Cake::query()
    .has_related("fruits", "cake_id", "id", "name", "Mango")
    .get().await?;

// has_no_related() - no related row matches; a cake with no fruits at all passes too
let cakes = Cake::query()
    .has_no_related("fruits", "cake_id", "id", "name", "Mango")
    .get().await?;

// where_exists() with a model query applies that model's soft-delete scope
let cakes = Cake::query()
    .where_exists(Fruit::query().where_raw("fruits.cake_id = cakes.id"))
    .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", ["admin", "moderator"])
User::query().where_in(User::columns.role, vec!["admin", "moderator"])
User::query().where_in("id", &user_ids)   // any list: a Vec, an array, a slice, a set
User::query().where_not_in("status", ["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)
User::query().where_not_between("age", 18, 65)

// Two columns of the same row, or of a joined table
Order::query().where_column_gt("shipped_at", "ordered_at")
Post::query().inner_join("users", "posts.user_id", "users.id")
    .where_column_lt("posts.created_at", "users.banned_at")

// Only when a request asks for it
User::query()
    .when(params.only_active, |q| q.where_eq("active", true))
    .when_some(params.role, |q, role| q.where_eq("role", role))
}

Every filter — the JSON and array filters included — has or_where_* and and_where_* forms, and works the same on a query, inside an OR group and on a batch update; when/when_some work on each of them too.

A typed column also builds a condition of its own, which where_col applies; the value must have the column's type, so a mismatch is a compile error:

#![allow(unused)]
fn main() {
User::query().where_col(User::columns.age.gte(18))
User::query().where_col(User::columns.role.is_in(["admin", "moderator"]))
User::query().where_col(User::columns.email.ends_with("@example.com"))
User::query().where_col(User::columns.deleted_at.is_null())
}

A typed column names its model's table, so in another model's query it means that model's column: Post::query().inner_join("users", ..).where_eq(User::columns.active, true) filters users.active.

Values are anything serde::Serialize, and they are bound as the column's own type, so pass the native value: a Uuid, a chrono date or timestamp, or a Decimal compares correctly on every backend (where_eq("id", user_id), where_gt("created_at", since)). A few rules worth knowing:

  • NULL. where_eq(col, None::<T>) is IS NULL. where_not and where_not_in follow SQL and never match a NULL column. A None inside where_in also matches NULL rows, and one inside where_not_in keeps the non-NULL rows outside the list.
  • NaN and infinity. A filter value holding a NaN or infinite float fails the query, as does set() or having() given one: JSON has no such number, and the null it would become means SQL NULL. Check a float parsed from a request with is_finite().
  • Columns are identifiers. The column argument is a column, table.column, or a typed column; anything else is rejected when the query runs. SQL expressions go through where_raw(), whose SQL you vouch for — never pass user input to it. When the expression needs a value, write a ? for it and pass the value to where_raw_with(), which binds it: where_raw_with("LOWER(email) = LOWER(?)", vec![email.into()]). The ? works on every backend, and or_where_raw_with/and_where_raw_with join an OR group the same way.
  • Long lists. A where_in/where_not_in list of more than 1,000 integers is rendered inline instead of bound, so an id list of any length works. Other values are bound one parameter each, which caps them at the backend's limit: 32,766 on SQLite, 65,535 on PostgreSQL and MySQL.
  • LIKE and case. where_contains/where_starts_with/where_ends_with escape % and _, so they are safe for user input. Whether they are case-sensitive is the backend's: PostgreSQL is, MySQL (with its default collations) and SQLite (for ASCII) are not.
  • Text equality follows the collation. MySQL's default collation ignores case and accents, so there where_eq("email", "ada@example.com") finds Ada@Example.com, "cafe" finds café, and a unique index refuses both spellings; MariaDB's default collations also ignore trailing spaces, so "a " finds "a". PostgreSQL and SQLite compare text exactly. Normalize values you look up, such as lowercasing emails before saving, to get the same answer everywhere.

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

All or_where_* calls on one query are combined into one OR group, ANDed with the rest of the query: .where_eq("active", true).or_where_eq("role", "admin").or_where_eq("role", "moderator") matches active = true AND (role = 'admin' OR role = 'moderator'). Use or_where(|group| ..) or begin_or() / end_or() for further, separate OR groups.

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

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

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

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

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

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

Fluent OR API (begin_or / end_or)

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

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

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

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

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

AND Methods within OR Groups

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

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

Available and_where_* methods:

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

Real-World Examples

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

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

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

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

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

Ordering

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

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

Take a request's sort string with sort(): terms separated by commas, each a column with an optional direction — name, name desc, or -created_at for descending. Every column is checked as order_by checks one, so the string cannot smuggle an expression in:

#![allow(unused)]
fn main() {
// ?sort=-created_at,name
let posts = Post::query().sort(&params.sort).get().await?;
}

order_by takes one direction: order_by("name desc", Order::Asc) names two and fails the query. reorder(column, direction) replaces every order the query has so far, such as one a scope added.

A model named Order shadows the prelude's Order enum; write SortOrder::Desc instead, the same enum under a second name.

Where NULLs sort is the backend's: PostgreSQL puts them last in ascending order and first in descending order, MySQL and SQLite the other way round. Sort on IS NULL first to get one order everywhere:

#![allow(unused)]
fn main() {
User::query()
    .order_by_raw("last_login_at IS NULL", Order::Asc) // NULLs last on every backend
    .order_asc(User::columns.last_login_at)
    .get()
    .await?;
}

Pagination

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

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

Give a paged query a unique order, such as one ending in the primary key. Without one, PostgreSQL returns rows in storage order, which an UPDATE changes, so consecutive pages can repeat one row and skip another. Model::paginate(page, per_page) orders by the primary key for you.

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. On a join that repeats a key, the rows that share it are handed over in one batch, so only a key repeated more often than the chunk size fails.

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)

// Rows as JSON, for projections that are not a whole model
let rows = User::query()
    .select(vec!["id", "email"])
    .get_json()
    .await?;  // Vec<serde_json::Value>

// Lock the rows read until the transaction ends (SELECT ... FOR UPDATE)
let user = User::query()
    .where_eq("id", 1)
    .lock_for_update()
    .first()
    .await?;
}

Use lock_for_update() inside a transaction before changing what you read; see Concurrent Updates.

A query reads the model's columns by name rather than SELECT *, so a column added to the table while the application runs does not disturb it.

get() builds complete models, so it refuses a select() that leaves model columns out: the missing ones would read as None or a default, and saving such a model would write those back over the stored values. Read a partial row with get_json().

get_json() returns each model column exactly as the model's own JSON has it, on every backend — SQLite stores booleans as integers and JSON and dates as text, and the model's types are what put them back. Other columns (aliases, aggregates, window values) are decoded by the type the database declares: text comes back verbatim, binary as an array of bytes, NUMERIC/DECIMAL as a decimal string. SQLite declares no type for an expression, so it is read by what SQLite stores: a float sum or a running total is a number. Database::raw_json has no model, so it reports what the database stores: on SQLite a boolean is 0/1 and a JSON column is its text, as it is on MariaDB, which declares JSON columns as text, and a MySQL DATETIME has no offset.

Joins

Name both sides of a join as table.column (or alias.column); a bare column name invalidates the query, and the error surfaces when it runs. Everywhere else a bare name of one of the model's columns means the model's table — filters, select(), ordering and aggregates are written table.column once the query joins — so reach a joined table's column as table.column. Two selected columns may not share an output name, since a row can hold only one of them: alias one (tags.id AS tag_id).

#![allow(unused)]
fn main() {
let posts = Post::query()
    .inner_join("users", "posts.user_id", "users.id")
    .where_eq("users.active", true)
    .get()
    .await?;
}

left_join and right_join keep the unmatched rows of their outer side, and inner_join_as, left_join_as and right_join_as give the joined table an alias, which is how a table joins itself or the same table twice. A right join's unmatched rows have NULL in every model column, which get() cannot turn into models, so read one with get_json():

#![allow(unused)]
fn main() {
// Every user, with or without posts
let rows = Post::query()
    .right_join("users", "posts.user_id", "users.id")
    .select_raw("users.name AS author")
    .select_raw("posts.title AS title")
    .get_json()
    .await?;

// Each employee with their manager, if any
let rows = Employee::query()
    .left_join_as("employees", "manager", "employees.manager_id", "manager.id")
    .select_raw("employees.name AS name")
    .select_raw("manager.name AS manager")
    .get_json()
    .await?;
}

Aggregates

sum, avg, min, max and count_distinct each run one statement, and each reads its result as the type you ask for. sum returns that type; avg, min and max return an Option, which is None over no rows. A sum is never read through a float, so an integer or Decimal total stays exact, and min/max work on any column — a number, a text, a date:

#![allow(unused)]
fn main() {
let revenue: Decimal = Order::query().where_eq("paid", true).sum("total").await?;
let average: Option<f64> = Product::query().avg(Product::columns.price).await?;
let first_signup: Option<DateTime<Utc>> = User::query().min("created_at").await?;
let oldest = User::query().max::<i32>("age").await?;
}

aggregates() computes several over the same rows in one statement and returns them as the tuple you ask for, typed the same way:

#![allow(unused)]
fn main() {
let (orders, revenue, largest): (u64, Decimal, Option<Decimal>) = Sale::query()
    .where_eq("region", "EU")
    .aggregates(&[Aggregate::count(), Aggregate::sum("amount"), Aggregate::max("amount")])
    .await?;
}

count() ignores limit() and offset(), so a paged query counts its total across every page; the aggregates, Aggregate::count() included, work on the rows the limit and offset leave.

Grouping

group_by groups the rows, and having keeps the groups whose aggregate passes a comparison, with the value bound; several having calls must all hold. A raw condition works too, as trusted SQL:

#![allow(unused)]
fn main() {
let busy_regions: Vec<RegionTotal> = Sale::query()
    .select_raw("region, SUM(revenue) AS revenue")
    .group_by("region")
    .having(Aggregate::sum("revenue").gt(5_000))
    .having(Aggregate::count().gte(3))
    .get_as()
    .await?;

Sale::query().group_by("rep").having("COUNT(DISTINCT region) > 1");
}

Aggregate offers gt, gte, lt, lte, eq and ne.

where_has::<R> keeps the rows with a related R row that passes a closure over R's own query, so R's soft-delete scope applies and any filter works; where_doesnt_have::<R> keeps the rows with none. The first key is R's column that holds the second, this model's:

#![allow(unused)]
fn main() {
// Users with a published post
User::query().where_has::<Post>(Post::columns.user_id, User::columns.id, |posts| {
    posts.where_eq(Post::columns.published, true)
});

// Posts whose author is active: the key sits on this side
Post::query().where_has::<User>(User::columns.id, Post::columns.user_id, |users| {
    users.where_eq(User::columns.active, true)
});
}

A model related to itself works the same way: Category::query().where_has::<Category>("parent_id", "id", |children| children.where_eq("active", true)) keeps the categories with an active child. The related rows are read under an alias of their own, so a nested where_has, a typed column or a qualified "categories.active" inside the closure names the child, not the outer row.

find(id) on a query looks a key up among the rows the query matches: Post::query().where_eq("author_id", me).find(post_id). A union is refused, since the key would filter its first query only.

Columns, Values, Your Own Types and Pages

pluck reads one column of every row, value the column of the first row, and get_as each row as a type of your own whose fields are the columns or their aliases — the shape of a join or a grouped select_raw() that no model has. On a union() the column is read off the union's rows, and an encrypted column comes back decrypted, as get() returns it:

#![allow(unused)]
fn main() {
let emails: Vec<String> = User::query().where_eq("active", true).pluck("email").await?;
let newest: Option<String> = Post::query().latest().value("title").await?;

#[derive(Deserialize)]
struct AuthorPosts { author: String, posts: i64 }

let rows: Vec<AuthorPosts> = Post::query()
    .inner_join("users", "posts.user_id", "users.id")
    .select_raw("users.name AS author, COUNT(*) AS posts")
    .group_by("users.name")
    .get_as()
    .await?;
}

paginate(page, per_page) returns a Paginated<M>: the page's models in items, and in total how many rows match across every page, with last_page() and has_next_page() worked out from them. It serializes as {"items": .., "total": .., "page": .., "per_page": .., "last_page": ..}, ready to return from an API:

#![allow(unused)]
fn main() {
let page = Post::query().where_eq("published", true).order_desc("id").paginate(2, 20).await?;
println!("page {} of {} ({} posts)", page.page, page.last_page(), page.total);
}

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?;
}

A union's order_by sorts the combined rows, so a column is written by its bare output name there even when a query of the union joins another table.

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_json()
    .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_json()
    .await?;

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

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

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

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

// NTILE - distribute into buckets
let products = Product::query()
    .ntile("price_quartile", 4, "price", Order::Asc)
    .get_json()
    .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_json()
    .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(CTE::with_columns(
        "daily_stats",
        vec!["sale_date", "total_sales", "order_count"],
        "SELECT DATE(created_at), SUM(amount), COUNT(*) FROM sales GROUP BY DATE(created_at)".to_string(),
    ))
    .where_raw("date IN (SELECT sale_date FROM daily_stats WHERE total_sales > 10000)")
    .get()
    .await?;

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


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

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

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

Search Basics

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

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

// Search with ranking (ordered by relevance)
let ranked = Article::search(&["title", "content"], "rust async")
    .with_ranking()
    .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").get().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?;
}

Every mode builds the backend's syntax from the words of the search text, so an operator a user types is never parsed as syntax. In Boolean mode each term is required and one written -term or -"a phrase" is left out; MySQL also reads its ~ < > * operators, and a MySQL term without + is optional. SQLite's NOT needs something to subtract from, so there a search of exclusions only matches nothing. Phrase matches the words in order, Prefix words beginning with each search word, and Proximity(n) the words within n words of each other in either order (on PostgreSQL at most 64 apart). A search with no word left to search for matches nothing on every backend.

On PostgreSQL the text search configuration (language, english by default) is written into the statement as a constant, the way FullTextIndex writes it into the index, so a search can use that index; it must therefore be a configuration name. On SQLite a search reads the columns it names from the table's FTS5 index, which FullTextIndex fills with the rows already in the table when it is created, and get_ranked() reports the negated bm25() score, so a higher rank is a better match on every backend.

A search on a soft-delete model leaves trashed rows out, as a query does; with_trashed() reads them too and only_trashed() reads nothing else:

#![allow(unused)]
fn main() {
let binned = Article::search(&["title"], "rust").only_trashed().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
    // Custom weights for ranking (title > summary > content)
    .weights(SearchWeights::new(1.0, 0.5, 0.3, 0.1));

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

stop_words, min_word_length and max_word_length leave terms out of the search text before it is sent, on every backend; the index is untouched. A search whose every term is left out matches nothing, and a SearchMode::Phrase search is sent as written.

#![allow(unused)]
fn main() {
let config = FullTextConfig::new()
    .stop_words(vec!["the".into(), "a".into()])
    .min_word_length(3);
// Searches for "rust" and "ownership" only.
let results = Article::search(&["content"], "the rust of ownership")
    .config(config)
    .get()
    .await?;
}

Text Highlighting

highlight() marks the whole-word matches in the searched columns of each get_ranked() result. The record's text is HTML-escaped around the tags, so a stored <script> reaches the page as text; set escape_html: false for tags that are not HTML:

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

let ranked = Article::search(&["title", "content"], "rust async")
    .highlight(HighlightConfig::default()) // `<mark>` tags, 10 words around the first match
    .get_ranked()
    .await?;
for field in &ranked[0].highlights {
    println!("{}: {}", field.field, field.highlighted);
}
}

The same marking is available on any text, without the escaping:

#![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 the statements for your database, then run each in order
let statements = index.to_sql(DatabaseType::Postgres);
// PostgreSQL: CREATE INDEX "idx_articles_search" ON "articles"
//             USING GIN ((to_tsvector('english', ...)))

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

let statements = index.to_sql(DatabaseType::SQLite);
// SQLite: the FTS5 virtual table, then the triggers that keep it in sync

for statement in &statements {
    Database::execute(statement).await?;
}
}

A SQLite table has one full-text index, the <table>_fts table its searches read, whatever the index is named. Applying the same index again changes nothing; one over a column that index does not hold fails with no such column instead of keeping the first index's columns, so drop <table>_fts and its triggers to index other columns. A table named with its attached database (tenant.posts) gets its index in that database.

The SQLite index follows the table's rowid, which a VACUUM keeps only for a table with an INTEGER PRIMARY KEY. After vacuuming a UUID- or text-keyed table, run index.sqlite_rebuild_sql() before anything else touches it, or searches return other rows.

MySQL's boolean-mode operators (+ - < > ( ) ~ * " @) are sanitized before a query reaches MATCH ... AGAINST: each term keeps one leading operator and a trailing *, a quoted phrase stays a phrase, and a query left with no searchable term matches nothing instead of failing with a syntax error. In natural-language mode the operators are dropped.

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

JSON, upsert, window functions, and CTEs work on every backend. The two capabilities that differ are worth checking:

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

if db_type.supports_arrays() {
    // Native array columns (PostgreSQL only); elsewhere arrays are JSON
}

if db_type.supports_returning() {
    // `BatchUpdateBuilder::execute_returning()` works (PostgreSQL and SQLite)
}
}

Database-Specific JSON Operations

TideORM translates JSON filters to each backend's syntax:

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

Generated SQL by database:

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

where_json_key_not_exists and where_json_path_not_exists negate the two tests. On every backend a member that holds JSON null exists, and a NULL column matches neither a test nor its negation.

PostgreSQL's JSON operators exist only for jsonb, so the column is cast: they work on a json column (what t.json(..) creates) too, and on a jsonb column the cast is dropped at planning, so its GIN index still applies.

SQLite has no containment operators, so TideORM rebuilds PostgreSQL's @> and <@ from json_each: an object matches key by key, an array element by element, and a bare scalar matches a top-level array that holds it. MySQL's JSON_CONTAINS lets a value match an array holding it at any depth, so on its own {"tags": "a"} would match {"tags": ["a", "b"]}; TideORM adds a type check for each path of the document you pass, so MySQL and MariaDB follow PostgreSQL there too. Below an array the paths are not known in advance, so MySQL's reading stays there: [{"k": [1, 2]}] contains [{"k": 1}] on MySQL and not on PostgreSQL.

Database-Specific Array Operations

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

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

Generated SQL:

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

Database-Specific Optimizations

applies optimizations based on your database:

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

Feature Compatibility Matrix

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

Raw SQL Queries

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

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

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

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

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

Name the columns in raw SQL rather than SELECT * when the table can change under a running application. Statements are prepared and cached per connection, and after a column is added PostgreSQL rejects a cached SELECT * (cached plan must not change result type) while SQLite can report the old column list, until the connection is replaced. The query builder is not affected: it always names the model's columns.

A raw statement that is not a plain read clears the whole query cache when it runs, since its tables cannot be known; a read is one statement that starts with SELECT, SHOW, EXPLAIN, DESCRIBE, PRAGMA, VALUES or a WITH holding no write. A SELECT that calls a function which writes is read as a read, so clear the cache after one yourself: QueryCache::global().clear().


Query Logging

Enable SQL query logging for development/debugging:

# Set environment variable
TIDE_LOG_QUERIES=true cargo run

When enabled, every statement is printed to stderr: query-builder and migration statements before they run, the rest — find, save, update, delete, upserts and raw SQL — once they complete. The variable is read once, when the first statement runs. QueryLogger records them too, except the migrator's, with the query builder's tagged by table. TIDE_LOG_LEVEL (error, warn, info, debug, trace) and TIDE_SLOW_QUERY_MS configure the structured QueryLogger the way QueryLogger::global() does in code; settings made in code take precedence.

To look at a query before it runs, print debug(): the statement with its values written in, the parameterized statement, and the clauses it was built from. A query that a terminal would refuse — an unsafe order_by taken from a request, a bad raw fragment, a zero page — reports the reason on an Invalid: line, and validate() returns the same error without running anything, so a handler can answer 400 first:

#![allow(unused)]
fn main() {
let query = Post::query().where_eq("published", true).order_by(params.sort.as_str(), Order::Asc);
println!("{}", query.debug());
query.validate()?;
}

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));
}

Classifying Database Failures

failure_kind() says what the database reported, independent of backend and message wording, and is_retryable() whether running the operation again can succeed:

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

match User::create(user).await {
    Ok(user) => { /* created */ }
    Err(e) if e.failure_kind() == DbFailureKind::UniqueViolation => { /* email taken */ }
    Err(e) if e.is_retryable() => { /* deadlock, lock timeout, dropped connection: retry */ }
    Err(e) => return Err(e),
}
}

Constraint violations (UniqueViolation, ForeignKeyViolation, NotNullViolation, CheckViolation) are never retryable, and neither is InvalidValue: a value too long for its column, out of its range, or not valid for its type. SQLite checks none of that, so a string longer than its VARCHAR(255) is stored there and refused by PostgreSQL and MySQL; #[validate(max_length = 255)] keeps the backends in step. Deadlock, SerializationFailure, LockNotAvailable (including MySQL's lock wait timeout and a busy SQLite database), StatementTimeout, ConnectionTimeout and ConnectionClosed are. A connection the server closes under a statement — a restart, a failover, MySQL's KILL — is ConnectionClosed; the pool replaces it, so the next call gets a fresh one.


Profiling

TideORM exposes two different profiling tools:

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

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

Global Profiling

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

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

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

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

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

GlobalProfiler::disable();
}

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

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

GlobalStats exposes:

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

What Global Profiling Does Not Capture

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

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

Manual Profile Reports

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

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

let mut profiler = Profiler::start();

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

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

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

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

Query Analysis

QueryAnalyzer can inspect SQL and return optimization suggestions.

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

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

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

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

  • Query logging is covered in Queries.
  • Slow-query thresholds for logs and profiler statistics are separate settings, both 100ms by default. ProfileReport::suggestions() counts slow queries against the profiler's threshold (GlobalProfiler::set_slow_threshold).
  • The global profiler is process-wide, so reset it between tests when you need deterministic assertions.

Benchmarking

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

Benchmark Groups

The current benchmark targets fall into three categories:

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

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

Prerequisites

PostgreSQL-backed benches are opt-in, like the PostgreSQL tests: they run when TEST_DATABASE_URL, POSTGRESQL_DATABASE_URL or RUN_POSTGRES_TESTS is set, and otherwise print a note and measure nothing. Point them at a server with:

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

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

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

Quick Start

Run the default benchmark set:

cargo bench

Prefer a focused target when you are investigating a bottleneck:

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

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

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

Run feature-gated benchmarks explicitly:

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

Compile all benchmark targets without executing them:

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

Comparing Bottlenecks

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

Save a baseline:

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

Run the modified code against that baseline:

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

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

Workflow Notes

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

Relations

Model Relations

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

Defining Relations

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

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

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

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

Relation Types

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

Relation Attributes

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

Loading Relations

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

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

An owner whose key is NULL (a nullable local_key that is unset) has no related rows: load() returns none, count() zero, and attach, detach and sync refuse it, rather than matching every row whose foreign key is NULL. A model may hold several relations to one model, such as an author and an editor that are both BelongsTo<User>; each loads by its own keys, eagerly too. query_with(db).with(..) reads the relations from db as well.

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.

The field's wrapper type decides the relation kind. The has_one/has_many/belongs_to/has_many_through attribute may be omitted, but when present it must name the same kind as the wrapper, and HasOne, HasMany and BelongsTo fields must declare foreign_key; either mistake is 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());
}
}

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().

Filter, order and page the query before with(); after it, query(|q| ..) reaches every query method:

#![allow(unused)]
fn main() {
let users = User::query()
    .with("posts")
    .query(|q| q.where_gt("age", 18).where_null("banned_at").order_desc("id"))
    .get()
    .await?;
}

Hidden Attributes in Eager-Loaded Payloads

to_json() serializes a cached relation inline, and each payload is filtered by the target model's hidden list, at any nesting depth. A User that declares hidden = "password_hash" keeps it out of post.to_json(None) even though the hidden list belongs to User and the call was made on Post:

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

let post = Post::query().with("author").first().await?.unwrap();
// {"id": 1, "title": "…", "author": {"id": 7, "email": "…"}}  — no password_hash
let body = post.to_json(None);
}

The owning model's hidden list is not applied to a relation payload, so a column that one model hides and another does not keeps its value inside the payload that does not hide it. Two payload kinds still fall back to the owning model's list because their type is not known statically: attachment blobs, and MorphTo relations.

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.

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?;
}

When the pivot model has soft delete, a trashed pivot row no longer links its pair: load(), load_with(), eager loading and count() leave it out, attach() restores it, and sync() deletes it with the live rows.

attach() of a pair already linked succeeds without a second row, even when two calls race. When another unique key of the pivot keeps the pair out, such as one on role_id alone, attach() and sync() return an error rather than report a link they did not make.

The pivot attribute names the pivot model's own table: the relation reads, deletes and loads its pivot rows through that model, so a relation naming another table is refused. load(), count() and eager loading return each related row once, however many pivot rows link it. load_with() joins the pivot table instead, so its closure can order by or read a pivot column, and returns a row per pivot row that links it. The pivot keys may name the fields of renamed columns, and a pivot key need not share the owner's integer type.

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

An owner that keys its children by another column names it with local_key, as #[tideorm(morph_name = "imageable", local_key = "uuid")]; MorphTo::load_as::<Owner>() then looks the owner up by that column, and by the primary key otherwise.

Eager loading (with("images"), and every other relation) matches keys by value, as the database does, so an i64 key finds the rows of an i32 foreign key or a text *_id column holding it.



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.14.1", features = ["postgres", "attachments"] }

Model Setup

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

impl HasAttachments for Product {
    fn has_one_files() -> Vec<&'static str> {
        vec!["thumbnail"]
    }

    fn has_many_files() -> Vec<&'static str> {
        vec!["images", "documents"]
    }

    fn get_files_data(&self) -> Result<FilesData, AttachmentError> {
        Ok(self.files.as_ref().map(FilesData::from_json).unwrap_or_default())
    }

    fn set_files_data(&mut self, data: FilesData) -> Result<(), AttachmentError> {
        self.files = Some(data.to_json());
        Ok(())
    }
}
}

Every option goes inside the one #[tideorm::model(..)] attribute — combining it with a separate #[tideorm(..)] on the struct is a compile error. The attribute records the slot names on the model; the HasAttachments impl is yours to write, because only you know which column holds the payload. Once it exists, attach(), detach() and sync() are available on Product with the attachments feature enabled and tideorm::prelude::* in scope.

Relation Types

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

Attaching Files

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

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

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

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

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

// Add custom metadata
let attachment = FileAttachment::new("uploads/photo.jpg")
    .add_metadata("width", 1920)
    .add_metadata("height", 1080)
    .add_metadata("photographer", "John Doe");
product.attach_with_metadata("images", attachment)?;
// Metadata sits beside the attachment's own fields in its JSON. An entry named
// like one of them (`size`, `key`) is kept only while that field is unset.

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

Detaching Files

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

// Remove the thumbnail only while it is this file (hasOne)
product.detach("thumbnail", Some("uploads/old-thumb.jpg"))?;

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

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

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

product.update().await?;
}

Syncing Files (Replace All)

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

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

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

product.update().await?;
}

Getting Files

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

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

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

FileAttachment Structure

Each attachment stores:

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

JSON Storage Format

Attachments are stored in JSONB with this structure:

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

File URL Generation

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

Global Base URL

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

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

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

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

Custom URL Generator

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

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

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

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

Parameters available to URL generators:

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

Per-Model URL Routing

There is no per-model override: the derive already emits the whole ModelMeta impl for a #[tideorm::model] struct, so a second hand-written impl ModelMeta for Product is a conflicting-implementation error. Every model resolves file_url_generator() to the one configured on TideConfig.

Route per model inside that single generator instead — it receives the field name, and FileAttachment::metadata carries anything else you stashed at attach time:

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

fn product_aware_url_generator(field_name: &str, file: &FileAttachment) -> String {
    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),
    }
}

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

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 = file.url("thumbnail");  // Uses the global generator with the field name

// 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 = file.url("video");

// Using the model's generator (its `file_url_generator()` override, if any)
let url = file.url_with_generator("thumbnail", Product::file_url_generator());

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

URL Generator Priority

URL generators are resolved in this order:

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

Translations (i18n)

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

Enable the feature first:

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

Model Setup

#![allow(unused)]
fn main() {
#[tideorm::model(table = "products", 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<JsonValue>,
}
}

The attribute records which fields are translatable; the HasTranslations impl that reads and writes the translations column is yours to write, as shown in Translation Configuration below. Once it exists, set_translation() and get_translated() are available on Product with the translations feature enabled and tideorm::prelude::* in scope. The fallback value behind get_translated() is what your get_default_value() returns.

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();

// A model whose serde derive renames fields (`rename_all = "camelCase"`) says
// where each field lands, so the translation replaces it there:
// fn serialized_key(field: &'static str) -> &'static str {
//     <Self as ModelMeta>::serialized_name(field)
// }
// Result includes raw translations field
}

Translation Configuration

The impl the translation methods need:

#![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, each with its impl from above:

#![allow(unused)]
fn main() {
#[tideorm::model(
    table = "products",
    translatable = "name,description",
    has_one_files = "thumbnail",
    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<JsonValue>,
    pub files: Option<JsonValue>,
}

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

Create one per request or unit of work. Its identity map keeps every model it has loaded until the manager is dropped or clear()ed, and find answers from that map without asking the database again, so a manager shared for the life of a process grows without bound and keeps serving rows other connections have since changed.

Enabling the Feature

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

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

Aggregate Workflow

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

use tideorm::prelude::*;

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

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

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

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

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

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

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

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

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

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

Managed Lifecycle Workflow

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

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

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

    entity_manager.flush().await?;

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

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

    Ok(())
}
}

Managed entities expose:

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

merge(...) attaches a detached instance into the current context. detach(...) keeps the in-memory value but removes it from future flushes. clear() detaches the whole context. Persisting or merging an entity the context was about to remove takes the removal back and writes it; merging one a persist() has not flushed keeps it an insert.

When the context saves a row it also holds as a managed entity, through entity_manager.save(..) or a relation sync, the managed entity moves onto what was stored: the fields it has not changed take the stored values, the ones it changed keep its edits, so a later flush does not write the values it loaded back over the newer save. A relation copy of the row, loaded before, is not moved; reload it.

A managed entity keeps the primary key it was loaded or saved with: a flush refuses one whose key was changed, rather than write it over the row holding the new key. Detach it and persist a new entity instead.

A manager runs one flush() or save() at a time: a second one waits for the first, so an entity waiting to be inserted is inserted once. A flush or save called inside another, such as from a callback, joins it instead. An edit made to a managed entity while a flush writes it stays over the row as stored, and the entity stays dirty for the next flush.

A flush that fails, is cancelled part way, or runs inside a transaction that later rolls back leaves the context as it was before the flush, since none of what it wrote was committed. The one outcome no client can know is a flush cancelled while its COMMIT is on the wire: the database may have committed it. The context is restored then too, so reload what the flush wrote before flushing it again.

Relations of a Model the Manager Loaded

When a model was loaded through EntityManager::find(...) or find_managed(...), every relation it holds, the polymorphic and self-referencing ones included, queries through the entity manager's database handle: load(), load_with(...), count() and exists() work even if no global database is configured.

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

What Aggregate Saves Synchronize

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

Primary Key Support

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

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

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

Notes

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

Migrations

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

Schema Builder Column Types

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

Numeric Types

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

String Types

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

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 (TIME(6) on MySQL)
t.datetime("logged_at");               // TIMESTAMP (Postgres) / DATETIME(6) (MySQL)
t.timestamp("created_at");             // TIMESTAMP (without timezone) / DATETIME(6) (MySQL)
t.timestamptz("expires_at");           // TIMESTAMPTZ (with timezone) / DATETIME(6) (MySQL)
}

Special Types

#![allow(unused)]
fn main() {
t.uuid("external_id");                 // UUID (Postgres) / BINARY(16) (MySQL)
t.json("metadata");                    // JSON
t.jsonb("data");                       // JSONB (PostgreSQL only)
t.binary("file_data");                 // BYTEA / LONGBLOB (MySQL) / BLOB (SQLite)
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)
}

MySQL and MariaDB

Every timestamp helper renders DATETIME(6) there: TIMESTAMP only spans 1970–2038 and a column without fractional digits drops microseconds, and a CURRENT_TIMESTAMP default is raised to CURRENT_TIMESTAMP(6) to match. text() and binary() render LONGTEXT/LONGBLOB, because TEXT and BLOB stop at 64 KB; a default on one of them, or on a JSON column, is written as the parenthesized expression MySQL requires. MySQL cannot index a TEXT column without a prefix length, so give an indexed or unique string column string() (VARCHAR(255)). Tables are created DEFAULT CHARSET=utf8mb4, whatever the database's own default is.

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
    }
}
}

A string default is escaped the way the backend reads a literal, so default(r"C:\temp\") stores those backslashes on MySQL and MariaDB too, and a current-time default may be spelled now(), CURRENT_TIMESTAMP or LOCALTIMESTAMP on any backend. A generated index name (idx_<table>_<columns>) past 63 bytes is shortened with a hash, as PostgreSQL would otherwise cut two names that share a prefix to one and MySQL refuses them; a model's #[index] names are shortened the same way. create_table_if_not_exists can run again after a half-applied migration: on MySQL, which has no CREATE INDEX IF NOT EXISTS, it skips the indexes the table already has.

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>>,
}
}

Renaming a Table

#![allow(unused)]
fn main() {
async fn up(&self, schema: &mut Schema) -> Result<()> {
    schema.rename_table("posts", "articles").await
}

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

MySQL and MariaDB run RENAME TABLE, PostgreSQL and SQLite ALTER TABLE ... RENAME TO. The rows, columns and indexes move with the table, and foreign keys in other tables follow it.


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?;
}

SchemaWriter::write_schema("schema.sql").await? writes the same file at any time. On PostgreSQL, MySQL and MariaDB it reads every table of the connected database, with its columns, primary key and indexes. What those cannot describe comes after the tables as the catalog declares it: a full-text, expression, partial or prefix index, or one with INCLUDE columns or NULLS NOT DISTINCT. A generated column is written with its expression, and the tables' foreign keys and CHECK constraints (and PostgreSQL's EXCLUDE ones) come last, added with ALTER TABLE once every table they name exists. An index MySQL builds on an expression is named in a comment instead. SQLite keeps the statement that created each table, view, index and trigger, so its file is those statements, generated columns, collations, constraints and AUTOINCREMENT included; an FTS5 table's shadow tables are left out, since the FTS5 table recreates them.

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