Skip to content

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 all
let rows = Todo::get().await?;
// First
let first = Todo::first().await?;
// By id
let one = Todo::find_id(42).await?;
// With a filter — drop to SeaORM native
let 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

MacroTraits
Just timestampsoverkill
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:

config/app.toml
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"
HelperReturnsUse 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()NaiveDateTimenaive DB columns
time::utc_now()DateTime<Utc> (real UTC)logs, distributed tracing
time::tz()chrono_tz::Tzconstructing 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().