Rust in Everything
基于 sea-orm 2.0.4

升级到 2.0

主线位置:不在某一条主线上,而是把三条主线在本站的落点各过一遍:定义主线(11 个展开写法实体能不能原样编译)、查询主线和写入主线(站点的语句在 2.0.4 下发出的 SQL、返回的结果变没变),再加上迁移。sqlx 0.8.6 → 0.9.0 那一层见 sqlx 卷的升级到 0.9,本页不重复。

flowchart TB
  copy["拷一份工作区"] --> bump["两行版本号"]
  bump --> upd["cargo update 离线"]
  upd --> chk["cargo check"]
  chk --> errs["10 个错误"]
  errs --> fix["三行 use ExprTrait"]
  fix --> ok["9 个 crate 通过"]
  ok --> run["同一批实验在 2.0.4 上重跑"]
  run --> same["SQL 与结果相同"]
  run --> diff["迁移事务、错误文本"]

本页只给结论和最小改动清单,升不升、什么时候升由站长决定。所有实验都在 scratchpad 里的拷贝上做,本站仓库没有改动。平台:M2 Max、macOS、rustc 1.99.0、Postgres 16.14。

怎么试的

把 89a61b1 的整个工作区 git archive 到 scratchpad(exp-seaorm-site-up),先原样 cargo check --offline --locked -p app --no-default-features --features server,payments --tests,确认拷贝在 1.1.20 下能编译。然后只改两行:工作区 Cargo.toml 的 sea-orm = { version = "1.1.4", ... 改成 "2.0.4",crates/migration/Cargo.toml 的 sea-orm-migration = { version = "1.1", ... 改成 "2.0.4",feature 列表不动。离线更新锁文件,cargo update --offline -p sea-orm -p sea-orm-migration(只留 sea 和 sqlx 开头的行):

     Locking 61 packages to highest compatible versions
    Updating sea-orm v1.1.20 -> v2.0.4
      Adding sea-orm-arrow v2.0.0-rc.5
    Updating sea-orm-cli v1.1.20 -> v2.0.4
    Updating sea-orm-macros v1.1.20 -> v2.0.4
    Updating sea-orm-migration v1.1.20 -> v2.0.4
    Updating sea-query v0.32.7 -> v1.0.2
    Removing sea-query-binder v0.7.0
    Updating sea-query-derive v0.4.3 -> v1.0.0
      Adding sea-query-sqlx v0.9.1
    Updating sea-schema v0.16.2 -> v0.18.1
    Updating sqlx v0.8.6 -> v0.9.0
    Updating sqlx-core v0.8.6 -> v0.9.0
    Updating sqlx-macros v0.8.6 -> v0.9.0
    Updating sqlx-macros-core v0.8.6 -> v0.9.0
    Updating sqlx-mysql v0.8.6 -> v0.9.0
    Updating sqlx-postgres v0.8.6 -> v0.9.0
    Updating sqlx-sqlite v0.8.6 -> v0.9.0
note: pass `--verbose` to see 211 unchanged dependencies behind latest

锁文件里多出来的 sea-orm-arrow(以及 arrow-* 一族)是 sea-orm 的可选依赖,cargo tree -i arrow 在这个构建里什么也没打印,不会被编译;sqlx-mysql、sqlx-sqlite 在 1.1.20 的锁文件里本来就有。sea-orm-cli 也是原来就有的:sea-orm-migration 1.1.20 和 2.0.4 的默认 feature 都是 ["cli"]。实际打开的 feature(cargo tree -f '{p} [{f}]',节选):

sea-orm v2.0.4 [chrono,default,macros,postgres-array,runtime-tokio,runtime-tokio-rustls,rust_decimal,schema-sync,sea-query-sqlx,sea-schema,serde_json,sqlite-use-returning-for-3_35,sqlx,sqlx-core,sqlx-dep,sqlx-postgres,stream,time,uuid,with-chrono,with-json,with-rust_decimal,with-time,with-uuid]
sqlx v0.9.0 [_rt-tokio,chrono,json,postgres,runtime-tokio,rust_decimal,sqlx-postgres,time,tls-rustls,tls-rustls-ring,tls-rustls-ring-webpki,uuid]

本站 Cargo.toml 里写的 feature 名(sqlx-postgres、runtime-tokio-rustls、macros、with-chrono)在 2.0.4 里都还在,不用改。sea-orm 2.0.4 的 rust-version 是 1.94.0(1.1.20 是 1.81);本站 Dockerfile 的构建镜像是 rust:1-trixie,跟着最新稳定版走。

编译错误:10 个,一个原因

cargo check --offline -p app --no-default-features --features server,payments --tests,退出码 101。按错误码数(grep '^error' | sort | uniq -c):

   2 error[E0599]: `sea_orm::sea_query::Expr` is not an iterator
   2 error[E0308]: mismatched types
   2 error[E0277]: the trait bound `sea_orm::Condition: From<bool>` is not satisfied
   1 error[E0599]: no method named `add` found for enum `sea_orm::sea_query::Expr` in the current scope
   1 error: could not compile `module-forum` (lib) due to 1 previous error
   1 error: could not compile `module-course` (lib) due to 4 previous errors
   1 error: could not compile `module-admin` (lib) due to 2 previous errors

七个真正的错误,落在三个函数里,形状各不相同,原因是同一个。论坛的 post_reply,计数加一:

error[E0599]: no method named `add` found for enum `sea_orm::sea_query::Expr` in the current scope
   --> crates/modules/forum/src/server.rs:665:81
    |
665 |       .col_expr(topic::Column::ReplyCount, Expr::col(topic::Column::ReplyCount).add(1))
    |                                                                                 ^^^ method not found in `sea_orm::sea_query::Expr`
    |
   ::: ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/sea-query-1.0.2/src/expr/trait.rs:99:8
    |
 99 |     fn add<R>(self, right: R) -> Expr
    |        --- the method is available for `sea_orm::sea_query::Expr` here
    |
    = help: items from traits can only be used if the trait is in scope
help: trait `ExprTrait` which provides `add` is implemented but not in scope; perhaps you want to import it
    |
  1 + use sea_orm::ExprTrait;
    |

后台审核队列的两个 GROUP BY(第 628、638 行,两条一样,这里只贴一条)。Expr 自己没有 count 了,编译器在作用域里找到的唯一一个 count 是 Iterator::count:

error[E0599]: `sea_orm::sea_query::Expr` is not an iterator
   --> crates/modules/admin/src/server.rs:628:60
    |
628 |         .column_as(Expr::col(moderation_queue::Column::Id).count(), "count")
    |                                                            ^^^^^ `sea_orm::sea_query::Expr` is not an iterator
    |
    ...
help: trait `ExprTrait` which provides `count` is implemented but not in scope; perhaps you want to import it
    |
  1 + use sea_orm::ExprTrait;
    |

课程的标注列表最不直观。Expr 实现了 PartialEq,ExprTrait 不在作用域时,.eq(me_id) 被解析成 PartialEq::eq(&self, &Expr) -> bool,于是报的是参数类型不对,紧接着 Condition::add 又收到一个 bool。第 2857 行的 .ne("private") 同样各报一个 E0308、一个 E0277:

error[E0308]: mismatched types
    --> crates/modules/course/src/server.rs:2856:53
     |
2856 |       .add(Expr::col(annotation::Column::UserId).eq(me_id))
     |                                                  -- ^^^^^ expected `&Expr`, found `i32`
     |                                                  |
     |                                                  arguments to this method are incorrect
     |
note: method defined here
    --> ~/.rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/src/rust/library/core/src/cmp.rs:258:8
     |
 258 |     fn eq(&self, other: &Rhs) -> bool;
     |        ^^

error[E0277]: the trait bound `sea_orm::Condition: From<bool>` is not satisfied
    --> crates/modules/course/src/server.rs:2856:12
     |
2856 |       .add(Expr::col(annotation::Column::UserId).eq(me_id))
     |        --- ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `From<bool>` is not implemented for `sea_orm::Condition`
     |        |
     |        required by a bound introduced by this call
     ...
help: consider removing this method call, as the receiver has type `sea_orm::sea_query::Expr` and `sea_orm::sea_query::Expr: From<bool>` trivially holds
     |
2856 -       .add(Expr::col(annotation::Column::UserId).eq(me_id))
2856 +       .add(Expr::col(annotation::Column::UserId))
     |

最后那条「考虑删掉这个方法调用」的建议是错的,照做会把过滤条件变成 "user_id" 本身;这一组错误里没有一条提到 ExprTrait。三处的共同点是方法挂在 Expr::col(..) 上:sea-query 1.0 里 Expr 是一个枚举,eq、ne、add、sub、count 这些方法只定义在 ExprTrait 上(Expr 的形状见语句是一棵树)。挂在实体列上的 topic::Column::Id.count()、Column::Status.eq(..) 走的是 ColumnTrait,不受影响,所以站点里大量的 filter(xxx::Column::Yyy.eq(..)) 一处都没报。sea-orm 2.0.4 在 src/query/mod.rs 里 pub use sea_query::ExprTrait,用 sea_orm::ExprTrait 就行。

最小改动是给这三个函数各加一个导入:

--- crates/modules/forum/src/server.rs
-    use sea_orm::sea_query::Expr;
+    use sea_orm::{sea_query::Expr, ExprTrait};
--- crates/modules/admin/src/server.rs
-      use sea_orm::{sea_query::Expr, QuerySelect};
+      use sea_orm::{sea_query::Expr, ExprTrait, QuerySelect};
--- crates/modules/course/src/server.rs
-    use sea_orm::{sea_query::Expr, ColumnTrait, Condition, EntityTrait, QueryFilter, QueryOrder};
+    use sea_orm::{sea_query::Expr, ColumnTrait, ExprTrait, Condition, EntityTrait, QueryFilter, QueryOrder};

(位置是 forum 第 663 行、admin 第 622 行、course 第 2848 行。)改完再查,-p app 退出码 0;九个用到 sea-orm 的 crate 各自带上 server feature 跑 cargo check --all-targets(包括 module-moderation 连真库的集成测试和 app-core 的单测),也都是 0:

app-core exit=0 0 errors, 1 warnings
migration exit=0 0 errors, 1 warnings
module-admin exit=0 0 errors, 1 warnings
module-comments exit=0 0 errors, 1 warnings
module-course exit=0 0 errors, 1 warnings
module-forum exit=0 0 errors, 1 warnings
module-moderation exit=0 0 errors, 1 warnings
module-payment exit=0 0 errors, 1 warnings
module-search exit=0 0 errors, 1 warnings

每个 crate 的那一条警告都是 the following packages contain code that will be rejected by a future version of Rust: proc-macro-error2 v2.0.1,和 sea-orm 无关;cargo clippy -p app --all-targets 也只有这一条。没有一条 deprecation 警告。

📝NOTE
冷知识:Expr 的这些方法是被有意删掉的

sea-query 0.32 里有两个类型:Expr(构造用的中间值)和 SimpleExpr(树里的节点),eq、add、count 等方法在 Expr 上有一份固有方法,在 0.32.0 加进来的 ExprTrait(sea-query#791,CHANGELOG 写「Added ExprTrait to unify Expr and SimpleExpr methods」)上又有一份,所以本站在 1.1.20 下不用导入任何 trait 就能写 Expr::col(..).eq(..)。sea-query#889 把 Expr 变成 SimpleExpr 的别名之后,sea-query#890「Remove inherent SimpleExpr methods that duplicate ExprTrait」删掉了重复的那份,PR 说明写着「This also removes these methods from Expr, because Expr has recenly become an alias of SimpleExpr (#889)」,迁移办法是「Where the methods are no longer found, just use sea_query::ExprTrait, as the compiler suggests」和「Where the methods conflict with PartialEq or PartialOrd methods, qualify them as TheTrait::the_method or use Rust's built-in operators」。它首次发布于 sea-query 1.0.0,sea-query 的 CHANGELOG 在这一条下面贴的第二段报错 expected &Expr, found &str 就是本站 course 那一处的样子。sea-orm 2.0.0 的发布说明也写了「for most compile errors, you can simply add use sea_orm::ExprTrait; in scope」。

实体不用动:11 个展开写法的实体在 2.0.4 下原样编译,宏生成的 SQL 也和 1.1.20 逐字相同(下一节)。dense 格式(#[sea_orm::model])是新增的写法,不是替代;改不改是另一件事,它会让宏输出翻倍、编译变慢(设计取舍),本站目前也用不到它带来的 Entity Loader 和 ActiveModelEx。

编译通过以后:哪些行为变了

把本站怎么用 sea-orm 的实验 crate 拷一份(exp-seaorm-site2),依赖换成 sea-orm 2.0.4、sea-orm-migration 2.0.4,代码一字不改(它用 use sea_orm::*,ExprTrait 随 glob 进来),在另一个 schema 里重跑同一批程序,把两边的输出逐行 diff:读路径的六组语句(queries_wire)、三处 on_conflict(upserts_wire)、事务和计数交错(txns)、find_also_related 吞错(also_related)都没有任何差别;并发首次登录(login_race)两边都是一个成功、一个 23505,只是哪个请求赢每次不一定。有差别的是迁移和错误文本。逐条对照 1.1.20 → 2.0.4 的改动:

改动本站依据
每个迁移一个事务(#2980,2.0.0-rc.36)会变下一小节
DbErr 加了六个分支、#[non_exhaustive]不受影响站点没有 match 过 DbErr,只构造 DbErr::Custom(pool.rs 和一个测试)
DbErr 的 Display文本会变模板没变;NULL 解码的文字变了(下文),sqlx 0.9 在数据库错误后面加 at line N
count() 尊重 limit(#3097)不受影响站点的 count() 都接在没有 limit 的 find() 后面;num_items 的 COUNT(*) 子查询两边逐字相同
delete_by_id 返回 ValidatedDeleteOne不受影响.exec(db) 照样得到 DeleteResult;删不存在的行两边都是 Ok(0)
空 insert_many 不再发语句不受影响站点没有 insert_many
eq(None) 生成 IS NULL不受影响站点没有拿 Option 值做 eq / ne 的过滤
DatabaseConnection 从 enum 变 struct不受影响站点只把它当类型传递和 clone(),没有按变体匹配
mock 不再去掉 Clone注释过时payment Cargo.toml 那条注释的理由不存在了(Mock 与 Proxy)
find_also_related 吞右边的解码错误没变2.0.4 的 from_query_result_optional 还是 .ok(),also_related 两边都输出「已注销」
connect_timeout 落到 acquire_timeout没变DB_CONNECT_TIMEOUT_SECS 照样无效(DatabaseConnection 与 ConnectOptions)
sqlx 0.9见 sqlx 卷取消 begin 让连接带着事务回池的问题还在(升级到 0.9)

count() 在 2.0.4 里仍然是 PaginatorTrait 的方法,站点 use sea_orm::PaginatorTrait 的写法照旧能用;行为差别见分页与游标。

迁移:一个大事务变成九个小事务

1.1.20 的 exec_with_connection 在 Postgres 上先 begin,把建记账表、读已应用列表和所有待执行的迁移都放进去,最后一次 commit。2.0.4 的 exec_up_with 对每个迁移问一次 should_use_transaction:迁移的 use_transaction() 返回 None(默认)时,Postgres 上就是 true,于是每个迁移连同它那一行 seaql_migrations 记录各自一个事务。机制在 sea-orm-migration。本站的 9 个迁移都没有覆盖 use_transaction。

同一个迁移 crate(两行版本号之外原样拷贝)对空 schema 跑一遍,2.0.4 下经代理(src/bin/migrate_wire.rs,节选):

  wire> CREATE TABLE IF NOT EXISTS "seaql_migrations" ( "version" varchar NOT NULL PRIMARY KEY, "applied_at" bigint NOT NULL )
  ...
  wire> SELECT "version", "applied_at" FROM "seaql_migrations" ORDER BY "version" ASC
  wire> BEGIN
  wire< ReadyForQuery(T)
  ...
  wire> INSERT INTO "seaql_migrations" ("version", "applied_at") VALUES ($1, $2) RETURNING "version"
  wire> COMMIT
  wire< ReadyForQuery(I)
  wire> BEGIN
  wire< ReadyForQuery(T)
  ...
  wire> INSERT INTO "seaql_migrations" ("version", "applied_at") VALUES ($1, $2) RETURNING "version"
  wire> COMMIT
  wire< ReadyForQuery(I)
  ...
Migrator::up = Ok(())

80 行输出里有 9 对 BEGIN / COMMIT,建记账表和读已应用列表移到了事务外面。全部成功时结果一样;差别在中途失败。设想生产库停在第 7 个迁移,一次部署带来第 8、9 个,而第 9 个失败了(实验里先手工加一个同名的 product_kind 列来制造失败,src/bin/partial.rs):

Migrator::up(&db, Some(7)).await?;
sql(&db, "alter table orders add column product_kind text").await?;
println!("Migrator::up = {:?}", Migrator::up(&db, None).await.map_err(|e| e.to_string()));
let applied: Vec<String> = Migrator::get_applied_migrations(&db).await?.iter().map(|m| m.name().to_string()).collect();
println!("applied: {} (last {:?})", applied.len(), applied.last());
// ... 再从 information_schema 查两列在不在

1.1.20 下:

Migrator::up = Err("Execution Error: error returned from database: column \"product_kind\" of relation \"orders\" already exists")
applied: 7 (last Some("m20260628_000007_memberships"))
users.token_version exists: false, orders.product_ref exists: false

第 9 个迁移的错误把同一个事务里的第 8 个也带走了:users.token_version 没有加上,库停在部署之前的样子。在 STRICT_MIGRATION=1 下这个实例启动失败,回滚二进制就能接着跑。

❗IMPORTANT
动脑筋:同样的部署、同样的失败,在 2.0.4 下 applied 是几个,token_version 在不在?

同一个 partial.rs,依赖换成 sea-orm-migration 2.0.4,schema 换成 so_site_v2,其余一字不改。平台:M2 Max、macOS、rustc 1.99.0、Postgres 16.14。

这个差别对本站的影响要看迁移之间的依赖。第 9 个迁移把 orders.course_slug 改名成 product_ref,实体 order::Model 里已经是 product_ref;部分成功之后回滚到旧二进制,旧代码读的列都还在(第 8 个只是加列,第 9 个没有生效),不会出问题。以后写迁移时要记住:在 2.0 下,一次部署里的多个迁移不再同生同死,后面的失败不会撤销前面的;要原子,就把它们写进同一个迁移。反过来,2.0 允许 use_transaction() 返回 Some(false),可以写 CREATE INDEX CONCURRENTLY 了,1.1.20 下做不到。没有锁、没有校验和这两点 2.0.4 没有变(本站怎么用 sqlx)。

错误文本

DbErr 的 #[error(..)] 模板在两个版本之间一个字没改,只是 2.0.4 的 DbErr 多了六个分支。变的是装在里面的文字。站点的实体读到不该是 NULL 的 NULL(src/bin/misc.rs 的最后一行:把 users.nickname 改成可空、写一个 NULL,再 find_by_id),两边逐行 diff 只有这一行不同:

< NULL nickname: Err("Type Error: A null value was encountered while decoding \"nickname\"")
---
> NULL nickname: Err("Type Error: Missing value for column 'nickname'")

2.0 的实体 Model 先按 Option 读每一列,NULL 在 sea-orm 这一层被报成 Missing value for column(missing-value-for-column)。另一处是 sqlx 0.9 在数据库错误后面接的 at line N:上面迁移失败的那句话在 2.0.4 下是 ... column "product_kind" of relation "orders" already exists at line 7347(见动脑筋答案)。本站把 DbErr 的文本直接放进 ServerFnError(本站怎么用 sea-orm),这两处变化会出现在浏览器和日志里;站点代码没有按错误文本做判断的地方,逻辑不受影响。

最小改动清单

文件改动必须
Cargo.toml 第 49 行sea-orm 版本 "1.1.4" → "2.0.4"是
crates/migration/Cargo.toml 第 21 行sea-orm-migration 版本 "1.1" → "2.0.4"是
crates/modules/forum/src/server.rs 第 663 行导入里加 ExprTrait是
crates/modules/admin/src/server.rs 第 622 行导入里加 ExprTrait是
crates/modules/course/src/server.rs 第 2848 行导入里加 ExprTrait是
crates/modules/payment/Cargo.toml 第 35 行注释的理由在 2.0 下不成立,可以删掉或改写否
以后的迁移记住每个迁移一个事务;要一起成败的改动放进同一个迁移否

Cargo.lock 由 cargo update -p sea-orm -p sea-orm-migration 生成,sqlx 随之到 0.9.0。上表之外,本站的 Rust 代码在这次升级里不需要任何改动。

动脑筋答案

Migrator::up = Err("Execution Error: error returned from database: column \"product_kind\" of relation \"orders\" already exists at line 7347")
applied: 8 (last Some("m20260721_000008_users_token_version"))
users.token_version exists: true, orders.product_ref exists: false

8 个,token_version 在。

  • 第 8 个迁移在自己的事务里提交了,seaql_migrations 记下了它,users.token_version 加上了。
  • 第 9 个迁移的事务里,RENAME COLUMN 先成功、ADD COLUMN 再失败,整个事务回滚,所以 product_ref 也不在,seaql_migrations 里没有第 9 行。下一次启动只会重跑第 9 个。
  • 错误文本末尾多了 at line 7347:这是 sqlx 0.9 的 PgDatabaseError 的 Display 接上的服务端 C 源码行号,1.1.20(sqlx 0.8.6)下同一个错误没有它。

小结

  • 拷一份工作区,两行版本号改成 2.0.4,离线 cargo update,sqlx 随之到 0.9.0;站点写的 feature 名都还在。
  • 编译错误 10 个,都在 Expr::col(..) 后面的方法上:sea-query 1.0 把它们只留在 ExprTrait 里(sea-query#890)。.eq 会被解析成 PartialEq::eq,报的是类型不对。forum、admin、course 各加一个 use sea_orm::ExprTrait 就全部通过,测试和 clippy 也干净。
  • 11 个展开写法的实体原样可用,不需要改 dense 格式。
  • 站点的读路径、三处 on_conflict、事务和计数交错、find_also_related 吞错,在两个版本上发出的 SQL 和结果逐字相同。
  • 迁移从一个大事务变成每个一个事务:中途失败时前面的迁移留在库里,不再整体回滚。
  • 错误文本有两处变化:NULL 解码从 A null value was encountered 变成 Missing value for column,数据库错误末尾多了 at line N;DbErr 新分支和 non_exhaustive、count 尊重 limit、delete_by_id、空 insert_many、DatabaseConnection 改成 struct 都碰不到本站。

💬 关联讨论

发起讨论
加载中...