Rust in Everything
基于 sea-orm 2.0.4

join 与 find_related

主线位置:查询主线上「一次查两张表」的分支。关系 讲了 RelationDef 怎么变成 JOIN ... ON;这一页讲 sea-orm 拿这条 join 做什么:find_also_related 把每一行拆成两个 Model,find_with_related 再把同一个父记录的行合并成一个 Vec,find_related 和 has_related 把关联表当成过滤条件。它们最后都走 Select 与过滤 里的 Selector::all / one,每次只发一条语句。

flowchart TB
  sel["Select E"] -->|left_join| j["LEFT JOIN 语句"]
  j -->|"select_also: A_ 前缀"| two["SelectTwo"]
  j -->|"select_with: A_ 前缀 + ORDER BY 主键"| many["SelectTwoMany"]
  two -->|"B_ 前缀列"| m2["SelectTwoModel"]
  many -->|"B_ 前缀列"| m2
  m2 -->|"每行一对"| pairs["Vec 父 Option 子"]
  pairs -->|consolidate| grouped["Vec 父 Vec 子"]
  sel -->|has_related| ex["WHERE EXISTS"]
  model["Model"] -->|find_related| inner["INNER JOIN + 主键过滤"]

实验接着用 Select 与过滤 的 author / post 两张表(ann 有 a1、a2、a3 三篇,bob 没有,cid 有 c1、c2,dee 有 d1),再加一个多对多:

CREATE TABLE tag (id serial PRIMARY KEY, name text NOT NULL);
CREATE TABLE post_tag (post_id int REFERENCES post(id), tag_id int REFERENCES tag(id),
                       PRIMARY KEY (post_id, tag_id));
INSERT INTO tag (name) VALUES ('rust'), ('sql');
INSERT INTO post_tag VALUES (1, 1), (2, 2), (4, 1), (4, 2);

实体是 关系 那一页的 dense 写法:author 有 #[sea_orm(has_many)] posts,post 有 belongs_to 的 author 和 #[sea_orm(has_many, via = "post_tag")] tags。平台 M2 Max、macOS、rustc 1.99.0、Postgres 16.14;SQL 取自 sqlx 的语句日志(实际发出的文本),需要看参数值的地方另开 debug-print。

几个 join 入口

Select 上有四个按实体名 join 的方法,left_join 是代表:

    pub fn left_join<R>(self, _: R) -> Self
    where
        R: EntityTrait,
        E: Related<R>,
    {
        self.join_join(JoinType::LeftJoin, E::to(), E::via())
    }

参数 _: R 只用来推断类型,值本身不用。E: Related<R> 决定走哪条连线:to() 是到 R 的那条,多对多时 via() 是到中间表的那条,join_join 先 join via 再 join to。

方法join 类型连线
left_join(R) / right_join(R) / inner_join(R)LEFT / RIGHT / INNERE::via()(若有)再 E::to(),用 join
reverse_join(R)INNERR::to(),用 join_rev;只要求 R: Related<E>
join(JoinType, RelationDef) / join_rev / join_as调用方给调用方给

join / join_rev 各接哪张表、ON 条件怎么从 RelationDef 来、自引用为什么要别名,都在 关系。这些方法只加 JOIN,SELECT 列表还是 E 的列,结果仍然读成 E::Model;要把对面的列也取回来,就是下面的 find_also_related。

find_also_related:一行一对

find_also_related 是两步:

    pub fn find_also_related<R>(self, r: R) -> SelectTwo<E, R>
    where
        R: EntityTrait,
        E: Related<R>,
    {
        self.left_join(r).select_also(r)
    }

两张表都有 id,同一行里两个 id 列分不开,所以 select_also 先给已有的列加前缀,再由 SelectTwo::new 把 R 的列加进来:

    pub fn select_also<F>(mut self, _: F) -> SelectTwo<E, F>
    where
        F: EntityTrait,
    {
        self = self.apply_alias(SelectA.as_str());
        SelectTwo::new(self.into_query())
    }

前缀是 select_def! 定义的六个常量 SelectA 到 SelectF,值是 "A_" 到 "F_",两表用前两个,SelectThree 到 SelectSix 依次用到 F_。apply_alias 遍历 SELECT 列表:

    pub(crate) fn apply_alias(mut self, pre: &str) -> Self {
        self.query().exprs_mut_for_each(|sel| {
            match &sel.alias {
                Some(alias) => {
                    let alias = format!("{}{}", pre, alias.to_string().as_str());
                    sel.alias = Some(alias.into_iden());
                }
                None => {
                    let col = match &sel.expr {
                        SimpleExpr::Column(col_ref) => match col_ref.column() {
                            Some(col) => col,
                            None => {
                                panic!("cannot apply alias for Column with asterisk");
                            }
                        },
                        // ... AsEnum 分支同理
                        _ => panic!("cannot apply alias for expr other than Column or AsEnum"),
                    };
                    let alias = format!("{}{}", pre, col.to_string().as_str());
                    sel.alias = Some(alias.into_iden());
                }
            };
        });
        self
    }

已有别名的列,别名前面加 A_;没有别名的,从表达式里取列名,加 A_ 当别名。R 那一侧由 prepare_select_col 直接生成 B_列名 的别名:

pub(crate) fn prepare_select_col<F, S, A>(selector: &mut S, alias: A)
where
    F: EntityTrait,
    S: QueryTrait<QueryStatement = SelectStatement>,
    A: IdenStatic,
{
    for col in <F::Column as Iterable>::iter() {
        let alias = format!("{}{}", alias.as_str(), col.as_str());
        selector.query().expr(SelectExpr {
            expr: col.select_as(col.into_expr()),
            alias: Some(alias.into_iden()),
            window: None,
        });
    }
}
📝NOTE
冷知识:apply_alias 的 panic 分支直到 2.0.3 还会被走到

apply_alias 只认得「列」和「枚举 CAST」两种没有别名的表达式。如果某列的 select_as 返回别的东西(比如为 citext 列生成的 CAST(.. AS text)),find_also_related 在发 SQL 之前就 panic。#3174(「find_also_related panics on entities generated by sea-orm-cli for citext columns」)报了这个。#3194 的说明写道:「Combined selects prefix the first-entity projection aliases, but can only infer an unaliased projection name from a plain column or an enum cast」,修法是「Preserve the column logical name as the projection alias whenever select_as produces a transformed expression」。这就是 into_select_expr 里那个 _ => Some(self.as_str().into_iden()) 分支:Select::new 现在就给这类列带上别名,apply_alias 走 Some(alias) 那条路。首次发布于 2.0.3,release notes 的兼容性说明提醒它「also changes the generated SQL for regular queries」:CAST("hello"."two" AS integer) 变成了 CAST("hello"."two" AS integer) AS "two"。

src/bin/related.rs 的第一段:

  let rows = author::Entity::find()
    .find_also_related(post::Entity)
    .order_by_asc(author::Column::Id)
    .order_by_asc(post::Column::Id)
    .all(&db)
    .await?;
  for (a, p) in &rows {
    println!("{} {:?}", a.name, p.as_ref().map(|p| &p.title));
  }
 INFO sqlx::query: summary="SELECT \"author\".\"id\" AS \"A_id\", …" db.statement="\n\nSELECT \"author\".\"id\" AS \"A_id\", \"author\".\"name\" AS \"A_name\", \"post\".\"id\" AS \"B_id\", \"post\".\"title\" AS \"B_title\", \"post\".\"likes\" AS \"B_likes\", \"post\".\"author_id\" AS \"B_author_id\" FROM \"author\" LEFT JOIN \"post\" ON \"author\".\"id\" = \"post\".\"author_id\" ORDER BY \"author\".\"id\" ASC, \"post\".\"id\" ASC\n" rows_affected=7 rows_returned=7 elapsed=3.01025ms elapsed_secs=0.00301025
ann Some("a1")
ann Some("a2")
ann Some("a3")
bob None
cid Some("c1")
cid Some("c2")
dee Some("d1")

一条语句、七行,每行一对:ann 出现三次,没有文章的 bob 是 None。find_also_related 自己不加 ORDER BY,上面的排序是手写的。

拆回两个 Model

SelectTwo 的 all 用 SelectTwoModel 读每一行,它的 SelectorTrait 实现:

impl<M, N> SelectorTrait for SelectTwoModel<M, N>
where
    M: FromQueryResult + Sized,
    N: FromQueryResult + Sized,
{
    type Item = (M, Option<N>);

    fn from_raw_query_result(res: QueryResult) -> Result<Self::Item, DbErr> {
        Ok((
            M::from_query_result(&res, SelectA.as_str())?,
            N::from_query_result_optional(&res, SelectB.as_str())?,
        ))
    }
}

第二个参数就是列名前缀:读 M 的 id 字段去找 A_id 列,读 N 的去找 B_id。左边用 from_query_result,出错就是错;右边用 from_query_result_optional,LEFT JOIN 没匹配上时那几列全是 NULL,要变成 None。问题出在它的默认实现(FromQueryResult::from_query_result_optional):

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

        // would really like to do the following, but can't without version bump:
        // match Self::from_query_result_nullable(res, pre) {
        //     Ok(v) => Ok(Some(v)),
        //     Err(TryGetError::Null(_)) => Ok(None),
        //     Err(TryGetError::DbErr(err)) => Err(err),
        // }
    }

.ok() 把所有错误都变成 None,不只是「全是 NULL」。DeriveEntityModel 和 derive(FromQueryResult) 都不覆盖这个方法(宏只生成 from_query_result 和 from_query_result_nullable),注释里那段「需要升大版本才能改」的代码,到 2.0 也没有换上;1.1.20 里是同样的写法。src/bin/also_swallow.rs 用一个读不出来的结构体做右边:

// `likes` is int4 in the table; String cannot decode it.
#[derive(Debug, FromQueryResult)]
#[allow(dead_code)]
struct BadPost {
  id: i32,
  likes: String,
}
// ...
  let rows = author::Entity::find()
    .find_also_related(post::Entity)
    .order_by_asc(author::Column::Id)
    .into_model::<author::Model, BadPost>()
    .all(&db)
    .await;
  // 打印行数和前三行
  // ...
  let rows = post::Entity::find()
    .find_also_related(author::Entity)
    .into_model::<BadPost, author::Model>()
    .all(&db)
    .await;
  println!("{:?}", rows.map(|v| v.len()));
--- B side cannot decode
Ok, 7 rows
  ann None
  ann None
  ann None
--- same struct, A side
Err(Query(SqlxError(ColumnDecode { index: "\"A_likes\"", source: "mismatched types; Rust type `core::option::Option<alloc::string::String>` (as SQL type `TEXT`) is not compatible with SQL type `INT4`" })))

同一个类型错误,放在左边是 ColumnDecode,放在右边是一列安静的 None,看起来和「没有关联记录」一模一样。实体字段类型和列类型对不上(改了列类型没改实体)时,find_also_related / find_with_related 的右边就会这样「全都没有关联」。

find_both_related(2.0.0-rc.38,#2997)是 INNER JOIN 版本,返回 SelectTwoRequired,右边用 from_query_result,类型是 (E::Model, F::Model),没有 Option,也就没有上面的问题。同样的数据,它返回 6 行(bob 没了):

 INFO sqlx::query: summary="SELECT \"author\".\"id\" AS \"A_id\", …" db.statement="\n\nSELECT \"author\".\"id\" AS \"A_id\", \"author\".\"name\" AS \"A_name\", \"post\".\"id\" AS \"B_id\", \"post\".\"title\" AS \"B_title\", \"post\".\"likes\" AS \"B_likes\", \"post\".\"author_id\" AS \"B_author_id\" FROM \"author\" INNER JOIN \"post\" ON \"author\".\"id\" = \"post\".\"author_id\"\n" rows_affected=6 rows_returned=6 elapsed=583.208µs elapsed_secs=0.000583208
6 rows

find_with_related:一条 join,再在内存里合并

find_with_related 和 find_also_related 只差最后一步:

    pub fn find_with_related<R>(self, r: R) -> SelectTwoMany<E, R>
    where
        R: EntityTrait,
        E: Related<R>,
    {
        self.left_join(r).select_with(r)
    }

select_with 同样 apply_alias("A_"),构造的是 SelectTwoMany,它的 new 比 SelectTwo 多一步(prepare_order_by):

    pub(crate) fn new(query: SelectStatement) -> Self {
        Self::new_without_prepare(query)
            .prepare_select()
            .prepare_order_by()
    }
    // ...
    fn prepare_order_by(mut self) -> Self {
        for col in <E::PrimaryKey as Iterable>::iter() {
            self.query.order_by((E::default(), col), Order::Asc);
        }
        self
    }

执行时 SelectTwoMany::all 先按 SelectTwoModel 读出 Vec<(E::Model, Option<F::Model>)>,再交给 consolidate_query_result。它按父表主键的列数(编译期常量 ARITY)选一种键:单列主键的键是一个 Value,两列是 (Value, Value),更多是 Vec<Value>,然后进 consolidate_query_result_of:

fn consolidate_query_result_of<L, R, KEY: ModelKey<L>>(
    mut rows: Vec<(L::Model, Option<R::Model>)>,
    model_key: KEY,
) -> Vec<(L::Model, Vec<R::Model>)>
where
    L: EntityTrait,
    R: EntityTrait,
{
    // group items by unique key on left model
    let mut hashmap: HashMap<KEY::Type, Vec<R::Model>> =
        rows.iter_mut().fold(HashMap::new(), |mut acc, row| {
            let key = model_key.get(&row.0); // keep left model in place
            if let Some(value) = row.1.take() {
                // take ownership of right model
                if let Some(vec) = acc.get_mut(&key) {
                    vec.push(value)
                } else {
                    acc.insert(key, vec![value]);
                }
            } else {
                acc.entry(key).or_default(); // insert empty vec
            }

            acc
        });

    // re-iterate so that we keep the same order
    rows.into_iter()
        .filter_map(|(l_model, _)| {
            // right model is empty here already
            let l_pk = model_key.get(&l_model);
            // the first time we encounter a left model, the value is taken
            // subsequently the key will be empty
            let r_models = hashmap.remove(&l_pk);
            r_models.map(|r_models| (l_model, r_models))
        })
        .collect()
}

两遍。第一遍按父记录的主键值分组,把右边的 Model 从行里拿走(take)放进对应的 Vec;右边是 None(LEFT JOIN 没匹配上)时也登记这个键,值是空 Vec。第二遍按原来的行序走,父记录第一次出现时从表里 remove 出它的 Vec,以后再出现,键已经不在了,filter_map 把这一行丢掉。所以:

  • 合并的依据只是父表主键的值,不比较父记录的其他列。
  • 不要求同一个父记录的行相邻,ORDER BY 对正确性不是必需的;父记录的顺序是它在结果里第一次出现的顺序,子记录的顺序是行序。
  • 右边的 None 不会变成 Vec 里的元素,没有子记录就是空 Vec。

既然不依赖相邻,构造时那个 ORDER BY 父表主键是做什么的?它比这个算法早。2021 年的第一版 SelectTwoMany 就有 prepare_order_by,那时的合并算法只看「这一行和上一行是不是同一个父记录」,必须靠排序把同一个父记录的行排在一起。那个算法还留在文件里,叫 consolidate_query_result_of_ordered_rows,注释是「This is the legacy consolidate algorithm. Kept for reference」,标了 #[allow(dead_code)]。现在 ORDER BY 的作用只剩让父记录的顺序确定。

📝NOTE
冷知识:换成 HashMap 的第一版把没有子记录的父记录弄丢了

#1728(「Adding find_with_linked」,首次出现在 0.12.0-rc.4,正式版 0.12.1)把上面那个按相邻行合并的算法换成了按主键值分组的 HashMap。第一遍只在右边有值时才往表里插键,没有子记录的父记录在表里没有键,第二遍 hashmap.remove 拿到 None,被 filter_map 丢掉。#1800 的标题直接说了现象:「find_with_related does not return records with no related entity. It just records that have more than one related entities.」,期望是「return A with an empty list of Bs」。修复是一个 4 行的提交(「Fix find_with_related #1800」),给 if let Some(value) 加上 else,在键不存在时插入空 Vec;首次发布于 0.12.2,CHANGELOG 记为「Fixed find_with_related consolidation logic」。今天的 acc.entry(key).or_default(); // insert empty vec 就是这个 else 的后来写法。

related.rs 的第二、三段,一对多和经过 post_tag 的多对多:

  let rows = author::Entity::find().find_with_related(post::Entity).all(&db).await?;
  for (a, ps) in &rows {
    println!("{} {:?}", a.name, ps.iter().map(|p| &p.title).collect::<Vec<_>>());
  }
  // ...
  let rows = post::Entity::find()
    .filter(post::Column::AuthorId.eq(1))
    .find_with_related(tag::Entity)
    .all(&db)
    .await?;
--- 2. author find_with_related post
 INFO sqlx::query: summary="SELECT \"author\".\"id\" AS \"A_id\", …" db.statement="\n\nSELECT \"author\".\"id\" AS \"A_id\", \"author\".\"name\" AS \"A_name\", \"post\".\"id\" AS \"B_id\", \"post\".\"title\" AS \"B_title\", \"post\".\"likes\" AS \"B_likes\", \"post\".\"author_id\" AS \"B_author_id\" FROM \"author\" LEFT JOIN \"post\" ON \"author\".\"id\" = \"post\".\"author_id\" ORDER BY \"author\".\"id\" ASC\n" rows_affected=7 rows_returned=7 elapsed=637.5µs elapsed_secs=0.0006375
ann ["a2", "a3", "a1"]
bob []
cid ["c1", "c2"]
dee ["d1"]
--- 3. post find_with_related tag (via post_tag)
 INFO sqlx::query: summary="SELECT \"post\".\"id\" AS \"A_id\", …" db.statement="\n\nSELECT \"post\".\"id\" AS \"A_id\", \"post\".\"title\" AS \"A_title\", \"post\".\"likes\" AS \"A_likes\", \"post\".\"author_id\" AS \"A_author_id\", \"tag\".\"id\" AS \"B_id\", \"tag\".\"name\" AS \"B_name\" FROM \"post\" LEFT JOIN \"post_tag\" ON \"post\".\"id\" = \"post_tag\".\"post_id\" LEFT JOIN \"tag\" ON \"post_tag\".\"tag_id\" = \"tag\".\"id\" WHERE \"post\".\"author_id\" = $1 ORDER BY \"post\".\"id\" ASC\n" rows_affected=3 rows_returned=3 elapsed=2.0515ms elapsed_secs=0.0020515
a1 ["rust"]
a2 ["sql"]
a3 []

两处值得看。ann 的三篇是 a2, a3, a1:ORDER BY 只有父表主键,同一个父记录内部的行序由 Postgres 决定,这一次恰好不是插入顺序,要稳定的子记录顺序得自己再加一个 order_by。多对多的版本是两个 LEFT JOIN(先中间表、再 tag),中间表的列不进 SELECT。

自己加的 order_by 只能排在内置的那个后面,所以按别的列给父记录排序是做不到的(with_limit.rs 中间一段):

  let rows = author::Entity::find()
    .find_with_related(post::Entity)
    .order_by_desc(author::Column::Name)
    .all(&db)
    .await?;
DEBUG sea_orm::driver::sqlx_postgres: SELECT "author"."id" AS "A_id", "author"."name" AS "A_name", "post"."id" AS "B_id", "post"."title" AS "B_title", "post"."likes" AS "B_likes", "post"."author_id" AS "B_author_id" FROM "author" LEFT JOIN "post" ON "author"."id" = "post"."author_id" ORDER BY "author"."id" ASC, "author"."name" DESC
...
  ann ["a2", "a3", "a1"]
  bob []
  cid ["c1", "c2"]
  dee ["d1"]

name DESC 成了第二排序键,主键唯一,它永远不起作用。要换顺序,得像 Select 与过滤 里说的那样先 QueryTrait::query(&mut q).clear_order_by()。合并算法不在乎行序,清掉以后结果依旧正确,只是顺序变了。

❗IMPORTANT
动脑筋:清掉内置的 ORDER BY,改成按 post.likes 倒序,下面这段返回的第一个作者是谁?cid 的两篇是什么顺序?
  let mut q = author::Entity::find().find_with_related(post::Entity);
  QueryTrait::query(&mut q).clear_order_by();
  let rows = q.order_by_desc(post::Column::Likes).all(&db).await?;
数据见页首(likes:a1 10、a2 30、a3 20、c1 5、c2 50、d1 40)。平台:M2 Max、macOS、rustc 1.99.0、Postgres 16.14。答案在页末。

limit 限制的是 join 后的行

SelectTwoMany 也实现了 QuerySelect,所以 .limit(n) 能编译,它改的是那条 join 语句的 LIMIT。src/bin/with_limit.rs:

  for n in [3, 4, 5] {
    println!("--- find_with_related(post).limit({n})");
    let rows = author::Entity::find().find_with_related(post::Entity).limit(n).all(&db).await?;
    println!("  {} authors", rows.len());
    show(&rows);
  }
--- find_with_related(post).limit(3)
DEBUG sea_orm::driver::sqlx_postgres: SELECT "author"."id" AS "A_id", "author"."name" AS "A_name", "post"."id" AS "B_id", "post"."title" AS "B_title", "post"."likes" AS "B_likes", "post"."author_id" AS "B_author_id" FROM "author" LEFT JOIN "post" ON "author"."id" = "post"."author_id" ORDER BY "author"."id" ASC LIMIT 3
 INFO sqlx::query: summary="SELECT \"author\".\"id\" AS \"A_id\", …" db.statement="\n\nSELECT \"author\".\"id\" AS \"A_id\", \"author\".\"name\" AS \"A_name\", \"post\".\"id\" AS \"B_id\", \"post\".\"title\" AS \"B_title\", \"post\".\"likes\" AS \"B_likes\", \"post\".\"author_id\" AS \"B_author_id\" FROM \"author\" LEFT JOIN \"post\" ON \"author\".\"id\" = \"post\".\"author_id\" ORDER BY \"author\".\"id\" ASC LIMIT $1\n" rows_affected=3 rows_returned=3 elapsed=3.539541ms elapsed_secs=0.003539541
  1 authors
  ann ["a1", "a2", "a3"]
--- find_with_related(post).limit(4)
...
  2 authors
  ann ["a1", "a2", "a3"]
  bob []
--- find_with_related(post).limit(5)
...
  3 authors
  ann ["a1", "a2", "a3"]
  bob []
  cid ["c1"]

LIMIT 作用在 join 之后的行上,sea-orm 不改写它。limit(3) 的三行全是 ann 的,于是只有 1 个作者;limit(5) 有 3 个作者,但 cid 只拿到了 c1,c2 落在第 6 行被截掉了,返回值里看不出它不完整。offset 一样按行算,翻页会把一个作者的文章切到两页上。

想要「前 n 个作者,各带全部文章」,得分两步:先对父表 limit(或 分页),再为这批父记录查子表,这正是 Loader 与 N+1 做的事。源码里 SelectTwoMany::all 后面留着一段注释,说的是同一件事:

    // pub fn paginate()
    // we could not implement paginate easily, if the number of children for a
    // parent is larger than one page, then we will end up splitting it in two pages
    // so the correct way is actually perform query in two stages
    // paginate the parent model and then populate the children

SelectTwoMany 上因此只有 all,没有 paginate,也没有 one:

error[E0599]: no method named `one` found for struct `SelectTwoMany<E, F>` in the current scope
   --> src/bin/two_many_one.rs:7:66
    |
  7 |   let r = author::Entity::find().find_with_related(post::Entity).one(&db).await?;
    |           ----------------------                                 ^^^
    |           |
    |           method `one` is available on `sea_orm::Select<exp_seaorm_select1::author::Entity>`
📝NOTE
冷知识:SelectTwoMany::one 在 0.9.0 被删掉了

早期的 SelectTwoMany 有 one(),走的是通用的 Selector::one,先 limit(1) 再读一行,返回 Option<(Model, Option<Model>)>:一对多查询只拿回一个子记录。#486(「Allow to fetch entity and all related ones」)说「For a One-to-Many relation this is really weird, what is expected is Option<(Model, Vec<Model>)>」。#813(「Drop SelectTwoMany::one() method」)的处理不是修正返回类型,而是删掉:理由是它「returns Option<(E, Option<F>)> and the return value is a pair of models instead of (E, Vec<F>) which is weird select result for a one-to-many relationship」。首次发布于 0.9.0,CHANGELOG 给的替代办法是「first querying a single model from Entity, then use ModelTrait::find_related on the model」,SelectTwoMany::all 的文档注释里至今留着这段话。正确的 one 本来就做不出来:LIMIT 1 只留一行,要拿全一个父记录的子记录,LIMIT 必须作用在父表上,和上面的分页是同一个问题。

find_related:从一个 Model 出发

CHANGELOG 推荐的替代办法是 ModelTrait::find_related:

    fn find_related<R>(&self, _: R) -> Select<R>
    where
        R: EntityTrait,
        Self::Entity: Related<R>,
    {
        <Self::Entity as Related<R>>::find_related().belongs_to(self)
    }

Related::find_related 是 Select::<R>::new().join_join_rev(JoinType::InnerJoin, Self::to(), Self::via()):从 R 出发,INNER JOIN 回自己的表(多对多再经过中间表)。belongs_to(self) 就是上一页讲的按 self 的主键加过滤,条件写在自己的表上。related.rs 的第四段:

  let ann = author::Entity::find_by_id(1).one(&db).await?.ok_or(DbErr::Custom("no ann".into()))?;
  let posts = ann.find_related(post::Entity).all(&db).await?;
  println!("{:?}", posts.iter().map(|p| &p.title).collect::<Vec<_>>());
  let p4 = posts[0].clone();
  let a = p4.find_related(author::Entity).one(&db).await?;
  println!("{:?}", a.map(|a| a.name));
 INFO sqlx::query: summary="SELECT \"author\".\"id\", \"author\".\"name\" FROM …" db.statement="\n\nSELECT \"author\".\"id\", \"author\".\"name\" FROM \"author\" WHERE \"author\".\"id\" = $1 LIMIT $2\n" rows_affected=1 rows_returned=1 elapsed=561.834µs elapsed_secs=0.000561834
 INFO sqlx::query: summary="SELECT \"post\".\"id\", \"post\".\"title\", \"post\".\"likes\", …" db.statement="\n\nSELECT \"post\".\"id\", \"post\".\"title\", \"post\".\"likes\", \"post\".\"author_id\" FROM \"post\" INNER JOIN \"author\" ON \"author\".\"id\" = \"post\".\"author_id\" WHERE \"author\".\"id\" = $1\n" rows_affected=3 rows_returned=3 elapsed=561.625µs elapsed_secs=0.000561625
["a1", "a2", "a3"]
 INFO sqlx::query: summary="SELECT \"author\".\"id\", \"author\".\"name\" FROM …" db.statement="\n\nSELECT \"author\".\"id\", \"author\".\"name\" FROM \"author\" INNER JOIN \"post\" ON \"post\".\"author_id\" = \"author\".\"id\" WHERE \"post\".\"id\" = $1 LIMIT $2\n" rows_affected=1 rows_returned=1 elapsed=564.125µs elapsed_secs=0.000564125
Some("ann")

两个方向都是两张表的 INNER JOIN。查 ann 的文章,本来 WHERE "post"."author_id" = 1 就够了,sea-orm 却 join 了 author,条件写在 "author"."id" 上;查 a1 的作者,手里已经有 author_id,照样 join 回 post、按 "post"."id" 过滤。原因是 find_related 对每种关系只用一种写法:belongs_to 只认识 self 的主键,不知道对面哪一列存着它,所以必须把 self 的表 join 进来;同一套代码对多对多(self 的主键只在中间表的另一头)也成立。代价是多一次 join,主键上的 join 在 Postgres 里通常很便宜,但它不是最简的 SQL。

想要单表查询,就自己写过滤:post::Entity::find().filter(post::Column::AuthorId.eq(ann.id))。

has_related:EXISTS 子查询

2.0 新加的 has_related 回答「有没有满足条件的关联记录」,不 join、不取对面的列:

    pub fn has_related<R, C>(mut self, _: R, condition: C) -> Self
    where
        R: EntityTrait,
        E: Related<R>,
        C: IntoCondition,
    {
        let mut to = None;
        let mut condition = Condition::all().add(condition.into_condition());
        condition = condition.add(if let Some(via) = E::via() {
            to = Some(E::to());
            via
        } else {
            E::to()
        });
        let mut subquery = R::find()
            .select_only()
            .expr(Expr::cust("1"))
            .filter(condition)
            .into_query();
        if let Some(to) = to {
            // join the junction table
            subquery.inner_join(to.from_tbl.clone(), to);
        }
        self.query.cond_where(Expr::exists(subquery));
        self
    }

子查询是 SELECT 1 FROM R WHERE 调用方的条件 AND 关联条件,关联条件就是 RelationDef 转成的 Condition(impl From<RelationDef> for Condition,见 关系),它引用外层的表,所以这是一个关联子查询;多对多时关联条件是 via(自己到中间表),中间表再 INNER JOIN 到 R。related.rs 的第五段:

  let names: Vec<String> = author::Entity::find()
    .has_related(post::Entity, post::Column::Likes.gte(40))
    // ...
  let titles: Vec<String> = post::Entity::find()
    .has_related(tag::Entity, tag::Column::Name.eq("sql"))
    // ...
 INFO sqlx::query: summary="SELECT \"author\".\"id\", \"author\".\"name\" FROM …" db.statement="\n\nSELECT \"author\".\"id\", \"author\".\"name\" FROM \"author\" WHERE EXISTS(SELECT 1 FROM \"post\" WHERE \"post\".\"likes\" >= $1 AND \"author\".\"id\" = \"post\".\"author_id\")\n" rows_affected=2 rows_returned=2 elapsed=700.959µs elapsed_secs=0.000700959
["cid", "dee"]
 INFO sqlx::query: summary="SELECT \"post\".\"id\", \"post\".\"title\", \"post\".\"likes\", …" db.statement="\n\nSELECT \"post\".\"id\", \"post\".\"title\", \"post\".\"likes\", \"post\".\"author_id\" FROM \"post\" WHERE EXISTS(SELECT 1 FROM \"tag\" INNER JOIN \"post_tag\" ON \"post_tag\".\"tag_id\" = \"tag\".\"id\" WHERE \"tag\".\"name\" = $1 AND \"post\".\"id\" = \"post_tag\".\"post_id\")\n" rows_affected=2 rows_returned=2 elapsed=842.667µs elapsed_secs=0.000842667
["c1", "a2"]

和用 inner_join 加 filter 比,EXISTS 不会因为一个作者有两篇高赞文章就把他返回两次,不需要 distinct;结果仍是 Select<E>,后面可以接分页、find_with_related。返回的作者没有固定顺序(["c1", "a2"] 不是 id 序),要排序得自己加。

has_related 第二行的 Condition::all().add(..) 有来历。src/bin/has_any.rs 传一个 Condition::any():

  let names: Vec<String> = author::Entity::find()
    .has_related(
      post::Entity,
      Condition::any()
        .add(post::Column::Likes.gte(45))
        .add(post::Column::Title.eq("d1")),
    )
    // ...
 INFO sqlx::query: summary="SELECT \"author\".\"id\", \"author\".\"name\" FROM …" db.statement="\n\nSELECT \"author\".\"id\", \"author\".\"name\" FROM \"author\" WHERE EXISTS(SELECT 1 FROM \"post\" WHERE (\"post\".\"likes\" >= $1 OR \"post\".\"title\" = $2) AND \"author\".\"id\" = \"post\".\"author_id\")\n" rows_affected=2 rows_returned=2 elapsed=3.666583ms elapsed_secs=0.003666583
["cid", "dee"]

调用方的 OR 组被括起来,再和关联条件 AND。

📝NOTE
冷知识:has_related 曾把关联条件 OR 进了调用方的条件

has_related 在 2.0.0-rc.28 加入,第一版的第二行是 let mut condition = condition.into_condition();,接着直接 condition.add(E::to())。调用方给的是 Condition::any() 时,add 加进的是同一个 OR 组,上面的子查询就成了 likes >= 45 OR title = 'd1' OR author.id = post.author_id,最后一项让每个有文章的作者都满足条件。2.0.0-rc.43 的 release notes(#3126,「Fix has_related condition building with Condition::any()」)这样描述:「When the caller passed a Condition::any() (an OR group), the join condition was folded into that disjunction, so the relation constraint was no longer guaranteed. The caller's condition is now wrapped in Condition::all() first, yielding (caller condition) AND (fk join) regardless of any() / all().」改动只有这一行。

动脑筋答案

with_limit.rs 最后一段的输出:

--- clear_order_by, then order by post.likes desc
DEBUG sea_orm::driver::sqlx_postgres: SELECT "author"."id" AS "A_id", "author"."name" AS "A_name", "post"."id" AS "B_id", "post"."title" AS "B_title", "post"."likes" AS "B_likes", "post"."author_id" AS "B_author_id" FROM "author" LEFT JOIN "post" ON "author"."id" = "post"."author_id" ORDER BY "post"."likes" DESC
 INFO sqlx::query: summary="SELECT \"author\".\"id\" AS \"A_id\", …" db.statement="\n\nSELECT \"author\".\"id\" AS \"A_id\", \"author\".\"name\" AS \"A_name\", \"post\".\"id\" AS \"B_id\", \"post\".\"title\" AS \"B_title\", \"post\".\"likes\" AS \"B_likes\", \"post\".\"author_id\" AS \"B_author_id\" FROM \"author\" LEFT JOIN \"post\" ON \"author\".\"id\" = \"post\".\"author_id\" ORDER BY \"post\".\"likes\" DESC\n" rows_affected=7 rows_returned=7 elapsed=576.875µs elapsed_secs=0.000576875
  bob []
  cid ["c2", "c1"]
  dee ["d1"]
  ann ["a2", "a3", "a1"]

第一个是 bob。他没有文章,LEFT JOIN 给他的那一行 post.likes 是 NULL,Postgres 的 DESC 默认 NULLS FIRST,这一行排在最前面。之后的行序是 c2(50)、d1(40)、a2(30)、a3(20)、a1(10)、c1(5):cid 的两行一头一尾,中间隔着 dee 和 ann。consolidate_query_result_of 按主键值分组,照样把 c2、c1 归到 cid 名下,顺序是行序 ["c2", "c1"];作者的顺序是各自第一次出现的位置:bob(第 1 行)、cid(第 2 行)、dee(第 3 行)、ann(第 4 行)。这个结果同时说明了两件事:合并不需要相邻,ORDER BY 父表主键只是为了顺序。

小结

  • left_join / inner_join / reverse_join 按 Related 选连线,只加 JOIN,结果仍是 E::Model;ON 条件的来历在 关系。
  • find_also_related = left_join + select_also:左表列加 A_ 前缀,右表列生成 B_列名,每行读成 (M, Option<N>),不加 ORDER BY。右边用的默认 from_query_result_optional 是 .ok(),右边的解码错误被静默变成 None;find_both_related 用 INNER JOIN,没有这个问题。
  • find_with_related 多一个 ORDER BY 父表主键,all 之后按父表主键值用 HashMap 合并:不要求行相邻,父记录按第一次出现排序,子记录按行序,没有子记录得到空 Vec。之后加的 order_by 只能当第二排序键。
  • find_with_related(..).limit(n) 限制的是 join 后的行:父记录可能少于 n 个,最后一个的子记录可能不全,而且看不出来。SelectTwoMany 因此只有 all,one 在 0.9.0 删掉,paginate 从没实现;先限制父表、再查子表用 Loader。
  • model.find_related(R) 总是 INNER JOIN 回自己的表、按自己的主键过滤,两个方向都多一次 join。
  • has_related(R, cond) 生成 WHERE EXISTS(SELECT 1 FROM R WHERE (cond) AND 关联条件),不会重复返回父记录;Condition::any() 的括号问题在 2.0.0-rc.43 修好。

💬 关联讨论

发起讨论
加载中...