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 '%@%'");
}