Entities
Entities are standard SeaORM models
with two Oxide conveniences layered on top: the timestamps_behavior!()
macro for automatic timestamps and a Model facade for zero-argument
queries.
Declare an entity
Convention: src/app/domain/<domain>/models/<name>.rs.
use oxide::db::sea_orm::entity::prelude::*;use oxide::timestamps_behavior;use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize)]#[sea_orm(table_name = "todos")]pub struct Model { #[sea_orm(primary_key)] pub id: i64, pub title: String, pub done: bool, pub created_at: Option<ChronoDateTimeUtc>, pub updated_at: Option<ChronoDateTimeUtc>,}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]pub enum Relation {}
timestamps_behavior!();timestamps_behavior!() installs an ActiveModelBehavior::before_save
that stamps created_at on insert and updated_at on every write.
Re-export named aliases
In src/app/domain/todo/models/mod.rs:
pub mod todo;
pub use todo::{ ActiveModel as TodoActiveModel, Column as TodoColumn, Entity as Todo,};Now call sites read Todo::find(), TodoActiveModel { title: Set(...), .. },
TodoColumn::Title.contains(...).
Querying
Use SeaORM’s native API for builders, and the Model facade for the
quick common cases.
use oxide::db::sea_orm::{ColumnTrait, EntityTrait, QueryFilter};use oxide::db::{conn, Model};
use crate::app::domain::todo::models::{Todo, TodoColumn};
// List alllet rows = Todo::get().await?;
// Firstlet first = Todo::first().await?;
// By idlet one = Todo::find_id(42).await?;
// With a filter — drop to SeaORM nativelet done = Todo::find() .filter(TodoColumn::Done.eq(true)) .all(conn()) .await?;Todo::find_id is our rename of what would clash with Entity::find()
(which returns the SeaORM select builder).
Writing
use oxide::db::sea_orm::{ActiveModelTrait, Set};use oxide::db::conn;
let new = TodoActiveModel { title: Set("buy bread".into()), done: Set(false), ..Default::default()};let inserted = new.insert(conn()).await?;println!("id = {}", inserted.id);Lifecycle hooks
Two complementary tools — pick based on what the model needs.
Timestamps only — model_behavior!()
The most common case. Fills created_at (insert) and updated_at
(every save). One line, zero ceremony.
use oxide::db::sea_orm::entity::prelude::*;use oxide::model_behavior;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]#[sea_orm(table_name = "articles")]pub struct Model { #[sea_orm(primary_key)] pub id: i64, pub title: String, pub created_at: Option<ChronoDateTimeUtc>, // required pub updated_at: Option<ChronoDateTimeUtc>, // required}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]pub enum Relation {}
model_behavior!();The old timestamps_behavior!() macro is an alias for
model_behavior!() — existing entities keep compiling.
UUID + timestamps + custom hooks — the traits
When you need uuid auto-fill or custom logic in before_save
(soft-delete, audit log, encryption-at-rest, tenant_id), implement
HasUuid
and
HasTimestamps
and write before_save by hand. Each trait does one thing; you
compose them in whatever order makes sense.
use oxide::lifecycle::{HasTimestamps, HasUuid};use oxide::async_trait::async_trait;use oxide::sea_orm::{ActiveValue, ConnectionTrait, DbErr};use oxide::sea_orm::entity::prelude::*;use oxide::chrono::{DateTime, Utc};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]#[sea_orm(table_name = "articles")]pub struct Model { #[sea_orm(primary_key)] pub id: i64, pub uuid: String, pub title: String, pub created_at: Option<ChronoDateTimeUtc>, pub updated_at: Option<ChronoDateTimeUtc>,}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]pub enum Relation {}
impl HasUuid for ActiveModel { fn uuid_field(&mut self) -> &mut ActiveValue<String> { &mut self.uuid }}
impl HasTimestamps for ActiveModel { fn created_at_field(&mut self) -> &mut ActiveValue<Option<DateTime<Utc>>> { &mut self.created_at } fn updated_at_field(&mut self) -> &mut ActiveValue<Option<DateTime<Utc>>> { &mut self.updated_at }}
#[async_trait]impl ActiveModelBehavior for ActiveModel { async fn before_save<C>(mut self, _db: &C, insert: bool) -> Result<Self, DbErr> where C: ConnectionTrait { if insert { self.fill_uuid(); } self.update_timestamps(insert);
// Add custom logic here — encrypt fields, set tenant_id, etc.
Ok(self) }}UUID auto-fill only fires when the field is ActiveValue::NotSet —
pre-populate manually for deterministic values (e.g. tests).
For tests, override the clock per-model:
impl HasTimestamps for ActiveModel { fn fresh_timestamp() -> DateTime<Utc> { DateTime::from_timestamp(1_767_225_600, 0).unwrap() // 2026-01-01 UTC } // ...}When to use which
| Macro | Traits | |
|---|---|---|
| Just timestamps | ✓ | overkill |
| UUID column | — | ✓ |
Custom before_save logic (soft-delete, audit, encryption) | — | ✓ |
| Per-model fixed test clock | — | ✓ |
The rule of thumb: macro for the 80% of plain CRUD models with just timestamps; traits for anything richer.
Timezone-aware timestamps
model_behavior! reads timestamps from oxide::time::now(), which
honours app.timezone:
timezone = "${APP_TIMEZONE:-Asia/Bangkok}"With that set, every created_at / updated_at written to the DB
stores Bangkok wall-clock time — matching Laravel’s
'timezone' => 'Asia/Bangkok' behavior. The MySQL DATETIME column
contains the literal local time with no UTC offset, so reading it
back gives the same Bangkok clock value.
For app-level logic that wants honest tz semantics:
use oxide::time;
let now: chrono::DateTime<chrono_tz::Tz> = time::now_in_tz();// now.timezone() == chrono_tz::Asia::Bangkok// now.format("%Y-%m-%d %H:%M:%S %z") → "2026-04-28 09:00:00 +0700"| Helper | Returns | Use for |
|---|---|---|
time::now() | DateTime<Utc> (clock reads Bangkok) | DB writes (matches existing column types) |
time::now_in_tz() | DateTime<chrono_tz::Tz> | display, business logic |
time::now_naive() | NaiveDateTime | naive DB columns |
time::utc_now() | DateTime<Utc> (real UTC) | logs, distributed tracing |
time::tz() | chrono_tz::Tz | constructing other tz-aware values |
The conn() shortcut
oxide::db::conn() returns the global DatabaseConnection installed by
DatabaseServiceProvider. Controllers don’t need to inject Database
for simple queries — just use oxide::db::conn and pass it where SeaORM
expects &impl ConnectionTrait.
For controllers that want it explicit, declare #[injectable] with
db: Arc<Database> and use self.db.conn().