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.
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 everyother 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:
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:
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_idandfollower_id. All four columns arei32(ori64). Position is the only thing distinguishing them:Foreign-key columns are equally exposed. A
comment::Modelcarriespost_id: i32anduser_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
Foreign-key columns spell the parent's alias:
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:
Now the swapped insert from the motivation section is a compile error:
3.
find_by_id/filter_by_idare a single permissive functionThe safety contract lives on
Id<E, T>, not on the function signature, so one signature handles both typed and untyped entities: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 replacingpub id: i32with a one-line alias.I've been working on a prototype for this that I'll work to make a draft PR soon.