Rust in Everything
基于 sea-orm 2.0.4

分页与游标

主线位置:查询主线的一个分叉。Select<E> 在 .all(&db) 之前先交给 paginate 或 cursor_by:前者每次取页时往语句上加 LIMIT / OFFSET,另外能发一条 COUNT;后者在 all 的时候才加 WHERE 键 > 值、ORDER BY 键 和 LIMIT。加完之后都还是走 query_all → FromQueryResult,和普通查询一样(一次 find 的全过程)。

flowchart TB
  sel["Select E"] -->|paginate| pg[Paginator]
  pg --> fp["fetch_page n"]
  pg --> ni[num_items]
  fp --> lo["LIMIT 和 OFFSET"]
  ni --> cnt["COUNT 包子查询"]
  sel -->|cursor_by| cur[Cursor]
  cur --> ab["after 和 before"]
  cur --> fl["first 和 last"]
  ab --> allq["all 时拼 WHERE"]
  fl --> allq
  allq --> ob["ORDER BY 键列"]

实验 crate 依赖 sea-orm =2.0.4(sqlx-postgres、runtime-tokio-rustls、macros、with-json、postgres-array、serde,加上默认 feature),对照组是 sea-orm 1.1.20(本站用的版本)。平台 M2 Max、macOS、rustc 1.99.0、Postgres 16.14。表 item(id serial, cat text, score int) 有 10 行:

id12345678910
catabacbacbac
score50404030303020201010

SQL 都是 sqlx 的语句日志,即实际发出的文本;LIMIT / OFFSET 是绑定参数(一次 find 的全过程 里验证过)。

Paginator:LIMIT / OFFSET,外加一条 COUNT

PaginatorTrait::paginate(db, page_size) 实现在 Select、SelectTwo(先 into_model())、Selector(直接装)、SelectorRaw(把原始 SQL 包成 Expr::cust)上,结果都是一个 Paginator:

pub struct Paginator<'db, C, S>
where
    C: ConnectionTrait,
    S: SelectorTrait + 'db,
{
    pub(crate) query: SelectStatement,
    pub(crate) page: u64,
    pub(crate) page_size: u64,
    pub(crate) db: &'db C,
    pub(crate) selector: PhantomData<S>,
}

一条语句、一个页码、一个页大小,加上连接的引用。paginate 里有一句 assert!(page_size != 0, "page_size should not be zero"),页大小为 0 是 panic,不是错误。取页是 fetch_page:

    pub async fn fetch_page(&self, page: u64) -> Result<Vec<S::Item>, DbErr> {
        let query = self
            .query
            .clone()
            .limit(self.page_size)
            .offset(self.page_size * page)
            .to_owned();
        let rows = self.db.query_all(&query).await?;
        // ...
    }

每次克隆原语句,加 LIMIT page_size OFFSET page_size * page,页码从 0 开始。总数是 num_items:

    pub async fn num_items(&self) -> Result<u64, DbErr> {
        let query = SelectStatement::new()
            .expr(Expr::cust("COUNT(*) AS num_items"))
            .from_subquery(
                self.query
                    .clone()
                    .reset_limit()
                    .reset_offset()
                    .clear_order_by()
                    .to_owned(),
                "sub_query",
            )
            .to_owned();
        let result = match self.db.query_one(&query).await? {
            Some(res) => res,
            None => return Ok(0),
        };
        Ok(result.try_get::<i64>("", "num_items")? as u64)
    }

原语句去掉 LIMIT、OFFSET、ORDER BY,整个包成子查询再 COUNT(*)。num_pages 是 num_items 之后按 compute_pages_number 向上取整。src/bin/paginate.rs:

  let p = item::Entity::find().order_by_asc(item::Column::Id).paginate(&db, 3);
  println!("num_items = {}", p.num_items().await?);
  println!("num_pages = {}", p.num_pages().await?);
  println!("page 0 = {:?}", ids(&p.fetch_page(0).await?));
  println!("page 3 = {:?}", ids(&p.fetch_page(3).await?));
  println!("page 4 = {:?}", ids(&p.fetch_page(4).await?));
--- 1. paginate(3): num_items / num_pages / fetch_page
 INFO sqlx::query: summary="SELECT COUNT(*) AS num_items …" db.statement="\n\nSELECT COUNT(*) AS num_items FROM (SELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" FROM \"item\") AS \"sub_query\"\n" rows_affected=1 rows_returned=1 elapsed=2.217917ms elapsed_secs=0.002217917
num_items = 10
 INFO sqlx::query: summary="SELECT COUNT(*) AS num_items …" db.statement="\n\nSELECT COUNT(*) AS num_items FROM (SELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" FROM \"item\") AS \"sub_query\"\n" rows_affected=1 rows_returned=1 elapsed=268.541µs elapsed_secs=0.000268541
num_pages = 4
 INFO sqlx::query: summary="SELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" …" db.statement="\n\nSELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" FROM \"item\" ORDER BY \"item\".\"id\" ASC LIMIT $1 OFFSET $2\n" rows_affected=3 rows_returned=3 elapsed=2.098583ms elapsed_secs=0.002098583
page 0 = [1, 2, 3]
 INFO sqlx::query: summary="SELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" …" db.statement="\n\nSELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" FROM \"item\" ORDER BY \"item\".\"id\" ASC LIMIT $1 OFFSET $2\n" rows_affected=1 rows_returned=1 elapsed=325.416µs elapsed_secs=0.000325416
page 3 = [10]
 INFO sqlx::query: summary="SELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" …" db.statement="\n\nSELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" FROM \"item\" ORDER BY \"item\".\"id\" ASC LIMIT $1 OFFSET $2\n" rows_affected=0 rows_returned=0 elapsed=323.541µs elapsed_secs=0.000323541
page 4 = []
  • num_items 和 num_pages 各发了一条 COUNT,结果不缓存。两个都要时用 num_items_and_pages,只数一次。
  • 子查询里保留了全部列:COUNT(*) 用不着它们,但 sea-orm 不改写原语句的 SELECT 列表,交给 Postgres 的优化器。
  • 超出范围的页(page 4)是空 Vec,不是错误。fetch_and_next / into_stream 就靠「拿到空页」判断结束。
  • 每次 fetch_page 都是独立的一条语句,各自借一次连接,两页之间没有快照,这是后面「数据变化」一节的根源。

Paginator 不检查有没有 ORDER BY,结构体文档只写了一句「Add an ORDER BY to your query — without one, page contents are not deterministic across calls.」。没有 ORDER BY 时 Postgres 不保证两次扫描的顺序相同,OFFSET 3 跳过的三行和上一页给你的三行不一定是同一批。Loader 与 N+1 里 Entity Loader 的 paginate 就是没有 ORDER BY 的例子。

group_by 与 distinct

因为整条原语句原样进了子查询,GROUP BY、DISTINCT 的语义都保留,数出来的是「组数」「去重后的行数」,正好是分页要的总数:

--- 2. group_by
 INFO sqlx::query: summary="SELECT COUNT(*) AS num_items …" db.statement="\n\nSELECT COUNT(*) AS num_items FROM (SELECT \"item\".\"cat\", COUNT(\"item\".\"id\") AS \"n\" FROM \"item\" GROUP BY \"item\".\"cat\") AS \"sub_query\"\n" rows_affected=1 rows_returned=1 elapsed=1.612167ms elapsed_secs=0.001612167
num_items = 3
 INFO sqlx::query: summary="SELECT \"item\".\"cat\", COUNT(\"item\".\"id\") AS …" db.statement="\n\nSELECT \"item\".\"cat\", COUNT(\"item\".\"id\") AS \"n\" FROM \"item\" GROUP BY \"item\".\"cat\" ORDER BY \"item\".\"cat\" ASC LIMIT $1 OFFSET $2\n" rows_affected=2 rows_returned=2 elapsed=837.541µs elapsed_secs=0.000837541
page 0 = [("a", 4), ("b", 3)]
--- 3. distinct
 INFO sqlx::query: summary="SELECT COUNT(*) AS num_items …" db.statement="\n\nSELECT COUNT(*) AS num_items FROM (SELECT DISTINCT \"item\".\"score\" FROM \"item\") AS \"sub_query\"\n" rows_affected=1 rows_returned=1 elapsed=869.708µs elapsed_secs=0.000869708
num_items = 5

对应的代码(select_only().column(Cat).column_as(Id.count(), "n").group_by(Cat).into_tuple::<(String, i64)>().paginate(&db, 2) 和 select_only().column(Score).distinct().into_tuple::<i32>())都是先 into_tuple 再 paginate,Selector 也实现了 PaginatorTrait。如果 num_items 是在原语句上把列换成 COUNT(*),GROUP BY 的结果就会变成「每组一个计数」,所以包一层子查询是唯一不用理解原语句就能数对的写法,代价是 Postgres 要把子查询先算出来。

count 与 num_items

PaginatorTrait 还有一个 count,也是 SELECT COUNT(*) FROM (..),但子查询只去掉 ORDER BY:

        let paginator = self.paginate(db, 1);
        let query = SelectStatement::new()
            .expr(Expr::cust("COUNT(*) AS num_items"))
            .from_subquery(
                paginator.query.clone().clear_order_by().to_owned(),
                "sub_query",
            )
            .to_owned();

同一个带 limit(2) 的查询,2.0.4 和 1.1.20 各数一次(src/bin/paginate.rs 的第 4 段,exp-seaorm-select2-old/src/bin/count_old.rs):

  let q = item::Entity::find().order_by_asc(item::Column::Id).limit(2);
  println!("count     = {}", q.clone().count(&db).await?);
  println!("num_items = {}", q.paginate(&db, 2).num_items().await?);
--- 4. limit(2) then count vs paginate.num_items
 INFO sqlx::query: summary="SELECT COUNT(*) AS num_items …" db.statement="\n\nSELECT COUNT(*) AS num_items FROM (SELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" FROM \"item\" LIMIT $1) AS \"sub_query\"\n" rows_affected=1 rows_returned=1 elapsed=644.208µs elapsed_secs=0.000644208
count     = 2
 INFO sqlx::query: summary="SELECT COUNT(*) AS num_items …" db.statement="\n\nSELECT COUNT(*) AS num_items FROM (SELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" FROM \"item\") AS \"sub_query\"\n" rows_affected=1 rows_returned=1 elapsed=246.292µs elapsed_secs=0.000246292
num_items = 10
sea-orm 1.1.20: count     = 10
sea-orm 1.1.20: num_items = 10

2.0.4 的 count 尊重 LIMIT,num_items 不尊重;1.1.20 两个都是 10。num_items 回答的是「不分页一共有多少」,所以必须扔掉 LIMIT / OFFSET;count 回答的是「这条查询会返回多少行」。

📝NOTE
冷知识:count() 在 2.0.0 才开始尊重 limit

1.1.20 的 count 只有一行 self.paginate(db, 1).num_items().await,借用了 num_items 的 reset_limit() / reset_offset(),于是 .limit(2).count() 得到全表行数。#3097(「Move count() to CountTrait」)把它列为破坏性改动:「Move the count method into CountTrait to prevent accidental resets of limit and offset」。合并时没有真的新建 CountTrait,提交说明写的是「Instead of a dedicated CountTrait, keep count() as a default method on PaginatorTrait. It wraps the query as-is in a SELECT COUNT(*) FROM (..) subquery, so it honors any limit/offset set on the query, unlike num_items which resets them.」。首次发布于 2.0.0(不在 2.0.0-rc.43 里)。本站在 1.1.20 上,升级后凡是对带 limit 的查询调 count 的地方,结果会变。

Cursor:按键值翻页

OFFSET n 要让数据库先数出并扔掉 n 行,页越靠后越慢,而且「第 n 行」随数据变化而移动。游标分页(keyset pagination)换一种说法:「给我键比上一页最后一个值大的前 k 行」。Select::cursor_by(cols)(cursor_by)返回 Cursor:

pub struct Cursor<S>
where
    S: SelectorTrait,
{
    query: SelectStatement,
    table: DynIden,
    order_columns: Identity,
    secondary_order_by: Vec<(DynIden, Identity)>,
    first: Option<u64>,
    last: Option<u64>,
    before: Option<ValueTuple>,
    after: Option<ValueTuple>,
    sort_asc: bool,
    is_result_reversed: bool,
    phantom: PhantomData<S>,
}

after / before / first / last / asc / desc 只往字段里记值,语句在 all 里才改:

    pub async fn all<C>(&mut self, db: &C) -> Result<Vec<S::Item>, DbErr>
    where
        C: ConnectionTrait,
    {
        self.apply_limit();
        self.apply_order_by();
        self.apply_filters()?;

        let rows = db.query_all(&self.query).await?;
        // ...
        if self.is_result_reversed {
            buffer.reverse()
        }
        Ok(buffer)
    }

apply_order_by 先 clear_order_by(),再按游标列排序:你在 Select 上写的 order_by 会被丢掉,排序只能由游标列决定。方向由 sort_asc 和「是不是 last」共同决定:last(n) 把方向反过来取 n 行,再在内存里 reverse(),让结果仍按原方向排列。apply_filters 里比较符也跟着方向走:

            let condition = self.apply_filter(values, |c, v| {
                let exp = Expr::col((self.table.clone(), c.clone()));
                if self.sort_asc { exp.gt(v) } else { exp.lt(v) }
            });
            self.query.cond_where(condition);

after 的意思是「排序方向上在它后面」:升序是 >,降序是 <;before 反过来。src/bin/cursor.rs 的前两段:

  let mut c = item::Entity::find().cursor_by(item::Column::Id);
  println!("{:?}", ids(&c.desc().after(8).first(3).all(&db).await?));

  let mut c = item::Entity::find().cursor_by(item::Column::Id);
  println!("{:?}", ids(&c.after(2).before(7).last(2).all(&db).await?));
--- 1. cursor_by(id).desc().after(8).first(3)
 INFO sqlx::query: summary="SELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" …" db.statement="\n\nSELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" FROM \"item\" WHERE \"item\".\"id\" < $1 ORDER BY \"item\".\"id\" DESC LIMIT $2\n" rows_affected=3 rows_returned=3 elapsed=3.39ms elapsed_secs=0.00339
[7, 6, 5]
--- 2. cursor_by(id).after(2).before(7).last(2)
 INFO sqlx::query: summary="SELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" …" db.statement="\n\nSELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" FROM \"item\" WHERE \"item\".\"id\" > $1 AND \"item\".\"id\" < $2 ORDER BY \"item\".\"id\" DESC LIMIT $3\n" rows_affected=2 rows_returned=2 elapsed=1.145084ms elapsed_secs=0.001145084
[5, 6]

第二段是升序游标(默认 asc)取「2 和 7 之间的最后两行」:SQL 里是 DESC LIMIT 2 拿到 6、5,返回前倒成 [5, 6]。

Cursor 没有总数,也没有页码,只能「下一页」「上一页」。需要「共 N 页、跳到第 k 页」的界面用 Paginator;无限滚动、导出、按时间线翻用 Cursor。

📝NOTE
冷知识:降序游标是 0.12.11 才加的,查询也是从那时起推迟到 all 里拼

#2037(「Add cursor pagination desc order」)之前,游标只有升序。那时的 before / after 在被调用的当下就 self.query.cond_where(..),比较符写死成 lt / gt。加了 desc() 之后,比较符取决于方向,而方向可能在 after 之后才设,于是 PR 说明写着「Moved query builder logic inside all because it depends on sort order asc/desc」:after / before 改成只存 ValueTuple,apply_filters 在 all 里按 sort_asc 选 gt 还是 lt,就是上面那段代码。首次发布于 0.12.11,CHANGELOG 记为「Added desc to Cursor paginator」。

复合键

单列游标要求这一列唯一。按一个会重复的列翻页,页边界落在一组相同值的中间时,同组剩下的行会被下一页的 < 跳过。

❗IMPORTANT
动脑筋:按 score 翻页

score 有重复(40 有 id 2、3 两行)。每页 2 行、降序,第二页用第一页最后一行的 score 做 after:

  let mut c = item::Entity::find().cursor_by(item::Column::Score);
  let v = c.desc().first(2).all(&db).await?;
  let last = v.last().map(|m| m.score).unwrap_or_default();
  let mut c = item::Entity::find().cursor_by(item::Column::Score);
  let v = c.desc().after(last).first(2).all(&db).await?;
score 为 40 的两行,有几行出现在这两页里?平台:M2 Max、macOS、rustc 1.99.0、Postgres 16.14。答案在页末。

办法是在游标里加一个唯一的列做决胜,最常见的是主键:cursor_by((Score, Id))。apply_filter 把两列的「字典序大于」展开成 OR:

            (Identity::Binary(c1, c2), ValueTuple::Two(v1, v2)) => Condition::any()
                .add(
                    Condition::all()
                        .add(Expr::col((self.table.clone(), c1.clone())).eq(v1.clone()))
                        .add(f(c2, v2)),
                )
                .add(f(c1, v1)),

即 (c1 = v1 AND c2 > v2) OR c1 > v1。三列再多一层,Identity::Many 用一个 fold 生成 n 个 AND 组。实测(另外加了一个普通过滤条件,看它们怎么拼):

  let mut c = item::Entity::find()
    .filter(item::Column::Cat.ne("z"))
    .cursor_by((item::Column::Score, item::Column::Id));
  let v = c.desc().after((30, 5)).first(3).all(&db).await?;
--- 4. cursor_by((score, id)) desc after((30, 5)) first(3), with a filter
 INFO sqlx::query: summary="SELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" …" db.statement="\n\nSELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" FROM \"item\" WHERE \"item\".\"cat\" <> $1 AND ((\"item\".\"score\" = $2 AND \"item\".\"id\" < $3) OR \"item\".\"score\" < $4) ORDER BY \"item\".\"score\" DESC, \"item\".\"id\" DESC LIMIT $5\n" rows_affected=3 rows_returned=3 elapsed=840.958µs elapsed_secs=0.000840958
[(4, 30), (8, 20), (7, 20)]
  • 原来的 WHERE 和游标条件用 AND 连起来,游标条件整体加了括号。
  • 两列同一个方向:ORDER BY score DESC, id DESC。Cursor 没有「第一列降序、第二列升序」的写法。
  • 值 30 绑了两次($2、$4),每个比较都是独立的参数。
  • 写成 OR 链,没有用 Postgres 支持的行比较 (score, id) < ($1, $2)。两种写法结果相同,能不能用上 (score, id) 的复合索引要看 EXPLAIN。

值的个数和列数不一致时,all 在拼语句前返回 DbErr::KeyArityMismatch:

--- 6. arity mismatch
Err(KeyArityMismatch { expected: 2, received: 1 })

一个 Cursor 只用一次

all 收的是 &mut self,三个 apply_* 直接改 self.query。同一个 Cursor 调两次 all,第一次加的条件还在:

  let mut c = item::Entity::find().cursor_by(item::Column::Id);
  println!("{:?}", ids(&c.after(2).first(2).all(&db).await?));
  println!("{:?}", ids(&c.after(4).all(&db).await?));
--- 5. reuse one cursor: after(2).first(2) then after(4)
 INFO sqlx::query: summary="SELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" …" db.statement="\n\nSELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" FROM \"item\" WHERE \"item\".\"id\" > $1 ORDER BY \"item\".\"id\" ASC LIMIT $2\n" rows_affected=2 rows_returned=2 elapsed=504.417µs elapsed_secs=0.000504417
[3, 4]
 INFO sqlx::query: summary="SELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" …" db.statement="\n\nSELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" FROM \"item\" WHERE \"item\".\"id\" > $1 AND \"item\".\"id\" > $2 ORDER BY \"item\".\"id\" ASC LIMIT $3\n" rows_affected=2 rows_returned=2 elapsed=512.792µs elapsed_secs=0.000512792
[5, 6]

第二次是 id > 2 AND id > 4。向前翻时多出来的条件恰好被新的条件包含,结果碰巧正确。往回翻就不行了(src/bin/cursor_back.rs,先 after(4).first(2) 拿到 5、6,再在同一个游标上 before(5).last(2) 想拿 3、4):

 INFO sqlx::query: summary="SELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" …" db.statement="\n\nSELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" FROM \"item\" WHERE \"item\".\"id\" > $1 ORDER BY \"item\".\"id\" ASC LIMIT $2\n" rows_affected=2 rows_returned=2 elapsed=4.910542ms elapsed_secs=0.004910542
[5, 6]
 INFO sqlx::query: summary="SELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" …" db.statement="\n\nSELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" FROM \"item\" WHERE \"item\".\"id\" > $1 AND \"item\".\"id\" > $2 AND \"item\".\"id\" < $3 ORDER BY \"item\".\"id\" DESC LIMIT $4\n" rows_affected=0 rows_returned=0 elapsed=1.259666ms elapsed_secs=0.001259666
[]

两处在累积:语句里留着第一次的 id > 4,字段 after 也还是 Some(4),第二次 apply_filters 又加了一遍,再加上新的 id < 5,结果是空集。ORDER BY 和 LIMIT 每次会重新设(clear_order_by / limit 覆盖),只有 WHERE 会累积。

想先 clone 一份模板也不行。Cursor 写了 #[derive(Clone)],但派生要求 S: Clone,而 SelectModel<M> 没有实现 Clone(src/bin/cursor_clone.rs):

error[E0599]: the method `clone` exists for struct `sea_orm::Cursor<SelectModel<exp_seaorm_select2::ent::item::Model>>`, but its trait bounds were not satisfied
   --> src/bin/cursor_clone.rs:9:17
    |
  9 |   let page0 = c.clone().all(&db).await?;
    |                 ^^^^^ method cannot be called due to unsatisfied trait bounds
    |
   ::: ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/sea-orm-2.0.4/src/executor/cursor.rs:27:1
    |
 27 | pub struct Cursor<S>
    | -------------------- doesn't satisfy `_: Clone`
    |
   ::: ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/sea-orm-2.0.4/src/executor/select.rs:100:1
    |
100 | pub struct SelectModel<M>
    | ------------------------- doesn't satisfy `_: Clone`
    |
    = note: the following trait bounds were not satisfied:
            `SelectModel<exp_seaorm_select2::ent::item::Model>: Clone`
            which is required by `sea_orm::Cursor<SelectModel<exp_seaorm_select2::ent::item::Model>>: Clone`

所以每一页都从 Select 重新 cursor_by,把上一页的最后一个键传进来(下一节的 cursor_page 就是这样写的)。这也符合游标分页的实际用法:每一页通常是一个新的 HTTP 请求,状态只有客户端带回来的那个键。

数据在翻页之间变化

一个「最新在前、每页 3 条」的列表。用户看完第一页,有人插入了一行;稍后又删掉了两行。两种分页各取一次第二页(src/bin/drift.rs):

async fn cursor_page(db: &DatabaseConnection, after: Option<i32>) -> Result<Vec<item::Model>, DbErr> {
  let mut c = item::Entity::find().cursor_by(item::Column::Id);
  c.desc().first(3);
  if let Some(id) = after {
    c.after(id);
  }
  c.all(db).await
}
  // ...
  let p = item::Entity::find().order_by_desc(item::Column::Id).paginate(&db, 3);
  println!("offset page 0          = {:?}", ids(&p.fetch_page(0).await?));
  db.execute_unprepared("INSERT INTO item (cat, score) VALUES ('d', 99)").await?;
  println!("offset page 1 (insert) = {:?}", ids(&p.fetch_page(1).await?));
  db.execute_unprepared("DELETE FROM item WHERE id IN (11, 10)").await?;
  println!("offset page 1 (delete) = {:?}", ids(&p.fetch_page(1).await?));
  // 重建表,用 cursor_page 重复同样的步骤,第二页传 after(第一页最后的 id)
offset page 0          = [10, 9, 8]
offset page 1 (insert) = [8, 7, 6]
offset page 1 (delete) = [6, 5, 4]
cursor page 0          = [10, 9, 8]
cursor page 1 (insert) = [7, 6, 5]
cursor page 1 (delete) = [7, 6, 5]
变化OFFSET 3 的第二页id < 8 的第二页
前面插入了 id 11[8, 7, 6]:8 重复[7, 6, 5]
之后删掉了 11、10[6, 5, 4]:7 被跳过[7, 6, 5]

OFFSET 按位置数:前面多一行,所有行往后挪一位,上一页的最后一行被挤进下一页;前面少一行,下一页的第一行被挤到上一页去,而上一页已经看过了,这一行就谁也看不到。游标按值比较,id < 8 不受前面发生什么影响,插入的 11 和删掉的 10 都在已经看过的那一侧。

游标也不是快照:在 id < 8 那一侧插入或删除的行照样会出现或消失,只是不会因为别处的变化而重复或漏掉。要两页之间完全一致,只能在一个可重复读的事务里翻完(见事务),这对面向用户的分页通常不现实。

动脑筋答案

src/bin/cursor.rs 的第 3 段:

--- 3. cursor_by(score) desc first(2): ties on score
 INFO sqlx::query: summary="SELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" …" db.statement="\n\nSELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" FROM \"item\" ORDER BY \"item\".\"score\" DESC LIMIT $1\n" rows_affected=2 rows_returned=2 elapsed=853.917µs elapsed_secs=0.000853917
page 0 = [(1, 50), (2, 40)]
 INFO sqlx::query: summary="SELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" …" db.statement="\n\nSELECT \"item\".\"id\", \"item\".\"cat\", \"item\".\"score\" FROM \"item\" WHERE \"item\".\"score\" < $1 ORDER BY \"item\".\"score\" DESC LIMIT $2\n" rows_affected=2 rows_returned=2 elapsed=688.5µs elapsed_secs=0.0006885
page 1 = [(5, 30), (4, 30)]

1 行。第一页拿到了 50 和一个 40(这次是 id 2),第二页的条件是 score < 40,另一个 40(id 3)既不在第一页、也不满足第二页的条件,从此看不到。哪一个 40 被拿走、哪一个丢掉也不确定:ORDER BY score DESC 不规定同分行的先后,同一页里的 (5, 30), (4, 30) 也不是按 id 排的。这就是要用 cursor_by((Score, Id)) 的原因:主键让排序成为全序,(score = 40 AND id < 2) OR score < 40 会把 id 3 带进下一页。

小结

  • Paginator::fetch_page(n) 是原语句加 LIMIT page_size OFFSET page_size * n,页码从 0 开始,越界是空页;num_items 把去掉 LIMIT / OFFSET / ORDER BY 的原语句包成子查询再 COUNT(*),GROUP BY、DISTINCT 因此都数得对;num_pages 每次再数一遍。
  • Paginator 不要求 ORDER BY,没有它页内容不确定;Entity Loader 的 paginate 默认就没有。
  • 2.0 的 count 保留 LIMIT / OFFSET,1.1.20 的 count 等于 num_items(#3097,2.0.0)。
  • Cursor 在 all 时才拼 WHERE、ORDER BY、LIMIT;after 是排序方向上的「之后」,last(n) 反向取再倒回来;原有的 order_by 被清掉。
  • 复合键展开成 (c1 = v1 AND c2 > v2) OR c1 > v1,所有列同一方向;单列游标遇到重复值会漏行,用主键做决胜列。
  • Cursor::all 会把条件累积在自己身上,Cursor<SelectModel<M>> 又不能 clone,每页重新 cursor_by。
  • 翻页之间有插入时 OFFSET 会重复上一页的行,有删除时会漏行;按键值比较的游标两种情况都不受影响,但也不是快照。

💬 关联讨论

发起讨论
加载中...