SchemaManager 与从实体建表
主线位置:定义主线的另一个出口。实体的
Column::def()返回的ColumnDef(列与值)在查询路径上只用到col_type,RelationDef(关系)在查询路径上只用来拼JOIN。这一页是它们剩下的字段(null、unique、indexed、default、on_delete……)的用处:翻译成 sea-query 的CREATE TABLE/CREATE INDEX/CREATE TYPE,再交给连接执行。
flowchart TB ent["Entity"] --> cols["每一列的 def"] ent --> rels["每个关系的 def"] cols --> cdef["列定义 ColumnDef"] cdef --> tbl["CREATE TABLE"] rels -->|"is_owner 为假"| fk["FOREIGN KEY 约束"] fk --> tbl cols -->|"indexed / unique_key"| idx["CREATE INDEX"] cols -->|"enum 列"| ty["CREATE TYPE"] tbl --> sm["SchemaManager / execute"] idx --> sm ty --> sm sm --> conn["execute"]
实验 crate 和上一页相同:sea-orm =2.0.4(sqlx-postgres、runtime-tokio-rustls、macros、with-chrono)、sea-orm-migration =2.0.4,对照 sea-orm 1.1.20,平台 M2 Max、macOS、rustc 1.99.0、Postgres 16.14。DDL 没有绑定参数,DbBackend::build(..).to_string() 打出来的就是发出去的文本,下面的 sqlx 日志可以对照。
SchemaManager:一层很薄的包装
SchemaManager 只有一个字段:
pub struct SchemaManager<'c> {
conn: SchemaManagerConnection<'c>,
}
SchemaManagerConnection 是 sea-orm 的 DatabaseExecutor 换了个名字(connection.rs):借来的 &DatabaseConnection、借来的 &DatabaseTransaction,或者 begin() 得到的自己持有的事务(2.0.0-rc.22 把它从 sea-orm-migration 挪进 sea-orm 本体)。建表、改表的方法全部是同一句:
pub async fn execute<S>(&self, stmt: S) -> Result<(), DbErr>
where
S: StatementBuilder,
{
self.conn.execute(&stmt).await.map(|_| ())
}
pub async fn create_table(&self, stmt: TableCreateStatement) -> Result<(), DbErr> {
self.execute(stmt).await
}
| 一组 | 方法 | 收什么 |
|---|---|---|
| 建 | create_table、create_index、create_foreign_key、create_type | sea-query 的 TableCreateStatement、IndexCreateStatement、ForeignKeyCreateStatement、TypeCreateStatement |
| 改 | alter_table、drop_table、rename_table、truncate_table、drop_index、drop_foreign_key、alter_type、drop_type | 对应的 sea-query 语句 |
| 查 | has_table、has_column、has_index | 名字字符串,返回 bool |
| 事务 | begin、commit | 见上一页的 use_transaction(false) |
方法名只是给语句分类,类型检查替你挡住「把 IndexCreateStatement 传给 create_table」,执行上没有任何区别:execute 收任何 StatementBuilder,最后都是 ConnectionTrait::execute,走 build → sqlx::query_with 那条路(QueryBuilder 与参数)。原生 SQL 用 manager.get_connection().execute_unprepared(..)。
三个「查」方法的语句来自 sea-schema 的 SchemaProbe。下面对实验库里已有的 author 表各调一次,sqlx 日志里是实际发出的语句(src/bin/inspect.rs,每行截掉了 summary= 和耗时,连接时的 SET search_path 也略去):
... db.statement="\n\nSELECT COUNT(*) > 0 AS \"has_table\" FROM (SELECT \"table_name\" AS \"table_name\" FROM \"information_schema\".\"tables\" WHERE (CURRENT_SCHEMA()) = \"tables\".\"table_schema\" AND \"table_type\" = $1 AND \"table_name\" = $2) AS \"subquery\"\n" rows_affected=1 rows_returned=1 ...
has_table(author) = true
...
has_table(nope) = false
... db.statement="\n\nSELECT COUNT(*) > 0 AS \"has_column\" FROM \"information_schema\".\"columns\" WHERE (CURRENT_SCHEMA()) = \"columns\".\"table_schema\" AND \"table_name\" = $1 AND \"column_name\" = $2\n" rows_affected=1 rows_returned=1 ...
has_column(author, email) = true
... db.statement="\n\nSELECT COUNT(*) > 0 AS \"has_index\" FROM \"pg_indexes\" WHERE \"schemaname\" = (CURRENT_SCHEMA()) AND \"tablename\" = $1 AND \"indexname\" = $2\n" rows_affected=1 rows_returned=1 ...
has_index(author, idx-author-name) = true
...
has_index(author, author_email_key) = true
三条都只看 CURRENT_SCHEMA(),也就是 search_path 里第一个存在的 schema,名字里写 "schema.table" 不会生效。has_index 查的是 pg_indexes,UNIQUE 约束背后的索引(author_email_key)也算。
sea_orm_migration::schema 模块(623 行)是另一种写法的辅助函数:pk_auto("id")、string("name")、string_null("bio")、table_auto("users") 这些,每个返回一个已经设好类型和 NOT NULL / NULL 的 sea-query ColumnDef。文件头注明 「Adapted from https://github.com/loco-rs/loco/blob/master/src/schema.rs」。需要注意的只有两点:不带后缀的版本都是 NOT NULL(string 是 .not_null(),要可空用 string_null);table_auto 会自动加 IF NOT EXISTS 和 created_at / updated_at 两列(timestamps,默认值 CURRENT_TIMESTAMP)。
create_table_from_entity:从实体推出 CREATE TABLE
Schema 只记一个 DbBackend,它的几个方法在 src/schema/entity.rs。create_table_from_entity:
let mut stmt = TableCreateStatement::new();
if let Some(comment) = entity.comment() {
stmt.comment(comment);
}
for column in E::Column::iter() {
let mut column_def = column_def_from_entity_column::<E>(column, backend);
stmt.col(&mut column_def);
}
if <<E::PrimaryKey as PrimaryKeyTrait>::ValueType as PrimaryKeyArity>::ARITY > 1 {
let mut idx_pk = Index::create();
for primary_key in E::PrimaryKey::iter() {
idx_pk.col(primary_key);
}
stmt.primary_key(idx_pk.name(format!("pk-{}", entity.to_string())).primary());
}
for relation in E::Relation::iter() {
let relation = relation.def();
if relation.is_owner || relation.skip_fk {
continue;
}
stmt.foreign_key(&mut relation.into());
}
stmt.table(entity.table_ref()).take()
三步:每一列一个列定义;复合主键时加一个名为 pk-<表名> 的表级主键;每个 is_owner == false 的关系(belongs_to 一侧,is_owner 的含义见关系)加一个外键约束,has_one / has_many 一侧跳过,skip_fk 的也跳过。外键名默认 fk-<from 表>-<from 列>,RelationDef 的 fk_name 可以覆盖(From<RelationDef> for TableForeignKey)。
每一列由 column_def_from_entity_column 生成:
let orm_column_def = column.def();
let types = match &orm_column_def.col_type {
ColumnType::Enum { name, variants } => match backend {
DbBackend::MySql => {
let variants: Vec<String> = variants.iter().map(|v| v.to_string()).collect();
ColumnType::custom(format!("ENUM('{}')", variants.join("', '")))
}
DbBackend::Postgres => ColumnType::Custom(name.clone()),
DbBackend::Sqlite => orm_column_def.col_type,
},
_ => orm_column_def.col_type,
};
let mut column_def = ColumnDef::new_with_type(column, types);
if !orm_column_def.null {
column_def.not_null();
}
if orm_column_def.unique {
column_def.unique_key();
}
if let Some(default) = orm_column_def.default {
column_def.default(default);
}
// ... comment、extra、renamed_from 写进注释
for primary_key in E::PrimaryKey::iter() {
if column.as_str() == primary_key.into_column().as_str() {
if E::PrimaryKey::auto_increment() {
column_def.auto_increment();
}
if <<E::PrimaryKey as PrimaryKeyTrait>::ValueType as PrimaryKeyArity>::ARITY == 1 {
column_def.primary_key();
}
}
}
ColumnDef 里的 indexed 和 unique_key 两个字段这里一个也没读。它们由另一个函数 create_index_from_entity 处理:indexed 且不 unique 的列各生成一条 CREATE INDEX "idx-<表>-<列>";unique_key = "名字" 相同的列合成一条 CREATE UNIQUE INDEX "idx-<表>-<名字>"。Postgres enum 列在建表语句里只写类型名(ColumnType::Custom(name)),CREATE TYPE 由第三个函数 create_enum_from_entity 生成。
实验:一对实体能推出什么
dense 写法的 author 和 post,把能影响 DDL 的属性都用上(src/bin/from_entity.rs):
#[derive(Clone, Debug, PartialEq, Eq, EnumIter, DeriveActiveEnum)]
#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "author_status")]
pub enum Status {
#[sea_orm(string_value = "active")]
Active,
#[sea_orm(string_value = "banned")]
Banned,
}
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "author")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)]
pub email: String,
#[sea_orm(indexed)]
pub name: String,
#[sea_orm(default_value = 0)]
pub score: i32,
#[sea_orm(default_expr = "Expr::current_timestamp()")]
pub created_at: DateTimeUtc,
pub bio: Option<String>,
pub status: Status,
#[sea_orm(unique_key = "tenant_handle")]
pub tenant: i32,
#[sea_orm(unique_key = "tenant_handle")]
pub handle: String,
#[sea_orm(has_many)]
pub posts: HasMany<super::post::Entity>,
}
// post
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub author_id: i32,
#[sea_orm(column_type = "Text")]
pub body: String,
#[sea_orm(belongs_to, from = "author_id", to = "id", on_delete = "Cascade")]
pub author: BelongsTo<super::author::Entity>,
}
let pg = DbBackend::Postgres;
let schema = Schema::new(pg);
p("table author", pg.build(&schema.create_table_from_entity(author::Entity)));
p("table post", pg.build(&schema.create_table_from_entity(post::Entity)));
for s in schema.create_enum_from_entity(author::Entity) { p("enum", pg.build(&s)); }
for s in schema.create_index_from_entity(author::Entity) { p("index", pg.build(&s)); }
println!("post indexes: {}", schema.create_index_from_entity(post::Entity).len());
table author: CREATE TABLE "author" ( "id" integer GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "email" varchar NOT NULL UNIQUE, "name" varchar NOT NULL, "score" integer NOT NULL DEFAULT 0, "created_at" timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, "bio" varchar, "status" author_status NOT NULL, "tenant" integer NOT NULL, "handle" varchar NOT NULL )
table post: CREATE TABLE "post" ( "id" bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "author_id" integer NOT NULL, "body" text NOT NULL, CONSTRAINT "fk-post-author_id" FOREIGN KEY ("author_id") REFERENCES "author" ("id") ON DELETE CASCADE )
enum: CREATE TYPE "author_status" AS ENUM ('active', 'banned')
index: CREATE INDEX "idx-author-name" ON "author" ("name")
index: CREATE UNIQUE INDEX "idx-author-tenant_handle" ON "author" ("tenant", "handle")
post indexes: 0
| 实体上写的 | 出现在哪 |
|---|---|
#[sea_orm(primary_key)] 整数主键 | 列上 GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY(1.1.20 是 serial / bigserial,见下面的冷知识) |
非 Option 字段 / Option<T> | NOT NULL / 什么都不写 |
String / column_type = "Text" | varchar(不限长度)/ text |
#[sea_orm(unique)] | 列上内联 UNIQUE,Postgres 建出一个约束 author_email_key |
default_value = 0 / default_expr = "..." | DEFAULT 0 / DEFAULT CURRENT_TIMESTAMP |
| enum 字段 | 列类型 author_status;CREATE TYPE 只在 create_enum_from_entity 里 |
#[sea_orm(indexed)] | 不在建表语句里;create_index_from_entity 的 CREATE INDEX "idx-author-name" |
unique_key = "tenant_handle" | 不在建表语句里;CREATE UNIQUE INDEX "idx-author-tenant_handle" |
belongs_to + on_delete = "Cascade" | post 的表级 CONSTRAINT "fk-post-author_id" ... ON DELETE CASCADE |
has_many | 什么也不生成 |
外键列 author_id 上的索引 | 没有(post indexes: 0);Postgres 不会自动给外键列建索引 |
推不出来的东西也很清楚:CHECK 约束(除非用 extra = "CHECK (...)" 原样拼进列定义)、部分索引、表达式索引、触发器、权限、外键列的索引。这些只能在迁移里手写。
1.1.20 用的 sea-query 0.32 给 Postgres 自增主键写 serial。同一对实体在 1.1.20 下打印出来(exp-seaorm-mig-old 的 from_entity):
CREATE TABLE "author" ( "id" serial NOT NULL PRIMARY KEY, "email" varchar NOT NULL UNIQUE, "name" varchar NOT NULL )
CREATE TABLE "post" ( "id" bigserial NOT NULL PRIMARY KEY, "author_id" integer NOT NULL, CONSTRAINT "fk-post-author_id" FOREIGN KEY ("author_id") REFERENCES "author" ("id") ON DELETE CASCADE )
sea-query PR #918「Replace serial with GENERATED BY DEFAULT AS IDENTITY」把它换掉,PR 的 Breaking Changes 一栏写的是「The query builder generates auto increment columns as GENERATED BY DEFAULT AS IDENTITY instead of SERIAL in PostgreSQL」,sea-query 的 CHANGELOG 把它记在 1.0.0 下,并给了退路:「To restore legacy behaviour, you can enable the option-postgres-use-serial feature flag」。sea-orm 2.0.4 把这个开关转成自己的 feature postgres-use-serial-pk。对已有的库没有影响(已经建好的 serial 列不会变),影响的是新建的表:同一套迁移在 1.1.20 下建出 serial 列(列默认值是 nextval(...)),在 2.0 下建出 identity 列。
create_table_from_entity 单独拿去执行,最先撞上的是 enum:
--- create_table_from_entity(author) alone
=> Err("Execution Error: error returned from database: type \"author_status\" does not exist at line 270")
正确的顺序是先 CREATE TYPE,再建表,再建索引,被引用的表要在引用它的表之前。SchemaBuilder::apply 就是按这个顺序做的:每个注册的实体存一份 EntitySchemaInfo(表、enum、索引三样),apply 按外键拓扑排序后逐个执行(排序见 schema sync)。下面故意先注册 post:
db.get_schema_builder().register(post::Entity).register(author::Entity).apply(&db).await?;
sqlx 日志(每行截掉了 summary= 和耗时,略去了池里第二条连接建立时的 SET search_path):
... db.statement="\n\nCREATE TYPE \"author_status\" AS ENUM ('active', 'banned')\n" ...
... db.statement="\n\nCREATE TABLE \"author\" ( \"id\" integer GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, \"email\" varchar NOT NULL UNIQUE, \"name\" varchar NOT NULL, \"score\" integer NOT NULL DEFAULT 0, \"created_at\" timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, \"bio\" varchar, \"status\" author_status NOT NULL, \"tenant\" integer NOT NULL, \"handle\" varchar NOT NULL )\n" ...
... db.statement="\n\nCREATE INDEX \"idx-author-name\" ON \"author\" (\"name\")\n" ...
... db.statement="\n\nCREATE UNIQUE INDEX \"idx-author-tenant_handle\" ON \"author\" (\"tenant\", \"handle\")\n" ...
... db.statement="\n\nCREATE TABLE \"post\" ( \"id\" bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, \"author_id\" integer NOT NULL, \"body\" text NOT NULL, CONSTRAINT \"fk-post-author_id\" FOREIGN KEY (\"author_id\") REFERENCES \"author\" (\"id\") ON DELETE CASCADE )\n" ...
apply 的文档写着「Fails if any table already exists」,它不带 IF NOT EXISTS,也不比较现有结构;要增量的版本是下一页的 sync。
问: 为什么不把索引直接放进 CREATE TABLE,一条语句建完?
答: Postgres 的 CREATE TABLE 里只能写约束(PRIMARY KEY、UNIQUE、FOREIGN KEY、CHECK),普通索引没有内联语法,只能单独 CREATE INDEX。Schema 上其实有一个 #[doc(hidden)] 的 create_table_with_index_from_entity,把索引塞进表语句里,在 Postgres 上生成的是 ..., CONSTRAINT "idx-author-name" ("name"), CONSTRAINT "idx-author-tenant_handle" UNIQUE ("tenant", "handle") ),执行报 syntax error at or near "("(同一个程序,先建了 enum)。
在迁移里用 create_table_from_entity 的代价
Schema 也可以在迁移里用:manager.create_table(Schema::new(backend).create_table_from_entity(user::Entity))。省事,但这个迁移的含义从此跟着实体走。假设 m1 写的时候 user 只有 id、name,后来 m2 用 ALTER TABLE ... ADD COLUMN bio 加了一列,实体也加了 bio。在一个新库上从头跑(src/bin/entity_in_migration.rs):
Applying all pending migrations
Applying migration 'm1_create_user'
Migration 'm1_create_user' has been applied
Applying migration 'm2_add_bio'
up: Err("Execution Error: error returned from database: column \"bio\" of relation \"user\" already exists at line 7347")
m1 用今天的实体建表,已经带上了 bio。老库没问题(m1 早就跑过),只有新库(CI、新同事、灾备恢复)会坏。迁移应当是那一刻结构的快照,用 sea-query 的 Table::create() 或 schema::* 辅助函数把列写死;create_table_from_entity 适合一次性的场合,比如测试里建表,或者下一页的 schema sync。
Schema::get_column_def::<E>(column) 是 column_def_from_entity_column 的公开包装,给 ALTER TABLE ... ADD COLUMN 用。对上面的 author::Column::Score(#[sea_orm(default_value = 0)] pub score: i32):
p("column def", pg.build(sea_query::Table::alter().table(author::Entity).add_column(schema.get_column_def::<author::Entity>(author::Column::Score))));
打印出来的 ALTER TABLE 是什么?如果 author 表里已经有行,执行会不会失败?
动脑筋答案
column def: ALTER TABLE "author" ADD COLUMN "score" integer NOT NULL DEFAULT 0
和建表语句里的列定义逐字相同:类型 integer、NOT NULL、DEFAULT 0。第二问在一张有一行数据的 author 表上试(src/bin/add_default.rs,第二条是去掉 DEFAULT 的同一个列定义):
ALTER TABLE "author" ADD COLUMN "score" integer NOT NULL DEFAULT 0
=> Ok(())
ALTER TABLE "author" ADD COLUMN "rank" integer NOT NULL
=> Err("Execution Error: error returned from database: column \"rank\" of relation \"author\" contains null values at line 6111")
score of the existing row = 0
有 DEFAULT 的 NOT NULL 列加得上,已有的行被填成默认值 0;没有 default_value 的 NOT NULL 列报 23502 ... contains null values。下一页 schema sync 的实验里正好撞上这个错。
小结
SchemaManager的建表、改表方法全是self.conn.execute(&stmt),区别只在参数类型;has_table/has_column/has_index只看CURRENT_SCHEMA()。create_table_from_entity推出列类型、NOT NULL、内联UNIQUE、DEFAULT、单列或复合主键、belongs_to一侧的外键(含ON DELETE/ON UPDATE)。indexed和unique_key的索引要create_index_from_entity,Postgres enum 的CREATE TYPE要create_enum_from_entity;单独执行建表语句会报type ... does not exist。外键列不会自动有索引。- 2.0 的自增主键是
GENERATED BY DEFAULT AS IDENTITY,1.1.20 是serial;postgres-use-serial-pk可以换回去。 SchemaBuilder::apply按 enum → 表 → 索引、被引用的表在前的顺序执行,表已存在就失败。- 迁移里用
create_table_from_entity会让旧迁移跟着实体变,新库上从头迁移时和后面的ALTER TABLE冲突。