Rust in Everything
基于 sea-orm 2.0.4

find_with_related(..).limit(n) 拿回的父记录不到 n 条

主线位置:查询主线上「一次查两张表」的分支。find_with_related 发一条 LEFT JOIN,再在内存里按父记录的主键合并;.limit(n) 改的是那条 join 语句的 LIMIT,数的是 join 之后的行,不是父记录。合并的机制见 join 与 find_related,两步加载见 Loader 与 N+1。

flowchart TB
  q["find_with_related.limit 3"] --> sql["LEFT JOIN ... LIMIT 3"]
  sql --> rows["3 行: ann a1 a2 a3"]
  rows --> merge["按 ann.id 合并"]
  merge --> res["1 个作者"]
  q2["父表 limit 3"] --> p["3 个作者"]
  p --> ld["load_many: WHERE author_id IN"]
  ld --> full["3 个作者, 子记录齐全"]

本页实验在 M2 Max、macOS、rustc 1.99.0、Postgres 16.14 上运行,依赖 sea-orm = "=2.0.4",对照组 =1.1.20。表:

CREATE TABLE author (id serial PRIMARY KEY, name text NOT NULL);
CREATE TABLE post (id serial PRIMARY KEY, title text NOT NULL, author_id int NOT NULL REFERENCES author(id));
INSERT INTO author (name) VALUES ('ann'), ('bob'), ('cid'), ('dee');
INSERT INTO post (title, author_id) VALUES ('a1', 1), ('a2', 1), ('a3', 1), ('c1', 3), ('c2', 3), ('d1', 4);

ann 有三篇,bob 没有,cid 两篇,dee 一篇。实体是 dense 写法:author 有 #[sea_orm(has_many)] posts,post 有 belongs_to 的 author。

现象

src/bin/with_limit.rs,想要「前 3 个作者和他们的文章」:

  let rows = author::Entity::find().find_with_related(post::Entity).limit(3).all(&db).await?;
  for (a, ps) in &rows {
    println!("{} {:?}", a.name, titles(ps));
  }
--- 1. find_with_related(post).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\".\"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.529709ms elapsed_secs=0.003529709
ann ["a1", "a2", "a3"]
--- 1b. limit(5)
...
ann ["a1", "a2", "a3"]
bob []
cid ["c1"]
  • limit(3):join 之后的前三行全是 ann 的,只拿回 1 个作者。
  • limit(5):3 个作者,但 cid 只有 c1,c2 在第 6 行被截掉了。返回的 Vec<(Model, Vec<Model>)> 里看不出 cid 的列表不完整。
  • 没有任何报错或警告。offset 同样按行算,翻页时一个作者的文章会被切到两页上。

本站用的 1.1.20(exp-seaorm-pit1-old/src/bin/with_limit_old.rs,展开写法的实体,同一张表):

--- find_with_related(post).limit(5)
ann ["a1", "a2", "a3"]
bob []
cid ["c1"]

出处

find_with_related 返回 SelectTwoMany,它和 Select 一样由 impl_query_trait!(QuerySelect) 实现了 QuerySelect,query() 返回的就是里面那条 join 语句。所以 QuerySelect::limit 能编译,改的是整条语句的 LIMIT。SelectTwoMany::all 先按行读回 (父, Option<子>),再交给 consolidate_query_result 按父主键合并,它拿到的已经是截断后的行。源码在 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 因此没有 paginate 和 one(one 在 0.9.0 被删掉的经过见 join 与 find_related 的冷知识),却留着 limit 和 offset。find_with_linked 返回的也是 SelectTwoMany,同样如此。

在 SeaQL/sea-orm 的 issue 里按 find_with_related limit、find_with_related paginate、SelectTwoMany limit 搜,没有找到报这个现象的。

📝NOTE
没有蠢问题

问: 既然知道是一对多,sea-orm 为什么不把 limit 改写成对父表的子查询?

答: limit 不是 SelectTwoMany 自己的方法,是 QuerySelect 这个通用 trait 给所有语句的,它只往 SelectStatement 里写一个 LIMIT,不知道语句是一条 join,更不知道哪一侧是「父」。要「先限制父记录,再取子记录」,语句的形状就变了(子查询或者两条语句),上面那段注释给出的方案正是两条语句:先对父表分页,再填子记录。2.0 的 Entity Loader 就是这么实现的,见下面的修法。

修法

先对父表 limit,再 load_many。 同一个程序的第二段:

  let authors = author::Entity::find().order_by_asc(author::Column::Id).limit(3).all(&db).await?;
  let posts = authors.load_many(post::Entity, &db).await?;
  for (a, ps) in authors.iter().zip(&posts) {
    println!("{} {:?}", a.name, titles(ps));
  }
--- 2. limit the authors, then load_many(post)
 INFO sqlx::query: summary="SELECT \"author\".\"id\", \"author\".\"name\" FROM …" db.statement="\n\nSELECT \"author\".\"id\", \"author\".\"name\" FROM \"author\" ORDER BY \"author\".\"id\" ASC LIMIT $1\n" rows_affected=3 rows_returned=3 elapsed=483.958µs elapsed_secs=0.000483958
 INFO sqlx::query: summary="SELECT \"post\".\"id\", \"post\".\"title\", \"post\".\"author_id\" …" db.statement="\n\nSELECT \"post\".\"id\", \"post\".\"title\", \"post\".\"author_id\" FROM \"post\" WHERE (\"post\".\"author_id\") IN (($1), ($2), ($3)) ORDER BY \"post\".\"id\" ASC\n" rows_affected=5 rows_returned=5 elapsed=465.708µs elapsed_secs=0.000465708
ann ["a1", "a2", "a3"]
bob []
cid ["c1", "c2"]

两条语句:LIMIT 只作用在 author 上,第二条按这 3 个作者的 id 取全部文章,cid 的 c2 回来了。load_many 返回的 Vec<Vec<post::Model>> 和 authors 一一对应,没有文章的 bob 是空的 Vec。父表的 ORDER BY 要自己写,否则「前 3 个」没有确定的含义(见分页与游标)。1.1.20 的 LoaderTrait::load_many 用法相同,输出也是 cid ["c1", "c2"]。

2.0 的 Entity Loader:load().with(..).paginate(..)。 第三段:

  let page = author::Entity::load().with(post::Entity).order_by_id_asc().paginate(&db, 3).fetch_page(0).await?;
--- 3. load().with(post).paginate(&db, 3).fetch_page(0)
 INFO sqlx::query: summary="SELECT \"author\".\"id\", \"author\".\"name\" FROM …" db.statement="\n\nSELECT \"author\".\"id\", \"author\".\"name\" FROM \"author\" ORDER BY \"author\".\"id\" ASC LIMIT $1 OFFSET $2\n" rows_affected=3 rows_returned=3 elapsed=423.708µs elapsed_secs=0.000423708
 INFO sqlx::query: summary="SELECT \"post\".\"id\", \"post\".\"title\", \"post\".\"author_id\" …" db.statement="\n\nSELECT \"post\".\"id\", \"post\".\"title\", \"post\".\"author_id\" FROM \"post\" WHERE (\"post\".\"author_id\") IN (($1), ($2), ($3)) ORDER BY \"post\".\"id\" ASC\n" rows_affected=5 rows_returned=5 elapsed=222.583µs elapsed_secs=0.000222583
ann ["a1", "a2", "a3"]
bob []
cid ["c1", "c2"]

同样是两条语句,LIMIT / OFFSET 加在父表上,结果放进每个 ModelEx 的 posts 字段。这只有 dense 格式的实体才有;本站的 1.1.20 用第一种。

怎么确认没中招

  • 找 find_with_related( 和 find_with_linked( 后面跟着 .limit( 或 .offset( 的调用。只要关系是一对多,这个组合的结果就可能不完整,应改成上面两种写法之一。
  • 测试数据里至少放一个子记录多于一条的父记录,并让它落在 LIMIT 的边界上;只有每个父记录恰好一个子记录时,这个坑不会出现。
  • 本站:按固定提交 89a61b1 在 crates/ 下没有 find_with_related,唯一的关联查询是 comments 里的 find_also_related(user::Entity)(评论到作者,多对一,一行一对),不受影响。

小结

  • find_with_related 是一条 LEFT JOIN;.limit(n) / .offset(n) 数的是 join 后的行。父记录少于 n 个,最后一个父记录的子记录被静默截断,2.0.4 和 1.1.20 一样。
  • limit 能编译是因为 SelectTwoMany 实现了通用的 QuerySelect;源码注释自己写着正确做法是「perform query in two stages」,所以没有 paginate。
  • 修法:父表上 order_by 加 limit,再 load_many;2.0 的 dense 实体可以用 load().with(..).paginate(..)。两种都是两条语句,子记录齐全。

💬 关联讨论

发起讨论
加载中...