Skip to content

Type-safe primary keys #3066

@AngelOnFira

Description

@AngelOnFira

Motivation

SeaORM today represents an entity's primary key as the raw scalar type; i32,
i64, Uuid, String. That makes every PK structurally identical to every
other PK of the same scalar, and mixing them up won't be caught by the type system.

There are several areas this failure mode can occur:

1. Untyped API usage

Concretely, today's API looks like this:

// Two entities, same scalar PK type.
mod user { pub struct Model { pub id: i32, pub name: String, /* … */ } }
mod post { pub struct Model { pub id: i32, pub title: String, /* … */ } }

let post: post::Model = /* … */;
let owner = user::Entity::find_by_id(post.id).one(db).await?; // logic error

This compiles, runs, and silently looks up the user whose id happens to equal a post id.

2. Untyped domain code

The same shape shows up in domain code:

fn ban_user(db: &DbConn, user_id: i32) { /* … */ }

let post: post::Model = /* … */;
ban_user(db, post.id);    // compiles, bans the wrong account

3. Multiple FK to the same column

The other recurring failure mode is junction tables that reference the same parent twice. For example, a "follower" join with both user_id and follower_id. All four columns are i32 (or i64). Position is the only thing distinguishing them:

user_follower::ActiveModel {
    user_id: Set(follower.id),   // swapped
    follower_id: Set(user.id),   // swapped
    ..Default::default()
}.insert(db).await?;             // compiles, records the inverse relationship

Foreign-key columns are equally exposed. A comment::Model carries post_id: i32 and user_id: i32. Any code that builds, edits, or filters comments can swap the two with no compiler complaint.

Proposed Solution

Use a core type Id<E, T> that gets generated for entities by the CLI, and integrate it with FK columns, and find_by_id and similar methods.

1. Codegen emits one-line aliases per table

// Generated by `sea-orm-cli generate entity --with-pk-newtypes`:
pub type CakeId = sea_orm::Id<Entity, i32>;

#[derive(DeriveEntityModel,)]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: CakeId,        // the alias, not the raw scalar
    pub name: String,
    /* … */
}

Foreign-key columns spell the parent's alias:

pub struct Model {
    #[sea_orm(primary_key)]
    pub id: CommentId,
    pub post_id: super::post::PostId,   // not raw i32
    pub user_id: super::user::UserId,
    /* … */
}

2. Role wrappers for self-referential junctions

When a table has more than one FK column pointing at the same parent, codegen emits per-column wrapper structs around the parent's alias so positional swaps fail to compile:

#[derive(DeriveValueType,)]
pub struct UserFollowerPkUserId(pub super::user::UserId);
#[derive(DeriveValueType,)]
pub struct UserFollowerPkFollowerId(pub super::user::UserId);

pub struct Model {
    #[sea_orm(primary_key)]
    pub user_id: UserFollowerPkUserId,
    #[sea_orm(primary_key)]
    pub follower_id: UserFollowerPkFollowerId,
    /* … */
}

Now the swapped insert from the motivation section is a compile error:

user_follower::ActiveModel {
    user_id: Set(UserFollowerPkFollowerId(follower.id)),  // type mismatch
    follower_id: Set(UserFollowerPkUserId(user.id)),      // type mismatch
    ..Default::default()
};

3. find_by_id / filter_by_id are a single permissive function

The safety contract lives on Id<E, T>, not on the function signature, so one signature handles both typed and untyped entities:

fn find_by_id<T>(values: T) -> Select<Self>
where T: Into<<Self::PrimaryKey as PrimaryKeyTrait>::ValueType>

4. Opt-in via CLI flag, backwards-compatible

The whole feature is gated behind sea-orm-cli generate entity --with-pk-newtypes. Existing projects don't get newtypes unless they regenerate with the flag. Hand-written entities should be able to adopt incrementally by converting one table at a time by replacing pub id: i32 with a one-line alias.


I've been working on a prototype for this that I'll work to make a draft PR soon.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels
    No fields configured for Feature.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions