Rust in Everything
基于 sea-orm 2.0.4

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,都是读结构的查询。

第二步:按外键排序

sorted_tables:

        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 实体的改动
v1id、name: String、#[sea_orm(unique)] email: String;另有 post,belongs_to user
v2加 bio: Option<String>
v3再加 nickname: String(非空、无默认值)
v4去掉 nickname,name 改成 i64
v6name 加 #[sea_orm(unique)]
v7email 去掉 #[sea_orm(unique)]
v8name 去掉 #[sea_orm(unique)]
v9name 改名 full_name,#[sea_orm(renamed_from = "name")]
v10新实体 comment(id, post_id),没有关系字段
v11comment.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。

📝NOTE
冷知识:修好一种唯一键,弄坏另一种

删多余唯一键的代码本来是 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 最多用来在开发时少写几个迁移。

❗IMPORTANT
动脑筋:删掉一个字段、放宽一个约束

在 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,失败时整体回滚。
  • 适合原型、开发库和测试;生产库的结构变化用迁移。

💬 关联讨论

发起讨论
加载中...