Rust in Everything
基于 sea-orm 2.0.4

关系

主线位置:定义主线的最后一段。DeriveEntityModel 生成了表和列,这一页讲表和表之间的连线:Relation 枚举的每个分支给出一个 RelationDef,Related<R> 说「从这个实体走到 R 用哪条(或哪两条)连线」,Linked 描述一条多跳或自引用的路径。join、find_related、Loader、建外键的 DDL 都只读 RelationDef。这些查询怎么执行在 join 与 find_related 和 Loader 与 N+1,本页只看每种关系变成什么样的 JOIN ... ON。

flowchart TB
  dense["dense 字段 BelongsTo 等"] -->|"sea_orm::model"| attrs["Relation 枚举 + 属性"]
  legacy["1.x 手写 Relation"] --> attrs
  attrs -->|DeriveRelation| builder["Entity::belongs_to 等"]
  builder --> rdef[RelationDef]
  rdef --> cond["ON 条件"]
  rdef --> fk["外键 DDL"]
  rdef --> rel["Related to / via"]
  rdef --> lnk["Linked 一串 def"]
  cond --> join["join / join_rev"]

实验 crate 依赖 sea-orm =2.0.4(sqlx-postgres、runtime-tokio-rustls、macros、with-json、postgres-array),平台 M2 Max、macOS、rustc 1.99.0、Postgres 16.14。实体是一组小表:author、post(author_id 指向 author)、tag、中间表 post_tag、自引用的 staff(reports_to_id 指向本表)。同一组表写了两遍:legacy 模块是 1.x 的展开写法,dense 模块是 2.0 的 #[sea_orm::model]。

RelationDef:一条连线的全部信息

RelationDef 是一个普通的公开结构体:

pub struct RelationDef {
    /// Cardinality of the relation.
    pub rel_type: RelationType,
    /// Table at the FK-owning end of the relation.
    pub from_tbl: TableRef,
    /// Table being pointed to by the foreign key.
    pub to_tbl: TableRef,
    /// FK column(s) on [`from_tbl`](Self::from_tbl).
    pub from_col: Identity,
    /// Referenced column(s) on [`to_tbl`](Self::to_tbl) (usually the primary key).
    pub to_col: Identity,
    /// `true` if `Self` is the FK-owning side (i.e. holds `from_col`).
    pub is_owner: bool,
    /// Skip emitting a `FOREIGN KEY` constraint when this relation is used
    /// to generate schema DDL.
    pub skip_fk: bool,
    /// `ON DELETE` action for the foreign key.
    pub on_delete: Option<ForeignKeyAction>,
    /// `ON UPDATE` action for the foreign key.
    pub on_update: Option<ForeignKeyAction>,
    /// Extra predicate to AND/OR into the join's `ON` clause; see
    /// [`RelationDef::on_condition`].
    #[debug("{}", on_condition.is_some())]
    pub on_condition: Option<Arc<dyn Fn(DynIden, DynIden) -> Condition + Send + Sync>>,
    /// Name of the foreign-key constraint to emit in DDL.
    pub fk_name: Option<String>,
    /// How [`on_condition`](Self::on_condition) is combined with the
    /// column equality predicate (`All` = AND, `Any` = OR).
    pub condition_type: ConditionType,
}

字段按用途分四组:

组字段谁读
连线本身from_tbl / from_col、to_tbl / to_col生成 ON from.col = to.col
方向和基数rel_type(RelationType 只有 HasOne、HasMany)、is_ownerLoader、find_related 判断该收集哪一边的键
附加条件on_condition、condition_type往 ON 里再并一个条件
外键 DDLskip_fk、on_delete、on_update、fk_nameFrom<RelationDef> for TableForeignKey,schema-sync 建表时用

注意没有 BelongsTo 这种 RelationType。belongs_to 只是「HasOne,且 is_owner = false」:外键在 from_tbl 上,from_tbl 这一边不是被指向的一方。字段注释里的「true if Self is the FK-owning side」容易读反,下面的实测输出里,持有外键的 post -> author 是 is_owner: false,被指向的 author -> post 是 is_owner: true。is_owner 的「owner」指的是被引用的那张表。

on_condition 是一个闭包,收两个参数:ON 左右两张表的名字(有别名时是别名)。所以它能写「右边那张表的某一列大于 10」而不用写死表名。Debug 打不出闭包,derive_more::Debug 的 #[debug(...)] 只打印它有没有设置。

从 RelationDef 到 ON 条件

join 不需要单独的「join 描述」,RelationDef 直接实现了 From<RelationDef> for Condition:

impl From<RelationDef> for Condition {
    fn from(mut rel: RelationDef) -> Condition {
        // Use table alias (if any) to construct the join condition
        let from_tbl = match rel.from_tbl.sea_orm_table_alias() {
            Some(alias) => alias,
            None => rel.from_tbl.sea_orm_table(),
        };
        let to_tbl = match rel.to_tbl.sea_orm_table_alias() {
            Some(alias) => alias,
            None => rel.to_tbl.sea_orm_table(),
        };
        let owner_keys = rel.from_col;
        let foreign_keys = rel.to_col;

        let mut condition = match rel.condition_type {
            ConditionType::All => Condition::all(),
            ConditionType::Any => Condition::any(),
        };

        condition = condition.add(join_tbl_on_condition(
            from_tbl.clone(),
            to_tbl.clone(),
            owner_keys,
            foreign_keys,
        ));
        if let Some(f) = rel.on_condition.take() {
            condition = condition.add(f(from_tbl.clone(), to_tbl.clone()));
        }

        condition
    }
}

join_tbl_on_condition 把 from_col 和 to_col 一一配对写成 from_tbl.a = to_tbl.b,复合键就是几个等式 AND 起来。表有别名时用别名,这是自引用能工作的前提(后面讲)。QuerySelect 的两个方法只差在 JOIN 后面跟哪张表(join / join_rev):

    fn join(mut self, join: JoinType, rel: RelationDef) -> Self {
        self.query().join(join, rel.to_tbl.clone(), rel);
        self
    }
    // ...
    fn join_rev(mut self, join: JoinType, rel: RelationDef) -> Self {
        self.query().join(join, rel.from_tbl.clone(), rel);
        self
    }

ON 条件是同一个,join_rev 只是把 from_tbl 接进来。实测(QueryTrait::build(DbBackend::Postgres).to_string(),select_only() 去掉了列,所以 SELECT 后面是空的;这里没有参数,to_string 和实际发出的 SQL 一样):

join(Inner, post::Relation::Author.def()):
  SELECT  FROM "post" INNER JOIN "author" ON "post"."author_id" = "author"."id"
join(Left, author::Relation::Post.def()):
  SELECT  FROM "author" LEFT JOIN "post" ON "author"."id" = "post"."author_id"
join_rev(Inner, post::Relation::Author.def()) from author:
  SELECT  FROM "author" INNER JOIN "post" ON "post"."author_id" = "author"."id"

第一行和第三行连的是同一条外键,一条从 post 出发、一条从 author 出发:有了 join_rev,不在 author 上声明反向关系也能从 author 连到 post。JoinType 由调用方给,RelationDef 里不存。

三个 trait:RelationTrait、Related、Linked

RelationTrait 是 Relation 枚举实现的 trait,只有一个必需方法:

pub trait RelationTrait: Iterable + Debug + 'static {
    /// The [`RelationDef`] describing this relation.
    fn def(&self) -> RelationDef;
    // ...
}

枚举的每个分支是一条外键连线。一个实体对另一个实体可以有好几条(post 的作者和编辑都指向 author),所以连线按名字区分,不按目标实体区分。

Related<R> 反过来按目标实体区分,说「到 R 走哪条」:

pub trait Related<R>
where
    R: EntityTrait,
{
    /// The relation from `Self` to `R`.
    fn to() -> RelationDef;

    /// Junction table relation, if `Self <-> R` is many-to-many.
    fn via() -> Option<RelationDef> {
        None
    }

    /// Build a [`Select<R>`] pre-joined with `Self` via this relation.
    fn find_related() -> Select<R> {
        Select::<R>::new().join_join_rev(JoinType::InnerJoin, Self::to(), Self::via())
    }
}

一对多、多对一只实现 to();多对多再实现 via(),返回「从自己到中间表」的那条线,to() 返回「从中间表到 R」的那条。find_related 从 R 出发,先 join_rev(to) 接上中间表,再 join_rev(via) 接上自己。post 到 tag 的实测:

Related<tag>::find_related():
  SELECT  FROM "tag" INNER JOIN "post_tag" ON "post_tag"."tag_id" = "tag"."id" INNER JOIN "post" ON "post"."id" = "post_tag"."post_id"

ModelTrait::find_related 在这个查询上再按 self 的主键加条件,那部分在 join 与 find_related。

Related<R> 一对实体只能实现一次,这是 Rust 的 trait 规则。post 到 author 有两条线时,Related<author> 只能指其中一条;要走另一条,或者要连续走好几跳,用 Linked:

pub trait Linked {
    /// Entity at the start of the chain.
    type FromEntity: EntityTrait;

    /// Entity reached at the end of the chain.
    type ToEntity: EntityTrait;

    /// Ordered chain of hops from `FromEntity` to `ToEntity`.
    fn link(&self) -> Vec<LinkDef>;

    /// Build a [`Select<ToEntity>`] that follows the chain.
    fn find_linked(&self) -> Select<Self::ToEntity> {
        find_linked(self.link().into_iter().rev(), JoinType::InnerJoin)
    }
}

LinkDef 就是 RelationDef 的类型别名。Linked 没有类型参数,由一个普通的 struct 来实现,所以同一对实体之间可以有任意多个 Linked,各起各的名字。

1.x 写法和 2.0 写法:同一个 RelationDef

1.x 的展开写法手写 Relation 枚举和 Related impl(本站就是这样写的):

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
  #[sea_orm(
    belongs_to = "super::author::Entity",
    from = "Column::AuthorId",
    to = "super::author::Column::Id",
    on_delete = "Cascade"
  )]
  Author,
}

impl Related<super::author::Entity> for Entity {
  fn to() -> RelationDef {
    Relation::Author.def()
  }
}

2.0 的 dense 写法把关系写成 Model 的字段:

#[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 author_id: i32,
  #[sea_orm(belongs_to, from = "author_id", to = "id", on_delete = "Cascade")]
  pub author: BelongsTo<super::author::Entity>,
  #[sea_orm(has_many, via = "post_tag")]
  pub tags: HasMany<super::tag::Entity>,
}

两者是不是同一种关系,不用比行为,看展开就够了。#[sea_orm::model](expand_sea_orm_model)把关系字段从 Model 里拿走,放进 ModelEx(见 DeriveEntityModel 与 dense 格式),同时为每个字段生成一个 Relation 分支,带着 1.x 的那套属性(expand_belongs_to_into 里是 #[sea_orm(#belongs_to = #related_entity, from = #from, to = #to, #extra)])。cargo expand --lib dense::post 的相关几行:

    /// Generated by sea-orm-macros
    pub enum Relation {
        /// Generated by sea-orm-macros
        #[sea_orm(
            belongs_to = "super::author::Entity",
            from = "Column::AuthorId",
            to = "super::author::Column::Id",
            on_delete = "Cascade"
        )]
        Author,
    }
    // ...
    impl sea_orm::entity::RelationTrait for Relation {
        fn def(&self) -> sea_orm::entity::RelationDef {
            match self {
                Self::Author => {
                    Entity::belongs_to(super::author::Entity)
                        .from(Column::AuthorId)
                        .to(super::author::Column::Id)
                        .on_delete(sea_orm::prelude::ForeignKeyAction::Cascade)
                        .into()
                }
                // ...
            }
        }
    }
    /// Generated by sea-orm-macros
    impl Related<super::author::Entity> for Entity {
        fn to() -> RelationDef {
            Relation::Author.def()
        }
    }
    /// Generated by sea-orm-macros
    impl Related<super::tag::Entity> for Entity {
        fn to() -> RelationDef {
            super::post_tag::Relation::Tag.def()
        }
        fn via() -> Option<RelationDef> {
            Some(<super::post_tag::Entity as Related<Entity>>::to().rev())
        }
    }

dense 的 Relation 枚举之后照样交给 DeriveRelation(impl_relation_trait),legacy::post 展开出来的 RelationTrait impl 与上面逐行相同。dense 写法自己没有一份「生成 RelationDef」的代码,它只是替你写了 1.x 的枚举和 Related impl。

用运行结果再核对一遍:src/bin/rel_defs.rs 对每一对关系同时比 == 和 Debug 字符串。

fn compare(name: &str, a: RelationDef, b: RelationDef) {
  let (da, db) = (format!("{a:?}"), format!("{b:?}"));
  println!("{name}: PartialEq={} Debug-equal={}", a == b, da == db);
  println!("  {da}");
  if da != db {
    println!("  dense: {db}");
  }
}
// ...
  compare("post->author", legacy::post::Relation::Author.def(), dense::post::Relation::Author.def());
  compare("author->post", legacy::author::Relation::Post.def(), dense::author::Relation::Post.def());
  // ... Related<tag>::to / via, staff 的 ReportsTo 同理
  let no_cascade = RelationDef { on_delete: None, ..legacy::post::Relation::Author.def() };
  println!("post->author vs same without on_delete: PartialEq={}", no_cascade == legacy::post::Relation::Author.def());
post->author: PartialEq=true Debug-equal=true
  RelationDef { rel_type: HasOne, from_tbl: Table(TableName(None, DynIden("post")), None), to_tbl: Table(TableName(None, DynIden("author")), None), from_col: Unary(DynIden("author_id")), to_col: Unary(DynIden("id")), is_owner: false, skip_fk: false, on_delete: Some(Cascade), on_update: None, on_condition: false, fk_name: None, condition_type: All }
author->post: PartialEq=true Debug-equal=true
  RelationDef { rel_type: HasMany, from_tbl: Table(TableName(None, DynIden("author")), None), to_tbl: Table(TableName(None, DynIden("post")), None), from_col: Unary(DynIden("id")), to_col: Unary(DynIden("author_id")), is_owner: true, skip_fk: false, on_delete: None, on_update: None, on_condition: false, fk_name: None, condition_type: All }
Related<tag>::to: PartialEq=true Debug-equal=true
  RelationDef { rel_type: HasOne, from_tbl: Table(TableName(None, DynIden("post_tag")), None), to_tbl: Table(TableName(None, DynIden("tag")), None), from_col: Unary(DynIden("tag_id")), to_col: Unary(DynIden("id")), is_owner: false, skip_fk: false, on_delete: None, on_update: None, on_condition: false, fk_name: None, condition_type: All }
Related<tag>::via: PartialEq=true Debug-equal=true
  RelationDef { rel_type: HasOne, from_tbl: Table(TableName(None, DynIden("post")), None), to_tbl: Table(TableName(None, DynIden("post_tag")), None), from_col: Unary(DynIden("id")), to_col: Unary(DynIden("post_id")), is_owner: true, skip_fk: false, on_delete: None, on_update: None, on_condition: false, fk_name: None, condition_type: All }
staff reports_to: PartialEq=true Debug-equal=true
  RelationDef { rel_type: HasOne, from_tbl: Table(TableName(None, DynIden("staff")), None), to_tbl: Table(TableName(None, DynIden("staff")), None), from_col: Unary(DynIden("reports_to_id")), to_col: Unary(DynIden("id")), is_owner: false, skip_fk: false, on_delete: None, on_update: None, on_condition: false, fk_name: None, condition_type: All }
post->author vs same without on_delete: PartialEq=true

五对全部相同。最后一行说明为什么还要比 Debug:PartialEq for RelationDef 只比 rel_type、两张表、两组列和 is_owner 六项,on_delete、fk_name、on_condition 不同也算相等。它回答的是「是不是同一条连线」,不是「两个定义是否完全一样」。

author->post 那行还有两点值得看。一是 on_delete: None:post 那边写了 Cascade,到 author 这边没了。has_many 的 EntityTrait::has_many 是 RelationBuilder::from_rel(RelationType::HasMany, R::to().rev(), true),即拿对方 Related<Self>::to() 的结果倒过来,from_rel 只抄表和列,外键属性全部重置。外键只在持有它的那一边声明一次,反向关系只是连线,不是第二个外键。二是因此 has_many / has_one 要求对方实现了 Related<Self>(R: EntityTrait + Related<Self>),对方没有这个 impl,这边就编译不过;对方有好几条线指过来时,用 has_many_via(R, rel) 点名(DeriveRelation 的 via_rel 属性生成的就是它)。

📝NOTE
没有蠢问题

问: dense 的 post 声明了 tags: HasMany<tag::Entity>,可展开出来的 Relation 枚举里只有 Author,没有 Tag。是漏了吗?

答: 没漏。Relation 的每个分支是一条外键连线,post 到 tag 之间没有外键,只有经过 post_tag 的两条线,它们属于 post_tag 的 Relation。expand_has_many_into 只在 has_many && via.is_none() 时才加分支;带 via 时只生成 Related<tag> 的 to / via(上面展开的最后一段)。1.x 写法里多对多也是这样写的:Relation 不出现 Tag,Related<tag> 手写两条线。

自引用:同一张表出现两次

staff.reports_to_id 指向 staff.id。RelationDef 照常生成(上面输出里 from_tbl、to_tbl 都是 staff),但直接拿去 join 会出问题。src/bin/rel_db.rs 真的发给数据库:

  let r = staff::Entity::find()
    .join(JoinType::LeftJoin, staff::Relation::ReportsTo.def())
    .all(&db)
    .await;
  println!("result: {r:?}");

sqlx 日志里实际发出的语句和结果:

 INFO sqlx::query: summary="SELECT \"staff\".\"id\", \"staff\".\"name\", \"staff\".\"reports_to_id\" …" db.statement="\n\nSELECT \"staff\".\"id\", \"staff\".\"name\", \"staff\".\"reports_to_id\" FROM \"staff\" LEFT JOIN \"staff\" ON \"staff\".\"reports_to_id\" = \"staff\".\"id\"\n" rows_affected=0 rows_returned=0 elapsed=904.042µs elapsed_secs=0.000904042
result: Err(Query(SqlxError(Database(PgDatabaseError { severity: Error, code: "42712", message: "table name \"staff\" specified more than once", detail: None, hint: None, position: None, where: None, schema: None, table: None, column: None, data_type: None, constraint: None, file: Some("parse_relation.c"), line: Some(463), routine: Some("checkNameSpaceConflicts") }))))

From<RelationDef> for Condition 用的是表名,两张表都叫 staff,ON "staff"."reports_to_id" = "staff"."id" 本身就分不清哪个是哪个,Postgres 在解析阶段就拒绝了。sea-orm 在构造时不检查这一点,错误要到发出去才出现。

解决办法是给其中一张表起别名,RelationDef 里 TableRef 可以带别名,ON 条件会改用别名。手动的办法是 join_as,它把 to_tbl 换成带别名的版本:

staff join_as(Left, ReportsTo, "mgr"):
  SELECT  FROM "staff" LEFT JOIN "staff" AS "mgr" ON "staff"."reports_to_id" = "mgr"."id"

另一个办法是 Linked,它每一跳都自动起别名。实验里的 legacy::staff 不实现 Related<Entity> for Entity,而是写一个 Linked:

pub struct ManagerLink;

impl Linked for ManagerLink {
  type FromEntity = Entity;
  type ToEntity = Entity;

  fn link(&self) -> Vec<RelationDef> {
    vec![Relation::ReportsTo.def()]
  }
}

find_linked 把这串连线倒着走,从 ToEntity 出发,第 i 跳的那张表一律叫 r{i}:

pub(crate) fn find_linked<I, E>(links: I, join: JoinType) -> Select<E>
where
    I: Iterator<Item = LinkDef>,
    E: EntityTrait,
{
    let mut select = Select::new();
    for (i, mut rel) in links.enumerate() {
        let from_tbl = format!("r{i}").into_iden();
        let to_tbl = if i > 0 {
            format!("r{}", i - 1).into_iden()
        } else {
            rel.to_tbl.sea_orm_table().clone()
        };
        let table_ref = rel.from_tbl;

        let mut condition = match rel.condition_type {
            ConditionType::All => Condition::all(),
            ConditionType::Any => Condition::any(),
        };
        condition = condition.add(join_tbl_on_condition(
            from_tbl.clone(),
            to_tbl.clone(),
            rel.from_col,
            rel.to_col,
        ));
        if let Some(f) = rel.on_condition.take() {
            condition = condition.add(f(from_tbl.clone(), to_tbl.clone()));
        }

        select.query().join_as(join, table_ref, from_tbl, condition);
    }
    select
}

FROM 后面是不带别名的 ToEntity,链上其余的表都是 r0、r1……,所以即使整条链都在同一张表上也不会撞名。ModelTrait::find_linked 再按最后一个别名 r{len-1} 加上起点的主键条件:

staff ManagerLink.find_linked():
  SELECT  FROM "staff" INNER JOIN "staff" AS "r0" ON "r0"."reports_to_id" = "staff"."id"
model.find_linked(ManagerLink):
  SELECT "staff"."id", "staff"."name", "staff"."reports_to_id" FROM "staff" INNER JOIN "staff" AS "r0" ON "r0"."reports_to_id" = "staff"."id" WHERE "r0"."id" = 7

读法:r0 是起点(id = 7 的员工),FROM "staff" 是要查的上级,条件是「r0 的 reports_to_id 等于这一行的 id」。发给数据库的版本(rel_db.rs 的第二部分,查 alice 的上级):

 INFO sqlx::query: summary="SELECT \"staff\".\"id\", \"staff\".\"name\", \"staff\".\"reports_to_id\" …" db.statement="\n\nSELECT \"staff\".\"id\", \"staff\".\"name\", \"staff\".\"reports_to_id\" FROM \"staff\" INNER JOIN \"staff\" AS \"r0\" ON \"r0\".\"reports_to_id\" = \"staff\".\"id\" WHERE \"r0\".\"id\" = $1 LIMIT $2\n" rows_affected=1 rows_returned=1 elapsed=1.487542ms elapsed_secs=0.001487542
result: Some(Model { id: 1, name: "boss", reports_to_id: None })
📝NOTE
冷知识:Linked 的每一跳直到 2.0.4 都忽略 condition_type

上面 find_linked 里 match rel.condition_type 那四行是 #3203 加的,首次发布于 2.0.4。之前这里写死 Condition::all()。PR 说明的原话:From<RelationDef> for Condition 会照 ConditionType::Any 从 Condition::any() 开始,「but LinkDef is the same RelationDef, and both linked paths build each hop starting from Condition::all() unconditionally」,于是声明了 Any 的一跳「gets its on_condition ANDed instead of ORed, silently returning different rows」。修法是两条路径(find_linked 和 left_join_linked)都改成按 rel.condition_type 选根条件,「Links using the default ConditionType::All produce exactly the same SQL as before」。2.0.4 的 release notes 把它记为「Respect condition_type in linked joins」。

dense 写法的自引用用 self_ref 属性,宏生成的仍然是普通的 Relation 分支,而且不生成 Related<Entity> for Entity。实验里的 dense::staff:

  #[sea_orm(
    self_ref,
    relation_enum = "ReportsTo",
    relation_reverse = "Manages",
    from = "reports_to_id",
    to = "id"
  )]
  pub reports_to: BelongsTo<Option<Entity>>,
  #[sea_orm(self_ref, relation_enum = "Manages", relation_reverse = "ReportsTo")]
  pub manages: HasMany<Entity>,

展开后:

    pub enum Relation {
        /// Generated by sea-orm-macros
        #[sea_orm(
            belongs_to = "Entity",
            from = "Column::ReportsToId",
            to = "Column::Id",
        )]
        ReportsTo,
        /// Generated by sea-orm-macros
        #[sea_orm(has_many = "Entity", via_rel = "Relation::ReportsTo")]
        Manages,
    }
    // ...
                Self::ReportsTo => {
                    Entity::belongs_to(Entity)
                        .from(Column::ReportsToId)
                        .to(Column::Id)
                        .into()
                }
                Self::Manages => Entity::has_many_via(Entity, Relation::ReportsTo).into(),

relation_enum 是必需的:同一个实体既是起点又是终点,分支名不能从目标实体名推出来。Manages 就是 ReportsTo 倒过来(from_col: id、to_col: reports_to_id、HasMany、is_owner: true)。整个展开里没有 impl Related<Entity> for Entity。自引用怎么加载见 Loader 与 N+1;自己写 join 时,前面的撞名规则照样适用。多对多的自引用(关注者表)走 RelatedSelfVia,它和 Related 的区别只是 via 不可选。

❗IMPORTANT
动脑筋:staff::Entity::find().join(JoinType::LeftJoin, staff::Relation::ReportsTo.def()) 会撞名。把 LeftJoin 换成 InnerJoin、把 join 换成 join_rev,四种组合里有几种能被 Postgres 执行?平台:M2 Max、macOS、rustc 1.99.0、Postgres 16.14。答案在页末。

BelongsTo:把基数写进类型

dense 写法里 belongs_to 字段的类型有两种:BelongsTo<E> 和 BelongsTo<Option<E>>。宏在 CompoundType::from_type 里从字段类型认出是哪一种(CardinalityKind::Required / Optional)。这个区分不进 RelationDef:CardinalityKind 只在 model_ex.rs 和 active_model_ex.rs 里用到,expand_belongs_to_into 生成的 Relation 分支两种写法一模一样。上面的 staff.reports_to 是 BelongsTo<Option<Entity>>,post.author 是 BelongsTo<author::Entity>,它们的 Relation 分支和 RelationDef 结构完全一致。所以它也不决定 join 用 INNER 还是 LEFT。

它影响的是两处类型。读出来的 BelongsTo<T> 是 Unloaded | Loaded(T::Loaded),而 Loaded 由 BelongsToCardinality 决定:

字段类型Loaded(..) 里装的能不能表达「没有」
BelongsTo<E>Box<E::ModelEx>不能
BelongsTo<Option<E>>Option<Box<E::ModelEx>>Loaded(None):加载过,外键是 NULL
HasOne<E>(旧写法,仍然支持)Option<Box<E::ModelEx>>Loaded(None)

写入那一侧,ActiveModelEx 的方法不同。cargo expand 里 post 只有 set_author,staff 多两个:

        pub fn set_reports_to_option(
            mut self,
            v: Option<impl Into<ActiveModelEx>>,
        ) -> Self {
            if v.is_none() {
                self.reports_to_id = sea_orm::Set(None);
            }
            self.reports_to = sea_orm::ActiveBelongsTo::<Option<Entity>>::set(v);
            self
        }
        // ...
        pub fn clear_reports_to(mut self) -> Self {
            self.reports_to_id = sea_orm::Set(None);
            self.reports_to = sea_orm::ActiveBelongsTo::Set(None);
            self
        }

「解除关联」就是把外键列设成 NULL。外键列不可空时没有这个操作,这些方法也就不生成。嵌套写入的完整过程见 ActiveModelEx。

基数和外键列是否可空必须一致,宏在 expand_active_belongs_to_into 里检查。外键 author_id: Option<i32> 却写 BelongsTo<author::Entity>:

error: BelongsTo<Entity> requires non-Option `from` fields
  --> src/bin/bt_mismatch.rs:12:34
   |
12 |     #[sea_orm(belongs_to, from = "author_id", to = "id")]
   |                                  ^^^^^^^^^^^

反过来,author_id: i32 配 BelongsTo<Option<author::Entity>>:

error: BelongsTo<Option<Entity>> requires at least one `Option<T>` `from` field
  --> src/bin/bt_mismatch2.rs:12:34
   |
12 |     #[sea_orm(belongs_to, from = "author_id", to = "id")]
   |                                  ^^^^^^^^^^^

「at least one」是给复合外键留的:几列里只要有一列可空,就算可选关联,解除时只把可空的那几列设成 NULL。

📝NOTE
冷知识:BelongsTo 是 2.0 正式版前最后一个 rc 才加的

2.0.0-rc.43 之前,dense 写法里 belongs_to 字段只能写成 HasOne<E>,而 HasOne 有表示「没找到」的状态,「外键不可空所以一定有」这件事类型里表达不了。#3118(标题「Support non-null HasOne」)的说明写着「Adds support for non-nullable HasOne relations」,并「Remove the NotFound representation for empty HasOne relations, as this state should never be possible for non-nullable relations」。接着 #3133(「Introduce BelongsTo」)把它做成可选的:「belongs_to now accepts the recommended BelongsTo<E> / BelongsTo<Option<E>> ... or the legacy HasOne<E> field type」。两者都首次发布于 2.0.0-rc.43,release notes 的说法是「Cardinality lives in the type parameter, so the foreign-key nullability is expressed — and checked — at compile time」,上面两条编译错误就是这个检查。

动脑筋答案

src/bin/self_join_quiz.rs(staff 表由 rel_db 建好):

  for jt in [JoinType::LeftJoin, JoinType::InnerJoin] {
    let a = staff::Entity::find().join(jt, staff::Relation::ReportsTo.def()).all(&db).await;
    println!("join     {jt:?}: {}", a.map(|v| format!("Ok({} rows)", v.len())).unwrap_or_else(|e| e.to_string()));
    let b = staff::Entity::find().join_rev(jt, staff::Relation::ReportsTo.def()).all(&db).await;
    println!("join_rev {jt:?}: {}", b.map(|v| format!("Ok({} rows)", v.len())).unwrap_or_else(|e| e.to_string()));
  }
join     LeftJoin: Query Error: error returned from database: table name "staff" specified more than once at line 463
join_rev LeftJoin: Query Error: error returned from database: table name "staff" specified more than once at line 463
join     InnerJoin: Query Error: error returned from database: table name "staff" specified more than once at line 463
join_rev InnerJoin: Query Error: error returned from database: table name "staff" specified more than once at line 463

0 种。join 接的是 rel.to_tbl,join_rev 接的是 rel.from_tbl,对 ReportsTo 来说两个都是没有别名的 staff;JoinType 只改 JOIN 前面的关键字,不碰表名。四条语句都是 FROM "staff" ... JOIN "staff" ON ...,Postgres 在解析阶段就拒绝。行尾的「at line 463」是错误响应里 line 字段(Postgres 源码 parse_relation.c 的行号),不是 SQL 的行号。

小结

  • RelationDef 是关系的全部运行时信息:两端的表和列、HasOne / HasMany、is_owner、附加 ON 条件、外键 DDL 属性。belongs_to 是 HasOne 加 is_owner = false,没有单独的类型。
  • From<RelationDef> for Condition 生成 ON from.col = to.col [AND/OR on_condition];join 接 to_tbl,join_rev 接 from_tbl,ON 条件相同。
  • RelationTrait 按连线命名,Related<R> 按目标实体选一条(多对多再给 via),Linked 是任意命名的连线序列,每跳自动起别名 r0、r1……
  • dense 写法的 #[sea_orm::model] 只是替你写出 1.x 的 Relation 枚举和 Related impl,再走同一个 DeriveRelation,展开和运行结果都证明两种写法的 RelationDef 逐字段相同。PartialEq 只比六个字段,不比 on_delete。
  • 反向的 has_many / has_one 由对方的 Related<Self>::to().rev() 得来,只抄表和列,外键属性只在持有外键的一边声明。
  • 自引用直接 join 会得到 42712 table name "staff" specified more than once;用 join_as 或 Linked 起别名。
  • BelongsTo<E> / BelongsTo<Option<E>> 不改变 RelationDef,只改变 ModelEx 里装的类型和 ActiveModelEx 能不能解除关联,并在编译期核对外键列的可空性。

💬 关联讨论

发起讨论
加载中...