Rust in Everything
基于 sea-orm 2.0.4

怎么读这一卷

主线位置:三条主线的总览。本页把查询主线、写入主线和定义主线各画一遍,标出每一步落在哪个函数上、哪一页讲它;「一次 find 的全过程」用一个完整的例子把查询主线走一遍,并用抓包证明实际发出的 SQL。

flowchart TB
  start[从这里开始] --> q{读这一卷为了什么}
  q -->|会用| use1[01 全景]
  use1 --> use2["02 实体与关系"]
  use2 --> use3["04 查询 05 写入"]
  use3 --> use4["06 的事务"]
  q -->|读懂实现| all[00 到 11 按顺序]

锁定版本

项本卷本站 Cargo.lock
sea-orm(及 sea-orm-macros、sea-orm-migration、sea-orm-cli、sea-orm-codegen)2.0.4,tag 2.0.4,commit db40a19(2026-09-27)1.1.20
sea-query1.0.2,tag 1.0.2,commit f2d5e04(2026-08-12)0.32.7
把 sea-query 的值绑到 sqlx 的 cratesea-query-sqlx 0.9.1(sea-query 仓库的 sea-query-sqlx/ 目录)sea-query-binder 0.7.0
sqlx0.9.0(上一卷)0.8.6
sea-schema(读数据库结构)0.18.10.16.2
rust-version / editionsea-orm 1.94.0 / 2024;sea-query 1.88.0 / 2024sea-orm 1.1.20:1.81 / 2021

源码链接一律指向 tag:https://github.com/SeaQL/sea-orm/blob/2.0.4/...、https://github.com/SeaQL/sea-query/blob/1.0.2/...。sea-orm 的 Cargo.toml 写的是 sea-query = { version = "~1.0.0", ... }、sea-query-sqlx = { version = "0.9", ... }、sqlx = { version = "0.9.0", ... },所以 2.0.4 只能和 sea-query 1.0.x、sqlx 0.9 搭配。

本站用的是 1.1.20,实体是 1.x 的「展开」写法(Model + Relation 枚举 + Related impl)。1.1.20 到 2.0.4 是大版本:ConnectionTrait 的 execute / query_* 改收 sea-query 语句、DatabaseConnection 从 enum 变成 struct、新的 dense 实体格式(#[sea_orm::model])、ActiveModelEx 嵌套写入、Entity Loader、每个迁移一个事务、RBAC、同步版本 sea-orm-sync。本卷讲 2.0.4,讲到和 1.1.20 不同的地方会标出来;哪些改动落到本站、要改哪些代码,集中在 10 在本站中。

三层

一次查询从上到下经过三个库,本卷主要讲上面两层:

flowchart TB
  user["你的代码 Entity::find"] --> orm["sea-orm 实体 查询 执行"]
  orm -->|"SelectStatement 等"| sq["sea-query 语句树"]
  sq -->|"QueryBuilder 写 SQL"| stmt["Statement 加 Values"]
  stmt -->|"sea-query-sqlx 绑参数"| sqlx["sqlx query_with"]
  sqlx --> pg[(Postgres)]
  • sea-orm:实体(宏生成的类型和 trait 实现)、查询和写入的构造器、执行器、连接与事务、迁移。它不自己写 SQL 字符串,而是把每个操作翻译成 sea-query 的语句。
  • sea-query:和数据库无关的语句树(SelectStatement、InsertStatement……),加上每种方言一个 QueryBuilder,把树写成 SQL 和一组参数。它不依赖 sqlx,也不连库。
  • sqlx:连接池、协议、预备语句缓存、类型编解码,上一卷从头讲过。本卷讲到池、事务的 BEGIN / ROLLBACK、协议消息、Encode / Decode 时只链过去,不重讲。

只讲 Postgres(sqlx-postgres)。MySQL、SQLite、rusqlite 只在对比时提到。

三条主线

全卷沿三条线展开,每页开头都会标出本页在哪条主线上。

查询主线:一次 find_by_id(..).one(&db)

let cake: Option<cake::Model> = cake::Entity::find_by_id(1).one(&db).await?;
flowchart TB
  find["find_by_id 造 Select"] --> one["Selector::one 加 limit 1"]
  one --> qo["ConnectionTrait::query_one"]
  qo --> build["build 选 PostgresQueryBuilder"]
  build --> raw["DatabaseConnection 分派"]
  raw --> sq["sqlx_query 绑 SqlxValues"]
  sq --> acq["pool.acquire 借连接"]
  acq --> fetch["sqlx fetch_one"]
  fetch --> qr["PgRow 包成 QueryResult"]
  qr --> fqr["Model::from_query_result"]
步骤源码讲它的页
1. EntityTrait::find_by_id 是默认方法:Self::find()(Select::new 列出实体每一列、填 FROM),再按主键逐列 filter(col.eq(v))entity/base_entity.rs、query/select.rs实体的几个 trait、Select 与过滤
2. Select::one → into_model() 得到 Selector<SelectModel<E::Model>> → Selector::one:self.query.limit(1),db.query_one(&self.query)executor/select.rs一次 find 的全过程
3. ConnectionTrait::query_one 是默认方法:get_database_backend().build(stmt),按后端选 sea-query 的 PostgresQueryBuilder,写出 SELECT "cake"."id", ... WHERE "cake"."id" = $1 LIMIT $2 和 Values,包成 Statement,再调 query_one_rawdatabase/connection.rs、database/statement.rs、sea-query backend/语句是一棵树、QueryBuilder 与参数
4. DatabaseConnection::query_one_raw 按 inner 分派到 SqlxPostgresPoolConnection::query_onedatabase/db_connection.rsDatabaseConnection 与 ConnectOptions
5. sqlx_query = sqlx::query_with(AssertSqlSafe(sql), SqlxValues(values)),sea-query-sqlx 逐个 Value 绑进 PgArgumentsdriver/sqlx_postgres.rs、sea-query-sqlx/src/sqlx_postgres.rs绑到 sqlx
6. SqlxPostgresPoolConnection::query_one:self.pool.acquire()(每条语句借一次),fetch_one(&mut *conn),RowNotFound 变成 Ok(None)同上一次 find 的全过程、sqlx 卷获取连接
7. PgRow 包成 QueryResult,SelectModel 调 Model::from_query_result(&row, ""):DeriveEntityModel 生成,按列名逐列 try_getexecutor/query.rs、executor/select.rs、宏DeriveEntityModel 与 dense 格式、列与值

第 6 步往下就是 sqlx 卷的查询主线和连接池主线:语句缓存、Parse / Bind / Execute、借出和归还时的 ping。

写入主线:一次 ActiveModel::insert / update / save

let am = cake::ActiveModel { name: Set("cheese cake".to_owned()), ..Default::default() };
let model: cake::Model = am.insert(&db).await?;
stateDiagram-v2
  state "ActiveModel 每列一个 ActiveValue" as AM
  state "before_save" as BS
  state "Insert 只放 Set 的列" as INS
  state "UpdateOne 按主键 WHERE" as UPD
  state "RETURNING 拿回整行" as RET
  state "after_save" as AFT
  [*] --> AM
  AM --> BS
  BS --> INS: insert 或主键有 NotSet
  BS --> UPD: update 或主键齐全
  INS --> RET
  UPD --> RET
  RET --> AFT
  AFT --> [*]
步骤源码讲它的页
1. ActiveModel 的每个字段是一个 ActiveValue<V>:Set(要写)、Unchanged(从库里读来、没改)、NotSet(不写)entity/active_value.rsActiveValue 与 ActiveModel
2. ActiveModelTrait::insert:before_save(self, db, true) → EntityTrait::insert 得到 Insert<A> → exec_with_returning(Postgres 支持 RETURNING,一条语句拿回整行)→ after_saveentity/active_model.rs、query/insert.rs、executor/insert.rsinsert
3. ActiveModelTrait::update:before_save(self, db, false) → EntityTrait::update 得到 UpdateOne(主键列有 NotSet 时,PrimaryKeyNotSet 错误先存在里面,执行时才返回)→ exec_update_and_return_updated:UPDATE ... RETURNING,没有返回行就是 RecordNotUpdatedquery/update.rs、executor/update.rsupdate 与 delete
4. ActiveModelTrait::save:is_update 看主键的每一列,有一列 NotSet 就 insert,否则 update;ActiveModelBehavior 的钩子在 ActiveModelBehaviorentity/active_model.rssave 与 ActiveModelBehavior
5. 2.0 的 ActiveModelEx:user::ActiveModel::builder().set_profile(..).add_post(..).save(&db),按外键依赖排顺序逐个写entity/active_model_ex.rs、宏嵌套写入 ActiveModelEx

写入同样经过查询主线的第 3 到 6 步:Insert / UpdateOne 里装的是 sea-query 的 InsertStatement / UpdateStatement,最后也是 query_with 加一次 acquire。update 时如果除主键外没有一列是 Set,exec_update_and_return_updated 根本不发 UPDATE,改为按主键查回原行,细节见 update 与 delete。

定义主线:一个实体从宏到 trait

#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "cake")]
pub struct Model {
  #[sea_orm(primary_key)]
  pub id: i32,
  pub name: String,
}
flowchart TB
  model["你写的 Model 结构体"] --> attr["sea_orm::model 属性宏"]
  model --> derive["DeriveEntityModel"]
  derive --> ent["Entity 单元结构体"]
  derive --> col["Column 枚举"]
  derive --> pk["PrimaryKey 枚举"]
  derive --> am["ActiveModel"]
  derive --> fqr["FromQueryResult 等实现"]
  attr --> ex["ModelEx ActiveModelEx"]
  attr --> rel["Relation EntityLoader"]
  rel --> rd["RelationDef"]
步骤源码讲它的页
1. DeriveEntityModel 转给 expand_derive_entity_model:从 Model 生成 Entity(单元结构体,实现 EntityName / EntityTrait)、Column(ColumnTrait)、PrimaryKey(PrimaryKeyTrait)、ActiveModel,以及 FromQueryResult、ModelTrait、ActiveModelTrait 等实现sea-orm-macros/src/derives/entity_model.rs 等实体的几个 trait、DeriveEntityModel 与 dense 格式
2. 属性宏 sea_orm_model(2.0 新增,sea-orm 以 model 的名字导出,写作 #[sea_orm::model])再多生成 ModelEx、ActiveModelEx、EntityLoader、强类型列 COLUMN,并从 HasOne / HasMany / BelongsTo 字段生成 Relationsea-orm-macros/src/derives/model_ex.rs 等DeriveEntityModel 与 dense 格式
3. 列:ColumnTrait 的 def() 给出 ColumnDef,eq / is_in / contains 把值变成 sea-query 的 Exprentity/column.rs、entity/column_def.rs列与值
4. 关系:RelationDef 是运行时的描述(两端的表和列、join 类型),RelationTrait、Related 返回它;join、find_related、Loader 都只读它entity/relation.rs、entity/compound/关系

一个两列的实体,加上 #[sea_orm::model],cargo expand 出来是 2 007 行、141 个 impl 块。定义主线是另外两条主线的前提:查询主线第 1 步的 Column::iter()、第 7 步的 from_query_result,写入主线的 ActiveModel 和 ActiveModelBehavior,都是这里生成的。

阅读路线

  • 只想会用:读 01 全景,02 里的实体的几个 trait 和关系,04 查询,05 写入,06 里的事务,遇到报错再查 09 常见坑。
  • 想读懂实现:按顺序读。02 是全卷的重点,三条主线都从宏生成的东西出发;03 讲 sea-query,是 sea-orm 和 sqlx 之间的那一层。
  • 想自己写一个:读完 01、02 和 03,直接去 11 动手。

前置知识

  • sqlx 卷的查询主线和连接池主线:Executor、&Pool 每次重新借连接、Transaction 和 Drop 时的回滚。sea-orm 的每条语句最后都是一个 sqlx::query_with(..).fetch_*(&mut *conn)。
  • async/await 和 tokio。sea-orm 的 ConnectionTrait 用 #[async_trait::async_trait] 声明,方法返回 boxed future。
  • serde:with-json feature、ActiveModel::from_json、serde feature,见 serde 卷。
  • tracing:tracing-spans feature 给每条语句建 span,见 tracing 卷。

实验环境

关于行为的说法(发出了什么 SQL、几条语句、是 join 还是第二条查询、有没有 SAVEPOINT、报什么错、rustc 说了什么)都配一个小程序和它的真实输出。平台是 M2 Max、macOS、rustc 1.99.0、Postgres 16.14(本机容器)。实验 crate 的依赖锁定版本:

sea-orm = { version = "=2.0.4", features = ["sqlx-postgres", "runtime-tokio-rustls", "macros", "with-chrono", "with-json"] }
sea-query = "=1.0.2"
sqlx = { version = "=0.9.0", default-features = false, features = ["postgres", "runtime-tokio"] }
tokio = { version = "1", features = ["full"] }

四种看 SQL 的办法

同一个 cake::Entity::find_by_id(1).one(&db),有四种办法看它的 SQL,看到的东西不一样。

1. 不连库:build 和 to_string。 QueryTrait::build(DbBackend::Postgres) 返回 Statement,就是交给驱动的那一份;into_query().to_string(PostgresQueryBuilder) 把值内联进 SQL,只能用来读。注意 .one() 自己会加 limit(1),要看同一条 SQL 得手动加上:

sql    = SELECT "cake"."id", "cake"."name" FROM "cake" WHERE "cake"."id" = $1 LIMIT $2
values = Some(Values([Int(Some(1)), BigUnsigned(Some(1))]))
to_string = SELECT "cake"."id", "cake"."name" FROM "cake" WHERE "cake"."id" = 1 LIMIT 1

2. sqlx 的语句日志。 sea-orm 的 ConnectOptions 默认 sqlx_logging(true)、sqlx_logging_level(log::LevelFilter::Info)(见 ConnectOptions::new),建池时交给 sqlx 的 log_statements;sqlx 自己的默认级别是 Debug,sea-orm 把它提到了 Info。程序里有一个 tracing_subscriber,RUST_LOG=sqlx=info 就能看到每条实际执行的语句,带 $N 占位符,不带参数值:

 INFO sqlx::query: summary="SET search_path = \"so_intro\"" db.statement="" rows_affected=0 rows_returned=0 elapsed=531.5µs elapsed_secs=0.0005315
 INFO sqlx::query: summary="SELECT \"cake\".\"id\", \"cake\".\"name\" FROM …" db.statement="\n\nSELECT \"cake\".\"id\", \"cake\".\"name\" FROM \"cake\" WHERE \"cake\".\"id\" = $1 LIMIT $2\n" rows_affected=1 rows_returned=1 elapsed=3.539417ms elapsed_secs=0.003539417
结果 Some(Model { id: 1, name: "cheese cake" })

第一条 SET search_path 是 set_schema_search_path 在每条新连接上执行的。日志格式见 sqlx 卷的怎么读这一卷。

3. debug-print feature。 驱动的每个执行方法开头都有一句 debug_print!("{}", stmt),开了这个 feature 时 debug_print! 展开成 tracing::debug!,target 是 sea_orm::driver::sqlx_postgres,内容是 Statement 的 Display。它不直接写标准输出,要 RUST_LOG=sea_orm=debug 加一个订阅者才看得到:

DEBUG sea_orm::driver::sqlx_postgres: create schema if not exists so_intro;
     drop table if exists so_intro.cake;
     create table so_intro.cake (id serial primary key, name text not null);
     insert into so_intro.cake (name) values ('cheese cake');
DEBUG sea_orm::driver::sqlx_postgres: SELECT "cake"."id", "cake"."name" FROM "cake" WHERE "cake"."id" = 1 LIMIT 1
结果 Some(Model { id: 1, name: "cheese cake" })

不开 feature、只设 RUST_LOG=sea_orm=debug,这两行都没有。

4. MockDatabase。 开 mock feature,MockDatabase::new(DatabaseBackend::Postgres).append_query_results([..]).into_connection() 得到一个不连库的 DatabaseConnection,按顺序返回预置的结果,并记下每条语句。into_transaction_log() 拿到的是完整的 Statement,占位符和参数值都在:

mock 结果 Some(Model { id: 1, name: "cheese cake" })
[Transaction { stmts: [Statement { sql: "SELECT \"cake\".\"id\", \"cake\".\"name\" FROM \"cake\" WHERE \"cake\".\"id\" = $1 LIMIT $2", values: Some(Values([Int(Some(1)), BigUnsigned(Some(1))])), db_backend: Postgres }] }]

它不执行 SQL,所以证明不了 SQL 能跑、结果对,只能证明生成了什么,见 Mock 与 Proxy。

办法要连库占位符参数值看到的是
build / to_string否build 有,to_string 内联build 在 values 里构造出来的语句,不经过 .one() 这类执行方法的加工
sqlx 日志是有没有sqlx 实际执行的每一条,包括 SET search_path 这类 sea-orm 内部发的
debug-print是内联内联进驱动的每条 Statement
MockDatabase否有有进连接的每条 Statement,经过了执行方法的加工

本卷展示 SQL 时会说明是哪一种,优先用「实际发出的」:sqlx 日志、MockDatabase 的日志,或者一次 find 的全过程里那个能解出 Bind 参数字节的协议代理。

📝NOTE
没有蠢问题

问: debug-print 打出来的 ... WHERE "cake"."id" = 1 LIMIT 1 可以直接复制到 psql 里跑,这不就是发给数据库的 SQL 吗?

答: 不是。Statement 的 Display 调 sea-query 的 inject_parameters,把 values 按 Postgres 的字面量写法塞回 $N 的位置,这是为了给人看。发给数据库的是 $1、$2 加上二进制格式的参数,SQL 文本里从来没有这些值,这也是 sea-orm 能对运行期拼出来的 SQL 用 AssertSqlSafe 的前提。两者在可读性上看不出差别,在行为上有差别:内联的字面量由服务端重新推断类型,参数的类型由客户端在 Bind 前定好(上面 LIMIT 的参数是 int8);内联的版本每换一个值就是另一条 SQL,参数版本始终是同一条,能命中语句缓存。排查「生成了什么」用它没问题,排查「数据库收到了什么」要看 sqlx 日志或抓包。

小结

  • 全卷对着 sea-orm 2.0.4、sea-query 1.0.2、sea-query-sqlx 0.9.1、sqlx 0.9.0 写;本站用 1.1.20、sea-query 0.32.7、sea-query-binder 0.7.0、sqlx 0.8.6。
  • 三层:sea-orm 把操作翻译成 sea-query 的语句树,sea-query 按方言写出 SQL 和参数,sqlx 借连接执行。
  • 查询主线:find_by_id → Selector::one(limit(1))→ query_one 里 build → sqlx_query → 每条语句 acquire 一次 → fetch_one → from_query_result。
  • 写入主线:ActiveValue 三态 → before_save → Insert 或 UpdateOne + RETURNING → after_save;save 看主键有没有 NotSet。
  • 定义主线:DeriveEntityModel 和 #[sea_orm::model] 生成 Entity、Column、PrimaryKey、ActiveModel 和一串 impl,关系落到 RelationDef。
  • 看 SQL 有四种办法,只有 sqlx 日志和抓包看到的是实际发出的;debug-print 是一条 tracing::debug!,值是内联的。

💬 关联讨论

发起讨论
加载中...