Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 12 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,28 +237,31 @@ into a `Page` — the rows, and the cursor to the next page if that extra row
came back:

```rust
// The request's token; the empty one -- the first page -- is the empty cursor.
let cursor = Cursor::parse(&page_token)?;
let pager = Pager::new(Cursor::new(order_by.clone()), 50);

let mut query = QueryComposer::<Postgres>::new(LIST_USERS);
query
.bind_value(tenant_id)
.bind_value(pager.limit()) // 51: one past the page
.push_order_by(order_by);
if let Some(cursor) = cursor {
query.with_cursor(cursor); // where the previous page left off
}
.push_order_by(order_by)
.with_cursor(cursor); // where the previous page left off, if anywhere

let rows = query.build()?.fetch_all(&pool).await?;
let page = pager.next_page(rows)?; // Page { rows: at most 50, cursor }

// The cursor's values come off the page's last row, so no one has to know
// each key's Rust type. `None` on the last page.
let next_page_token = page.cursor.map(|cursor| cursor.encode());

// ... and on the next request:
let cursor = Some(Cursor::parse(&page_token)?);
// each key's Rust type. On the last page it is the empty cursor, which
// encodes as the empty token.
let next_page_token = page.cursor.encode();
```

The empty token and the empty cursor are each other's image, as AIP-158 reads
the empty `page_token`: the first page on the way in, the last on the way out.
`with_cursor` takes the empty cursor as no cursor at all, so the first page
needs no branch.

A page that exactly fills the size is the last one: the extra row is what says
there is another. `Cursor::after_row` builds the same cursor by hand, from
whichever row a caller picks.
Expand Down
73 changes: 64 additions & 9 deletions crates/sqlx-query/src/clauses/pager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,14 @@ struct CursorKey {

/// A keyset pagination cursor. See the module docs for what's built and
/// what's deliberately not here yet.
#[derive(Debug, Clone, Serialize, Deserialize)]
///
/// The default is the empty cursor: no keys and no position, the start of a
/// traversal. It is what an empty `page_token` parses to and what
/// [`encode`](Self::encode)s back to one — AIP-158's empty token, the first
/// page on the way in and the last on the way out — and
/// [`QueryComposer::with_cursor`](crate::QueryComposer::with_cursor) takes it
/// as no cursor at all.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Cursor {
keys: Vec<CursorKey>,
}
Expand Down Expand Up @@ -113,11 +120,21 @@ impl Cursor {
Cursor { keys }
}

/// Whether this is the empty cursor: no keys, so no position — the start
/// of a traversal, or, on a [`Page`], the end of one.
#[must_use]
pub fn is_empty(&self) -> bool {
self.keys.is_empty()
}

/// Decodes a `page_token` string produced by [`encode`](Self::encode)
/// back into a `Cursor` carrying both its `order_by` and its boundary
/// values — no separate [`new`](Self::new)/[`after`](Self::after) call
/// needed, unlike building one from scratch.
///
/// An empty or all-whitespace token is the [empty](Self::is_empty)
/// cursor, the first page, as AIP-158 reads it.
///
/// # Errors
///
/// [`CursorError::TokenInvalidBase64`] or
Expand All @@ -127,6 +144,10 @@ impl Cursor {
pub fn parse(token: &str) -> Result<Self, CursorError> {
use base64::Engine as _;

if token.trim().is_empty() {
return Ok(Cursor::default());
}

let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(token)
.map_err(|_| CursorError::TokenInvalidBase64)?;
Expand All @@ -145,6 +166,9 @@ impl Cursor {
/// back to the client as-is. See the module docs for the wire format
/// and what the checksum does and doesn't guard against.
///
/// The [empty](Self::is_empty) cursor encodes as the empty string, the
/// token that says there is no next page.
///
/// # Panics
///
/// If any key's value is unset — unreachable through this type's public
Expand All @@ -153,6 +177,10 @@ impl Cursor {
pub fn encode(&self) -> String {
use base64::Engine as _;

if self.is_empty() {
return String::new();
}

let token = CursorToken {
checksum: checksum_of(self),
cursor: self.clone(),
Expand Down Expand Up @@ -297,6 +325,10 @@ impl Pager {
/// sorted by. A position `cursor` already carries is replaced on each
/// page, so the cursor a request came in with works too.
///
/// The ordering is what a cursor positions by, so it cannot be empty
/// for a traversal to go past its first page: over an
/// [empty](Cursor::is_empty) cursor every page is the last.
///
/// A `size` of 0 is taken as 1: a page with no rows could never say
/// where the next one starts.
#[must_use]
Expand All @@ -321,7 +353,7 @@ impl Pager {
/// Keeps the page's `size` rows. If the extra row came back there is a
/// next page, and [`Page::cursor`] is positioned after the last row kept
/// (see [`Cursor::after_row`]); otherwise this is the last page and it is
/// `None`.
/// the [empty](Cursor::is_empty) cursor.
///
/// # Errors
///
Expand All @@ -344,15 +376,18 @@ impl Pager {
for<'r> crate::value::UuidRepr: Decode<'r, R::Database> + Type<R::Database>,
{
if rows.len() <= self.size {
return Ok(Page { rows, cursor: None });
return Ok(Page {
rows,
cursor: Cursor::default(),
});
}

// At least one row is left: `size` is never 0, and there were more.
rows.truncate(self.size);
let cursor = rows
.last()
.map(|last| self.cursor.clone().after_row(last))
.transpose()?;
let cursor = match rows.last() {
Some(last) => self.cursor.clone().after_row(last)?,
None => Cursor::default(),
};

Ok(Page { rows, cursor })
}
Expand All @@ -365,8 +400,9 @@ pub struct Page<R> {
/// The page's rows, at most the pager's size.
pub rows: Vec<R>,
/// The cursor to the next page — [`encode`](Cursor::encode) it as the
/// `next_page_token` — or `None` on the last page.
pub cursor: Option<Cursor>,
/// `next_page_token` — or the [empty](Cursor::is_empty) cursor on the
/// last page, which encodes as the empty token.
pub cursor: Cursor,
}

/// `key`'s 1-based position in `keys` — its placeholder number, since
Expand Down Expand Up @@ -533,6 +569,25 @@ mod tests {
));
}

/// The empty token and the empty cursor are each other's image: the
/// first page on the way in, the last on the way out.
#[test]
fn the_empty_token_is_the_empty_cursor() {
for token in ["", " "] {
assert!(Cursor::parse(token).unwrap().is_empty(), "{token:?}");
}
assert!(Cursor::default().is_empty());
assert_eq!(Cursor::default().encode(), "");
assert!(Cursor::default().to_where_clause().is_empty());
assert!(Cursor::default().to_order_by_clause().keys().is_empty());

let positioned = Cursor::new(OrderByClause::parse("rank desc").unwrap())
.after(vec![Value::Int(42)])
.unwrap();
assert!(!positioned.is_empty());
assert!(!positioned.encode().is_empty());
}

#[test]
fn a_pager_fetches_one_row_past_the_page() {
let cursor = Cursor::new(OrderByClause::default().asc("id"));
Expand Down
6 changes: 5 additions & 1 deletion crates/sqlx-query/src/composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,8 +174,12 @@ impl<DB: QueryDialect> QueryComposer<DB> {
/// Only one cursor makes sense per query, so unlike `push_where`/
/// `push_order_by` this doesn't accumulate — a second call replaces the
/// first.
///
/// The [empty](Cursor::is_empty) cursor — the first page's — is no
/// cursor: nothing is AND-ed in, and there is no ordering to check an
/// explicit one against.
pub fn with_cursor(&mut self, cursor: Cursor) -> &mut Self {
self.cursor = Some(cursor);
self.cursor = (!cursor.is_empty()).then_some(cursor);
self
}

Expand Down
39 changes: 39 additions & 0 deletions crates/sqlx-query/tests/composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,45 @@ fn cursor_and_filter_are_combined_with_and() {
);
}

/// The empty cursor -- the first page's, what an empty token parses to -- is
/// no cursor: nothing is AND-ed in, and an explicit `order_by` has no
/// cursor ordering to disagree with, so the first page composes exactly as
/// it would with no cursor set.
#[test]
fn an_empty_cursor_is_no_cursor() {
let sql = "SELECT * FROM t WHERE /* query.where AND */ TRUE ORDER BY /* query.order_by , */ id";

let mut first = QueryComposer::<sqlx::Postgres>::new(sql);
first
.push_order_by(OrderByClause::parse("rank desc").unwrap())
.with_cursor(Cursor::parse("").unwrap());
let mut untouched = QueryComposer::<sqlx::Postgres>::new(sql);
untouched.push_order_by(OrderByClause::parse("rank desc").unwrap());

let first = first.compose().expect("no ordering to mismatch");
assert_eq!(first.sql(), untouched.compose().unwrap().sql());
assert!(values(&first).is_empty());
}

/// An empty cursor after a real one clears it, as a second `with_cursor`
/// replaces the first.
#[test]
fn an_empty_cursor_replaces_a_real_one() {
let sql = "SELECT * FROM t WHERE /* query.where AND */ TRUE";
let mut query = QueryComposer::<sqlx::Postgres>::new(sql);
query
.with_cursor(rank_cursor())
.with_cursor(Cursor::default());

assert_eq!(
query.compose().unwrap().sql(),
QueryComposer::<sqlx::Postgres>::new(sql)
.compose()
.unwrap()
.sql()
);
}

/// An empty filter -- what a blank one parses to -- is no filter: the slot
/// drops exactly as if nothing had been pushed.
#[test]
Expand Down
17 changes: 8 additions & 9 deletions crates/sqlx-query/tests/execute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -335,22 +335,21 @@ async fn a_cursor_page_stays_inside_the_base_condition() {

/// Page through acme's orders `size` at a time by rank, carrying each
/// page's cursor to the next as a token -- the way it travels between
/// requests -- and return the ids of every page.
/// requests, from the empty token to the empty token -- and return the ids
/// of every page.
async fn pages(pool: &SqlitePool, size: usize) -> Vec<Vec<i64>> {
let order_by = OrderByClause::default().asc("rank").asc("id");
let pager = Pager::new(Cursor::new(order_by.clone()), size);
let (mut seen, mut token) = (Vec::new(), None::<String>);
let (mut seen, mut token) = (Vec::new(), String::new());

// Bounded, so a cursor that never runs out fails instead of hanging.
for _ in 0..10 {
let mut query = QueryComposer::<sqlx::Sqlite>::new(LIST_ORDERS);
query
.bind_value("acme")
.bind_value(pager.limit())
.push_order_by(order_by.clone());
if let Some(token) = &token {
query.with_cursor(Cursor::parse(token).expect("the token parses"));
}
.push_order_by(order_by.clone())
.with_cursor(Cursor::parse(&token).expect("the token parses"));

let rows = query
.build()
Expand All @@ -361,9 +360,9 @@ async fn pages(pool: &SqlitePool, size: usize) -> Vec<Vec<i64>> {
let page = pager.next_page(rows).expect("the row carries every key");

seen.push(page.rows.iter().map(|row| row.get("id")).collect());
match page.cursor {
Some(cursor) => token = Some(cursor.encode()),
None => return seen,
token = page.cursor.encode();
if token.is_empty() {
return seen;
}
}
panic!("still paging after ten pages: {seen:?}");
Expand Down
14 changes: 6 additions & 8 deletions crates/sqlx-query/tests/execute_postgres.rs
Original file line number Diff line number Diff line change
Expand Up @@ -347,16 +347,14 @@ async fn a_pager_pages_through_every_row_once() {
LIMIT $1";
let order_by = OrderByClause::default().asc("rank").asc("id");
let pager = Pager::new(Cursor::new(order_by.clone()), 2);
let (mut seen, mut cursor) = (Vec::<Vec<i64>>::new(), None);
let (mut seen, mut cursor) = (Vec::<Vec<i64>>::new(), Cursor::default());

for _ in 0..10 {
let mut query = QueryComposer::<sqlx::Postgres>::new(sql);
query
.bind_value(pager.limit())
.push_order_by(order_by.clone());
if let Some(cursor) = cursor.take() {
query.with_cursor(cursor);
}
.push_order_by(order_by.clone())
.with_cursor(std::mem::take(&mut cursor));

let rows = query
.build()
Expand All @@ -367,10 +365,10 @@ async fn a_pager_pages_through_every_row_once() {
let page = pager.next_page(rows).expect("the row carries every key");

seen.push(page.rows.iter().map(|row| row.get("id")).collect());
match page.cursor {
Some(next) => cursor = Some(next),
None => break,
if page.cursor.is_empty() {
break;
}
cursor = page.cursor;
}

assert_eq!(seen, [vec![2, 3], vec![4, 1], vec![5]]);
Expand Down
Loading