Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ jobs:
actix_example,
axum_example,
basic,
basic_typed_pk,
graphql_example,
jsonrpsee_example,
loco_example,
Expand Down
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,8 @@ Cargo.lock
.idea/*
*/.idea/*
.env.local
.DS_Store
.DS_Store
# Auto-generated trybuild stderr captures; we only assert must-fail,
# stderr content is regenerated each run via TRYBUILD=overwrite.
tests/value_type_pk_compile_fail/*.stderr
sea-orm-sync/tests/value_type_pk_compile_fail/*.stderr
36 changes: 35 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,40 @@ db.get_schema_registry("my_crate::*")
.await?;
```

## Type-safe primary keys

`find_by_id` / `filter_by_id` / `delete_by_id` accept any `T: FindByIdArg<E>`, which is implemented blanket for `T: Into<<E::PrimaryKey as PrimaryKeyTrait>::ValueType>`. So `&str → String` and `u8 → i32` style conversions still flow through. The type safety comes from the PK type itself: a newtype like `UserId` has no `From<i32>`, so `find_by_id(1)` against a `UserId` PK fails to compile. To get that compile-time protection, wrap each entity's PK in a per-entity newtype with `DeriveValueType` (or use a `sea_orm::Id<E, T>` alias):

```rust
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, DeriveValueType)]
pub struct UserId(pub i32);

#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "user")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: UserId,
pub name: String,
}
```

**Do not** add `impl From<i32> for UserId` (or any `From<inner>`). That re-opens the door to `find_by_id(1)` and defeats the safety contract. Construct ids explicitly with the tuple form: `UserId(1)`.

For foreign keys, spell the column type with the parent's newtype too:

```rust
// post.rs
pub struct Model {
#[sea_orm(primary_key)]
pub id: PostId,
pub user_id: super::user::UserId,
// ...
}
```

The trybuild harness at `tests/value_type_pk_compile_fail/` pins this contract: any regression that re-allows `find_by_id(raw_int)` or cross-PK confusion will start compiling and fail the test.

## Anti-Patterns -- DO NOT DO THESE

### 1. Do not specify `column_type` on custom wrapper types
Expand Down Expand Up @@ -209,7 +243,7 @@ Expr::col((self.entity_name(), *self)).like(s)

### 5. Do not manually impl traits that `DeriveValueType` now generates

In 2.0, `DeriveValueType` auto-generates `NotU8`, `IntoActiveValue`, and `TryFromU64`. Remove manual implementations to avoid conflicts.
In 2.0, `DeriveValueType` auto-generates `NotU8`, `IntoActiveValue`, `TryFromU64`, and the primary-key auto-increment hint (`DelegatesPkAutoIncrementHint` for struct wrappers, `PkAutoIncrementHint` for string wrappers). Remove manual implementations to avoid conflicts. Hand-writing the auto-increment hint collides directly for string wrappers and via the blanket bridge for struct wrappers.

### 6. PostgreSQL: `serial` is no longer the default

Expand Down
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ smol-potat = { version = "1.1" }
time = { version = "0.3.36", features = ["macros"] }
tokio = { version = "1.6", features = ["full"] }
tracing-subscriber = { version = "0.3.17", features = ["env-filter"] }
trybuild = { version = "1" }
uuid = { version = "1", features = ["v4"] }

[features]
Expand Down
22 changes: 22 additions & 0 deletions examples/basic_typed_pk/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
[workspace]
# A separate workspace

[package]
edition = "2024"
name = "sea-orm-example-basic-typed-pk"
publish = false
rust-version = "1.85.0"
version = "0.1.0"

[dependencies]
tokio = { version = "1", features = ["full"] }

[dependencies.sea-orm]
features = [
"sqlx-sqlite",
"runtime-tokio",
"debug-print",
]
path = "../../"

[patch.crates-io]
60 changes: 60 additions & 0 deletions examples/basic_typed_pk/Readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# SeaORM typed-PK example

A minimal task tracker that exercises the per-entity
`sea_orm::Id<E, T>` primary key wrappers produced by
`sea-orm-cli generate entity --with-pk-newtypes`.

Five tables, each chosen to cover one PK-newtype pattern not already
demonstrated by another table:

- **Cross-entity ID safety.** `user`, `project`, `task` each get a
distinct alias (`UserPk`, `ProjectPk`, `TaskPk`) so passing the
wrong id to the wrong API is a compile error.
- **Composite primary key with typed components.** `project_member`
is keyed by `(ProjectPk, UserPk)`, the alias types carry into
`find_by_id`, `delete_by_id`, and the tuple destructuring.
- **Self-reference.** `task.parent_task_id: Option<TaskPk>` resolves
to the local alias, not `super::task::TaskPk`.
- **Multi-FK to the same parent in non-PK position.** `task` has
both `assignee_id` and `reviewer_id` referencing `user.id`. Both
share the parent's `UserPk` type, codegen does **not** role-wrap
non-PK FK columns (role wrappers are PK-only by design).
- **Role wrappers on a junction.** `task_dependency` has two PK
columns, both FK-referencing `task.id`. Codegen emits
`TaskDependencyPkBlockerTaskId` and `TaskDependencyPkBlockedTaskId`
so swapping blocker/blocked at an insert site fails to compile.

## Running

```sh
cargo run
```

The example uses in-memory SQLite, creates the schema from the
generated entities via `Schema::create_table_from_entity`, and walks
through realistic operations (`reassign_task`, `create_subtask`,
`add_blocker`, `add_project_member`, `tasks_assigned_to`) defined in
`src/operations.rs`. Each operation takes typed PKs in its signature.

## Regenerating the entities

Everything under `src/entity/` is produced from `tasks.sql` by
`sea-orm-cli`. To regenerate (e.g. after editing the schema):

```sh
sqlite3 /tmp/typed_pk_tasks.db < examples/basic_typed_pk/tasks.sql
cargo run --manifest-path sea-orm-cli/Cargo.toml --bin sea-orm-cli -- \
generate entity \
--database-url sqlite:///tmp/typed_pk_tasks.db \
--with-pk-newtypes \
--output-dir examples/basic_typed_pk/src/entity
```

Running the CLI via `cargo run --manifest-path sea-orm-cli/...` uses
the branch's source rather than whatever `sea-orm-cli` is on `$PATH`,
so the generated output reflects the version of codegen this example
ships with.

The committed entity files are a snapshot, like `examples/basic`,
they're regenerated by hand when the schema changes rather than at
build time.
9 changes: 9 additions & 0 deletions examples/basic_typed_pk/src/entity/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0

pub mod prelude;

pub mod project;
pub mod project_member;
pub mod task;
pub mod task_dependency;
pub mod user;
7 changes: 7 additions & 0 deletions examples/basic_typed_pk/src/entity/prelude.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0

pub use super::project::Entity as Project;
pub use super::project_member::Entity as ProjectMember;
pub use super::task::Entity as Task;
pub use super::task_dependency::Entity as TaskDependency;
pub use super::user::Entity as User;
44 changes: 44 additions & 0 deletions examples/basic_typed_pk/src/entity/project.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0

use sea_orm::entity::prelude::*;

pub type ProjectPk = sea_orm::Id<Entity, i64>;

#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "project")]
pub struct Model {
#[sea_orm(primary_key, auto_increment)]
pub id: ProjectPk,
pub name: String,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::project_member::Entity")]
ProjectMember,
#[sea_orm(has_many = "super::task::Entity")]
Task,
}

impl Related<super::project_member::Entity> for Entity {
fn to() -> RelationDef {
Relation::ProjectMember.def()
}
}

impl Related<super::task::Entity> for Entity {
fn to() -> RelationDef {
Relation::Task.def()
}
}

impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
super::project_member::Relation::User.def()
}
fn via() -> Option<RelationDef> {
Some(super::project_member::Relation::Project.def().rev())
}
}

impl ActiveModelBehavior for ActiveModel {}
47 changes: 47 additions & 0 deletions examples/basic_typed_pk/src/entity/project_member.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0

use sea_orm::entity::prelude::*;

#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "project_member")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub project_id: super::project::ProjectPk,
#[sea_orm(primary_key, auto_increment = false)]
pub user_id: super::user::UserPk,
pub role: String,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::project::Entity",
from = "Column::ProjectId",
to = "super::project::Column::Id",
on_update = "NoAction",
on_delete = "NoAction"
)]
Project,
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::UserId",
to = "super::user::Column::Id",
on_update = "NoAction",
on_delete = "NoAction"
)]
User,
}

impl Related<super::project::Entity> for Entity {
fn to() -> RelationDef {
Relation::Project.def()
}
}

impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}

impl ActiveModelBehavior for ActiveModel {}
61 changes: 61 additions & 0 deletions examples/basic_typed_pk/src/entity/task.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0

use sea_orm::entity::prelude::*;

pub type TaskPk = sea_orm::Id<Entity, i64>;

#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "task")]
pub struct Model {
#[sea_orm(primary_key, auto_increment)]
pub id: TaskPk,
pub project_id: super::project::ProjectPk,
pub assignee_id: super::user::UserPk,
pub reviewer_id: Option<super::user::UserPk>,
pub parent_task_id: Option<TaskPk>,
pub title: String,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::project::Entity",
from = "Column::ProjectId",
to = "super::project::Column::Id",
on_update = "NoAction",
on_delete = "NoAction"
)]
Project,
#[sea_orm(
belongs_to = "Entity",
from = "Column::ParentTaskId",
to = "Column::Id",
on_update = "NoAction",
on_delete = "NoAction"
)]
SelfRef,
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::ReviewerId",
to = "super::user::Column::Id",
on_update = "NoAction",
on_delete = "NoAction"
)]
User2,
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::AssigneeId",
to = "super::user::Column::Id",
on_update = "NoAction",
on_delete = "NoAction"
)]
User1,
}

impl Related<super::project::Entity> for Entity {
fn to() -> RelationDef {
Relation::Project.def()
}
}

impl ActiveModelBehavior for ActiveModel {}
42 changes: 42 additions & 0 deletions examples/basic_typed_pk/src/entity/task_dependency.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0

use sea_orm::entity::prelude::*;

#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, DeriveValueType)]
#[sea_orm(try_from_u64)]
pub struct TaskDependencyPkBlockerTaskId(pub super::task::TaskPk);

#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, DeriveValueType)]
#[sea_orm(try_from_u64)]
pub struct TaskDependencyPkBlockedTaskId(pub super::task::TaskPk);

#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "task_dependency")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub blocker_task_id: TaskDependencyPkBlockerTaskId,
#[sea_orm(primary_key, auto_increment = false)]
pub blocked_task_id: TaskDependencyPkBlockedTaskId,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::task::Entity",
from = "Column::BlockedTaskId",
to = "super::task::Column::Id",
on_update = "NoAction",
on_delete = "NoAction"
)]
Task2,
#[sea_orm(
belongs_to = "super::task::Entity",
from = "Column::BlockerTaskId",
to = "super::task::Column::Id",
on_update = "NoAction",
on_delete = "NoAction"
)]
Task1,
}

impl ActiveModelBehavior for ActiveModel {}
Loading
Loading