ActiveModelEx 与嵌套写入
主线位置:写入主线在 2.0 的延伸。前几页的
ActiveModel::insert/update/save一次写一行;#[sea_orm::model]生成的ActiveModelEx在它们外面再包一层:一个对象带着它的关联对象,save时按外键依赖排好顺序,逐个调用前面那几个方法,并把整组写入放进一个事务。宏生成了哪些类型见 DeriveEntityModel 与 dense 格式,BelongsTo/HasMany字段怎么变成RelationDef见关系。
flowchart TB savecall["am_ex.save(db)"] --> beg["db.begin()"] beg --> bt["belongs_to: 先写父对象"] bt --> fk1["填自己的外键"] fk1 --> self_w["自己 insert 或 update"] self_w --> ho["has_one / has_many 子对象"] ho --> fk2["填子对象外键"] fk2 --> child["child.action 递归"] child --> m2m["多对多 establish_links"] m2m --> cmt["txn.commit()"]
实验 crate 依赖 sea-orm =2.0.4(sqlx-postgres、runtime-tokio-rustls、macros、with-json、postgres-array、serde),平台 M2 Max、macOS、rustc 1.99.0、Postgres 16.14。六张表,全部是 dense 写法:
| 实体 | 关系字段 | 外键 |
|---|---|---|
user(表 users) | profile: HasOne<profile::Entity>、posts: HasMany<post::Entity>、attachments: HasMany<attachment::Entity> | 无 |
profile | user: BelongsTo<user::Entity> | user_id int NOT NULL UNIQUE |
post | user: BelongsTo<user::Entity>、tags: HasMany<tag::Entity>(via = "post_tag") | user_id int NOT NULL,title 有 UNIQUE |
tag | 无 | name 有 UNIQUE |
post_tag | post、tag 两个 BelongsTo | 复合主键 (post_id, tag_id) |
attachment | user: BelongsTo<Option<user::Entity>> | user_id int,可空 |
post 和 post_tag 的 before_save / before_delete 钩子各打印一行。wire> / wire< 行和 save 与 ActiveModelBehavior 一样,来自夹在 sqlx 和 Postgres 之间的 TCP 代理:每条执行的语句一行,加上 ReadyForQuery 事务状态的变化(I 空闲、T 事务中、E 事务已失败)。sqlx 自己的日志里看不到 BEGIN,这里要看事务边界,所以用代理。
三种关系容器
ActiveModelEx 的标量字段和 ActiveModel 一样是 ActiveValue<T>,关系字段换成了三个枚举(src/entity/active_model_ex.rs):
| 字段类型 | 容器 | 变体 | save 时 |
|---|---|---|---|
BelongsTo<E> / BelongsTo<Option<E>> | ActiveBelongsTo | NotSet、Set(..) | Set 时先写父对象,再把它的主键填进自己的外键;Set(None) 把外键设成 NULL |
HasOne<E> | ActiveHasOne | NotSet、Set(Option<Box<..>>) | Set(Some) 替换现有的那一个;Set(None) 去掉现有的 |
HasMany<E>(含 via 多对多) | ActiveHasMany | NotSet、Replace(Vec)、Append(Vec) | Append 只写列表里的;Replace 还要删掉列表外的 |
ActiveHasMany 的注释把两种语义说得很直白:
pub enum ActiveHasMany<E: EntityTrait> {
/// Field is absent; existing related models are left as-is on save.
#[default]
NotSet,
/// Persist exactly this list of related models, deleting any existing
/// children that are not in the list.
Replace(Vec<E::ActiveModelEx>),
/// Persist these related models alongside any existing children; never
/// deletes.
Append(Vec<E::ActiveModelEx>),
}
三个类型的文档注释都带一句「Unstable: nested-ActiveModel relation mutation is exempt from semver」:2.0.0-rc.42 起这部分被标为不受 semver 约束,2.x 的小版本里语义可能变。下面所有结论只对 2.0.4 成立。
builder:set_x 与 add_x
ActiveModel::builder() 就是 ActiveModelEx::new(),new() 走 ActiveModelTrait::default(),后者在生成代码里是 <ActiveModel as ActiveModelBehavior>::new().into(),所以 ActiveModelBehavior::new 里预填的字段 builder 也有。每个字段生成一个按值收 self 的方法,cargo expand 出来 user 的是这些(每个方法上的 /// Generated by sea-orm-macros 删掉了):
pub fn set_name(mut self, v: impl Into<String>) -> Self {
self.name = sea_orm::Set(v.into());
self
}
// ...
pub fn set_profile(
mut self,
v: impl Into<super::profile::ActiveModelEx>,
) -> Self {
self.profile = sea_orm::ActiveHasOne::<
super::profile::Entity,
>::set(Some(v.into()));
self
}
// ... set_profile_option, clear_profile
pub fn add_post(mut self, v: impl Into<super::post::ActiveModelEx>) -> Self {
self.posts.push(v.into());
self
}
post 那边是 set_user(BelongsTo)和 add_tag(多对多)。HasMany 字段的方法名由 expand_compound 用 pluralizer::pluralize(name, 1, false) 把字段名变成单数再加 add_:posts → add_post,attachments → add_attachment。ActiveHasMany::push 在 NotSet 上会变成 Append,所以 builder 攒出来的子对象列表都是 Append。参数是 impl Into<ActiveModelEx>,ActiveModel、另一个 builder、ModelEx 都能传。
save 怎么排顺序
insert、update、save 三个方法都只是把 ActiveModelAction::{Insert, Update, Save} 交给同一个生成的 action(模板在 ActiveModelActionTokens::expand)。user 的 action 展开后,主干是这样(关系部分各留一段):
pub async fn action<'a, C>(
mut self,
action: sea_orm::ActiveModelAction,
db: &'a C,
) -> Result<Self, sea_orm::DbErr>
where
C: sea_orm::TransactionTrait,
{
// ...
let txn = db.begin().await?;
let db = &txn;
let mut deleted = sea_orm::DeleteResult::empty();
let profile = std::mem::take(&mut self.profile);
let posts = self.posts.take();
let attachments = self.attachments.take();
let model: ActiveModel = self.into();
let will_insert = !model.is_update()
&& #[allow(non_exhaustive_omitted_patterns)]
match action {
sea_orm::ActiveModelAction::Insert
| sea_orm::ActiveModelAction::Save => true,
_ => false,
};
let mut model: Self = if model.is_changed() || will_insert {
match action {
sea_orm::ActiveModelAction::Insert => model.insert(db).await,
sea_orm::ActiveModelAction::Update => model.update(db).await,
sea_orm::ActiveModelAction::Save => {
if !model.is_update() {
model.insert(db).await
} else {
model.update(db).await
}
}
}?
.into_ex()
.into()
} else {
model.into()
};
match profile {
ActiveHasOne::NotSet => {}
ActiveHasOne::Set(Some(child)) => {
let mut child = *child;
if let Some(item) = model
.find_related(super::profile::Entity)
.one(db)
.await?
{
// an existing profile with another primary key: clear its FK or delete it
// ...
}
child.set_parent_key(&model)?;
let child = if child.is_changed() {
Box::pin(child.action(action, db)).await?
} else {
child
};
// ...
}
// ...
}
if posts.is_replace() {
// delete or detach existing posts that are not in the list
// ...
}
model.posts = posts.empty_holder();
for mut posts in posts.into_vec() {
posts.set_parent_key(&model)?;
let posts = if posts.is_changed() {
Box::pin(posts.action(action, db)).await?
} else {
posts
};
model.posts.push(posts);
}
// ... attachments, same as posts
txn.commit().await?;
Ok(model)
}
自己这一行用的是 ActiveModel 的 insert / update,save 的分岔也照抄了 is_update,所以 save 与 ActiveModelBehavior 里讲的主键判断和钩子在每一层都成立。只是多了一道门:没有任何字段被 Set、主键也已经有了的对象(is_changed() 为假且 will_insert 为假)不写,原样放回去。子对象先 set_parent_key 把刚拿到的父主键填进外键,再递归调 action。
belongs_to 方向反过来,要先写父对象。post 的 action 在写自己之前处理 user 字段(模板是 save_related_model):
let user = match self.user.take() {
ActiveBelongsTo::NotSet => ActiveBelongsTo::NotSet,
ActiveBelongsTo::Set(model) => {
let mut model = *model;
if model.is_update() {
self.set_parent_key(&model)?;
if model.is_changed() {
model = Box::pin(model.action(action, db)).await?;
}
} else {
model = Box::pin(model.action(action, db)).await?;
self.set_parent_key(&model)?;
}
ActiveBelongsTo::<super::user::Entity>::set(model)
}
};
父对象已有主键,直接抄过来,只有改过才写;没有主键,先写进去拿到主键再抄。整体顺序就是 mermaid 图那样:belongs_to 的父对象、自己、has_one、has_many、多对多。跑一个三层的 builder:
let alice = user::ActiveModel::builder()
.set_name("alice")
.set_profile(profile::ActiveModel::builder().set_bio("hi"))
.add_post(post::ActiveModel::builder().set_title("p1").add_tag(tag::ActiveModel::builder().set_name("rust")))
.add_post(post::ActiveModel::builder().set_title("p2").add_tag(tag::ActiveModel::builder().set_name("sql")))
.add_attachment(attachment::ActiveModel::builder().set_name("a.png"))
.save(&db)
.await?;
--- 1. builder: user + profile + 2 posts + tags + attachment, save(&db)
wire> BEGIN
wire< ReadyForQuery(T)
wire> INSERT INTO "users" ("name") VALUES ($1) RETURNING "id", "name"
wire> SELECT "profile"."id", "profile"."user_id", "profile"."bio" FROM "profile" INNER JOIN "users" ON "users"."id" = "profile"."user_id" WHERE "users"."id" = $1 LIMIT $2
wire> SAVEPOINT _sqlx_savepoint_1
wire> INSERT INTO "profile" ("user_id", "bio") VALUES ($1, $2) RETURNING "id", "user_id", "bio"
wire> RELEASE SAVEPOINT _sqlx_savepoint_1
wire> SAVEPOINT _sqlx_savepoint_1
[post hook] before_save insert=true title=Set("p1")
wire> INSERT INTO "post" ("user_id", "title") VALUES ($1, $2) RETURNING "id", "user_id", "title"
wire> SAVEPOINT _sqlx_savepoint_2
wire> INSERT INTO "tag" ("name") VALUES ($1) RETURNING "id", "name"
wire> RELEASE SAVEPOINT _sqlx_savepoint_2
[post_tag hook] before_save insert=true Set(1)/Set(1)
wire> INSERT INTO "post_tag" ("post_id", "tag_id") VALUES ($1, $2) ON CONFLICT ("post_id", "tag_id") DO NOTHING RETURNING "post_id", "tag_id"
wire> RELEASE SAVEPOINT _sqlx_savepoint_1
wire> SAVEPOINT _sqlx_savepoint_1
[post hook] before_save insert=true title=Set("p2")
wire> INSERT INTO "post" ("user_id", "title") VALUES ($1, $2) RETURNING "id", "user_id", "title"
wire> SAVEPOINT _sqlx_savepoint_2
wire> INSERT INTO "tag" ("name") VALUES ($1) RETURNING "id", "name"
wire> RELEASE SAVEPOINT _sqlx_savepoint_2
[post_tag hook] before_save insert=true Set(2)/Set(2)
wire> INSERT INTO "post_tag" ("post_id", "tag_id") VALUES ($1, $2) ON CONFLICT ("post_id", "tag_id") DO NOTHING RETURNING "post_id", "tag_id"
wire> RELEASE SAVEPOINT _sqlx_savepoint_1
wire> SAVEPOINT _sqlx_savepoint_1
wire> INSERT INTO "attachment" ("user_id", "name") VALUES ($1, $2) RETURNING "id", "user_id", "name"
wire> RELEASE SAVEPOINT _sqlx_savepoint_1
wire> COMMIT
wire< ReadyForQuery(I)
returned: user.id=Unchanged(1) profile.user_id=Some(Unchanged(1)) posts=[(Unchanged(1), Unchanged(1), 1), (Unchanged(2), Unchanged(1), 1)]
users=1 profile=1 post=2 tag=2 post_tag=2 attachment=1
24 条语句,其中 14 条是 BEGIN / SAVEPOINT / RELEASE / COMMIT,真正读写数据的 10 条。顺序和生成代码一一对应:users 先插,拿到 id = 1;profile 的 user_id、两个 post 的 user_id 都是填进去的 1。profile 前面那条 SELECT 来自 has_one 分支里的 find_related(..).one(db):替换语义要先看看现在有没有一个旧的 profile,即使 user 是刚插入的、不可能有。返回值是写回之后的整张图,每一层的主键和外键都是 Unchanged。
反方向,post 的 user 是新的:
--- 7. belongs_to: save a post whose user is new
wire> BEGIN
wire< ReadyForQuery(T)
wire> SAVEPOINT _sqlx_savepoint_1
wire> INSERT INTO "users" ("name") VALUES ($1) RETURNING "id", "name"
wire> RELEASE SAVEPOINT _sqlx_savepoint_1
[post hook] before_save insert=true title=Set("d1")
wire> INSERT INTO "post" ("user_id", "title") VALUES ($1, $2) RETURNING "id", "user_id", "title"
wire> COMMIT
wire< ReadyForQuery(I)
post.user_id=Unchanged(4)
users 先于 post,用户 4 号的 id 进了 post.user_id。
多对多的子对象(tags 里的每个 tag)先各自 action,已有主键又没改过的跳过;然后 establish_links 为每个 tag 造一个 post_tag,逐个跑 before_save(db, true),再用一条 insert_many(..).on_conflict_do_nothing().exec_with_returning_many(..) 插进去,对插入成功的行跑 after_save。post_tag 的主键正好是两个外键时,它不先查已有的关联(ON CONFLICT DO NOTHING 已经能挡住重复)。把一个读出来的 tag 和一个新 tag 挂到新 post 上:
let rust = tag::Entity::find_by_id(1).one(&db).await?.unwrap();
let p = post::ActiveModel::builder()
.set_title("e1")
.set_user_id(4)
.add_tag(sea_orm::IntoActiveModel::into_active_model(rust))
.add_tag(tag::ActiveModel::builder().set_name("pg"))
.save(&db)
.await?;
--- 8. many-to-many: attach an existing tag (loaded) and a new one
wire> BEGIN
wire< ReadyForQuery(T)
[post hook] before_save insert=true title=Set("e1")
wire> INSERT INTO "post" ("user_id", "title") VALUES ($1, $2) RETURNING "id", "user_id", "title"
wire> SAVEPOINT _sqlx_savepoint_1
wire> INSERT INTO "tag" ("name") VALUES ($1) RETURNING "id", "name"
wire> RELEASE SAVEPOINT _sqlx_savepoint_1
[post_tag hook] before_save insert=true Set(7)/Set(1)
[post_tag hook] before_save insert=true Set(7)/Set(3)
wire> INSERT INTO "post_tag" ("post_id", "tag_id") VALUES ($1, $2), ($3, $4) ON CONFLICT ("post_id", "tag_id") DO NOTHING RETURNING "post_id", "tag_id"
wire> COMMIT
wire< ReadyForQuery(I)
tags=[(Unchanged(1), Unchanged("rust")), (Unchanged(3), Unchanged("pg"))]
users=1 profile=0 post=2 tag=3 post_tag=2 attachment=1
rust 是全 Unchanged,没写,只建了关联;pg 插入后拿到 3。两行关联一条 INSERT。反过来,给第二个 post add_tag(tag::ActiveModel::builder().set_name("rust")),这个新对象没有主键,会被当成新行插入(另一个程序 dup_tag,打印 DbErr 的 Display):
result: Err("Query Error: error returned from database: duplicate key value violates unique constraint \"tag_name_key\" at line 666")
builder 不会按唯一列去找已有的行。复用已有的行,要传读出来的那个。
上面两行 [post_tag hook],在 rc.41 之前不会出现。#3010(「ActiveModelEx odd ActiveModelBehavior with a junction table」)报告:「When adding an entry related to a table through a junction table, the ActiveModelBehavior functions are not run for the junction table」。修复 #3027 的说明是「ActiveModelBehavior is now triggered for the junction table when adding a via model」,做法是「Runs ActiveBehavior for via_models」,也就是 establish_links 里那段逐个 am.before_save(db, true) 和插入后的 after_save。首次发布于 2.0.0-rc.41,release notes 写的是关联表的行「now run their ActiveModelBehavior hooks (before_save / after_save on inserted via-models, before_delete / after_delete on removed ones) instead of being inserted/deleted without hooks」。下文删除一节会看到,删整个 post 时关联表的删除仍然不走钩子。
一个事务,每层一个 SAVEPOINT
action 的第一句是 db.begin(),最后一句是 txn.commit(),中间所有语句都发在 &txn 上。C 的约束是 TransactionTrait,不是 ConnectionTrait:&DatabaseConnection 上 begin 是 BEGIN,&DatabaseTransaction 上 begin 开的是嵌套事务,也就是 SAVEPOINT(名字 _sqlx_savepoint_N 由 sqlx 按深度起,见 sqlx 卷的事务;sea-orm 这一层见事务)。每个子对象的 action 再调一次 begin,所以上面的日志里每个子对象外面都包着一对 SAVEPOINT / RELEASE SAVEPOINT,第二层的 tag 是 _sqlx_savepoint_2。
调用方自己已经开了事务,把 &txn 传进去,最外层也变成 SAVEPOINT,提交与否由调用方决定:
let txn = db.begin().await?;
let carol = user::ActiveModel::builder()
.set_name("carol")
.add_post(post::ActiveModel::builder().set_title("c1"))
.save(&txn)
.await?;
println!("carol.id={:?}", carol.id);
txn.rollback().await?;
println!("after caller rollback: {}", counts(&db).await?);
--- 6. builder save(&txn) inside a caller's transaction
wire> SAVEPOINT _sqlx_savepoint_1
wire> INSERT INTO "users" ("name") VALUES ($1) RETURNING "id", "name"
wire> SAVEPOINT _sqlx_savepoint_2
[post hook] before_save insert=true title=Set("c1")
wire> INSERT INTO "post" ("user_id", "title") VALUES ($1, $2) RETURNING "id", "user_id", "title"
wire> RELEASE SAVEPOINT _sqlx_savepoint_2
wire> RELEASE SAVEPOINT _sqlx_savepoint_1
carol.id=Unchanged(3)
after caller rollback: users=0 profile=0 post=0 tag=2 post_tag=0 attachment=1
(BEGIN 在打开打印之前就发了,所以不在输出里。这时 users 是 0,因为第 5 步已经把 alice 删了,见后文。)
问: 整组写入已经在一个事务里了,为什么每个子对象还要一个 SAVEPOINT?
答: 没有人专门决定「这里要 savepoint」。action 是同一个模板生成的,每一层都无条件 db.begin()、txn.commit(),它不知道自己是最外层还是被父对象调用的;begin 在事务上的含义就是 savepoint。好处是任何一层单独调用都自带事务,代价是每个被写入的子对象多两次往返(SAVEPOINT、RELEASE SAVEPOINT),上面 10 条数据语句配了 14 条事务控制语句。这些 savepoint 也不是为了「子对象失败、父对象保留」:错误用 ? 一路往外传,每一层的 txn 都在 drop 时回滚,最后整组撤销(下一节)。
C: TransactionTrait 在 2.0.0 里还给使用者带来过一个编译错误。
#[sea_orm::model] 在 2.0.0 上过不了 clippyaction::<C> 调 db.begin() 得到 C::Transaction,再把它交给子对象的 action,于是 action::<C> 里递归出现 action::<C::Transaction>。2.0.0 的 TransactionTrait 只要求 type Transaction: ConnectionTrait + TransactionTrait + TransactionSession,C::Transaction::Transaction 可以是另一个类型。#3147(「2.0 - New Entity Format - Compiler (recursion) error」)报告一个普通的 dense 实体编译报「future returned by cascade_delete is not Send」。修复 #3153 的说明:「Proving those futures Send without a concrete C walks the unbounded C::Transaction::Transaction::… chain and overflows (E0275 / clippy::future_not_send)」,改法是在 trait 上要求 Sync,并把 Transaction 钉成一个 Send 的不动点「(Transaction::Transaction == Transaction), collapsing the chain to one type」。首次发布于 2.0.1。2.0.4 的定义就是这样:type Transaction: ConnectionTrait + TransactionTrait<Transaction = Self::Transaction> + TransactionSession + Send,DatabaseTransaction 的 Transaction 是它自己。
中途失败留下什么
第二个 builder 和第一个结构相同,但第二个 post 的标题 p1 已经存在,post.title 有唯一约束:
let r = user::ActiveModel::builder()
.set_name("bob")
.set_profile(profile::ActiveModel::builder().set_bio("yo"))
.add_post(post::ActiveModel::builder().set_title("q1"))
.add_post(post::ActiveModel::builder().set_title("p1"))
.save(&db)
.await;
--- 2. failure in the middle: second post has a duplicate title
wire> BEGIN
wire< ReadyForQuery(T)
wire> INSERT INTO "users" ("name") VALUES ($1) RETURNING "id", "name"
wire> SELECT "profile"."id", "profile"."user_id", "profile"."bio" FROM "profile" INNER JOIN "users" ON "users"."id" = "profile"."user_id" WHERE "users"."id" = $1 LIMIT $2
wire> SAVEPOINT _sqlx_savepoint_1
wire> INSERT INTO "profile" ("user_id", "bio") VALUES ($1, $2) RETURNING "id", "user_id", "bio"
wire> RELEASE SAVEPOINT _sqlx_savepoint_1
wire> SAVEPOINT _sqlx_savepoint_1
[post hook] before_save insert=true title=Set("q1")
wire> INSERT INTO "post" ("user_id", "title") VALUES ($1, $2) RETURNING "id", "user_id", "title"
wire> RELEASE SAVEPOINT _sqlx_savepoint_1
wire> SAVEPOINT _sqlx_savepoint_1
[post hook] before_save insert=true title=Set("p1")
wire> INSERT INTO "post" ("user_id", "title") VALUES ($1, $2) RETURNING "id", "user_id", "title"
wire< ErrorResponse 23505 duplicate key value violates unique constraint "post_title_key"
wire> ROLLBACK TO SAVEPOINT _sqlx_savepoint_1
wire> ROLLBACK
wire< ReadyForQuery(E)
wire< ReadyForQuery(T)
wire< ReadyForQuery(I)
result: Err(Query(SqlxError(Database(PgDatabaseError { severity: Error, code: "23505", message: "duplicate key value violates unique constraint \"post_title_key\"", detail: Some("Key (title)=(p1) already exists."), hint: None, position: None, where: None, schema: Some("so_write2"), table: Some("post"), column: None, data_type: None, constraint: Some("post_title_key"), file: Some("nbtinsert.c"), line: Some(666), routine: Some("_bt_check_unique") }))))
users=1 profile=1 post=2 tag=2 post_tag=2 attachment=1
bob、他的 profile、已经 RELEASE 掉的 q1,全都没有留下:计数和第 1 步结束时一样。过程是这样的:第二个 post 的 action 里 model.insert(db).await 返回 Err,? 让它提前返回,它的 txn(_sqlx_savepoint_1)被 drop,排进一条 ROLLBACK TO SAVEPOINT;错误再从 user 的 action 里 ? 出去,最外层的 txn 被 drop,排进 ROLLBACK。Drop 不能 await,两条都只是排进写缓冲,随后一起发出(sea-orm 这一层见事务,sqlx 这一层见忘了 commit),服务端的状态依次是 E(事务已失败)、T(回到 savepoint 之后)、I(整个事务回滚)。wire> 和 wire< 是两个方向各自的打印,交错的先后不代表服务端的处理顺序。调用方拿到的是原样的 DbErr::Query,没有被包装成别的错误。
唯一留下的痕迹是序列:bob 拿过 users.id = 2,后面第 6 步的 carol 是 3,第 7 步的用户是 4。钩子返回的错误走同一条路径,也会让整组回滚。
改一张读出来的图
EntityLoader 读出来的是 ModelEx,into() 变成 ActiveModelEx。关系字段的转换在 HasMany::into_active_model:
pub fn into_active_model(self) -> ActiveHasMany<E> {
match self {
HasMany::Loaded(models) => {
ActiveHasMany::Append(models.into_iter().map(Into::into).collect())
}
HasMany::Unloaded => ActiveHasMany::NotSet,
}
}
加载过的列表变成 Append,没加载的是 NotSet。读出 alice 和她的两个 post,改第一个的标题,从 Vec 里去掉第二个,再 save:
let loaded = user::Entity::load().filter_by_id(1).with(post::Entity).with(attachment::Entity).one(&db).await?.unwrap();
let mut am: user::ActiveModelEx = loaded.clone().into();
println!("posts is {}", if am.posts.is_append() { "Append" } else { "Replace/NotSet" });
am.posts[0].title = Set("p1 edited".into());
am.posts.as_mut_vec().truncate(1);
let am = am.save(&db).await?;
save 之后 post 表里还有几行?这次 save 发了几条读写数据的语句(不算 BEGIN / SAVEPOINT / RELEASE / COMMIT)?平台:M2 Max、macOS、rustc 1.99.0、Postgres 16.14。答案在页末。想让「列表里没有的」被处理掉,要明确换成 Replace:replace_all,或者直接赋值 ActiveHasMany::Replace(..)。has_many 分支(模板 has_many_action)在 Replace 时先把库里现有的子对象全读出来,用 ActiveHasMany::find 按主键找它在不在列表里,不在的交给这一段:
let mut item = item.into_active_model();
if item.clear_parent_key::<Entity>()? {
item.update(db).await?;
} else {
deleted.merge(item.into_ex().delete(db).await?);
}
外键可空,就把外键设成 NULL 再 update,子行留着、和父对象断开;外键不可空,就把子对象整个删掉,而且是 ActiveModelEx::delete,连同它自己的关系一起删。接着上面,把列表换成 Replace:
let mut am = am;
let keep: Vec<post::ActiveModelEx> = am.posts.as_slice()[..1].to_vec();
am.posts.replace_all(keep);
let _am = am.save(&db).await?;
--- 4. same, but replace_all
wire> BEGIN
wire< ReadyForQuery(T)
wire> SELECT "post"."id", "post"."user_id", "post"."title" FROM "post" INNER JOIN "users" ON "users"."id" = "post"."user_id" WHERE "users"."id" = $1
wire> SAVEPOINT _sqlx_savepoint_1
wire> DELETE FROM "post_tag" WHERE "post_tag"."post_id" IN ($1)
[post hook] before_delete id=Unchanged(2)
wire> DELETE FROM "post" WHERE "post"."id" = $1
wire> RELEASE SAVEPOINT _sqlx_savepoint_1
wire> COMMIT
wire< ReadyForQuery(I)
users=1 profile=1 post=1 tag=2 post_tag=1 attachment=1
post.user_id 不可空,2 号 post 连同它在 post_tag 里的关联被删掉。留下的 1 号上一步已经存过、全是 Unchanged,这次没有再写。attachment.user_id 可空,走的是 UPDATE ... SET "user_id" = $1(下一节的 cascade_delete 里能看到这条)。
多对多的 Replace 由 establish_links 处理,删的是关联表的行,不是另一端的实体,删之前对每一行跑 before_delete,删完对 RETURNING 回来的行跑 after_delete。把 e1 的两个 tag 换成只剩 pg:
--- 9. many-to-many Replace: keep only the second tag
wire> BEGIN
wire< ReadyForQuery(T)
wire> SELECT "post_tag"."post_id", "post_tag"."tag_id" FROM "post_tag" WHERE "post_tag"."post_id" IN ($1)
[post_tag hook] before_delete Unchanged(7)/Unchanged(1)
wire> DELETE FROM "post_tag" WHERE ("post_tag"."post_id", "post_tag"."tag_id") IN (($1, $2)) RETURNING "post_id", "tag_id"
wire> COMMIT
wire< ReadyForQuery(I)
users=1 profile=0 post=2 tag=3 post_tag=1 attachment=1
tag 还是 3 行,rust 这个 tag 本身没动。
删除:cascade_delete
#[sea_orm::model] 给 Model 加了 cascade_delete,就是 self.into_ex().delete(db)(expand_active_model_ex 里生成)。ActiveModelEx::delete 也开一个事务,先处理每个 has_one / has_many、再删多对多的关联、最后删自己。user 展开后:
let txn = db.begin().await?;
let db = &txn;
let mut deleted = sea_orm::DeleteResult::empty();
if let Some(item) = self.find_related(super::profile::Entity).one(db).await?
{
let mut item = item.into_active_model();
if item.clear_parent_key::<Entity>()? {
item.update(db).await?;
} else {
deleted.merge(item.into_ex().delete(db).await?);
}
}
for item in self.find_related(super::post::Entity).all(db).await? {
// same as profile
// ...
}
for item in self.find_related(super::attachment::Entity).all(db).await? {
// same as profile
// ...
}
let model: ActiveModel = self.into();
deleted.merge(model.delete(db).await?);
txn.commit().await?;
Ok(deleted)
它不看数据库,也不看实体上的 on_delete = "Cascade"(active_model_ex.rs 里没有用到 on_delete,它只进建表的 DDL,见关系):每种子关系都是先 SELECT 出来,再按外键是否可空决定 UPDATE 置空还是递归删除。删 alice:
let alice = user::Entity::find_by_id(1).one(&db).await?.unwrap();
let r = alice.cascade_delete(&db).await?;
println!("rows_affected={}", r.rows_affected);
--- 5. cascade_delete alice
wire> BEGIN
wire< ReadyForQuery(T)
wire> SELECT "profile"."id", "profile"."user_id", "profile"."bio" FROM "profile" INNER JOIN "users" ON "users"."id" = "profile"."user_id" WHERE "users"."id" = $1 LIMIT $2
wire> SAVEPOINT _sqlx_savepoint_1
wire> DELETE FROM "profile" WHERE "profile"."id" = $1
wire> RELEASE SAVEPOINT _sqlx_savepoint_1
wire> SELECT "post"."id", "post"."user_id", "post"."title" FROM "post" INNER JOIN "users" ON "users"."id" = "post"."user_id" WHERE "users"."id" = $1
wire> SAVEPOINT _sqlx_savepoint_1
wire> DELETE FROM "post_tag" WHERE "post_tag"."post_id" IN ($1)
[post hook] before_delete id=Unchanged(1)
wire> DELETE FROM "post" WHERE "post"."id" = $1
wire> RELEASE SAVEPOINT _sqlx_savepoint_1
wire> SELECT "attachment"."id", "attachment"."user_id", "attachment"."name" FROM "attachment" INNER JOIN "users" ON "users"."id" = "attachment"."user_id" WHERE "users"."id" = $1
wire> UPDATE "attachment" SET "user_id" = $1 WHERE "attachment"."id" = $2 RETURNING "id", "user_id", "name"
wire> DELETE FROM "users" WHERE "users"."id" = $1
wire> COMMIT
wire< ReadyForQuery(I)
rows_affected=4
users=0 profile=0 post=0 tag=2 post_tag=0 attachment=1
几处值得对照:
profile和post的外键不可空,被删;attachment的外键可空,只是置空,表里还剩 1 行。rows_affected=4是profile、post_tag、post、users各 1 行,attachment的UPDATE不算。- 删
post时,它的关联行由delete_links用一条delete_many删掉,post_tag的before_delete没有跑(对比上一节第 9 步跑了);而post自己的before_delete在关联行已经删掉之后才被调用,因为ActiveModelEx::delete把「删自己」放在最后,钩子在ActiveModel::delete里面。 - 数据库层面的
ON DELETE CASCADE是一条DELETE由服务端处理所有子行;cascade_delete是客户端逐层SELECT再逐行DELETE/UPDATE,子行越多语句越多,但每一行都经过 ORM 的钩子(关联表除外)。
动脑筋答案
--- 3. load alice with posts, edit posts[0], drop posts[1] from the Vec, save
posts is Append
wire> BEGIN
wire< ReadyForQuery(T)
wire> SAVEPOINT _sqlx_savepoint_1
[post hook] before_save insert=false title=Set("p1 edited")
wire> UPDATE "post" SET "title" = $1 WHERE "post"."id" = $2 RETURNING "id", "user_id", "title"
wire> RELEASE SAVEPOINT _sqlx_savepoint_1
wire> COMMIT
wire< ReadyForQuery(I)
users=1 profile=1 post=2 tag=2 post_tag=2 attachment=1
还是 2 行,只发了 1 条 UPDATE。posts 是 Append,has_many 分支只在 is_replace() 时去读、去删现有的子对象,Append 只写列表里还在的:从 Vec 里拿掉一个元素,等于「这次不管它」,不等于「删掉它」。user 本身全是 Unchanged、主键已有,is_changed() 和 will_insert 都为假,没有 UPDATE users;attachments 也是全 Unchanged,没有语句;只有改过标题的 1 号 post 走了 update,钩子里 insert=false。
小结
ActiveModelEx的标量字段是ActiveValue,关系字段是ActiveBelongsTo、ActiveHasOne、ActiveHasMany(NotSet/Replace/Append)。builder 的set_x/add_x按值收self,add_x攒成Append。这部分在 2.0 被标为 semver 豁免。save/insert/update都进同一个生成的action:先写belongs_to的父对象,拿主键填自己的外键;再写自己(没改过且已有主键的跳过);再给has_one/has_many子对象填外键、递归action;最后多对多establish_links,关联表一条INSERT ... ON CONFLICT DO NOTHING,带钩子。- 每一层
action都db.begin()、txn.commit():在连接上是BEGIN,在事务上是SAVEPOINT。中途任何一条语句或钩子失败,错误原样返回,整组回滚,只留下序列的空号。传&txn进去,提交权在调用方。 - 读出来的
HasMany是Append:从Vec里删元素不会删行。要删用replace_all/Replace:外键可空的子对象被置空,不可空的被递归删除;多对多只删关联行。 cascade_delete在客户端逐层SELECT再DELETE/UPDATE,不看on_delete;被删实体的关联行用delete_many删,不跑关联表的钩子。- 已有的行要按读出来的模型传进 builder,新建一个同名对象会被当成新行插入。