Rust in Everything
基于 sea-orm 2.0.4

save 与 ActiveModelBehavior

主线位置:写入主线的入口和两端。save 是 insert 和 update 前面的一个分岔:它只看主键,选一条路走下去。不管走哪条,ActiveModelTrait 的方法都在语句前后各调一次 ActiveModelBehavior 的钩子。ActiveValue 三态见 ActiveValue 与 ActiveModel,两条路本身(INSERT ... RETURNING、UPDATE ... WHERE 主键)见 insert 和 update 与 delete,这一页只讲分岔和钩子。

flowchart TB
  save["am.save(db)"] --> isup{"主键有 NotSet?"}
  isup -->|有| ins["insert(db)"]
  isup -->|没有| upd["update(db)"]
  ins --> bs1["before_save(true)"]
  bs1 --> sql1["INSERT RETURNING"]
  sql1 --> as1["after_save(true)"]
  upd --> bs2["before_save(false)"]
  bs2 --> sql2["UPDATE RETURNING"]
  sql2 --> as2["after_save(false)"]
  as1 --> back["into_active_model"]
  as2 --> back

实验 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。表有三张:note(id serial 主键、title、revision)、audit(钩子往里记一笔)、pair(复合主键 (a, b),都不自增)。下面的 wire> 行是一个夹在 sqlx 和 Postgres 之间的 TCP 代理打印的:客户端每执行一条语句(Query 消息,或者 Bind 一条解析过的语句)打一行 SQL,wire< 行是服务端的 ErrorResponse 和 ReadyForQuery 里事务状态的变化(I 空闲、T 事务中、E 事务已失败)。代理先打印再转发,所以 wire> 和钩子里 println! 的先后就是真实的先后。用代理而不是 sqlx 日志,是因为 sqlx 日志里没有 BEGIN(原因见 sqlx 卷的忘了 commit),而本页要看事务边界。

save:只看主键里有没有 NotSet

ActiveModelTrait::save 只有一个 if:

    /// Insert the model if primary key is `NotSet`, update otherwise.
    /// Only works if the entity has auto increment primary key.
    async fn save<'a, C>(self, db: &'a C) -> Result<Self, DbErr>
    where
        <Self::Entity as EntityTrait>::Model: IntoActiveModel<Self>,
        Self: ActiveModelBehavior,
        C: ConnectionTrait,
    {
        let res = if !self.is_update() {
            self.insert(db).await
        } else {
            self.update(db).await
        }?;
        Ok(res.into_active_model())
    }

    /// Returns true if the primary key is fully-specified
    #[doc(hidden)]
    fn is_update(&self) -> bool {
        let mut is_update = true;
        for key in <Self::Entity as EntityTrait>::PrimaryKey::iter() {
            let col = key.into_column();
            if self.is_not_set(col) {
                is_update = false;
                break;
            }
        }
        is_update
    }

is_update 逐个看主键列,只要有一列是 NotSet 就判成 insert。Set 和 Unchanged 在这里没有区别,不看其他列,也不查数据库里有没有这一行。save 的返回值不是 Model,是 insert / update 拿回的 Model 再 into_active_model(),所有字段都是 Unchanged。所以可以接着改、接着 save,第二次主键已经是 Unchanged,就走 update:

  let am = note::ActiveModel { id: NotSet, title: Set("first".into()), revision: NotSet };
  let saved = am.save(&db).await?;
  println!("returned: {saved:?}");

  let mut am = saved;
  am.title = Set("first, edited".into());
  let saved = am.save(&db).await?;
  println!("returned: {saved:?}");
--- 1. save with id NotSet
  [hook] before_save insert=true id=NotSet title=Set("first")
  wire> INSERT INTO "note" ("title", "revision") VALUES ($1, $2) RETURNING "id", "title", "revision"
  [hook] after_save insert=true model=Model { id: 1, title: "first", revision: 1 }
  wire> INSERT INTO audit (note_id, action) VALUES ($1, $2)
returned: ActiveModel { id: Unchanged(1), title: Unchanged("first"), revision: Unchanged(1) }
--- 2. save the returned ActiveModel after changing title
  [hook] before_save insert=false id=Unchanged(1) title=Set("first, edited")
  wire> UPDATE "note" SET "title" = $1, "revision" = $2 WHERE "note"."id" = $3 RETURNING "id", "title", "revision"
  [hook] after_save insert=false model=Model { id: 1, title: "first, edited", revision: 2 }
  wire> INSERT INTO audit (note_id, action) VALUES ($1, $2)
returned: ActiveModel { id: Unchanged(1), title: Unchanged("first, edited"), revision: Unchanged(2) }

revision 两次都不是调用方给的:第一次是 NotSet,第二次是 Unchanged(1),是 before_save 改成了 Set(1) 和 Set(2)(钩子的代码在后面)。audit 那条 INSERT 是 after_save 发的。

源码注释里那句「Only works if the entity has auto increment primary key」说的是复合主键或手填主键的表。拿 pair 试三种情况:

  let r = pair::ActiveModel { a: Set(1), b: NotSet, v: Set("x".into()) }.save(&db).await;
  println!("result: {r:?}");

  let r = pair::ActiveModel { a: Set(1), b: Set(2), v: Set("x".into()) }.save(&db).await;
  println!("result: {r:?}");

  let m = pair::ActiveModel { a: Set(1), b: Set(2), v: Set("x".into()) }.insert(&db).await?;
  println!("inserted: {m:?}");
  let r = pair::ActiveModel { a: Set(1), b: Set(2), v: Set("y".into()) }.save(&db).await;
  println!("result: {r:?}");

这一段看的是 sqlx 日志(sqlx=info,每条语句一个事件;建表和 SET search_path 那几行删掉了):

--- 1. a Set, b NotSet: save
summary="INSERT INTO \"pair\" (\"a\", …" db.statement="\n\nINSERT INTO \"pair\" (\"a\", \"v\") VALUES ($1, $2) RETURNING \"a\", \"b\", \"v\"\n" rows_affected=0 rows_returned=0 elapsed=2.112125ms elapsed_secs=0.002112125
result: Err(Query(SqlxError(Database(PgDatabaseError { severity: Error, code: "23502", message: "null value in column \"b\" of relation \"pair\" violates not-null constraint", detail: Some("Failing row contains (1, null, x)."), hint: None, position: None, where: None, schema: Some("so_write2"), table: Some("pair"), column: Some("b"), data_type: None, constraint: None, file: Some("execMain.c"), line: Some(2057), routine: Some("ExecConstraints") }))))
--- 2. a and b Set, row does not exist: save
summary="UPDATE \"pair\" SET \"v\" …" db.statement="\n\nUPDATE \"pair\" SET \"v\" = $1 WHERE \"pair\".\"a\" = $2 AND \"pair\".\"b\" = $3 RETURNING \"a\", \"b\", \"v\"\n" rows_affected=0 rows_returned=0 elapsed=866.958µs elapsed_secs=0.000866958
result: Err(RecordNotUpdated)
--- 3. same values: insert, then save
summary="INSERT INTO \"pair\" (\"a\", …" db.statement="\n\nINSERT INTO \"pair\" (\"a\", \"b\", \"v\") VALUES ($1, $2, $3) RETURNING \"a\", \"b\", \"v\"\n" rows_affected=1 rows_returned=1 elapsed=893.333µs elapsed_secs=0.000893333
inserted: Model { a: 1, b: 2, v: "x" }
summary="UPDATE \"pair\" SET \"v\" …" db.statement="\n\nUPDATE \"pair\" SET \"v\" = $1 WHERE \"pair\".\"a\" = $2 AND \"pair\".\"b\" = $3 RETURNING \"a\", \"b\", \"v\"\n" rows_affected=1 rows_returned=1 elapsed=688.166µs elapsed_secs=0.000688166
result: Ok(ActiveModel { a: Unchanged(1), b: Unchanged(2), v: Unchanged("y") })
主键状态save 走新行的结果已有行的结果
任一列 NotSetinsert自增主键:数据库生成;手填主键:23502 非空约束自增主键:插成第二行
全部 Set / UnchangedupdateRecordNotUpdated按主键更新

复合主键只设一列,save 照样判成 insert,INSERT 里干脆没有 b。两列都设上,save 一律判成 update,表里没有这一行就得到 RecordNotUpdated:save 没法插入一条主键由调用方给出的新行。这种表要新建就直接调 insert;要「有则更新、无则插入」,用 on_conflict(见 insert)。UPDATE 的 SET 里只有 v,没有主键列,即使它们是 Set,这一点见 update 与 delete。

从 JSON 来的 ActiveModel:没有主键就是新行

表单或 API 请求体通常不带主键,主键在 URL 里。直接 from_json 再 save,主键是 NotSet,save 判成 insert:

  let am = note::ActiveModel::from_json(json!({ "title": "first, edited again" }))?;
  println!("from_json: {am:?}");
  let saved = am.save(&db).await?;
  println!("returned id: {:?}", saved.id);
--- 3. from_json without id, then save
from_json: ActiveModel { id: NotSet, title: Set("first, edited again"), revision: NotSet }
  [hook] before_save insert=true id=NotSet title=Set("first, edited again")
  wire> INSERT INTO "note" ("title", "revision") VALUES ($1, $2) RETURNING "id", "title", "revision"
  [hook] after_save insert=true model=Model { id: 2, title: "first, edited again", revision: 1 }
  wire> INSERT INTO audit (note_id, action) VALUES ($1, $2)
returned id: Unchanged(2)

本意是改 1 号,结果多了一个 2 号,没有任何报错。from_json 的规则是 JSON 里有的键变 Set,没有的变 NotSet(细节见 ActiveValue 与 ActiveModel)。要更新就从库里那一行出发,用 set_from_json:它先把主键列取出来,整体换成 from_json 的结果,再把主键放回去,JSON 里的 id 被忽略。

  let mut am = note::Entity::find_by_id(1).one(&db).await?.unwrap().into_active_model();
  am.set_from_json(json!({ "id": 999, "title": "via set_from_json" }))?;
  println!("after set_from_json: {am:?}");
  let saved = am.save(&db).await?;
  println!("returned: {saved:?}");
--- 4. load + set_from_json keeps the primary key
  wire> SELECT "note"."id", "note"."title", "note"."revision" FROM "note" WHERE "note"."id" = $1 LIMIT $2
after set_from_json: ActiveModel { id: Set(1), title: Set("via set_from_json"), revision: NotSet }
  [hook] before_save insert=false id=Set(1) title=Set("via set_from_json")
  wire> UPDATE "note" SET "title" = $1 WHERE "note"."id" = $2 RETURNING "id", "title", "revision"
  [hook] after_save insert=false model=Model { id: 1, title: "via set_from_json", revision: 2 }
  wire> INSERT INTO audit (note_id, action) VALUES ($1, $2)
returned: ActiveModel { id: Unchanged(1), title: Unchanged("via set_from_json"), revision: Unchanged(2) }

两个细节。主键放回去时用的是 self.set(col, v),原来的 Unchanged(1) 变成了 Set(1),对 save 没有影响。JSON 里没有的 revision 变成了 NotSet,而不是保留读出来的 Unchanged(2):set_from_json 换掉的是整个 ActiveModel,只有主键被保留。这次 revision 停在 2,没有被钩子加一,原因在下一节。

四个钩子和一个 new

ActiveModelBehavior 每个实体都必须实现,哪怕是空的 impl ActiveModelBehavior for ActiveModel {}(DeriveActiveModelBehavior 宏生成的就是这一行,expand_derive_active_model_behavior)。五个方法都有默认实现:

#[allow(unused_variables)]
#[async_trait::async_trait]
pub trait ActiveModelBehavior: ActiveModelTrait {
    // ...
    fn new() -> Self {
        <Self as ActiveModelTrait>::default()
    }
    // ...
    async fn before_save<C>(self, db: &C, insert: bool) -> Result<Self, DbErr>
    where
        C: ConnectionTrait,
    {
        Ok(self)
    }
    // ...
    async fn after_save<C>(
        model: <Self::Entity as EntityTrait>::Model,
        db: &C,
        insert: bool,
    ) -> Result<<Self::Entity as EntityTrait>::Model, DbErr>
    where
        C: ConnectionTrait,
    {
        Ok(model)
    }
    // ...
    async fn before_delete<C>(self, db: &C) -> Result<Self, DbErr>
    // ...
    async fn after_delete<C>(self, db: &C) -> Result<Self, DbErr>
    // ...
}
方法谁调用拿到什么返回值的用处
newActiveModel 的 Default(..Default::default())、builder()无预填字段的初始 ActiveModel
before_saveinsert、update、update_without_returning(save 经由前两者)self、db、insert交给 INSERT / UPDATE 的就是它返回的 ActiveModel
after_saveinsert、updateRETURNING 拿回的 Model、db、insert调用方拿到的 Model
before_deletedeleteself、db交给 DELETE 的 ActiveModel
after_deletedeletebefore_delete 返回值的克隆、db被丢弃,只看 Ok / Err

new 不是钩子,是构造函数。宏生成的 impl Default for ActiveModel 调的是 ActiveModelBehavior::new(),不是全 NotSet 的 ActiveModelTrait::default,所以覆盖 new 就能让 ..Default::default() 带上默认值。

钩子按值收 self、返回 Self,改字段就是改完再交回去。实验里 note 的 before_save 拒绝空标题,并维护 revision;after_save 用同一个 db 往 audit 写一行,标题是 "boom" 时故意报错:

  #[async_trait::async_trait]
  impl ActiveModelBehavior for ActiveModel {
    async fn before_save<C>(mut self, _db: &C, insert: bool) -> Result<Self, DbErr>
    where
      C: ConnectionTrait,
    {
      println!("  [hook] before_save insert={insert} id={:?} title={:?}", self.id, self.title);
      if let sea_orm::ActiveValue::Set(t) = &self.title {
        if t.is_empty() {
          return Err(DbErr::Custom("title must not be empty".into()));
        }
      }
      // bump revision: 1 on insert, old+1 on update
      self.revision = if insert {
        Set(1)
      } else {
        match self.revision.clone().into_value() {
          Some(sea_orm::Value::Int(Some(r))) => Set(r + 1),
          _ => sea_orm::ActiveValue::NotSet,
        }
      };
      Ok(self)
    }

    async fn after_save<C>(model: Model, db: &C, insert: bool) -> Result<Model, DbErr>
    where
      C: ConnectionTrait,
    {
      println!("  [hook] after_save insert={insert} model={model:?}");
      db.execute_raw(sea_orm::Statement::from_sql_and_values(
        db.get_database_backend(),
        "INSERT INTO audit (note_id, action) VALUES ($1, $2)",
        [model.id.into(), (if insert { "insert" } else { "update" }).into()],
      ))
      .await?;
      if model.title == "boom" {
        return Err(DbErr::Custom("after_save refused".into()));
      }
      Ok(model)
    }
    // before_delete / after_delete only print
  }

before_save 只看得到 ActiveModel 里有的东西。上一节 set_from_json 之后 revision 是 NotSet,钩子不知道库里是 2,只能留着 NotSet,UPDATE 里也就没有这一列。

📝NOTE
没有蠢问题

问: 钩子能不能自己去读库里的旧值,比如在 before_save 里查出当前的 revision 再加一?

答: 能,db 就在参数里,但那是一条额外的 SELECT,和随后的 UPDATE 之间没有任何东西把它们绑在一起。在 &DatabaseConnection 上,两条语句各自借一次连接、各自是一个隐式事务(见一次 find 的全过程),两个请求可能读到同一个旧值、写出同一个新值。真要做计数或乐观锁,让数据库算:update_many().col_expr(.., Expr::col(..).add(1)),或者 WHERE revision = 旧值 再看 rows_affected(见 update 与 delete)。

调用顺序在 insert 和 delete 里一目了然:

        let am = ActiveModelBehavior::before_save(self, db, true).await?;
        let model = <Self::Entity as EntityTrait>::insert(am)
            .exec_with_returning(db)
            .await?;
        Self::after_save(model, db, true).await
        let am = ActiveModelBehavior::before_delete(self, db).await?;
        let am_clone = am.clone();
        let delete_res = Self::Entity::delete(am).exec(db).await?;
        ActiveModelBehavior::after_delete(am_clone, db).await?;
        Ok(delete_res)

update 和 insert 一样,只是传 false。after_delete 拿到的是删除前那个 ActiveModel 的克隆,不是数据库里被删的那一行,DELETE 没有 RETURNING。实验里删 2 号:

--- 8. delete
  wire> SELECT "note"."id", "note"."title", "note"."revision" FROM "note" WHERE "note"."id" = $1 LIMIT $2
  [hook] before_delete id=Unchanged(2)
  wire> DELETE FROM "note" WHERE "note"."id" = $1
  [hook] after_delete id=Unchanged(2) title=Unchanged("first, edited again")
rows_affected=1

after_delete 能看到 title,是因为这个 ActiveModel 是从读出来的 Model 转的。只设了主键的 ActiveModel { id: Set(2), ..Default::default() } 去删,after_delete 里其他字段都是 NotSet。

2.0 新加的 update_without_returning 只跑一半:

--- 10. update_without_returning
  [hook] before_save insert=false id=Set(3) title=Set("no returning")
  wire> UPDATE "note" SET "title" = $1, "revision" = $2 WHERE "note"."id" = $3
rows_affected=1

没有 RETURNING 就没有 Model,after_save 的参数无从谈起,文档注释写明了「does not run ActiveModelBehavior::after_save」。

📝NOTE
冷知识:钩子拿到 db,是 0.11 才有的

0.10 及以前,四个钩子都是同步函数:before_save(self, insert)、after_save(model, insert)、before_delete(self)、after_delete(self),没有连接参数,钩子里没法查库也没法写库。#1145 的说明写的是「making it possible to mimic database triggers by automatically executing database queries based on other database queries」,做法是「Adds &impl ConnectionTrait to arguments of ActiveModelBehaviour's methods」。接手的 #1328 把整个 trait 改成 async trait,说明里的升级指引是:默认实现「No code changes is required」,覆盖了钩子的要在 impl 上加 #[async_trait::async_trait]。两者首次发布于 0.11.0-rc.1(正式版 0.11.0),CHANGELOG 记为「The ActiveModelBehavior trait becomes async trait」。上面 after_save 往 audit 写的那一行,就是 #1145 想要的「模拟触发器」。

覆盖钩子要写 #[async_trait]

trait 本身声明在 #[async_trait::async_trait] 下面,sea_orm::entity::prelude 也重新导出了 async_trait(prelude.rs),所以上面的 impl 不用额外依赖。去掉 impl 上那行属性,只写 async fn before_save:

  // no #[async_trait::async_trait] here
  impl ActiveModelBehavior for ActiveModel {
    async fn before_save<C>(self, _db: &C, _insert: bool) -> Result<Self, DbErr>
    where
      C: ConnectionTrait,
    {
      Ok(self)
    }
  }
error[E0195]: lifetime parameters or bounds on method `before_save` do not match the trait declaration
    --> src/bin/no_async_trait.rs:17:25
     |
  17 |     async fn before_save<C>(self, _db: &C, _insert: bool) -> Result<Self, DbErr>
     |                         ^^^ lifetimes do not match method in trait
     |
    ::: ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/sea-orm-2.0.4/src/entity/active_model.rs:1064:1
     |
1064 | #[async_trait::async_trait]
     | --------------------------- lifetimes in impl do not match this method in trait

async_trait 把 async fn 改写成返回装箱 future 的普通函数,并补上生命周期参数。对加了属性的 impl 跑 cargo expand,before_save 变成这样:

        fn before_save<'life0, 'async_trait, C>(
            self,
            _db: &'life0 C,
            insert: bool,
        ) -> ::core::pin::Pin<
            Box<
                dyn ::core::future::Future<
                    Output = Result<Self, DbErr>,
                > + ::core::marker::Send + 'async_trait,
            >,
        >
        where
            C: ConnectionTrait,
            C: 'async_trait,
            'life0: 'async_trait,
        // ...

trait 那边是同样的改写;impl 不加属性,就是一个原生的 async fn,没有 'life0、'async_trait,签名对不上。trait 文档注释里的示例(impl ActiveModelBehavior for ActiveModel { async fn before_save ... })标了 ignore、没有这行属性,照抄就是这个错误。只写空 impl 时没有方法要对签名,不需要属性。

钩子里报错:什么被回滚了

before_save 返回 Err,? 让 insert 直接返回,INSERT 根本没发:

--- 5. before_save returns Err
  [hook] before_save insert=true id=NotSet title=Set("")
result: Err(Custom("title must not be empty"))

after_save 返回 Err 时,INSERT 已经执行完了。ActiveModelTrait::insert 里没有 begin,它收的 db 是 C: ConnectionTrait,在 &DatabaseConnection 上每条语句自动提交。钩子用的又是同一个 db,所以它自己写的 audit 也已经提交了。下面这段在 &db 和 &txn 上各跑一遍同一个会失败的插入:

  println!("before: {}", counts(&db, &w).await?);
  let r = note::ActiveModel { title: Set("boom".into()), ..Default::default() }.insert(&db).await;
  println!("result: {r:?}");
  println!("after: {}", counts(&db, &w).await?);

  println!("before: {}", counts(&db, &w).await?);
  let txn = db.begin().await?;
  let r = note::ActiveModel { title: Set("boom".into()), ..Default::default() }.insert(&txn).await;
  println!("result: {r:?}");
  println!("inside txn: {}", counts(&txn, &w).await?);
  txn.rollback().await?;
  println!("after rollback: {}", counts(&db, &w).await?);

counts 数 note 和 audit 的行数(它自己的 SELECT 不打印)。

❗IMPORTANT
动脑筋:第一段开始前 note 有 2 行、audit 有 4 行。insert(&db) 返回 Err(Custom("after_save refused")) 之后,两张表各有几行?平台:M2 Max、macOS、rustc 1.99.0、Postgres 16.14。答案在页末。

想让「钩子失败」等于「整次写入失败」,就把写入放进事务:db.begin() 拿到 DatabaseTransaction,传给 insert,钩子拿到的 db 就是这个事务,它发的语句在同一条连接、同一个事务里;出错后 rollback(或者让 txn 被 drop),INSERT 和钩子写的东西一起撤销。事务本身的机制见事务。2.0 的 ActiveModelEx::save 会自己开事务,见 ActiveModelEx。

不走钩子的路径

钩子只在 ActiveModelTrait 的四个方法里被调用。在 2.0.4 的 src/ 和 sea-orm-macros/src/ 里搜 before_save / after_save / before_delete / after_delete,调用点全在 src/entity/active_model.rs:上面四个方法,加上多对多建立关联用的 establish_links(见 ActiveModelEx)。Entity::insert(am).exec(..)、insert_many、update_many、delete_many、delete_by_id 都直接走查询构造器:

  let r = note::Entity::insert(note::ActiveModel { title: Set("raw insert".into()), revision: Set(0), ..Default::default() })
    .exec(&db)
    .await?;
  // ... insert_many of two rows, update_many with revision + 10, delete_many, delete_by_id(1)
--- 9. paths that skip hooks
  wire> INSERT INTO "note" ("title", "revision") VALUES ($1, $2) RETURNING "id"
Entity::insert(..).exec: last_insert_id=5
  wire> INSERT INTO "note" ("title", "revision") VALUES ($1, $2), ($3, $4) RETURNING "id"
insert_many: last_insert_id=Some(7)
  wire> UPDATE "note" SET "revision" = "revision" + $1 WHERE "note"."title" LIKE $2
update_many: rows_affected=2
  wire> DELETE FROM "note" WHERE "note"."title" = $1
delete_many: rows_affected=1
  wire> DELETE FROM "note" WHERE "note"."id" = $1
delete_by_id: rows_affected=1
notes=3 audits=5

一行 [hook] 都没有,audit 还是 5 行。revision 也没被钩子管:Entity::insert 那行写进去的是调用方给的 0。批量方法不走钩子有它的道理:update_many 根本没有 ActiveModel,只有列表达式和 WHERE,钩子的 self 无从构造;insert_many 一条语句插多行,要跑钩子就得逐个调用、改完再拼,after_save 也拿不到逐行的 Model(这里的 RETURNING 只要了 "id")。

入口before_*after_*
am.insert(db) / am.update(db) / am.save(db)是是
am.update_without_returning(db)是否
am.delete(db)是是
Entity::insert(am).exec(db)、Entity::update(am).exec(db)、Entity::delete(am).exec(db)否否
insert_many、update_many、delete_many、delete_by_id否否

钩子是 ORM 层的约定,不是数据库的约束。必须对每一行成立的规则(非空、唯一、取值范围)放在表上;钩子适合放「通过 ActiveModel 写入时顺手做的事」,并且要知道上表下半部分会绕过它。

动脑筋答案

--- 6. after_save returns Err, no transaction
before: notes=2 audits=4
  [hook] before_save insert=true id=NotSet title=Set("boom")
  wire> INSERT INTO "note" ("title", "revision") VALUES ($1, $2) RETURNING "id", "title", "revision"
  [hook] after_save insert=true model=Model { id: 3, title: "boom", revision: 1 }
  wire> INSERT INTO audit (note_id, action) VALUES ($1, $2)
result: Err(Custom("after_save refused"))
after: notes=3 audits=5
--- 7. after_save returns Err, inside a transaction
before: notes=3 audits=5
  wire> BEGIN
  wire< ReadyForQuery(T)
  [hook] before_save insert=true id=NotSet title=Set("boom")
  wire> INSERT INTO "note" ("title", "revision") VALUES ($1, $2) RETURNING "id", "title", "revision"
  [hook] after_save insert=true model=Model { id: 4, title: "boom", revision: 1 }
  wire> INSERT INTO audit (note_id, action) VALUES ($1, $2)
result: Err(Custom("after_save refused"))
inside txn: notes=4 audits=6
  wire> ROLLBACK
  wire< ReadyForQuery(I)
after rollback: notes=3 audits=5

各多一行:note 3 行、audit 5 行。调用方拿到的是 Err,但 3 号 note 已经在表里,钩子写的 audit 也在。两条 INSERT 之间、Err 返回之前,没有一条 BEGIN,连接一直在 I 状态,每条语句执行完就提交了。

第二段的区别只在 wire> BEGIN 和状态变成 T。同样两条 INSERT、同样的 Err,事务里能看到 4 行和 6 行,ROLLBACK 之后回到 3 和 5。被撤销的 4 号主键不会再被用到:序列的 nextval 不随事务回滚,接下来第 9 步的 Entity::insert 拿到的是 5。

小结

  • save 只看主键:任一主键列是 NotSet 就 insert,否则 update;不查库,不看其他列。返回值是 into_active_model() 之后的全 Unchanged,可以接着改接着存。
  • 复合主键或手填主键的表,save 插不了新行:主键全设上就走 update,表里没有这一行得到 RecordNotUpdated。新建用 insert,「有则改无则插」用 on_conflict。
  • from_json 不带主键时 save 会插一条新行而不报错;更新要从读出来的行出发,用 set_from_json,它保留主键,但其他没出现在 JSON 里的列会变成 NotSet。
  • ActiveModelBehavior 有 new 和四个钩子:before_save(self, db, insert) 的返回值就是要写的 ActiveModel,after_save(model, db, insert) 的返回值就是调用方拿到的 Model,after_delete 拿到的是删除前的克隆。update_without_returning 不跑 after_save。
  • trait 用 #[async_trait] 声明,覆盖钩子的 impl 也要加,否则 E0195。
  • 钩子不开事务,用的是调用方给的 db。在 &DatabaseConnection 上 after_save 报错时,INSERT 和钩子自己写的东西都已经提交;要一起撤销,就在事务里调用。
  • 钩子只在 ActiveModelTrait 的 insert / update / save / delete(以及多对多的关联表写入)里被调用;Entity::insert(..).exec、insert_many、update_many、delete_many、delete_by_id 都绕过它。

💬 关联讨论

发起讨论
加载中...