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 Type | PostgreSQL | MySQL | SQLite | Notes |
|---|---|---|---|---|
i8 | — | SMALLINT | INTEGER | The PostgreSQL driver has no binding for i8; use i16 there |
i16 | SMALLINT | SMALLINT | INTEGER | |
i32 | INTEGER | INT | INTEGER | |
i64 | BIGINT | BIGINT | INTEGER | Recommended for primary keys |
u8 | — | TINYINT UNSIGNED | INTEGER | Not supported on PostgreSQL |
u16 | — | SMALLINT UNSIGNED | INTEGER | Not supported on PostgreSQL |
u32 | INTEGER | INT UNSIGNED | INTEGER | PostgreSQL stores only up to i32::MAX |
u64 | — | BIGINT UNSIGNED | — | MySQL only |
f32 | REAL | FLOAT | REAL | |
f64 | DOUBLE PRECISION | DOUBLE | REAL | |
bool | BOOLEAN | TINYINT(1) | INTEGER | |
String | TEXT | LONGTEXT | TEXT | Schema 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::Uuid | UUID | BINARY(16) | TEXT | MySQL binds a UUID as 16 raw bytes, so the column must be BINARY(16) |
rust_decimal::Decimal | DECIMAL | DECIMAL(65,30) | REAL | SQLite reads decimals back through f64, so REAL is forced; store a string or minor units if you need exactness |
serde_json::Value | JSONB | JSON | TEXT | |
Vec<u8> | BYTEA | LONGBLOB | BLOB | Binary data |
chrono::NaiveDate | DATE | DATE | TEXT | Date only |
chrono::NaiveTime | TIME | TIME(6) | TEXT | Time only |
chrono::NaiveDateTime | TIMESTAMP | DATETIME(6) | TEXT | No timezone |
chrono::DateTime<Utc> | TIMESTAMPTZ | DATETIME(6) | TEXT | With 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:
DateTime with Timezone (Recommended for most cases)
Use chrono::DateTime<Utc> for timestamps that should include timezone information:
#![allow(unused)] fn main() { use chrono::{DateTime, Utc}; #[tideorm::model(table = "sessions")] pub struct Session { #[tideorm(primary_key, auto_increment)] pub id: i64, pub user_id: i64, pub token: String, pub expires_at: DateTime<Utc>, // Maps to TIMESTAMPTZ in PostgreSQL pub created_at: DateTime<Utc>, // Maps to TIMESTAMPTZ in PostgreSQL pub updated_at: DateTime<Utc>, // Maps to TIMESTAMPTZ in PostgreSQL } }
In migrations, use timestamptz():
#![allow(unused)] fn main() { schema.create_table("sessions", |t| { t.id(); t.big_integer("user_id").not_null(); t.string("token").not_null(); t.timestamptz("expires_at").not_null(); t.timestamps(); // Uses TIMESTAMPTZ by default }).await?; }
DateTime without Timezone
Use chrono::NaiveDateTime when you don't need timezone information:
#![allow(unused)] fn main() { use chrono::NaiveDateTime; #[tideorm::model(table = "logs")] pub struct Log { #[tideorm(primary_key, auto_increment)] pub id: i64, pub message: String, pub logged_at: NaiveDateTime, // Maps to TIMESTAMP (no timezone) } }
In migrations, use timestamp():
#![allow(unused)] fn main() { schema.create_table("logs", |t| { t.id(); t.text("message").not_null(); t.timestamp("logged_at").default_now(); }).await?; }
Date Only
Use chrono::NaiveDate for date-only fields:
#![allow(unused)] fn main() { use chrono::NaiveDate; #[tideorm::model(table = "events")] pub struct Event { #[tideorm(primary_key, auto_increment)] pub id: i64, pub name: String, pub event_date: NaiveDate, // Maps to DATE } }
In migrations, use date():
#![allow(unused)] fn main() { schema.create_table("events", |t| { t.id(); t.string("name").not_null(); t.date("event_date").not_null(); }).await?; }
Time Only
Use chrono::NaiveTime for time-only fields:
#![allow(unused)] fn main() { use chrono::NaiveTime; #[tideorm::model(table = "schedules")] pub struct Schedule { #[tideorm(primary_key, auto_increment)] pub id: i64, pub name: String, pub start_time: NaiveTime, // Maps to TIME pub end_time: NaiveTime, } }
In migrations, use time():
#![allow(unused)] fn main() { schema.create_table("schedules", |t| { t.id(); t.string("name").not_null(); t.time("start_time").not_null(); t.time("end_time").not_null(); }).await?; }
Examples
See the TideORM Examples repository for complete working examples.
Testing
Use the smallest command that covers your change.
# Fast library validation
cargo test --lib
# Broad compatibility pass
cargo test --all-features
# Default backend suite
cargo test --features postgres
# SQLite integration suite
cargo test --test sqlite_integration_tests --features "sqlite runtime-tokio" --no-default-features
# PostgreSQL integration suite
cargo test --test postgres_integration_tests
# MySQL integration suite
cargo test --test mysql_integration_tests --features mysql
# 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
Default Behavior (Recommended)
Define most models with #[tideorm::model(table = "...")]:
#![allow(unused)] fn main() { #[tideorm::model(table = "products")] pub struct Product { #[tideorm(primary_key, auto_increment)] pub id: i64, pub name: String, pub price: f64, } }
The #[tideorm::model] macro automatically implements:
Debug- for printing/loggingClone- for cloning instancesDefault- for creating default instancesSerialize- for JSON serializationDeserialize- for JSON deserialization
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.
| Attribute | Description |
|---|---|
#[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 field | Index that field's column |
Field-Level Attributes
| Attribute | Description |
|---|---|
#[tideorm(primary_key)] | Mark as primary key |
#[tideorm(auto_increment)] | Auto-increment field for a single-column primary key |
#[tideorm(nullable)] | Optional/nullable field |
#[tideorm(column = "name")] | Custom column name |
#[tideorm(default = "value")] | 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 batchupdate_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("...")orTokenConfig::set_encryption_key("..."). - Supported encrypted field types are
String,Text,Option<String>, andOption<Text>. A batchset()of an encrypted field takes a string orNone. pluck,value,get_jsonandget_asreturn 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 implicitidkey.
CRUD Operations
Create
#![allow(unused)] fn main() { let user = User { email: "john@example.com".to_string(), name: "John Doe".to_string(), active: true, ..Default::default() }; let user = user.save().await?; println!("Created user with id: {}", user.id); }
For auto-increment primary keys, TideORM treats 0 as an unsaved record marker internally. You usually do not need to assign it yourself when constructing a new model. Natural keys, composite keys, and non-auto-increment primary keys are considered persisted unless the primary key value is empty.
Read
#![allow(unused)] fn main() { // Get all let users = User::all().await?; // Find by primary key let user = User::find(1).await?; // Option<User> // Composite primary key example let membership = UserRole::find((1_i64, 2_i64)).await?; // Query builder (see above) let users = User::query().where_eq("active", true).get().await?; }
Update
#![allow(unused)] fn main() { let mut user = User::find(1).await?.unwrap(); user.name = "Jane Doe".to_string(); let user = user.update().await?; }
Dirty Tracking
Requires the dirty-tracking feature.
Persisted models loaded or saved through TideORM keep a baseline of their last known persisted column values. Use changed_fields() to inspect which persisted fields differ from that baseline, and original_value() to inspect the previous value before saving.
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
| Callback | When Called |
|---|---|
before_validation | Before validation runs |
after_validation | After validation passes |
before_save | Before create or update |
after_save | After create or update |
before_create | Before inserting new record |
after_create | After inserting new record |
before_update | Before updating existing record |
after_update | After updating existing record |
before_delete | Before deleting record |
after_delete | After deleting record |
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:
| Method | Description |
|---|---|
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).await | Decode 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):
| Category | Methods |
|---|---|
| WHERE | where_eq, where_not, where_gt, where_gte, where_lt, where_lte, where_like, where_not_like, where_in, where_not_in, where_null, where_not_null, where_between |
| OR WHERE | or_where_eq, or_where_not, or_where_gt, or_where_gte, or_where_lt, or_where_lte, or_where_like, or_where_not_like, or_where_in, or_where_not_in, or_where_null, or_where_not_null, or_where_between |
| ORDER BY | order_by, order_asc, order_desc |
| GROUP BY | group_by |
| Aggregations | sum, avg, min, max, count_distinct |
| HAVING | having (Aggregate::sum(..).gt(..), Aggregate::count().gte(..), ...) |
| Window | partition_by, order_by (in WindowFunctionBuilder) |
Self-Referencing Relations
Support for hierarchical data like org charts, categories, or comment threads:
#![allow(unused)] fn main() { #[tideorm::model(table = "employees")] pub struct Employee { #[tideorm(primary_key)] pub id: i64, pub name: String, pub manager_id: Option<i64>, #[tideorm(foreign_key = "manager_id")] pub manager: SelfRef<Employee>, #[tideorm(foreign_key = "manager_id")] pub reports: SelfRefMany<Employee>, } // Usage: let emp = Employee::find(5).await?.unwrap(); let manager_rel = emp.manager.clone(); let reports_rel = emp.reports.clone(); // Load parent (manager) let manager = manager_rel.load().await?; let has_manager = manager_rel.exists().await?; // Load children (direct reports) let reports = reports_rel.load().await?; let count = reports_rel.count().await?; // Load entire subtree recursively in one recursive CTE query let tree = reports_rel.load_tree(3).await?; // 3 levels deep }
SelfRef and SelfRefMany fields are wired automatically when you provide the self-referencing foreign_key. local_key defaults to 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>)isIS NULL.where_notandwhere_not_infollow SQL and never match a NULL column. ANoneinsidewhere_inalso matches NULL rows, and one insidewhere_not_inkeeps 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()orhaving()given one: JSON has no such number, and thenullit would become means SQL NULL. Check a float parsed from a request withis_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 throughwhere_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 towhere_raw_with(), which binds it:where_raw_with("LOWER(email) = LOWER(?)", vec![email.into()]). The?works on every backend, andor_where_raw_with/and_where_raw_withjoin an OR group the same way. - Long lists. A
where_in/where_not_inlist 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. LIKEand case.where_contains/where_starts_with/where_ends_withescape%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")findsAda@Example.com,"cafe"findscafé, 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 = valueand_where_not(col, val)- AND column != valueand_where_gt(col, val)- AND column > valueand_where_gte(col, val)- AND column >= valueand_where_lt(col, val)- AND column < valueand_where_lte(col, val)- AND column <= valueand_where_like(col, pattern)- AND column LIKE patternand_where_in(col, values)- AND column IN (values)and_where_not_in(col, values)- AND column NOT IN (values)and_where_null(col)- AND column IS NULLand_where_not_null(col)- AND column IS NOT NULLand_where_between(col, min, max)- AND column BETWEEN min AND max
Real-World Examples
#![allow(unused)] fn main() { // E-commerce: Flash sale eligibility let flash_sale_products = Product::query() .where_eq("active", true) .where_gt("stock", 100) .where_gte("rating", 4.3) .where_null("discount_percent") // Not already discounted .get() .await?; // Inventory: Reorder alerts let reorder_needed = Product::query() .where_eq("active", true) .begin_or() .or_where_lt("stock", 50).and_where_gt("rating", 4.5) // Popular items low .or_where_lt("stock", 30) // Any item critically low .end_or() .order_by("stock", Order::Asc) .get() .await?; // Marketing: Cross-sell recommendations let recommendations = Product::query() .where_eq("active", true) .begin_or() .or_where_eq("brand", "Apple") .or_where_eq("brand", "Samsung").and_where_gt("price", 500.0) .or_where_eq("featured", true).and_where_not("category", "Electronics") .end_or() .order_by("rating", Order::Desc) .limit(10) .get() .await?; // Search: Multi-pattern name matching let search_results = Product::query() .begin_or() .or_where_like("name", "iPhone%") .or_where_like("name", "Galaxy%") .or_where_like("name", "%Pro%") .end_or() .where_eq("active", true) .get() .await?; // Analytics: Price segmentation let segmented = Product::query() .begin_or() .or_where_eq("category", "Electronics") .or_where_eq("category", "Books") .end_or() .begin_or() .or_where_gt("price", 1000.0) // Premium .or_where_lt("price", 50.0) // Budget .end_or() .order_by("price", Order::Desc) .get() .await?; }
Ordering
#![allow(unused)] fn main() { // Basic ordering - works with both strings and typed columns User::query() .order_by("created_at", Order::Desc) // String-based .order_by(User::columns.name, Order::Asc) // Typed column .get() .await?; // Convenience methods - also work with typed columns User::query().order_asc(User::columns.name) // ORDER BY name ASC User::query().order_desc(User::columns.created_at) // ORDER BY created_at DESC User::query().latest() // ORDER BY created_at DESC User::query().oldest() // ORDER BY created_at ASC }
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(¶ms.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.
Filtering by Related Rows
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?; }
Full-Text Search
TideORM provides full-text search capabilities across PostgreSQL (tsvector/tsquery), MySQL (FULLTEXT), and SQLite (FTS5).
Enable the feature explicitly when you need the full-text search API:
tideorm = { version = "0.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:
| Operation | PostgreSQL | MySQL | SQLite |
|---|---|---|---|
| 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:
| Operation | PostgreSQL | MySQL/SQLite |
|---|---|---|
| Contains | col @> ARRAY['a','b'] | JSON_CONTAINS(col, '["a","b"]') |
| Contained By | col <@ ARRAY['a','b'] | JSON_CONTAINS('["a","b"]', col) |
| Overlaps | col && ARRAY['a','b'] | JSON_OVERLAPS(col, '["a","b"]') (MySQL 8+) |
Database-Specific Optimizations
applies optimizations based on your database:
| Feature | PostgreSQL | MySQL | SQLite |
|---|---|---|---|
| Parameter Style | $1, $2, ... | ?, ?, ... | ?, ?, ... |
| Identifier Quoting | "column" | `column` | "column" |
| Float Casting | FLOAT8 | DOUBLE | REAL |
Feature Compatibility Matrix
| Feature | PostgreSQL | MySQL | SQLite |
|---|---|---|---|
| JSON/JSONB | ✅ | ✅ | ✅ (JSON1) |
| Native JSON Operators | ✅ | ✅ | ❌ |
| Native Arrays | ✅ | ❌ | ❌ |
| RETURNING Clause | ✅ | ❌ | ✅ (3.35+) |
| Upsert | ✅ | ✅ | ✅ |
| Window Functions | ✅ | ✅ (8.0+) | ✅ (3.25+) |
| CTEs | ✅ | ✅ (8.0+) | ✅ (3.8+) |
| Full-Text Search | ✅ | ✅ | ✅ (FTS5) |
| Schemas | ✅ | ✅ | ❌ |
Raw SQL Queries
For complex queries that can't be expressed with the query builder:
#![allow(unused)] fn main() { // Execute raw SQL and return model instances let users: Vec<User> = Database::raw::<User>( "SELECT * FROM users WHERE age > 18" ).await?; // With parameters (use $1, $2 for PostgreSQL, ? for MySQL/SQLite) let users: Vec<User> = Database::raw_with_params::<User>( "SELECT * FROM users WHERE age > $1 AND status = $2", vec![18.into(), "active".into()] ).await?; // Execute raw SQL statement (INSERT, UPDATE, DELETE) let affected = Database::execute( "UPDATE users SET active = false WHERE last_login < NOW() - INTERVAL '1 year'" ).await?; // Execute with parameters let affected = Database::execute_with_params( "DELETE FROM users WHERE status = $1", vec!["banned".into()] ).await?; }
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:
GlobalProfilerfor lightweight application-wide query timing and slow-query counters.Profilerfor manual profiling sessions where you want to build a detailedProfileReportyourself.
Use GlobalProfiler when you want to observe real query execution with minimal setup. Use Profiler when you want to capture custom SQL strings, table names, row counts, and run QueryAnalyzer over a curated set of queries.
Global Profiling
GlobalProfiler records aggregate timing data for real executed queries when it is enabled.
#![allow(unused)] fn main() { use tideorm::prelude::*; use tideorm::profiling::GlobalProfiler; GlobalProfiler::enable(); GlobalProfiler::reset(); GlobalProfiler::set_slow_threshold(200); let users = User::query() .where_eq("active", true) .limit(25) .get() .await?; let stats = GlobalProfiler::stats(); println!("{}", stats); GlobalProfiler::disable(); }
When enabled, TideORM records query timings from the main execution paths:
- query-builder reads and counts routed through the database helpers
- raw SQL helpers such as
Database::raw,Database::execute, and JSON/raw parameterized helpers - direct CRUD methods generated by the
Modelderive such asfind,save,update,delete, anddestroy - aggregate helpers such as
count_distinct,sum,avg,min, andmax - full-text search execution paths
- internal executor paths used by batch and model helpers
GlobalStats exposes:
total_queriestotal_time()avg_query_time()slow_queriesslow_percentage()
What Global Profiling Does Not Capture
GlobalProfiler is intentionally lightweight. It tracks counts and durations, but it does not retain SQL text or per-query metadata.
If you need a detailed report with SQL strings, operation types, slowest queries, table counts, or custom annotations, build that report manually with Profiler and ProfiledQuery.
Manual Profile Reports
Profiler is a report builder. It does not automatically subscribe to all TideORM queries by itself.
#![allow(unused)] fn main() { use std::time::Duration; use tideorm::profiling::{ProfiledQuery, Profiler}; let mut profiler = Profiler::start(); profiler.record("SELECT * FROM users WHERE active = true", Duration::from_millis(12)); profiler.record_full( ProfiledQuery::new("SELECT * FROM posts WHERE user_id = 42", Duration::from_millis(37)) .with_table("posts") .with_rows(8), ); let report = profiler.stop(); println!("{}", report); }
This is useful when you are instrumenting a benchmark, comparing alternative query shapes, or building a focused performance report in tests.
Query Analysis
QueryAnalyzer can inspect SQL and return optimization suggestions.
#![allow(unused)] fn main() { use tideorm::profiling::QueryAnalyzer; let suggestions = QueryAnalyzer::analyze("SELECT * FROM users WHERE email = 'john@example.com'"); for suggestion in suggestions { println!("{}", suggestion); } }
Use this alongside Profiler when you want human-readable guidance about indexing, query shape, and complexity.
Related Features
- Query logging is covered in Queries.
- Slow-query thresholds for logs and profiler statistics are separate settings, 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, andor_clause_benchmarks - Feature-gated library benchmarks:
attachments_translations_benchmarksandfulltext_benchmarks - Library-only benchmarks:
cache_benchmarks,relations_benchmarks,stability_benchmarks,tokenization_benchmarks, andvalidation_benchmarks
cargo bench runs the default-enabled benchmark targets. Benches with required-features are skipped unless you enable the matching features explicitly.
Prerequisites
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-runchecks when changing shared benchmark support or feature wiring. - If a change affects query shape or SQL generation, pair the benchmark run with the profiling tools described in
docs/profiling.md.
Relations
Model Relations
Relations are declared as struct fields. You can load them on demand from an individual model, or batch-load them eagerly from a query to avoid N+1 lookups.
Defining Relations
#![allow(unused)] fn main() { use tideorm::prelude::*; #[tideorm::model(table = "users")] pub struct User { #[tideorm(primary_key, auto_increment)] pub id: i64, pub name: String, pub email: String, // One-to-one: User has one Profile #[tideorm(has_one = "Profile", foreign_key = "user_id")] pub profile: HasOne<Profile>, // One-to-many: User has many Posts #[tideorm(has_many = "Post", foreign_key = "user_id")] pub posts: HasMany<Post>, } #[tideorm::model(table = "profiles")] pub struct Profile { #[tideorm(primary_key, auto_increment)] pub id: i64, pub user_id: i64, pub bio: String, // Inverse: Profile belongs to User #[tideorm(belongs_to = "User", foreign_key = "user_id")] pub user: BelongsTo<User>, } #[tideorm::model(table = "posts")] pub struct Post { #[tideorm(primary_key, auto_increment)] pub id: i64, pub user_id: i64, pub title: String, pub content: String, // Inverse: Post belongs to User #[tideorm(belongs_to = "User", foreign_key = "user_id")] pub author: BelongsTo<User>, // One-to-many: Post has many Comments #[tideorm(has_many = "Comment", foreign_key = "post_id")] pub comments: HasMany<Comment>, } }
Relation Types
| Type | Attribute | Description |
|---|---|---|
HasOne<T> | has_one | One-to-one relationship (e.g., User has one Profile) |
HasMany<T> | has_many | One-to-many relationship (e.g., User has many Posts) |
BelongsTo<T> | belongs_to | Inverse relationship (e.g., Post belongs to User) |
HasManyThrough<T, P> | has_many_through | Many-to-many via pivot table |
MorphOne<T> | - | Polymorphic one-to-one |
MorphMany<T> | - | Polymorphic one-to-many |
Relation Attributes
| Attribute | Description | Required |
|---|---|---|
foreign_key | Foreign key column on related table | Yes |
local_key | Local key (defaults to primary key) | No |
owner_key | Owner key for BelongsTo | No |
pivot | Pivot table name for HasManyThrough | For through relations |
related_key | Related key on pivot table | For through relations |
Loading Relations
Relation helper fields such as HasOne<T>, HasMany<T>, and BelongsTo<T> are runtime helpers, not persisted columns. TideORM's generated serde implementation serializes cached relation payloads when they are loaded and rebuilds the wrappers with runtime context during deserialization, so JSON round trips can preserve loaded relations without treating the wrappers as stored schema fields.
Runtime relation helpers operate on a single local or foreign key value per query. For composite-key models, define local_key explicitly when needed and use custom queries when the relation requires matching multiple columns.
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
| Type | Description | Use Case |
|---|---|---|
has_one_files | Single file attachment | Avatar, thumbnail, profile picture |
has_many_files | Multiple file attachments | Gallery images, documents, media |
Attaching Files
#![allow(unused)] fn main() { use tideorm::prelude::*; // Attach a single file (hasOne) - replaces any existing product.attach("thumbnail", "uploads/thumb.jpg")?; // Attach multiple files (hasMany) - accumulates product.attach("images", "uploads/img1.jpg")?; product.attach("images", "uploads/img2.jpg")?; // Attach multiple at once product.attach_many("images", vec![ "uploads/img3.jpg", "uploads/img4.jpg", ])?; // Attach with metadata let attachment = FileAttachment::with_metadata( "uploads/document.pdf", Some("My Document.pdf"), // Original filename Some(1024 * 1024), // File size (1MB) Some("application/pdf"), // MIME type ); product.attach_with_metadata("documents", attachment)?; // Add custom metadata let attachment = FileAttachment::new("uploads/photo.jpg") .add_metadata("width", 1920) .add_metadata("height", 1080) .add_metadata("photographer", "John Doe"); product.attach_with_metadata("images", attachment)?; // 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:
| Field | Type | Description |
|---|---|---|
key | String | File path/key (e.g., "uploads/2024/01/image.jpg") |
filename | String | Extracted filename |
created_at | String | ISO 8601 timestamp when attached |
original_filename | Option<String> | Original filename if different |
size | Option<u64> | File size in bytes |
mime_type | Option<String> | MIME type |
metadata | HashMap | Custom metadata fields |
JSON Storage Format
Attachments are stored in JSONB with this structure:
{
"thumbnail": {
"key": "uploads/thumb.jpg",
"filename": "thumb.jpg",
"created_at": "2024-01-15T10:30:00Z"
},
"images": [
{
"key": "uploads/img1.jpg",
"filename": "img1.jpg",
"created_at": "2024-01-15T10:30:00Z",
"size": 1048576,
"mime_type": "image/jpeg"
},
{
"key": "uploads/img2.jpg",
"filename": "img2.jpg",
"created_at": "2024-01-15T10:31:00Z"
}
]
}
File URL Generation
TideORM can automatically generate full URLs for file attachments. This is useful when you store file keys/paths in the database but need to serve them from a CDN or storage service.
Global Base URL
Configure a base URL that will be prepended to all file keys:
#![allow(unused)] fn main() { TideConfig::init() .database("postgres://localhost/mydb") .file_base_url("https://cdn.example.com/uploads") .connect() .await?; }
Now when you call to_json(), file attachments will include a url field:
{
"thumbnail": {
"key": "products/123/thumb.jpg",
"filename": "thumb.jpg",
"url": "https://cdn.example.com/uploads/products/123/thumb.jpg"
}
}
Custom URL Generator
For more complex URL generation (signed URLs, image transformations, etc.), use a custom generator that receives both the field name and the full FileAttachment:
#![allow(unused)] fn main() { use tideorm::attachments::FileAttachment; // Define a custom URL generator function with field name and full metadata access fn smart_url_generator(field_name: &str, file: &FileAttachment) -> String { // Route based on field name first match field_name { "thumbnail" => { let quality = if file.size.unwrap_or(0) > 500_000 { "60" } else { "auto" }; return format!("https://thumbs.example.com/q_{}/{}", quality, file.key); } "avatar" => { return format!("https://avatars.example.com/w_200,h_200/{}", file.key); } _ => {} } // Fall back to mime_type routing match file.mime_type.as_deref() { Some(m) if m.starts_with("video/") => { format!("https://stream.example.com/{}", file.key) } Some(m) if m.starts_with("image/") => { let quality = if file.size.unwrap_or(0) > 1_000_000 { "80" } else { "auto" }; format!("https://images.example.com/q_{}/{}", quality, file.key) } _ => format!("https://cdn.example.com/{}", file.key), } } // Use it globally TideConfig::init() .database("postgres://localhost/mydb") .file_url_generator(smart_url_generator) .connect() .await?; }
Parameters available to URL generators:
field_name- The attachment field name (e.g., "thumbnail", "avatar", "documents")file- The fullFileAttachmentstruct with:key- Storage key/pathfilename- Extracted filenamecreated_at- Creation timestamporiginal_filename- Original upload name (if available)size- File size in bytes (if available)mime_type- MIME type (if available)metadata- Custom HashMap for additional data
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:
- Model-specific generator - If the model overrides
file_url_generator() - Global custom generator - If set via
TideConfig::file_url_generator() - Global base URL - If set via
TideConfig::file_base_url() - Key as-is - If no configuration, returns the key unchanged
Translations (i18n)
TideORM provides a translation system for multilingual content. Translations are stored in a JSONB column.
Enable the feature first:
[dependencies]
tideorm = { version = "0.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 isNew,Managed,Removed, orDetached.
merge(...) attaches a detached instance into the current context. detach(...) keeps the in-memory value but removes it from future flushes. clear() detaches the whole context. 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>, andHasManyThrough<T, P>relations are synchronized with the saved aggregate. - New related models are inserted, changed related models are updated, removed
HasOne<T>orHasMany<T>children are deleted by the child model's own primary key, andHasManyThrough<T, P>pivot rows are attached or detached as needed. - Both
foreign_keyandlocal_keyare honored, so non-idrelation keys work. - Nested loaded child graphs continue through the same context.
- Unloaded relations remain untouched.
Primary Key Support
Entity-manager identity tracking works with the same primary-key shapes as the generated model APIs:
- Auto-increment numeric keys, such as
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
EntityManageris explicit. It does not replace TideORM's global database APIs.EntityManager::save()is designed for aggregate workflows around loaded models plus loadedHasOne<T>,HasMany<T>, andHasManyThrough<T, P>relations.BelongsTo<T>participates in entity-manager-aware loads and identity reuse, but aggregate saves do not cascadeBelongsTo<T>updates.- Reuse the same
EntityManagerfor aggregate loads, managed edits, and flushes when you want one consistent persistence context.
Migrations
Use migrations for schema changes in deployed environments. The schema builder reference below covers the column helpers TideORM provides.
Schema Builder Column Types
The migration schema builder provides convenience methods for all common column types:
Numeric Types
#![allow(unused)] fn main() { t.small_integer("count"); // SMALLINT t.integer("quantity"); // INTEGER t.big_integer("total"); // BIGINT t.float("rate"); // REAL/FLOAT t.double("precise_rate"); // DOUBLE PRECISION t.decimal("price"); // DECIMAL(10,2) t.decimal_with("amount", 16, 4); // DECIMAL(16,4) }
String Types
#![allow(unused)] fn main() { t.string("name"); // VARCHAR(255) t.text("description"); // TEXT (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.