This version is in beta. Some features may change before release.

Reusable model bases

#[derive(ModelBase)] declares shared columns once (id, created_at, updated_at, ...); a model embeds them flat with a single #[model(base = ...)] — the Django abstract-base-model pattern, native fields and all.

Reusable model bases

Most tables in a real app repeat the same handful of columns — a primary key, created_at, updated_at. Declaring them on every model is the kind of duplication Django solved with an abstract base model. umbral does the same with #[derive(ModelBase)] plus a #[umbral(flatten)] field.

Declare the shared columns once, with the full #[umbral(...)] attribute set:

Code
rust
use umbral::prelude::*;
use chrono::{DateTime, Utc};
 
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, sqlx::FromRow, ModelBase)]
pub struct TimeStamped {
#[umbral(primary_key)] pub id: i64,
#[umbral(auto_now_add)] pub created_at: DateTime<Utc>,
#[umbral(auto_now)] pub updated_at: DateTime<Utc>,
}

The flat way: #[model(base = ...)]

For a single base — the dominant case, one audit-timestamp mixin — reach for the #[model(base = ...)] attribute. One line embeds the base as real, flat fields: you access article.id and article.created_at natively, never article.base.id.

Code
rust
#[model(base = TimeStamped)]
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, sqlx::FromRow, Model)]
#[umbral(table = "article")]
pub struct Article {
pub title: String,
#[umbral(unique)] pub slug: String,
}

That's the whole declaration. Article gains id, created_at, updated_at as top-level fields spliced in ahead of title / slug, so:

Code
rust
let a = Article::objects().create(Article {
title: "Hello".into(),
slug: "hello".into(),
..Default::default() // fills the base's id/created_at/updated_at placeholders
}).await?;
 
assert!(a.id > 0); // native — no `.base.`
assert!(a.created_at > epoch); // auto_now_add stamped it
 
Article::objects()
.filter(Article::CREATED_AT.gt(cutoff)) // typed const, no mixin_cols! line
.order_by(Article::ID.desc());
Warning

Write #[model(base = ...)] ABOVE the #[derive(...)] line. Attribute macros expand top-to-bottom, and this one has to splice the base's fields in before the derives read the struct — the same rule as serde_with::serde_as. Put it below the derives and you get a clear error telling you to flip the order.

What the single attribute buys you, versus the nested-field form below:

  • Native field access — article.id, not article.base.id.
  • One attribute, not four — no nested base field and no #[umbral(flatten)] #[serde(flatten)] #[sqlx(flatten)] trio.
  • Typed column consts for free — Article::CREATED_AT works immediately, with no mixin_cols! line (the inherited columns are the model's own fields, so #[derive(Model)] emits their consts like any other).

The DB schema and JSON shape are identical to the nested-field form — same columns, same flat JSON — so the two mechanisms coexist and you can move a model between them without a migration.

Info

Several bases compose too. #[model(base = A, B, …)] splices multiple bases in as flat, native fields in declaration order — #[model(base = TimeStamped, SoftDelete)] gives you article.id, article.created_at and article.deleted_at, all top-level. At most one base may declare the primary key. See Composing several bases.

The nested-field way: #[umbral(flatten)]

The original form embeds a base as a nested field, and is still the way to compose several bases into one model. The three attributes on the field are what make it work — #[umbral(flatten)] splices the base's columns into the model, and #[serde(flatten)] + #[sqlx(flatten)] flatten its values on write and read:

Code
rust
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, sqlx::FromRow, Model)]
pub struct Article {
#[umbral(flatten)]
#[serde(flatten)]
#[sqlx(flatten)]
pub base: TimeStamped,
 
pub title: String,
#[umbral(unique)] pub slug: String,
}

Article now has four columns — id, created_at, updated_at, title, slug — exactly as if you had written them inline. The migration engine creates them, Article::objects().create(...) stamps created_at/updated_at and autoincrements the base's id, and reads hydrate the nested base:

Code
rust
let a = Article::objects().create(Article {
base: Default::default(), // id: 0, created_at/updated_at: epoch — placeholders only
title: "Hello".into(),
slug: "hello".into(),
}).await?;
 
assert!(a.base.id > 0); // base PK autoincremented over the 0 sentinel
assert!(a.base.created_at > epoch); // auto_now_add stamped a real timestamp
Info

#[derive(ModelBase)] gives the base a Default impl (and a new() alias) for free, so construction collapses to base: Default::default() — no more spelling out id: 0, created_at: , updated_at: by hand. This is safe because the typed insert (and update) path always overwrites the primary key and every auto_now_add / auto_now / auto_uuid column, regardless of what the struct carries — the Default values are placeholders that never reach the database. Don't also derive or hand-write Default on a #[derive(ModelBase)] struct; the generated impl will conflict with it.

Info

The base carries the primary key. When a model has no id of its own and embeds exactly one base whose base declares the PK, that column becomes the model's primary key — no extra wiring. A model can still declare its own id and use a base purely for non-key columns.

A soft-delete base

Bases pair naturally with the framework's other model features. A common one is soft-delete: a base carries the deleted_at tombstone column, and any model that embeds it and marks itself #[umbral(soft_delete)] gets hide-instead-of-delete for free.

Code
rust
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, sqlx::FromRow, ModelBase)]
pub struct SoftDeleteBase {
#[umbral(primary_key)] pub id: i64,
pub deleted_at: Option<DateTime<Utc>>,
}
 
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, sqlx::FromRow, Model)]
#[umbral(soft_delete)] // ← the marker stays on the MODEL
pub struct Document {
#[umbral(flatten)] #[serde(flatten)] #[sqlx(flatten)]
pub base: SoftDeleteBase,
pub title: String,
}

Document::objects().delete() now writes deleted_at = now() instead of removing the row; default queries auto-filter WHERE deleted_at IS NULL, and .with_deleted() opts back in:

Code
rust
Document::objects().filter(...).delete().await?; // soft: sets deleted_at
Document::objects().count().await?; // excludes tombstoned rows
Document::objects().with_deleted().count().await?; // includes them
Info

#[umbral(soft_delete)] is a struct-level marker, so it stays on the embedding model — a base can't carry it. What the base does carry is the deleted_at column the feature needs. This is the general pattern: put the shared columns on the base; keep model-level markers (soft_delete, audited, table = "...") on the model. See Soft delete for the full behavior.

Composing several bases

The flat way — #[model(base = A, B, …)]. List the bases; their columns splice in as flat, native fields in declaration order (A's, then B's, then the model's own):

Code
rust
#[model(base = TimeStamped, SoftDelete)]
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, sqlx::FromRow, Model)]
#[umbral(table = "article")]
pub struct Article {
pub title: String,
}
// article.id, article.created_at, article.updated_at (TimeStamped),
// article.deleted_at (SoftDelete), article.title — all top-level.

At most one base may declare the primary key; a base contributing only non-key columns (a bare deleted_at) simply omits #[umbral(primary_key)]. The typed consts (Article::CREATED_AT, Article::DELETED_AT) are generated for every base's columns automatically — no mixin_cols! needed.

The nested-field way — #[umbral(flatten)]. The older form embeds each base as a nested field (accessed as comment.audit.id, not comment.id); still supported:

Code
rust
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, sqlx::FromRow, Model)]
#[umbral(soft_delete)]
pub struct Comment {
#[umbral(flatten)] #[serde(flatten)] #[sqlx(flatten)]
pub audit: TimeStamped, // id, created_at, updated_at
#[umbral(flatten)] #[serde(flatten)] #[sqlx(flatten)]
pub trash: Deletable, // deleted_at (a base without its own PK)
pub body: String,
}

Every attribute works on the base

A base field supports the exact same #[umbral(...)] attributes as a model field — primary_key, auto_now_add, auto_now, unique, index, default, max_length, choices, FK options, and so on. They are resolved on the base's own derive, so the column behaves identically whether it was declared inline or inherited.

Typed column constants for base fields

Info

This section applies to the nested-field #[umbral(flatten)] form only. With #[model(base = ...)] the inherited columns are already the model's own fields, so Article::CREATED_AT and friends are generated automatically — no mixin_cols! line needed.

A model's own fields get typed constants (article::TITLE, Article::TITLE) for building filter / order_by predicates. With the nested-field form, base-inherited columns need one opt-in line — mixin_cols! — to get the same, bound to your model:

Code
rust
umbral::mixin_cols!(Article: TimeStamped); // one line, after the struct
 
Article::objects()
.filter(Article::ID.ge(100))
.order_by(Article::CREATED_AT.desc()) // now typed, like an own field

mixin_cols!(Model: Base) generates Model::ID, Model::CREATED_AT, … for every column the base contributes. Pass several bases (mixin_cols!(Comment: TimeStamped, SoftDeleteBase)) to pull consts from each.

Info

Same-crate vs. cross-crate. When the base lives in the same crate as the model (the common case), name it as you normally would — mixin_cols!(Article: TimeStamped). When the base comes from another crate, spell that crate so the generated-const macro resolves: mixin_cols!(Article: shared::TimeStamped). Without mixin_cols! the base columns still fully work for migrations, inserts, auto-stamping and reads — you only lose the typed query consts.

See also

  • Auto timestamps and the auto_now_add / auto_now attributes for the stamping behavior a base typically bundles.
  • arch.md and crates/umbral-core/src/orm/model.rs (ModelBase) for the design.
ormmodelsinheritancedrytimestamps