Entity First:schema sync
主线位置:定义主线反过来走。平常是先有表、再写实体(或用
sea-orm-cli generate entity从表生成实体);schema sync 从实体出发去改数据库。它复用上一页的EntitySchemaInfo(每个实体的CREATE TABLE、CREATE TYPE、CREATE INDEX),再用 sea-schema 读出数据库现有的结构,两边比较,缺什么补什么。
flowchart TB
derive["DeriveEntityModel"] -->|"entity-registry"| sub["inventory::submit"]
sub --> reg["按前缀挑实体"]
reg --> sb["SchemaBuilder"]
sb --> disc["sea-schema 读现有结构"]
disc --> sort["按外键拓扑排序"]
sort --> each{"逐个实体"}
each -->|"表不存在"| create["建类型 表 索引"]
each -->|"表存在"| diff["比较列 外键 索引"]
diff --> add["加列 改名 加外键"]
diff --> warn["类型不同只打警告"]
diff --> drop["DROP 多余的唯一约束"]
代码在 src/schema/builder.rs(686 行)、src/schema/topology.rs(432 行,从 topological-sort-rs 拷进来的)和 src/entity/registry.rs(58 行)。sync 的文档注释开头是「Unstable: schema sync is experimental and exempt from semver」,CHANGELOG 2.0.0-rc.42 也写着「Schema sync and nested-ActiveModel relation mutation are marked unstable (semver-exempt) while their APIs settle」,下面说的是 2.0.4 的行为,小版本里可能会变。
实验 crate 和前两页相同,sea-orm 多开 schema-sync、entity-registry 两个 feature,平台 M2 Max、macOS、rustc 1.99.0、Postgres 16.14。wire> 行来自协议代理,这一页只打印 CREATE / ALTER / DROP 开头的语句和错误,sea-schema 读结构的那些查询只计数不打印。
怎么收集实体
entity-registry = ["inventory", "sea-orm-macros/entity-registry"]。打开之后,DeriveEntity(DeriveEntityModel 会展开出它)多生成一段(impl_entity_registry):
quote! {
sea_orm::register_entity! {
sea_orm::EntityRegistry {
module_path: module_path!(),
schema_info: |schema| sea_orm::EntitySchemaInfo::new(Entity, schema),
}
}
}
register_entity! 就是 inventory::submit!:每个实体在链接期往一张全局表里放一个 EntityRegistry,记着它所在的模块路径和一个「给我一个 Schema,我还你 EntitySchemaInfo」的函数指针。不需要手写注册代码,也不需要在 main 里列出所有实体。
db.get_schema_registry(prefix)(DatabaseConnection::get_schema_registry)调 EntityRegistry::build_schema,按模块路径前缀挑实体:
let mut prefix = prefix.trim_end_matches("*");
if !prefix.contains("::") {
string = format!("{prefix}::");
prefix = &string;
}
if let Some((left, right)) = prefix.split_once("::") {
if left.contains("-") {
// convert crate name to module path
let left = left.replace('-', "_");
string = format!("{left}::{right}");
prefix = &string;
}
}
debug!("Registering entities with prefix `{prefix}`");
for entity in inventory::iter::<crate::EntityRegistry>() {
if entity.module_path.starts_with(prefix) {
schema.register_entity((entity.schema_info)(schema.helper()));
去掉末尾的 *;没有 :: 时补一个("my_crate" 变成 "my_crate::");crate 名里的 - 换成 _。然后是纯字符串的 starts_with。所以 "my_crate::user" 也会选中 my_crate::user_profile 里的实体。实验程序里 e1 模块下有 t 和 t_extra 两个实体,前缀写 "sync_more::e1::t"(不带 ::*):
--- e1 with prefix "sync_more::e1::t" (no ::*)
wire> CREATE TYPE "t_status" AS ENUM ('draft', 'done')
wire> CREATE TABLE "t" ( "id" integer GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "status" t_status NOT NULL )
wire> CREATE TABLE "t_extra" ( "id" integer GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY )
sync => Ok(())
tables in so_mig_sync: ["t", "t_extra"]
想只选一个模块,前缀写到 :: 为止("sync_more::e1::t::")或者带上 ::*。不想用全局注册的话,db.get_schema_builder().register(user::Entity).register(post::Entity) 手动列出实体,得到的是同一个 SchemaBuilder;手动的 register 按表名去重,注册表那条路(register_entity)不去重。
sync 的约束是 C: ConnectionTrait + sea_schema::Connection,sea-orm 给 DatabaseConnection、DatabaseTransaction 和 DatabaseExecutor 都实现了后者(sea_schema_shim.rs),所以可以在事务里 sync,也可以在迁移的 up 里 sync(manager.get_connection() 就是一个 DatabaseExecutor)。
第一步:读出现有结构
sync 先查 CURRENT_SCHEMA(),收集所有实体用到的 schema(实体没写 schema_name 的算当前 schema),对每个 schema 跑一遍 sea-schema 的 SchemaDiscovery,得到现有的表(每张表还原成一个 TableCreateStatement)和 enum 类型。之后的比较都是在两组 TableCreateStatement 之间做的,不再查库。实验里每次 sync 一共发出 5 到 13 条语句,除了打印出来的 DDL,都是读结构的查询。
第二步:按外键排序
let mut sorter = TopologicalSort::<TableName>::new();
for entity in self.entities.iter() {
let table_name = get_table_name(entity.table.get_table_name());
sorter.insert(table_name);
}
for entity in self.entities.iter() {
let self_table = get_table_name(entity.table.get_table_name());
for fk in entity.table.get_foreign_key_create_stmts().iter() {
let fk = fk.get_foreign_key();
let ref_table = get_table_name(fk.get_ref_table());
if self_table != ref_table {
// self cycle is okay
sorter.add_dependency(ref_table, self_table.clone());
}
}
}
let mut sorted = Vec::new();
while let Some(i) = sorter.pop() {
sorted.push(i);
}
if sorted.len() != self.entities.len() {
// push leftover tables
for entity in self.entities.iter() {
let table_name = get_table_name(entity.table.get_table_name());
if !sorted.contains(&table_name) {
sorted.push(table_name);
}
}
}
被引用的表是「前驱」,引用它的表是「后继」。TopologicalSort 用一个 HashMap 记每个节点还有几个前驱,pop 取任意一个前驱数为 0 的节点。没有依赖关系的表之间谁先谁后由 HashMap 的迭代顺序决定,每次运行可能不同;有依赖的一定是被引用的在前。指向自己的外键(自引用)被跳过,不算环。
真有环时,pop 取不出环上的任何一个节点,这些表按注册顺序追加在最后,建表时照样会撞上「被引用的表还不存在」。a.b_id → b、b.a_id → a 两个实体,在空 schema 上 sync 两次:
--- cycle a <-> b on an empty schema
wire> CREATE TABLE "b" ( "id" integer GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "a_id" integer, CONSTRAINT "fk-b-a_id" FOREIGN KEY ("a_id") REFERENCES "a" ("id") )
wire< ErrorResponse 42P01 relation "a" does not exist
sync => Err("Execution Error: error returned from database: relation \"a\" does not exist at line 434")
tables in so_mig_cyc: []
--- cycle again
wire> CREATE TABLE "b" ( "id" integer GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "a_id" integer, CONSTRAINT "fk-b-a_id" FOREIGN KEY ("a_id") REFERENCES "a" ("id") )
wire< ErrorResponse 42P01 relation "a" does not exist
sync => Err("Execution Error: error returned from database: relation \"a\" does not exist at line 434")
tables in so_mig_cyc: []
建表语句把外键内联在 CREATE TABLE 里,所以环上的第一张表永远建不出来。循环外键要拆开:先建表不带外键(实体上 skip_fk),再在迁移里 ALTER TABLE ... ADD CONSTRAINT。
第三步:逐个实体比较
排好序之后,每个实体调一次 EntitySchemaInfo::sync。按代码的顺序,它做这几件事:
| 步骤 | 做什么 | 怎么判断「已有」 |
|---|---|---|
| enum | 实体的每个 CREATE TYPE 和库里的比较,没有就执行 | 两条 CREATE TYPE 语句 build 出来完全相等 |
| 表不存在 | 执行整条 CREATE TABLE(列、主键、内联外键) | 按表名(不带 schema)找 |
| 表存在:列 | 库里没有的列:有 renamed_from 就 RENAME COLUMN,否则 ADD COLUMN | 按列名 |
| 表存在:列类型 | 类型渲染成 SQL 后不同,打一条 warn!,不改(rc.42,#3106) | 只比类型,不比 NULL / 默认值 |
| 表存在:外键 | 库里没有的外键 ALTER TABLE ... ADD CONSTRAINT(SQLite 跳过) | 名字相同,或者引用表和两边的列都相同(compare_foreign_key) |
| 索引 | indexed / unique_key 的索引,库里没有就 CREATE INDEX IF NOT EXISTS | 只比列名列表,不比是否唯一 |
unique 列 | 已存在的列新加了 #[sea_orm(unique)]:CREATE UNIQUE INDEX IF NOT EXISTS "idx-<表>-<列>"(rc.35,#2971) | 有没有单列的唯一索引 |
| 多余的唯一键 | 库里有、实体里没有的唯一索引:Postgres 上 ALTER TABLE ... DROP CONSTRAINT <索引名>,其他库 DROP INDEX | 同上 |
sync 自己的文档说「Non-destructive by design. Sync only adds — it never drops or alters existing tables or columns」。这句话只对表和列成立:它会改列名,也会删唯一约束。
实验:一张表的十一个版本
sync_steps 程序里有 v1 到 v11 十一个模块,每个模块里是同名表的一个版本,每一步 db.get_schema_registry("sync_steps::vN::*").sync(&db),之后用 information_schema 列出 user 表的列。v1 之后往 user 里插了一行。各版本相对上一版的改动:
| 版本 | user 实体的改动 |
|---|---|
| v1 | id、name: String、#[sea_orm(unique)] email: String;另有 post,belongs_to user |
| v2 | 加 bio: Option<String> |
| v3 | 再加 nickname: String(非空、无默认值) |
| v4 | 去掉 nickname,name 改成 i64 |
| v6 | name 加 #[sea_orm(unique)] |
| v7 | email 去掉 #[sea_orm(unique)] |
| v8 | name 去掉 #[sea_orm(unique)] |
| v9 | name 改名 full_name,#[sea_orm(renamed_from = "name")] |
| v10 | 新实体 comment(id, post_id),没有关系字段 |
| v11 | comment.post_id 变成 belongs_to post;user、post 同时注册,不变 |
(v5 留给本页的动脑筋。)核心循环:
for (v, what) in steps {
println!("--- {v}: {what}");
let before = w.count.load(Ordering::SeqCst);
w.quiet.store(false, Ordering::SeqCst);
let r = db.get_schema_registry(&format!("sync_steps::{v}::*")).sync(&db).await;
settle().await;
w.quiet.store(true, Ordering::SeqCst);
let n = w.count.load(Ordering::SeqCst) - before;
println!(" sync => {:?}, {n} statements in total", r.map_err(|e| e.to_string()));
// ... 打印 user 的列;v6..v8 再打印 user 上的唯一索引,以及它是不是一个约束
}
加表、加列:
--- v1: user + post (post belongs_to user), registry order as inventory yields it
wire> CREATE TABLE "user" ( "id" integer GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "name" varchar NOT NULL, "email" varchar NOT NULL UNIQUE )
wire> CREATE TABLE "post" ( "id" integer GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "user_id" integer NOT NULL, "title" varchar NOT NULL, CONSTRAINT "fk-post-user_id" FOREIGN KEY ("user_id") REFERENCES "user" ("id") )
sync => Ok(()), 5 statements in total
user: id integer not null, name character varying not null, email character varying not null
--- v2: user + bio: Option<String>
wire> ALTER TABLE "user" ADD COLUMN "bio" varchar
sync => Ok(()), 10 statements in total
user: id integer not null, name character varying not null, email character varying not null, bio character varying
--- v3: user + nickname: String, the table has one row
wire> ALTER TABLE "user" ADD COLUMN "nickname" varchar NOT NULL
wire< ErrorResponse 23502 column "nickname" of relation "user" contains null values
sync => Err("Execution Error: error returned from database: column \"nickname\" of relation \"user\" contains null values at line 6111"), 10 statements in total
user: id integer not null, name character varying not null, email character varying not null, bio character varying
ADD COLUMN 用的是实体的完整列定义,非空又没有 default_value 的列加不到有数据的表上(上一页的动脑筋)。sync 不会替你先加可空列、回填、再设 NOT NULL。
改类型:
--- v4: user.name: String -> i64
schema sync: column `user`.`name` is `varchar` in the database but the entity defines `bigint`; sync is non-destructive and will not alter it (apply the change with a migration)
sync => Ok(()), 9 statements in total
user: id integer not null, name character varying not null, email character varying not null, bio character varying
只有一条 tracing::warn!(target sea_orm,订阅了 sea_orm=warn 才看得见),sync 返回 Ok,列还是 varchar。实体和库从此不一致,要靠迁移去改。
唯一约束:
--- v6: user.name gets #[sea_orm(unique)]
wire> CREATE UNIQUE INDEX IF NOT EXISTS "idx-user-name" ON "user" ("name")
sync => Ok(()), 10 statements in total
user: id integer not null, name character varying not null, email character varying not null, bio character varying
unique indexes on user: idx-user-name (index only), user_email_key (constraint), user_pkey (constraint)
--- v7: user.email loses #[sea_orm(unique)]
wire> ALTER TABLE "user" DROP CONSTRAINT "user_email_key"
sync => Ok(()), 10 statements in total
user: id integer not null, name character varying not null, email character varying not null, bio character varying
unique indexes on user: idx-user-name (index only), user_pkey (constraint)
--- v8: user.name loses #[sea_orm(unique)]
wire> ALTER TABLE "user" DROP CONSTRAINT "idx-user-name"
wire< ErrorResponse 42704 constraint "idx-user-name" of relation "user" does not exist
sync => Err("Execution Error: error returned from database: constraint \"idx-user-name\" of relation \"user\" does not exist at line 12276"), 10 statements in total
user: id integer not null, name character varying not null, email character varying not null, bio character varying
unique indexes on user: idx-user-name (index only), user_pkey (constraint)
--- v8 again (does the failure go away?)
sync => Err("Execution Error: error returned from database: constraint \"idx-user-name\" of relation \"user\" does not exist at line 12276")
--- fix by hand: DROP INDEX "idx-user-name", then v8 again
sync => Ok(())
同一个 #[sea_orm(unique)],两种来历,两种下场:
email的唯一性是 v1 建表时内联的UNIQUE,Postgres 建的是一个约束user_email_key(背后有一个同名索引)。去掉之后DROP CONSTRAINT "user_email_key"成功。name的唯一性是 v6 对已有列补的CREATE UNIQUE INDEX,只是一个索引,没有约束。去掉之后 sync 照样发DROP CONSTRAINT "idx-user-name",报42704。这个索引一直在,每次 sync 都会再试一次、再失败一次。
sync 在第一个错误处就返回,同一个表后面的步骤、排在后面的表都不处理了,所以这个错误会挡住之后所有的改动,直到手动 DROP INDEX。
删多余唯一键的代码本来是 DROP INDEX。issue #2994(rc.37)报的是:去掉 #[sea_orm(unique)] 后再 sync,Postgres 报「cannot drop index test_user_id_key because constraint test_user_id_key on table orders requires it」,因为列上内联的 UNIQUE 是约束、不能用 DROP INDEX 删。修复(commit 1431408「Fix schema sync drop unique constraint #2994」)在 Postgres 上改发 ALTER TABLE … DROP CONSTRAINT,CHANGELOG 2.0.0-rc.38 的说法是「DROP INDEX fails on PostgreSQL for unique indexes created via column-level UNIQUE because they are backed by a named constraint, not a standalone index. Schema sync now uses ALTER TABLE … DROP CONSTRAINT on PostgreSQL」。可是早三个版本,rc.35 的 #2971「Fix unique column in schema sync」加了上表里「unique 列」那一步:已有的列新加 unique 时,sync 用 CREATE UNIQUE INDEX 补,这种唯一键恰恰只是索引。2.0.4 里两条路径同时存在,于是有了 v8 的结果。在 SeaQL/sea-orm 里搜「schema sync drop constraint」「sync unique index postgres」「idx- does not exist」,没有找到报告这个问题的 issue。
改名、加表、加外键(v8 手动修好之后):
--- v9: name -> full_name, renamed_from = "name"
wire> ALTER TABLE "user" RENAME COLUMN "name" TO "full_name"
sync => Ok(()), 10 statements in total
user: id integer not null, full_name character varying not null, email character varying not null, bio character varying
--- v10: new table comment(id, post_id), no relation
wire> CREATE TABLE "comment" ( "id" integer GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "post_id" integer NOT NULL )
sync => Ok(()), 10 statements in total
user: id integer not null, full_name character varying not null, email character varying not null, bio character varying
--- v11: comment.post_id becomes belongs_to post; user and post registered unchanged
wire> ALTER TABLE "comment" ADD CONSTRAINT "fk-comment-post_id" FOREIGN KEY ("post_id") REFERENCES "post" ("id")
sync => Ok(()), 13 statements in total
user: id integer not null, full_name character varying not null, email character varying not null, bio character varying
renamed_from 的实现很朴素:上一页的 column_def_from_entity_column 把它写进列定义的注释(renamed_from "name"),sync 发现库里没有 full_name 时,从注释里解析出旧名字,发 RENAME COLUMN。库里已经有 full_name 之后它就不起作用了,这个属性留着也没事。v11 里 user、post 原样注册,没有任何 DDL;comment 多了一个外键,发一条 ADD CONSTRAINT。外键的名字和建表时内联的一样,都是 fk-<表>-<列>。
enum 加一个值
e2 给 t_status 加了第三个值 archived:
--- e2: the enum gets a third variant
wire> CREATE TYPE "t_status" AS ENUM ('draft', 'done', 'archived')
wire< ErrorResponse 42710 type "t_status" already exists
sync => Err("Execution Error: error returned from database: type \"t_status\" already exists at line 1167")
tables in so_mig_sync: ["t", "t_extra"]
enum 的比较是整条 CREATE TYPE 语句相等,值多了一个,语句就不相等,sync 认为这个类型不存在,再建一次。代码里那一行写着 // TODO add enum variants。和上面的唯一索引一样,这个错误之后每次 sync 都会出现;要在迁移里手写 ALTER TYPE "t_status" ADD VALUE 'archived'(sea-query 的 Type::alter().add_value(..)),库里的类型和实体一致之后 sync 才恢复正常。在 SeaQL/sea-orm 里搜「schema sync enum variant」「sync enum already exists」「add enum variant sync」,没有找到对应的 issue。
没有事务
sync 里每条 DDL 单独执行,不开事务。一个实体加两列、第二列失败(src/bin/sync_txn.rs,item 表已有一行,实体加 note: Option<String> 和 code: String):
let r = if in_txn {
let txn = db.begin().await?;
match db.get_schema_registry("sync_txn::two::*").sync(&txn).await {
Ok(()) => txn.commit().await,
Err(e) => Err(e),
}
} else {
db.get_schema_registry("sync_txn::two::*").sync(&db).await
};
--- in_txn = false
wire> ALTER TABLE "item" ADD COLUMN "note" varchar
wire> ALTER TABLE "item" ADD COLUMN "code" varchar NOT NULL
wire< ErrorResponse 23502 column "code" of relation "item" contains null values
sync => Err("Execution Error: error returned from database: column \"code\" of relation \"item\" contains null values at line 6111")
item columns: ["id", "note"]
--- in_txn = true
wire> ALTER TABLE "item" ADD COLUMN "note" varchar
wire> ALTER TABLE "item" ADD COLUMN "code" varchar NOT NULL
wire< ErrorResponse 23502 column "code" of relation "item" contains null values
sync => Err("Execution Error: error returned from database: column \"code\" of relation \"item\" contains null values at line 6111")
item columns: ["id"]
直接在连接上 sync,失败时 note 已经加上了;放进事务(Postgres 的 DDL 可以回滚),失败时什么都没留下。读结构的查询在事务里同样能跑。两个实例同时 sync 和两个实例同时迁移一样没有锁。
适合什么场景
sync 回答的问题是「让数据库至少有实体要的这些东西」:表、列、外键、索引。它不回答「从上一个版本怎么变到这个版本」:改类型、改可空性、删列、拆表、回填数据,它要么只打警告,要么什么都不做;删唯一键和加 enum 值在 2.0.4 上还会卡住。没有记录表,也就没有「这个库现在是哪个版本」。
适合的是结构还在快速变化、数据可以丢的场合:原型、本地开发库、测试里建一个空库(测试里也可以只用上一页的 apply)。CHANGELOG 里给过一个在迁移里调 sync 的写法(「SchemaBuilder::sync can now be used in migrations」,db.get_schema_builder().register(note::Entity).sync(db)),它的问题和在迁移里用 create_table_from_entity 一样:迁移的效果跟着当前的实体走,同一个迁移在不同时间的新库上建出不同的结构。生产库用迁移,sync 最多用来在开发时少写几个迁移。
在 v4 之后跑 v5:user 实体去掉 bio 字段,name 从 i64 改回 String 并且变成 Option<String>(库里 name 是 varchar NOT NULL,bio 是 varchar)。sync 会发几条 DDL?会打警告吗?之后 user 表的列是什么样?
动脑筋答案
--- v5: bio dropped from the entity, name: String -> Option<String>
sync => Ok(()), 9 statements in total
user: id integer not null, name character varying not null, email character varying not null, bio character varying
零条 DDL,也没有警告。9 条语句全是读结构的查询。
- 删字段:sync 只遍历实体的列去找库里有没有,库里多出来的
bio根本不在比较范围里,列原样留着。它可空,所以不影响用新实体插入;如果它是NOT NULL且没有默认值,新实体的INSERT不写这一列,会报23502。 name回到String,类型渲染出来都是varchar,和库里一致,v4 那条警告消失。Option<String>只改变了null,sync 比较列时只比类型,NOT NULL和DEFAULT的差异不打警告、不改。库里的name仍然是NOT NULL,用新实体插入一个name: None会被数据库拒绝。
小结
entity-registry用inventory在链接期收集所有DeriveEntityModel实体;get_schema_registry(prefix)按模块路径做字符串前缀匹配,"crate::user"也会匹配crate::user_profile。sync先用 sea-schema 读出现有结构,再按外键拓扑排序(被引用的表在前,自引用不算环),逐个实体比较;外键环会让环上的表一张也建不出来。- 会做的:建表(含 enum 类型和索引)、加列、按
renamed_from改列名、加外键、补索引、给已有列补唯一索引、删实体里已经没有的唯一键。只警告的:列类型不同。什么都不做的:删字段、改可空性和默认值。 - 2.0.4 上两种情况会让之后每次 sync 都失败:去掉一个由 sync 补出来的唯一索引(
DROP CONSTRAINT删不了索引,42704),给 enum 加值(再CREATE TYPE一次,42710)。 - 每条 DDL 单独执行、没有锁;放进
db.begin()得到的事务里 sync,失败时整体回滚。 - 适合原型、开发库和测试;生产库的结构变化用迁移。