Rust in Everything
基于 sea-orm 2.0.4

设计取舍

主线位置:三条主线的汇总。查询主线上的取舍是「运行期构造 SQL」「语句树与方言分开」「宽松解码」;写入主线上的是「ActiveValue 三态」;定义主线上的是「关系是值」「宏生成代码」;「后端是运行时 enum」「每条语句借一次连接」「ConnectOptions 是翻译」「DbErr 按位置包装」落在两条执行主线共用的连接层。每一条只说收益和代价,机制都在链接指向的页面里。

flowchart TB
  q[查询主线] --> t1["运行期构造 SQL"]
  q --> t2["语句树与方言"]
  q --> t9["宽松解码"]
  q --> t5["join 还是第二条语句"]
  w[写入主线] --> t6["ActiveValue 三态"]
  d[定义主线] --> t4["关系是值"]
  d --> t11["宏生成代码"]
  c[连接层] --> t3["后端是运行时 enum"]
  c --> t7["每条语句借一次连接"]
  c --> t8["ConnectOptions 翻译"]
  c --> t10["DbErr 按位置包装"]
  t11 --> t12["sed 生成同步版"]

先列一张表,下面逐条展开。「证据」一栏是本卷里能看到这个代价的页面。

取舍换来的付出的证据
1. 运行期构造 SQL,编译期对着实体检查按条件拼查询就是 if 和方法调用,编译不连库实体和库不一致、值的类型不对,要到运行期由 Postgres 报sea-orm 解决什么问题、列与值
2. 语句树和方言分开不连库就能看 SQL,Mock 和 RBAC 都建立在树上多一层;方言专有的语法换了后端被静默丢掉或 panic语句是一棵树、QueryBuilder 与参数
3. 后端是运行时的 enum一个 DatabaseConnection 类型装所有后端,签名简单每条语句一次 match;mock feature 改变 enum 的形状一次 find 的全过程、Mock 与 Proxy
4. 实体是类型,关系是值join、Loader、find_related 读同一个 RelationDef编译期只检查「有 Related」,别名、join 类型是运行期的事关系
5. 关联数据:join 还是第二条语句一条 join 或一条 IN,都没有 N+1join 的 LIMIT 管的是行;IN 不分块join 与 find_related、Loader 与 N+1
6. ActiveValue 三态update 只写 Set 的列,NotSet 交给默认值两套类型;忘了 Set 不报错;save 只看主键ActiveValue 与 ActiveModel、update 与 delete、save 与 ActiveModelBehavior
7. 每条语句单独借一次连接没有隐式会话,连接用完就还语句之间没有一致性,会话设置无意义;&db 和 &txn 混用编译器不拦一次 find 的全过程、事务
8. ConnectOptions 是一层翻译池的行为全是 sqlx 的,不用重学名字和效果对不上:connect_timeout 是 acquire 超时,慢日志关不掉DatabaseConnection 与 ConnectOptions
9. 解码宽松LEFT JOIN 的空侧、缺列的可选字段不用特殊处理几处把真正的错误变成 None 或直接丢掉join 与 find_related、部分列与自定义结果、绑到 sqlx
10. DbErr 按调用位置包装一个扁平的 enum,? 一路传上去同一个 sqlx 错误落在不同分支;只认两种 SQLSTATE错误
11. 宏生成大量代码用户只写一个 structdense 写法展开 1037 → 2206 行,编译时间约六倍DeriveEntityModel 与 dense 格式、本页实验
12. 同步版本用 sed 生成异步和同步一份源码4.5 万行生成代码,一致性靠记得重新生成源码地图

1. 运行期构造 SQL,编译期对着实体检查

Select<E> 是一棵 sea-query 的语句树加一个类型标记,filter、order_by、limit 往树上加节点,可以放在 if 里、在函数之间传;build 时才写成 SQL,值始终是 $N 参数(sea-orm 解决什么问题的搜索表单,一个函数三条 SQL)。编译不需要数据库。

代价是 rustc 能检查的只是「Rust 里的实体」:

  • 列名拼错是 E0599,在编译期;实体比表多一个字段,编译正常,运行时 42703 column cake.price does not exist(同一页)。
  • Column::Id.eq("abc") 收任意 Into<Value>,能编译,Postgres 报 42883 operator does not exist: integer = text。2.0 的强类型列 COLUMN.id.eq("abc") 在编译期报 E0277 the trait bound &str: NumericValue is not satisfied,但它检查的是类别:COLUMN.id.eq(2_i64) 在 int4 列上能编译(列与值)。
  • 参数的 Postgres 类型由 Rust 值的 Value 分支决定,不问库:同一段 SQL 在同一条连接上先绑 i32 再绑 i64,sqlx 按 SQL 文本复用预备语句,报 22P03 或 08P01(same-sql-different-type);i8 字段绑成 "char",插不进 sea-orm 自己建的 smallint 列(i8-char);DeriveActiveEnum 的 db_type 和库里的列不一致,编译能过,读写过滤失败的方式还不对称(ActiveEnum 与 ValueType、enum-type-mismatch)。

sqlx 的 query! 正好相反:检查对着库里真实的 schema,SQL 却是固定的(sqlx 卷的设计取舍第 1、2 条)。两者不互斥,sea-orm 的 get_postgres_connection_pool() 直接给出底下的 PgPool。

2. 语句树和方言分开

语句树里没有一个字段说它属于哪种数据库,ConnectionTrait::query_one 收的是树,执行前才由 build_any_stmt! 按连接的 DbBackend 选 QueryBuilder(语句是一棵树)。换来的东西都能从依赖关系和代码上看到:

  • 不连库就能 build(DbBackend::Postgres) 看 SQL,MockDatabase 记下的就是 Statement(Mock 与 Proxy)。
  • 一棵树能被审查。2.0 的 query_one 改收树(PR #2657),RestrictedConnection 用 audit() 列出树要动哪些表,*_raw 直接拒绝(一次 find 的全过程的冷知识)。
  • sea-query 不依赖驱动,迁移、代码生成、sea-orm-sync 的 rusqlite 都用它。

代价有两层。一是多一层:每个操作先建树,再由 QueryBuilder 按子句写成字符串,to_string 的输出只能看、不能执行(QueryBuilder 与参数)。二是「三种方言共用一棵树」并不意味着树上的东西三种方言都能写。树允许表达方言专有的语法(table_sample、PgBinOper),由对应的 builder 认领,其他 builder 要么用默认实现,要么不认识。下面这段只用 sea-query(src/bin/dialect.rs,M2 Max、macOS、rustc 1.99.0):

// Postgres-only syntax in a backend-neutral tree: what do the other builders do with it?
let mut sample = Query::select();
sample.column("id").from(Alias::new("cake")).table_sample(SampleMethod::SYSTEM, 10.0, None);
println!("table_sample pg     : {}", sample.to_string(PostgresQueryBuilder));
println!("table_sample mysql  : {}", sample.to_string(MysqlQueryBuilder));
println!("table_sample sqlite : {}", sample.to_string(SqliteQueryBuilder));

let mut ilike = Query::select();
ilike.column("id").from(Alias::new("cake")).and_where(Expr::col("name").ilike("%a%"));
println!("ilike pg            : {}", ilike.to_string(PostgresQueryBuilder));
let r = std::panic::catch_unwind(|| ilike.to_string(MysqlQueryBuilder));
println!("ilike mysql         : is_err={}", r.is_err());
table_sample pg     : SELECT "id" FROM "cake" TABLESAMPLE SYSTEM (10)
table_sample mysql  : SELECT `id` FROM `cake`
table_sample sqlite : SELECT "id" FROM "cake"
ilike pg            : SELECT "id" FROM "cake" WHERE "name" ILIKE '%a%'

thread 'main' (4015033) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/sea-query-1.0.2/src/backend/query_builder.rs:713:18:
not implemented
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
ilike mysql         : is_err=true

TABLESAMPLE 在 MySQL 和 SQLite 上没了,因为 prepare_table_sample 的默认实现是空函数:得到的是一条能执行、语义不同的语句。ILIKE 落进 prepare_bin_oper_common 最后的 _ => unimplemented!()。两者都不是编译错误,扩展方法只要 backend-postgres feature 打开就能调用。只用 Postgres 时这不是问题;它说明「同一套实体跑三种数据库」只对没用到方言扩展的查询成立。

3. 后端是运行时的 enum

DatabaseConnection 在 2.0 是一个 struct,里面的 DatabaseConnectionType 按 feature 打开分支,query_one_raw 等方法就是一个 match(一次 find 的全过程)。和 sqlx 用关联类型、每个驱动一个类型相反:用户的函数收 &DatabaseConnection 或 C: ConnectionTrait,签名里没有后端参数;后端由连接串在运行时决定;Mock、Proxy、RestrictedConnection 都是同一个 trait 的另一种实现,可以替换真连接,也可以包在真连接外面。

代价:

  • 用错了后端只能运行时发现,例如 get_postgres_connection_pool() 对非 Postgres 连接直接 panic!("Not Postgres Connection")。
  • 分支跟着 feature 走,而 DatabaseConnectionType 不是 non_exhaustive:打开 mock 会让下游对 db.inner 的穷尽 match 报 E0004;1.1.20 里 mock 还去掉了 DatabaseConnection: Clone。feature 经 Cargo 统一,一个 crate 的 dev-dependency 打开 mock,构建它的测试时同一次编译里的其他 crate 也看到变了形的 enum(Mock 与 Proxy)。
  • Mock 是 enum 的一个分支,不是一个数据库:按调用序号返回预置结果,不看 SQL,类型不匹配一律报成 null 错误;它能测「发了什么 SQL」,测不了「SQL 对不对」(同一页)。

4. 实体是类型,关系是值

实体是零大小的单元结构体,列是枚举值;关系是一个 RelationDef 值:两端的表和列、HasOne / HasMany、is_owner、附加的 ON 条件。join、find_related、Loader、Entity Loader、ActiveModelEx 的嵌套写入都只读这一份描述,1.x 展开写法和 2.0 dense 写法生成的 RelationDef 逐字段相同(关系)。

代价是关系在编译期只检查「这两个实体之间有 Related」:

  • RelationDef 里没有 join 类型,由调用方传;find_related 固定 INNER JOIN,而且从一个 Model 出发时两个方向都多一次 join 回自己的表(join 与 find_related)。
  • 表名就是标识符,自引用直接 join 得到 42712 table name "staff" specified more than once,起别名要换成 join_as 或 Linked(self-join-alias)。
  • 一对一靠不靠得住取决于库里有没有 UNIQUE:缺了它,join 重复父记录,Loader 让后来的子记录覆盖前面的(Loader 与 N+1)。

2.0 把一部分检查挪回了编译期:dense 写法的 BelongsTo<E> / BelongsTo<Option<E>> 和外键列的可空性不一致时,宏报 BelongsTo<Entity> requires non-Option `from` fields(关系)。这只影响 ModelEx / ActiveModelEx 的类型,不进 RelationDef。

5. 关联数据:join 还是第二条语句

两种取关联数据的办法都避开了 N+1,代价不同:

  • find_with_related 是一条 LEFT JOIN,加父表主键的 ORDER BY,在内存里按父主键合并。LIMIT 作用在 join 之后的行上:父记录可能不到 n 个,最后一个的子记录静默不全,所以 SelectTwoMany 只有 all,没有 one 和 paginate(join 与 find_related、with-related-limit)。
  • LoaderTrait 先拿父记录,再发一条 WHERE (col) IN ((..), ..),按键分组。父表的 LIMIT 和分页因此是对的;但它不分块,去重后的键超过 65535 个,sqlx 在发送前报 too many arguments for query: 65536;传进去的 Select 带的 LIMIT 是整条语句的,不是每组的(Loader 与 N+1、loader-too-many-keys)。

2.0 的 Entity Loader 把选择做成了固定规则:前两个不嵌套、不重复的一对一 join 进主查询,其余走 Loader;语句数是 1 加每层亮着的 Loader 位。规则是固定的,想要别的组合就要自己写 join 或 Loader。

6. ActiveValue 三态

读出来的 Model 是纯数据,写入要换成 ActiveModel,每个字段是 ActiveValue 的 Set / Unchanged / NotSet(ActiveValue 与 ActiveModel)。和「记一个改过的字段集合」相比,三态把每一列的意图放在值上:INSERT 写 Set 和 Unchanged,NotSet 的列不进 SQL、由数据库填默认值;UPDATE 只 SET 被 Set 的非主键列,主键(Set 或 Unchanged)进 WHERE;Set(None) 是写 NULL,NotSet 是不写,两者分得开。

代价:

  • 两套类型,into_active_model() 之后全是 Unchanged。什么都没 Set 就 update,不发 UPDATE,按主键 SELECT 一次,行存在就是 Ok;RecordNotUpdated 只在至少一列 Set 且 WHERE 没匹配时出现(update 与 delete、record-not-updated)。
  • save 只看主键里有没有 NotSet,不查库:from_json 来的对象缺主键就插成新行;手填主键的新行用 save 插不进去(save 与 ActiveModelBehavior、save-inserts-instead)。
  • 三态是一行的属性,到了多行的 insert_many 就表达不了:各行列取并集,某行 NotSet 的列补带类型的 NULL,不是 DEFAULT,not null default 的列因此报 23502(insert、insert-many-null-fill)。

7. 每条语句单独借一次连接

DatabaseConnection 只持有池,驱动的 query_one 每条语句 pool.acquire() 一次,执行完就还;三条紧挨着的语句可以落在 A、B、A 两个后端进程上(一次 find 的全过程)。没有隐式的会话,也就没有「这个连接上还开着什么」要操心,DatabaseConnection 可以随便克隆、在任务之间共享。

代价:

  • 两条语句之间没有任何一致性保证,SET 之类的会话设置在 &DatabaseConnection 上没有意义;要同一条连接就开事务,DatabaseTransaction 把一条连接放在 Arc<Mutex<..>> 里(事务)。
  • 一个函数收 C: ConnectionTrait 就能同时接 &db 和 &txn,传错了编译器不拦:事务开着时用 &db,看不到未提交的写入,池小时等到 ConnectionAcquire(Timeout),碰到同一行时自己等自己的行锁(db-inside-transaction)。
  • 只有显式的事务才把几条语句绑在一起。ActiveModelBehavior 的钩子用的是调用方给的 db,不自己开事务,after_save 报错时 INSERT 已经提交(after-save-committed);ActiveModelEx 的嵌套写入则每层都 begin,整张图一起成功或一起回滚(ActiveModelEx 与嵌套写入)。
  • 事务的控制语句直接交给 sqlx,于是 sqlx 0.9.0 的取消问题原样继承:取消 begin() 让带着事务的连接回池(cancelled-begin)。
📝NOTE
没有蠢问题

问: 既然 &db 和 &txn 传错了编译器不拦,能不能让它拦?

答: 能,代价是放弃这层通用性。DatabaseConnection 和 DatabaseTransaction 是两个具体类型,都实现 ConnectionTrait;混用之所以能编译,是因为函数签名写成了 C: ConnectionTrait。一个只该在事务里跑的函数写成收 &DatabaseTransaction,传 &db 就是普通的类型不匹配。sea-orm 自己的写入方法(insert、save、钩子)都是泛型的,所以这条边界只能画在你自己的函数上。

8. ConnectOptions 是一层翻译

ConnectOptions 自己几乎不实现什么,sqlx_pool_options 逐项把设了的值写进 sqlx 的 PoolOptions,没设的就用 sqlx 的默认值(10 条、30 秒、空闲 10 分钟、寿命 30 分钟);sea-orm 只自己定了 INFO 级别的语句日志、借出前 ping、不懒连接(DatabaseConnection 与 ConnectOptions)。池的行为因此全在 sqlx 卷的连接池里,不用重学。

代价是名字和效果之间多了一层,几处对不上:

  • connect_timeout 和 acquire_timeout 都写 sqlx 的 acquire_timeout,后写的 acquire_timeout 赢;连不上库最后只看到 PoolTimedOut(connect-timeout-ignored)。
  • 慢语句日志设成 Off 不会传给 sqlx,超过 1 秒的语句照样打 WARN,只有 sqlx_logging(false) 能关。
  • set_schema_search_path 是在每条新连接上执行一条拼出来的 SET:逗号后的空格进了 schema 名,值里带 " 时建池超时(search-path-quote);ConnectOptions::after_connect 每个池只调一次,文档说的是每条连接。
  • From<PgPool> 包装一个现成的池时完全绕过 ConnectOptions。

9. 解码宽松:几处错误变成 None

LEFT JOIN 没匹配上时右侧几列全是 NULL,要读成 None;可选的字段缺了列,也想读成 None。sea-orm 用最省事的办法实现了这两件事,于是几处真正的错误也变成了 None 或被丢掉:

位置做法后果证据
FromQueryResult::from_query_result_optionalOk(Self::from_query_result(res, pre).ok())find_also_related / find_with_related 右侧的解码错误变成 None,1.1.20 相同join 与 find_related、also-related-swallows-errors
TryGetable for Option<T>Null 和 ColumnNotFound 都给 Ok(None)Option 字段的列名写错、select_only 漏了列,读出来是 None列与值、下面的实验
into_json按列类型逐个试认不出的类型整列静默丢掉,numeric 变字符串部分列与自定义结果
sea-query-sqlx 的 into_argumentslet _ = args.add(..)编码错误被丢掉;含 NULL 的数组、超过 i64::MAX 的 BigUnsigned 反而 panic绑到 sqlx

第二行的后果比看上去大,因为实体 Model 和 #[derive(FromQueryResult)] 的每个 Option<T> 字段都走它。表里 body 是 'has body',一次 select_only 漏选了它,一次自定义结构体的字段名拼错(src/bin/opt_missing.rs,Postgres 16.14):

let m = post::Entity::find_by_id(1)
  .select_only()
  .column(post::Column::Id)
  .column(post::Column::Title)
  .one(&db)
  .await;
println!("model, body not selected : {m:?}");

// struct Typo { id: i32, bdy: Option<String> }, derive(FromQueryResult)
let t = Typo::find_by_statement(Statement::from_string(DbBackend::Postgres, "select id, body from post where id = 1"))
  .one(&db)
  .await;
println!("derive, field name typo  : {t:?}");
model, body not selected : Ok(Some(Model { id: 1, title: "t", body: None }))
derive, field name typo  : Ok(Some(Typo { id: 1, bdy: None }))

两次都是 Ok,有值的列读成了 None,从结果上分不出「这一列是 NULL」和「这一列根本没被选中」。

from_query_result_optional 的源码里留着一段注释:「would really like to do the following, but can't without version bump」,下面是区分 TryGetError::Null 和 DbErr 的写法。2.0 是一次大版本,这段仍然没有换上。反过来,非 Option 的路径是严格的:实体 Model 读到 NULL 或缺列都报 Missing value for column,all() 里一行出错整体失败(Select 与过滤)。

10. DbErr 按调用位置包装

DbErr 是一层扁平的 enum:Conn / Exec / Query 经 RuntimeErr 包着 sqlx 的错误,ConnectionAcquire 是借连接失败,其余分支是 sea-orm 自己的检查(RecordNotUpdated、PrimaryKeyNotSet ……)。用户只有一种错误类型,? 一路传上去(错误)。

代价:

  • 分支由出错的调用决定,不由错误本身决定:同一个唯一约束冲突,ActiveModel::insert 在 Postgres 上是 Query(它带 RETURNING,走查询),exec_without_returning 是 Exec;建池超时是 Conn(SqlxError(PoolTimedOut)),查询时借不到连接是 ConnectionAcquire(Timeout)。
  • sql_err() 只认 23505 和 23503;CHECK、NOT NULL、死锁要自己取 SQLSTATE。PartialEq 比的是 Display 文本。
  • Display 带出约束名、表名,连接错误带出整个连接串和密码,不能原样给用户。

11. 宏生成大量代码

用户写一个 Model struct,DeriveEntityModel 生成 Entity、Column、PrimaryKey、ActiveModel 和一串 impl;#[sea_orm::model] 再加上 ModelEx、ActiveModelEx、EntityLoader、强类型列 COLUMN。同一个三列实体,展开写法展开后 1037 行,dense 写法 2206 行(DeriveEntityModel 与 dense 格式)。

代价之一是报错指向宏,而且要知道宏生成了什么才看得懂:#[sea_orm::model] 写在 #[derive] 后面,报的是 cannot find type Relation in this scope(同一页);覆盖钩子忘了 #[async_trait] 报 E0195(save 与 ActiveModelBehavior);2.0 的 DerivePartialModel 自带 FromQueryResult,再 derive 一次报 E0119(部分列与自定义结果)。

另一个代价是编译时间,前面的页面没有量过,这里补一个实验。用脚本生成两个程序,各含 N 个三列实体(e0 … e{N-1},表名 t0 …),只差 dense 写法多一行 #[sea_orm::model]、展开写法多一个空的 Relation 枚举。每个实体是这样(dense 版):

pub mod e0 {
  use sea_orm::entity::prelude::*;
  #[sea_orm::model]
  #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
  #[sea_orm(table_name = "t0")]
  pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    pub title: String,
    pub body: Option<String>,
  }
  impl ActiveModelBehavior for ActiveModel {}
}

依赖(sea-orm =2.0.4,features sqlx-postgres、runtime-tokio-rustls、macros、with-chrono、with-json)先编好,只重编这一个 bin,关掉增量编译,用 -Ztime-passes 看 rustc 各阶段的耗时(M2 Max、macOS、rustc 1.99.0;RUSTC_BOOTSTRAP=1 让稳定版接受 -Z):

touch src/bin/$b.rs
RUSTC_BOOTSTRAP=1 CARGO_INCREMENTAL=0 cargo rustc -q --offline --bin $b -- -Ztime-passes 2>&1 \
  | grep -E '\s(total|macro_expand_crate|type_check_crate|coherence_checking|MIR_borrow_checking)$'
== plain40
time:   0.125; rss:   56MB ->  123MB (  +68MB)	macro_expand_crate
time:   0.084; rss:  161MB ->  193MB (  +32MB)	coherence_checking
time:   0.240; rss:  161MB ->  207MB (  +46MB)	type_check_crate
time:   0.270; rss:  207MB ->  253MB (  +46MB)	MIR_borrow_checking
time:   0.880; rss:   38MB ->   97MB (  +59MB)	total
== dense40
time:   0.284; rss:   55MB ->  168MB ( +113MB)	macro_expand_crate
time:   0.767; rss:  250MB ->  398MB ( +148MB)	coherence_checking
time:   1.120; rss:  250MB ->  432MB ( +182MB)	type_check_crate
time:   3.334; rss:  432MB ->  530MB (  +98MB)	MIR_borrow_checking
time:   5.144; rss:   38MB ->  101MB (  +63MB)	total
== plain200
time:   0.528; rss:   57MB ->  267MB ( +210MB)	macro_expand_crate
time:   0.461; rss:  427MB ->  465MB (  +39MB)	coherence_checking
time:   1.258; rss:  427MB ->  529MB ( +102MB)	type_check_crate
time:   1.338; rss:  529MB ->  688MB ( +160MB)	MIR_borrow_checking
time:   3.851; rss:   38MB ->   99MB (  +62MB)	total
== dense200
time:   1.162; rss:   57MB ->  477MB ( +420MB)	macro_expand_crate
time:   3.803; rss:  834MB -> 1386MB ( +553MB)	coherence_checking
time:   5.437; rss:  833MB -> 1523MB ( +690MB)	type_check_crate
time:  16.602; rss: 1523MB -> 1955MB ( +431MB)	MIR_borrow_checking
time:  24.754; rss:   38MB ->  106MB (  +69MB)	total
  • 200 个实体,dense 写法 24.8 秒,展开写法 3.9 秒,约 6.4 倍;40 个实体是 5.1 秒对 0.9 秒。两者都大致随 N 线性增长。dense 的源文件反而更短(2606 行对 2806 行)。
  • 时间不在宏展开上:macro_expand_crate 只有 1.2 秒对 0.5 秒。多出来的是检查生成的代码:借用检查 16.6 秒对 1.3 秒,类型检查 5.4 秒对 1.3 秒,trait 一致性检查 3.8 秒对 0.5 秒。峰值内存约 1.9 GB 对 0.7 GB。
  • 这台机器上同时有别的编译任务,单次数字有噪声:另外三轮 cargo check(同样关掉增量)dense200 是 26.8–35.8 秒,plain200 是 3.5–7.7 秒,倍数没有变。打开增量编译、只 touch 不改内容时,第二次 cargo check 是 0.98 秒对 0.56 秒(40 个实体),日常改一处代码的代价小得多;倍数落在冷编译、CI 和改了实体所在 crate 的时候。

实验里的实体没有关系字段;有 has_many / belongs_to 的实体会多生成 Loader 和嵌套写入的代码,只会更多。本站的 11 个实体都是展开写法,迁到 2.0 时是否改 dense,在升级到 2.0讨论。

12. 同步版本用 sed 生成

sea-orm-sync 不是用 maybe-async 一类的宏从同一份代码生成两种 API,而是 build-tools/make-sync.sh 把 src/ 复制一份,跑六十多条 sed 删掉 async、.await、#[async_trait],把 Stream 换成 Iterator(源码地图)。换来的是:异步版本的源码里没有任何为同步版本准备的宏,读起来就是普通的 async Rust;同步版本也是普通代码,不靠运行时。

代价:

  • 仓库里多了 4.5 万行生成的代码(sea-orm-sync/src 45 021 行,加上 tests、examples 约 7 万行)。在 2.0.4 上重跑脚本,结果和发布的逐字节相同;但 CI 不重跑脚本比较差异,两份一致靠改了 src/ 的人记得重新生成。
  • src/ 要迁就脚本:不能出现「删掉 async 就不成立」的写法,机械替换不了的地方用 #[cfg(feature = "sync")] 分开写,src/ 里有 57 处。
  • 同步版本只有 rusqlite 驱动,sqlx 的几个驱动 feature 在那里是空的。

小结

  • 查询是运行期的值,编译期只对着实体检查:列名拼错编译不过,实体和库不一致、值的类型和宽度不对要到运行期;2.0 的 COLUMN.x 按类别补了一部分。
  • 语句树和方言分开,换来不连库看 SQL、Mock 和 RBAC;方言扩展在别的后端上会被静默丢掉(TABLESAMPLE)或 panic(ILIKE)。
  • 后端是运行时的 enum,签名简单,代价是 feature 改变它的形状;关系是 RelationDef 值,所有取关联数据的路径共用它,join 类型、别名、UNIQUE 都是运行期的事。
  • join 的 LIMIT 管行,Loader 的 IN 不分块;ActiveValue 三态让写入只碰改过的列,代价是忘了 Set 不报错、save 只看主键、insert_many 补 NULL。
  • 每条语句借一次连接,没有会话,一致性只来自显式事务,&db / &txn 混用编译器不拦;ConnectOptions 是对 sqlx 的翻译,connect_timeout、慢日志、search_path 名不副实。
  • 解码在四处把错误变成 None 或丢掉;DbErr 按调用位置包装,只认两种 SQLSTATE。
  • dense 实体的宏输出翻倍,冷编译约慢 6 倍,时间花在检查生成的代码上;同步版本是 sed 的产物,一致性靠重新生成。

💬 关联讨论

发起讨论
加载中...