Rust in Everything
基于 sea-orm 2.0.4

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_typesea-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 (...)" 原样拼进列定义)、部分索引、表达式索引、触发器、权限、外键列的索引。这些只能在迁移里手写。

📝NOTE
冷知识:IDENTITY 代替 serial

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。

📝NOTE
没有蠢问题

问: 为什么不把索引直接放进 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。

❗IMPORTANT
动脑筋:get_column_def 生成什么

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 冲突。

💬 关联讨论

发起讨论
加载中...