DEV Community

Cover image for SeaORM 2.0: Strongly-Typed Column
SeaQL
SeaQL

Posted on

SeaORM 2.0: Strongly-Typed Column

SeaORM 2.0 Banner

In our last post, we introduced a new Entity format - designed to be more concise, more readable, and easy to write by hand.

We've also added a new COLUMN constant to make it more ergonomic, along with other enhancements.

Bye-bye CamelCase

Previously, column names in queries had to be written in CamelCase. This was because the Column type was defined as an enum, it's simpler for the type system and faster to compile than generating a struct per column, but at the cost of losing column‑specific type information.

Our new design keeps compilation fast while restoring stronger type guarantees. As a bonus, it eliminates the need for CamelCase and even saves a keystroke.

// old
user::Entity::find().filter(user::Column::Name.contains("Bob"))

// new
user::Entity::find().filter(user::COLUMN.name.contains("Bob"))

// compile error: the trait `From<{integer}>` is not implemented for `String`
user::Entity::find().filter(user::COLUMN.name.like(2))
Enter fullscreen mode Exit fullscreen mode

Under the hood, each Column value is wrapped in a byte-sized struct TypeAwareColumn. This wrapper is generic over Entity, so whether a table has 1 column or 100, the compile‑time cost stays roughly the same.

COLUMN Constant

pub struct NumericColumn<E: EntityTrait>(pub E::Column);

impl<E: EntityTrait> NumericColumn<E> {
    pub fn eq<V>(v: V) -> Expr { .. }
}
Enter fullscreen mode Exit fullscreen mode
// user.rs
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "user")]
pub struct Model {
    ..
}

// expands into following:

pub struct TypedColumn {
    pub id: NumericColumn<Entity>,
    pub name: StringColumn<Entity>,
    pub date_of_birth: DateLikeColumn<Entity>,
}

pub const COLUMN: TypedColumn = TypedColumn {
    id: NumericColumn(Column::Id),
    name: StringColumn(Column::Name),
    date_of_birth: DateLikeColumn(Column::DateOfBirth),
};

impl Entity {
    pub const COLUMN: TypedColumn = COLUMN;
}
Enter fullscreen mode Exit fullscreen mode

Type-Aware Helper Methods

Each column type wrapper exposes a set of methods that's relevant for the column's type. For example StringColumn::contains and ArrayColumn::contains are distinct methods that do the right thing!

Entity::COLUMN.name.contains("Bob") // WHERE "name" LIKE '%Bob%'

// tags is Vec<String>
Entity::COLUMN.tags.contains(vec!["awesome"]) // WHERE "tags" @> ARRAY ['awesome']
Enter fullscreen mode Exit fullscreen mode

Right now there are a set of types: BoolColumn, NumericColumn, StringColumn, BytesColumn, JsonColumn, DateLikeColumn, TimeLikeColumn, DateTimeLikeColumn, UuidColumn, IpNetworkColumn, and more relevant methods can be added, feel free to make sugguestions.

Column as typed value

One advantage of this design is that Columns are values: you can pass them into functions, combine with reflection, and build safe dynamic queries:

// returns an Expression fragment that can be used to build queries
fn filter_by_column(col: post::Column) -> Expr {
    col.eq("attribute")
}

// get an integer from a model depends on runtime condition
fn get_value_from(model: &post::Model, col: post::Column) {
    let value: i32 = model.get(col).unwrap();
    // do something on value
}
Enter fullscreen mode Exit fullscreen mode

Opt-in Only

These new structs are generated only when using the new #[sea_orm::model] or #[sea_orm::compact_model] macros. This keeps the change fully backwards‑compatible, and you incur no cost if you don't use them.

More Entity Enhancements

A big thanks to early-adopters who provided feedback to improve SeaORM 2.0.

Related Fields

The nested types for HasOne and HasMany have been changed from transparent type aliases to wrapper types. This makes it possible to distinguish between a relation that hasn’t been loaded and one that has loaded but yielded no models.

pub enum HasOne<E: EntityTrait> {
    #[default]
    Unloaded,
    NotFound,
    Loaded(Box<<E as EntityTrait>::ModelEx>),
}

pub enum HasMany<E: EntityTrait> {
    #[default]
    Unloaded,
    Loaded(Vec<<E as EntityTrait>::ModelEx>),
}
Enter fullscreen mode Exit fullscreen mode

We've added a range of methods to the wrapper type to make it feel as transparent as possible. The goal is to reduce friction while still preserving the benefits of a strong type system.

// len() / is_empty() methods
assert_eq!(users[0].posts.len(), 2);
assert!(!users[0].posts.is_empty());

// impl PartialEq
assert_eq!(users[0].posts, [post_1, post_2]);

// this creates HasOne::Loaded(Box<profile::ModelEx>)
profile: HasOne::loaded(profile::Model {
    id: 1,
    picture: "jpeg".into(),
    ..
})
Enter fullscreen mode Exit fullscreen mode

Entity Loader Paginator

Entity Loader now supports pagination. It has the same API as a regular Select:

let paginator = user::Entity::load()
    .with(profile::Entity)
    .order_by_asc(user::COLUMN.id)
    .paginate(db, 10);

let users: Vec<user::ModelEx> = paginator.fetch().await?;
Enter fullscreen mode Exit fullscreen mode

Added delete_by_key

In addition to find_by_key, now the delete_by_key convenience method is also added:

user::Entity::delete_by_email("bob@spam.com").exec(db).await?
Enter fullscreen mode Exit fullscreen mode

The delete_by_* methods now return DeleteOne instead of DeleteMany.
It doesn't change normal exec usage, but would change return type of exec_with_returning to Option<Model>:

fn delete_by_id<T>(values: T) -> DeleteMany<Self>         // old

fn delete_by_id<T>(values: T) -> ValidatedDeleteOne<Self> // new
Enter fullscreen mode Exit fullscreen mode

Self-Referencing Relations

Let's say we have a staff table, where each staff has a manager that they report to:

// staff.rs
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "staff")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    pub name: String,
    pub reports_to_id: Option<i32>,
    #[sea_orm(
        self_ref,
        relation_enum = "ReportsTo",
        relation_reverse = "Manages",
        from = "reports_to_id",
        to = "id"
    )]
    pub reports_to: HasOne<Entity>,
    #[sea_orm(self_ref, relation_enum = "Manages", relation_reverse = "ReportsTo")]
    pub manages: HasMany<Entity>,
}
Enter fullscreen mode Exit fullscreen mode

Entity Loader

let staff = staff::Entity::load()
    .with(staff::Relation::ReportsTo)
    .with(staff::Relation::Manages)
    .all(db)
    .await?;

assert_eq!(staff[0].name, "Alan");
assert_eq!(staff[0].reports_to, None);
assert_eq!(staff[0].manages[0].name, "Ben");
assert_eq!(staff[0].manages[1].name, "Alice");

assert_eq!(staff[1].name, "Ben");
assert_eq!(staff[1].reports_to.as_ref().unwrap().name, "Alan");
assert!(staff[1].manages.is_empty());

assert_eq!(staff[2].name, "Alice");
assert_eq!(staff[2].reports_to.as_ref().unwrap().name, "Alan");
assert!(staff[2].manages.is_empty());

assert_eq!(staff[3].name, "Elle");
assert_eq!(staff[3].reports_to, None);
assert!(staff[3].manages.is_empty());
Enter fullscreen mode Exit fullscreen mode

Model Loader

let staff = staff::Entity::find().all(db).await?;

let reports_to = staff
    .load_self(staff::Entity, staff::Relation::ReportsTo, db)
    .await?;

assert_eq!(staff[0].name, "Alan");
assert_eq!(reports_to[0], None);

assert_eq!(staff[1].name, "Ben");
assert_eq!(reports_to[1].as_ref().unwrap().name, "Alan");

assert_eq!(staff[2].name, "Alice");
assert_eq!(reports_to[2].as_ref().unwrap().name, "Alan");

assert_eq!(staff[3].name, "Elle");
assert_eq!(reports_to[3], None);
Enter fullscreen mode Exit fullscreen mode

It can work in reverse too.

let manages = staff
    .load_self_many(staff::Entity, staff::Relation::Manages, db)
    .await?;

assert_eq!(staff[0].name, "Alan");
assert_eq!(manages[0].len(), 2);
assert_eq!(manages[0][0].name, "Ben");
assert_eq!(manages[0][1].name, "Alice");

assert_eq!(staff[1].name, "Ben");
assert_eq!(manages[1].len(), 0);

assert_eq!(staff[2].name, "Alice");
assert_eq!(manages[2].len(), 0);

assert_eq!(staff[3].name, "Elle");
assert_eq!(manages[3].len(), 0);
Enter fullscreen mode Exit fullscreen mode

Unix Timestamp Column Type

Sometimes it may be desirable (or no choice but) to store a timestamp as i64 in database, but mapping it to a DateTimeUtc in application code.

We've created a new set of UnixTimestamp wrapper types that does this transparently:

#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "access_log")]
pub struct Model {
    .. // with `chrono` crate
    pub ts: ChronoUnixTimestamp,
    pub ms: ChronoUnixTimestampMillis,
    .. // with `time` crate
    pub ts: TimeUnixTimestamp,
    pub ms: TimeUnixTimestampMillis,
}
Enter fullscreen mode Exit fullscreen mode
let now = ChronoUtc::now();
let log = access_log::ActiveModel {
    ts: Set(now.into()),
    ..Default::default()
}
.insert(db)
.await?;

assert_eq!(log.ts.timestamp(), now.timestamp());
Enter fullscreen mode Exit fullscreen mode

Entity-First Workflow

SchemaBuilder can now be used in migrations.

#[async_trait::async_trait]
impl MigrationTrait for Migration {
    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        let db = manager.get_connection();

        db.get_schema_builder()
            .register(user::Entity)
            .apply(db) // or sync(db)
            .await
    }
}
Enter fullscreen mode Exit fullscreen mode

🧭 Instant GraphQL API

With Seaography, the Entities you wrote can instantly be exposed as a GraphQL schema, with full CRUD, filtering and pagination. No extra macros, no Entity re-generation is needed!

With SeaORM and Seaography, you can prototype quickly and stay in the flow. The Entity:

#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "user")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    pub name: String,
    #[sea_orm(unique)]
    pub email: String,
    #[sea_orm(has_one)]
    pub profile: HasOne<super::profile::Entity>,
    #[sea_orm(has_many)]
    pub posts: HasMany<super::post::Entity>,
}
Enter fullscreen mode Exit fullscreen mode

Instantly turned into a GraphQL type:

type User {
  id: Int!
  name: String!
  email: String!
  profile: Profile
  post(
    filters: PostFilterInput
    orderBy: PostOrderInput
    pagination: PaginationInput
  ): PostConnection!
}
Enter fullscreen mode Exit fullscreen mode

🖥️ SeaORM Pro: Admin Panel

SeaORM Pro is an admin panel solution allowing you to quickly and easily launch an admin panel for your application - frontend development skills not required, but certainly nice to have!

SeaORM Pro has been updated to support the latest features in SeaORM 2.0.

Features:

  • Full CRUD
  • Built on React + GraphQL
  • Built-in GraphQL resolver
  • Customize the UI with TOML config
  • Role Based Access Control (new in 2.0)

🦀 Rustacean Sticker Pack

The Rustacean Sticker Pack is the perfect way to express your passion for Rust.
Our stickers are made with a premium water-resistant vinyl with a unique matte finish.

Sticker Pack Contents:

  • Logo of SeaQL projects: SeaQL, SeaORM, SeaQuery, Seaography
  • Mascots: Ferris the Crab x 3, Terres the Hermit Crab
  • The Rustacean wordmark

Support SeaQL and get a Sticker Pack!

Rustacean Sticker Pack by SeaQL

Top comments (0)