Rust in Everything
基于 sea-orm 2.0.4

实体的几个 trait

主线位置:定义主线的终点,也是另外两条主线的起点。查询主线的第一步 Entity::find_by_id(42) 是 EntityTrait 的默认方法,它靠 PrimaryKey 找到主键列、靠 Column 写出 WHERE、靠 EntityName 写出 FROM;最后一步 Model::from_query_result 是 FromQueryResult 的方法。这一页只讲这些 trait 长什么样、为什么这样分;它们的实现由宏生成,逐段看在 DeriveEntityModel 与 dense 格式;列上的条件方法和取值在 列与值。

flowchart TB
  iden["Iden / IdenStatic"] --> en["EntityName 表名"]
  iden --> ct[ColumnTrait]
  iden --> pt[PrimaryKeyTrait]
  en --> et[EntityTrait]
  et -->|Model| mt["ModelTrait + FromQueryResult"]
  et -->|Column| ct
  et -->|PrimaryKey| pt
  et -->|ActiveModel| am[ActiveModelBehavior]
  et -->|Relation| rt[RelationTrait]
  pt -->|PrimaryKeyToColumn| ct
  ct -->|EntityName| en

本页的实体是下面这个 post(2.0 的 dense 写法;1.x 的展开写法生成的 trait 实现与它逐行相同,见下一页)。实验在 M2 Max、macOS、rustc 1.99.0、Postgres 16.14 上跑,依赖 sea-orm = "=2.0.4"(sqlx-postgres、runtime-tokio-rustls、macros、with-chrono、with-json、with-uuid、postgres-array),表建在自己的 schema 里:

pub mod post {
  use sea_orm::entity::prelude::*;

  #[sea_orm::model]
  #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
  #[sea_orm(table_name = "post")]
  pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    pub title: String,
    pub body: Option<String>,
  }

  impl ActiveModelBehavior for ActiveModel {}
}

类型就是表

宏生成的 Entity 是一个单元结构体:

    pub struct Entity;

它不持有任何数据,size_of::<post::Entity>() 是 0。表名、schema 名都不是字段,而是 EntityName 的方法返回的常量:

pub trait IdenStatic: Iden + Copy + Debug + Send + Sync + 'static {
    /// Method to call to get the static string identity
    fn as_str(&self) -> &'static str;
}

// ...
pub trait EntityName: IdenStatic + Default {
    /// Method to get the name for the schema, defaults to [Option::None] if not set
    fn schema_name(&self) -> Option<&str> {
        None
    }

    /// Method to get the comment for the schema, defaults to [Option::None] if not set
    fn comment(&self) -> Option<&str> {
        None
    }

    /// Get the name of the table
    fn table_name(&self) -> &'static str;

    /// Get the [TableRef] from invoking the `self.schema_name()`
    fn table_ref(&self) -> TableRef {
        match self.schema_name() {
            Some(schema) => (schema.to_owned(), self.into_iden()).into_table_ref(),
            None => self.into_table_ref(),
        }
    }
}

几个约束各有用处:

  • IdenStatic(定义)要求实现 sea-query 的 Iden,于是 post::Entity 本身就能当表名交给 sea-query;as_str 返回 &'static str,宏把它实现成 table_name()。
  • Copy 和 Default:泛型代码需要一个表的值时,直接 Self::default() 造一个,不用谁传进来。EntityTrait::update_many 就是 Update::many(Self::default()),ColumnTrait::entity_name 是 SeaRc::new(Self::EntityName::default())。单元结构体的 default() 什么也不做。
  • 关联函数不收 self:find、insert 都写成 post::Entity::find()。实体的「值」只在需要一个标识符时出现。

Entity 和 Column 是普通的 sea-query 标识符,可以绕过 sea-orm 直接拼语句(src/bin/traits_demo.rs,节选):

let sql = Query::select().column(post::Column::Title).from(post::Entity).to_string(PostgresQueryBuilder);
println!("sea-query: {sql}");
sea-query: SELECT "title" FROM "post"

sea-orm 自己拼语句时用的是 ColumnTrait::as_column_ref,它返回 (表, 列) 一对,所以 Select 里的列都带表名前缀("post"."title")。sea-query 的语句树和这些标识符怎么被写成 SQL,见 语句树。

Iden 在 sea-query 1.0 里是一个字符串

sea-query 的 Iden 只有一个必须实现的方法 unquoted,另外两个有默认实现:

pub trait Iden {
    // ...
    fn quoted(&self) -> Cow<'static, str> {
        Cow::Owned(self.to_string())
    }
    // ...
    fn to_string(&self) -> String {
        self.unquoted().to_owned()
    }
    // ...
    fn unquoted(&self) -> &str;
}

存进语句树的是 DynIden,它就是 quoted() 的结果:pub struct DynIden(pub(crate) Cow<'static, str>)。Cow::Borrowed 表示一个静态的、不需要转义的名字,Cow::Owned 总会被转义。宏为 Column 和 PrimaryKey 生成的 Iden 在所有列名都是不需要转义的简单名字时(is_static_iden)重写 quoted,返回 Cow::Borrowed(as_str());为 Entity 生成的只写了 unquoted,quoted 走默认实现。验证一下(src/bin/iden_cow.rs):

let kind = |c: Cow<'static, str>| if matches!(c, Cow::Borrowed(_)) { "Borrowed" } else { "Owned" };
println!("Entity       -> {}", kind(post::Entity.into_iden().inner()));
println!("Column::Title -> {}", kind(post::Column::Title.into_iden().inner()));
println!("PrimaryKey::Id -> {}", kind(post::PrimaryKey::Id.into_iden().inner()));
Entity       -> Owned
Column::Title -> Borrowed
PrimaryKey::Id -> Borrowed

列名不分配,表名每转一次 DynIden 分配一个 String。as_column_ref 每次都调 entity_name(),所以一条 SELECT 里每个带表名前缀的列引用都有一次这样的分配。和一次网络往返比,这点开销可以忽略,但它说明 Cow 这一层确实区分了两种情况。

📝NOTE
冷知识:DynIden 以前是一个 Rc<dyn Iden>

sea-query 0.32(本站用的 0.32.7)里 pub type DynIden = SeaRc<dyn Iden>;,SeaRc 按 thread-safe feature 是 Rc 或 Arc。标识符以 trait 对象的形式存进语句树,直到语句被序列化成 SQL 时才调用它渲染。sea-query#909 把它改成了今天的样子,CHANGELOG 记在 1.0.0 之下:「Previously, DynIden is an alias to SeaRc<dyn Iden>, and is lazily rendered. Now, it's an Cow<'static, str>, and is eagerly rendered. SeaRc is no longer an alias to Rc / Arc, now is only a unit struct. As such, Send / Sync is no longer needed.」PR 的说明是「Arc, vtable tricks are removed」,并指出对 sea-orm 影响不大:「For majority of usecases in SeaORM though, since they're 'static str anyway, there should be no difference.」

sea-orm 2.0 的 as_column_ref 里还写着 SeaRc::new(*self),但 SeaRc 现在是一个单元结构体,SeaRc::new 是「A legacy method, kept for compatibility」,直接返回 DynIden:当场调用 quoted(),然后丢掉原来的对象。

EntityTrait:七个关联类型

EntityTrait 是把一个实体的各部分串起来的地方:

pub trait EntityTrait: EntityName {
    #[allow(missing_docs)]
    type Model: ModelTrait<Entity = Self> + FromQueryResult;

    #[allow(missing_docs)]
    type ModelEx: ModelTrait<Entity = Self>;

    #[allow(missing_docs)]
    type ActiveModel: ActiveModelBehavior<Entity = Self>;

    // ...
    type ActiveModelEx: ActiveModelTrait<Entity = Self>;

    #[allow(missing_docs)]
    type Column: ColumnTrait;

    #[allow(missing_docs)]
    type Relation: RelationTrait;

    #[allow(missing_docs)]
    type PrimaryKey: PrimaryKeyTrait + PrimaryKeyToColumn<Column = Self::Column>;

每个关联类型都带着 Entity = Self 或 Column = Self::Column 指回来,编译器因此能检查「这个 Model 属于这张表」「这个主键的列是这张表的列」。find_by_id 里从 PrimaryKey 拿到的列,类型就是 Self::Column,可以直接 col.eq(v)。

ModelEx 和 ActiveModelEx 是 2.0 新加的,给 dense 格式里「带着关联数据的 Model」用(posts: HasMany<...> 这样的字段放不进 Model)。不用 dense 格式时,宏让它们就是 Model 和 ActiveModel,展开结果里是:

    impl sea_orm::entity::EntityTrait for Entity {
        type Model = Model;
        type ModelEx = Model;
        type ActiveModel = ActiveModel;
        type ActiveModelEx = ActiveModel;
        type Column = Column;
        type PrimaryKey = PrimaryKey;
        type Relation = Relation;
    }

dense 格式下这两行换成 ModelEx / ActiveModelEx,其余不变(下一页有对比)。Relation 和 RelationTrait 在 关系。

trait 里没有一个方法需要实现者写,查询和写入的入口全是默认方法:

方法返回做什么
find()Select<Self>Select::new():选出全部列,FROM 表名
find_by_id(v)Select<Self>find() 再对每个主键列 filter(col.eq(v))
insert(am) / insert_many(iter)Insert<A> / InsertMany<A>包一层,见 insert
update(am) / update_many()UpdateOne<A> / UpdateMany<Self>见 update 与 delete
delete(am) / delete_many()DeleteOne<Self> / DeleteMany<Self>同上
delete_by_id(v)ValidatedDeleteOne<Self>造一个只设了主键的 ActiveModel 再 Delete::one
belongs_to / has_one / has_many / has_many_viaRelationBuilder<Self, R>手写 Relation 时用,见 关系

find_by_id:主键的类型就是参数的类型

find_by_id:

    fn find_by_id<T>(values: T) -> Select<Self>
    where
        T: Into<<Self::PrimaryKey as PrimaryKeyTrait>::ValueType>,
    {
        let mut select = Self::find();
        let mut keys = Self::PrimaryKey::iter();
        for v in values.into().into_value_tuple() {
            if let Some(key) = keys.next() {
                let col = key.into_column();
                select = select.filter(col.eq(v));
            } else {
                unreachable!("primary key arity mismatch");
            }
        }
        select
    }

参数先 into() 成主键的 ValueType(单列主键就是那一列的 Rust 类型,复合主键是元组),再 into_value_tuple() 拆成一串 Value,和 PrimaryKey::iter() 一一配对。ValueType 是一个关联类型,所以参数个数和类型都在编译期检查:给单列 i32 主键传元组或字符串,rustc 直接拒绝(src/bin/pk_arity.rs):

fn main() {
  let _ = post::Entity::find_by_id((1, 2));
  let _ = post::Entity::find_by_id("1");
}
error[E0277]: the trait bound `i32: From<({integer}, {integer})>` is not satisfied
   --> src/bin/pk_arity.rs:5:36
    |
  5 |   let _ = post::Entity::find_by_id((1, 2));
    |           ------------------------ ^^^^^^ the trait `From<({integer}, {integer})>` is not implemented for `i32`
    |           |
    |           required by a bound introduced by this call
...
    = note: required for `({integer}, {integer})` to implement `Into<i32>`
note: required by a bound in `find_by_id`
   --> ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/sea-orm-2.0.4/src/entity/base_entity.rs:323:12
...
error[E0277]: the trait bound `i32: From<&str>` is not satisfied
   --> src/bin/pk_arity.rs:6:36
    |
  6 |   let _ = post::Entity::find_by_id("1");
    |           ------------------------ ^^^ the trait `From<&str>` is not implemented for `i32`
...

unreachable!("primary key arity mismatch") 因此真的走不到:元组的长度和 PrimaryKey 的变体数由同一个宏从同一组 #[sea_orm(primary_key)] 字段生成。这个检查比列上的 Column::Id.eq(..) 严格得多,后者收任何 Into<Value>,见 列与值。

ColumnTrait:一列知道什么

ColumnTrait 的骨架只有几项,其余几十个方法都是在 as_column_ref 之上造 Expr 的默认方法:

pub trait ColumnTrait: IdenStatic + Iterable + FromStr {
    #[allow(missing_docs)]
    type EntityName: EntityName;

    /// Get the column definition with SQL attributes
    fn def(&self) -> ColumnDef;

    // ...
    fn enum_type_name(&self) -> Option<&'static str> {
        None
    }

    /// Get the name of the entity the column belongs to
    fn entity_name(&self) -> DynIden {
        SeaRc::new(Self::EntityName::default())
    }

    /// Get the table.column reference
    fn as_column_ref(&self) -> (DynIden, DynIden) {
        (self.entity_name(), SeaRc::new(*self))
    }
  • Iterable(就是 strum 的 IntoEnumIterator):Column::iter() 按字段顺序给出每一列,find() 的 SELECT 列表就是这样来的。
  • FromStr:从列名找回变体,给按字符串排序、过滤的场合用。生成的实现接受三种写法:变体名的 snake_case、lowerCamelCase,以及 column_name 指定的名字。
  • def() 返回 ColumnDef(列类型、可空、唯一、索引、默认值),主要给建表和 schema 同步用;查询时 eq 这类方法也会调它,只为判断要不要给值加类型转换(enum 列、JSON 数组)。细节在 列与值。
let cols: Vec<&str> = post::Column::iter().map(|c| c.as_str()).collect();
println!("Column::iter() = {cols:?}");
println!("from_str(\"title\") = {:?}, from_str(\"Title\") = {:?}", post::Column::from_str("title"), post::Column::from_str("Title"));
Column::iter() = ["id", "title", "body"]
from_str("title") = Ok(Title), from_str("Title") = Err(ColumnFromStrErr("Title"))

Column 枚举的值不带表的信息,as_column_ref 靠关联类型 EntityName 和它的 Default 现造一个表名。这是 EntityName: Default 的另一个用处。

PrimaryKeyTrait 与复合主键

PrimaryKeyTrait 有一个关联类型和一个方法:

pub trait PrimaryKeyTrait: IdenStatic + Iterable {
    #[allow(missing_docs)]
    type ValueType: Sized
        + Send
        + Debug
        + PartialEq
        + IntoValueTuple
        + FromValueTuple
        + TryGetableMany
        + TryFromU64
        + PrimaryKeyArity;

    /// Method to determine if Primary Key is not auto_increment
    fn auto_increment() -> bool;
}

ValueType 的约束各有使用者:IntoValueTuple 给 find_by_id / delete_by_id 拆值;插入之后,TryGetableMany 把 RETURNING 回来的主键列读成 ValueType,主键是自己 Set 的、不用读回时,FromValueTuple 把那串 Value 直接变回去;TryFromU64 给不支持 RETURNING 的后端从 last_insert_id 造主键;PrimaryKeyArity 给出列数。

PrimaryKeyArity 是一个常量 ARITY。任何 TryGetable 的单个类型是 1(blanket impl),元组由宏实现到 12 个元素。primary_key_identity() 和 ModelTrait::get_primary_key_value() 都按 ARITY 在 match 里分 1、2、3 和更多,产出 Identity 的 Unary / Binary / Ternary / Many:常见的一到三列不分配 Vec。

PrimaryKeyToColumn 把主键变体映射到同名的列变体:PrimaryKey 只是 Column 的一个子集,单独成一个枚举是为了让 PrimaryKey::iter() 只遍历主键列。

复合主键就是两个字段都标 primary_key,ValueType 是元组(src/bin/traits_demo.rs,节选):

entity!(tag_link, "tag_link", {
  #[sea_orm(primary_key)] pub post_id: i32,
  #[sea_orm(primary_key)] pub tag_id: i32,
});

let stmt = tag_link::Entity::find_by_id((1, 2)).build(DbBackend::Postgres);
println!("find_by_id((1, 2)): {} {:?}", stmt.sql, stmt.values.map(|v| v.0).unwrap_or_default());
find_by_id((1, 2)): SELECT "tag_link"."post_id", "tag_link"."tag_id" FROM "tag_link" WHERE "tag_link"."post_id" = $1 AND "tag_link"."tag_id" = $2 [Int(Some(1)), Int(Some(2))]

(entity! 是实验程序里的一个小宏,展开成和上面 post 同样形状的 #[sea_orm::model] 模块。)这里打印的是 build 出来的 Statement:SQL 里是 $N 占位符,值另放在 values 里,和交给 sqlx 的完全一样。

auto_increment() 告诉写入路径「主键 NotSet 时是否由数据库生成」。默认值由宏推断,可以用 #[sea_orm(auto_increment = false)] 覆盖。

❗IMPORTANT
动脑筋:五个主键,哪几个 auto_increment() 是 true?

都不写 auto_increment 属性,除了第四个(M2 Max、macOS、rustc 1.99.0,src/bin/traits_demo.rs):

entity!(by_uuid, "by_uuid", { #[sea_orm(primary_key)] pub id: Uuid, pub n: i32 });
entity!(by_code, "by_code", { #[sea_orm(primary_key)] pub code: String, pub n: i32 });
entity!(by_i64_manual, "by_i64_manual", { #[sea_orm(primary_key, auto_increment = false)] pub id: i64 });
// tag_link: (post_id: i32, tag_id: i32) 复合主键,见上文

println!("auto_increment: i32={} Uuid={} String={} i64(manual)={} (i32,i32)={}",
  <post::PrimaryKey as PrimaryKeyTrait>::auto_increment(),
  <by_uuid::PrimaryKey as PrimaryKeyTrait>::auto_increment(),
  <by_code::PrimaryKey as PrimaryKeyTrait>::auto_increment(),
  <by_i64_manual::PrimaryKey as PrimaryKeyTrait>::auto_increment(),
  <tag_link::PrimaryKey as PrimaryKeyTrait>::auto_increment(),
);

ModelTrait 与 FromQueryResult

ModelTrait 让一个 Model 能按 Column 存取字段,值的类型统一是 sea-query 的 Value:

pub trait ModelTrait: Clone + Send + Debug {
    #[allow(missing_docs)]
    type Entity: EntityTrait;

    /// Get the [Value] of a column from a Model
    fn get(&self, c: <Self::Entity as EntityTrait>::Column) -> Value;

    // ...
    fn get_value_type(c: <Self::Entity as EntityTrait>::Column) -> ArrayType;

    // ...
    fn try_set(&mut self, c: <Self::Entity as EntityTrait>::Column, v: Value) -> Result<(), DbErr>;

get 是一个大 match,每个分支 self.field.clone().into();try_set 反过来用 ValueType::try_from(v),类型不符返回 DbErr::Type。按列动态存取的地方都靠它:get_primary_key_value 逐个 get 主键列;find_related(..) 生成的 belongs_to 过滤是 col.eq(model.get(col)),从当前这一行取关联列的值。find_related / find_linked 也是这里的默认方法,见 关系。

FromQueryResult 是查询主线的最后一步,Selector::one 拿到 QueryResult 后调它:

pub trait FromQueryResult: Sized {
    // ...
    fn from_query_result(res: &QueryResult, pre: &str) -> Result<Self, DbErr>;

    // ...
    fn from_query_result_optional(res: &QueryResult, pre: &str) -> Result<Option<Self>, DbErr> {
        Ok(Self::from_query_result(res, pre).ok())
        // ...
    }

    // ...
    fn from_query_result_nullable(res: &QueryResult, pre: &str) -> Result<Self, TryGetError> {
        Self::from_query_result(res, pre).map_err(TryGetError::DbErr)
    }

pre 是列名前缀:find_also_related 把两张表的列放进同一行,用 A_、B_ 这样的别名区分,读第二个 Model 时传 pre = "B_"(见 join 与关联查询)。from_query_result_nullable 把「NULL」和「真的出错」分开返回,Option<T> 的实现把 TryGetError::Null 变成 Ok(None),LEFT JOIN 没匹配上的那一侧就靠它变成 None。宏为 Model 生成的实现长什么样在下一页,一列 NULL 落进非 Option 字段时报什么在 列与值。

动脑筋答案

auto_increment: i32=true Uuid=false String=false i64(manual)=false (i32,i32)=false
  • i32:只有一个主键字段,没写属性,默认 true。
  • Uuid 和 String:宏里有一个常量 NOT_AUTO_INCRE_TYPE_SUFFIX = ["String", "Uuid"],主键字段的类型名(去掉 Option<> 后的文本)以其中之一结尾时,没写属性也推断为 false。判断是按类型的字面文本做的,一个叫 UserUuid 的 newtype 也会命中。
  • i64 写了 auto_increment = false,照办。
  • 复合主键:生成代码是 value && primary_keys.len() == 1,只要不是单列,就一定是 false。宏还会拒绝在复合主键上写 auto_increment(报 auto_increment cannot be used on composite primary_key)。

小结

  • Entity 是零大小的单元结构体,表名、schema 名是 EntityName 方法返回的常量;Default + Copy 让泛型代码随时造一个表的值。Entity 和 Column 都是 sea-query 的 Iden,能直接用在 sea-query 语句里。
  • sea-query 1.0 的 DynIden 是 Cow<'static, str>,不再是 Rc<dyn Iden>;列名是 Borrowed,宏生成的 Entity 的 quoted 走默认实现,每次是 Owned。
  • EntityTrait 用七个关联类型串起整个实体,2.0 新增的 ModelEx / ActiveModelEx 在非 dense 写法下就是 Model / ActiveModel;find、insert、delete_by_id 等全是默认方法。
  • find_by_id 的参数要 Into 主键的 ValueType,单列是那一列的类型,复合主键是元组,个数和类型都在编译期检查。
  • PrimaryKeyArity 给出主键列数,Identity 和 ValueTuple 对一到三列不分配;auto_increment 对 String / Uuid 结尾的类型和复合主键推断为 false。
  • ModelTrait 按 Column 存取 Value;FromQueryResult 带列名前缀 pre,Option<T> 的实现把 NULL 变成 None。

💬 关联讨论

发起讨论
加载中...