Rust in Everything
基于 sea-orm 2.0.4

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>无
profileuser: BelongsTo<user::Entity>user_id int NOT NULL UNIQUE
postuser: BelongsTo<user::Entity>、tags: HasMany<tag::Entity>(via = "post_tag")user_id int NOT NULL,title 有 UNIQUE
tag无name 有 UNIQUE
post_tagpost、tag 两个 BelongsTo复合主键 (post_id, tag_id)
attachmentuser: 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>>ActiveBelongsToNotSet、Set(..)Set 时先写父对象,再把它的主键填进自己的外键;Set(None) 把外键设成 NULL
HasOne<E>ActiveHasOneNotSet、Set(Option<Box<..>>)Set(Some) 替换现有的那一个;Set(None) 去掉现有的
HasMany<E>(含 via 多对多)ActiveHasManyNotSet、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 不会按唯一列去找已有的行。复用已有的行,要传读出来的那个。

📝NOTE
冷知识:关联表的钩子是 2.0.0-rc.41 才开始跑的

上面两行 [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 删了,见后文。)

📝NOTE
没有蠢问题

问: 整组写入已经在一个事务里了,为什么每个子对象还要一个 SAVEPOINT?

答: 没有人专门决定「这里要 savepoint」。action 是同一个模板生成的,每一层都无条件 db.begin()、txn.commit(),它不知道自己是最外层还是被父对象调用的;begin 在事务上的含义就是 savepoint。好处是任何一层单独调用都自带事务,代价是每个被写入的子对象多两次往返(SAVEPOINT、RELEASE SAVEPOINT),上面 10 条数据语句配了 14 条事务控制语句。这些 savepoint 也不是为了「子对象失败、父对象保留」:错误用 ? 一路往外传,每一层的 txn 都在 drop 时回滚,最后整组撤销(下一节)。

C: TransactionTrait 在 2.0.0 里还给使用者带来过一个编译错误。

📝NOTE
冷知识:#[sea_orm::model] 在 2.0.0 上过不了 clippy

action::<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?;
❗IMPORTANT
动脑筋: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,新建一个同名对象会被当成新行插入。

💬 关联讨论

发起讨论
加载中...