diff --git a/README.md b/README.md index 8f768c0..10c4f65 100644 --- a/README.md +++ b/README.md @@ -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::::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. diff --git a/crates/sqlx-query/src/clauses/pager.rs b/crates/sqlx-query/src/clauses/pager.rs index d04488d..24fde3a 100644 --- a/crates/sqlx-query/src/clauses/pager.rs +++ b/crates/sqlx-query/src/clauses/pager.rs @@ -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, } @@ -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 @@ -127,6 +144,10 @@ impl Cursor { pub fn parse(token: &str) -> Result { 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)?; @@ -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 @@ -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(), @@ -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] @@ -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 /// @@ -344,15 +376,18 @@ impl Pager { for<'r> crate::value::UuidRepr: Decode<'r, R::Database> + Type, { 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 }) } @@ -365,8 +400,9 @@ pub struct Page { /// The page's rows, at most the pager's size. pub rows: Vec, /// The cursor to the next page — [`encode`](Cursor::encode) it as the - /// `next_page_token` — or `None` on the last page. - pub cursor: Option, + /// `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 @@ -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")); diff --git a/crates/sqlx-query/src/composer.rs b/crates/sqlx-query/src/composer.rs index dbf44ba..18b523e 100644 --- a/crates/sqlx-query/src/composer.rs +++ b/crates/sqlx-query/src/composer.rs @@ -174,8 +174,12 @@ impl QueryComposer { /// 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 } diff --git a/crates/sqlx-query/tests/composer.rs b/crates/sqlx-query/tests/composer.rs index 9f66eca..fb76af8 100644 --- a/crates/sqlx-query/tests/composer.rs +++ b/crates/sqlx-query/tests/composer.rs @@ -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::::new(sql); + first + .push_order_by(OrderByClause::parse("rank desc").unwrap()) + .with_cursor(Cursor::parse("").unwrap()); + let mut untouched = QueryComposer::::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::::new(sql); + query + .with_cursor(rank_cursor()) + .with_cursor(Cursor::default()); + + assert_eq!( + query.compose().unwrap().sql(), + QueryComposer::::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] diff --git a/crates/sqlx-query/tests/execute.rs b/crates/sqlx-query/tests/execute.rs index 4d4ef09..2791634 100644 --- a/crates/sqlx-query/tests/execute.rs +++ b/crates/sqlx-query/tests/execute.rs @@ -335,11 +335,12 @@ 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> { 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::); + 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 { @@ -347,10 +348,8 @@ async fn pages(pool: &SqlitePool, size: usize) -> Vec> { 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() @@ -361,9 +360,9 @@ async fn pages(pool: &SqlitePool, size: usize) -> Vec> { 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:?}"); diff --git a/crates/sqlx-query/tests/execute_postgres.rs b/crates/sqlx-query/tests/execute_postgres.rs index ad8f6f1..ec10399 100644 --- a/crates/sqlx-query/tests/execute_postgres.rs +++ b/crates/sqlx-query/tests/execute_postgres.rs @@ -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::>::new(), None); + let (mut seen, mut cursor) = (Vec::>::new(), Cursor::default()); for _ in 0..10 { let mut query = QueryComposer::::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() @@ -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]]);