Skip to main content

paginator_utils/
cursor.rs

1use crate::params::{PaginationParams, SortDirection};
2use base64::alphabet;
3use base64::engine::general_purpose::{GeneralPurpose, GeneralPurposeConfig};
4use base64::engine::DecodePaddingMode;
5use base64::Engine;
6use serde::ser::SerializeMap;
7use serde::{Deserialize, Deserializer, Serialize, Serializer};
8use serde_json::Value;
9
10/// Cursors are emitted with the URL-safe alphabet and no padding so they can be
11/// placed in a query string without percent-encoding.
12const ENCODER: GeneralPurpose = GeneralPurpose::new(
13    &alphabet::URL_SAFE,
14    GeneralPurposeConfig::new().with_encode_padding(false),
15);
16
17/// Decoding accepts both alphabets, padded or not, so cursors issued by older
18/// versions (standard alphabet, padded) keep working.
19const DECODE_CONFIG: GeneralPurposeConfig =
20    GeneralPurposeConfig::new().with_decode_padding_mode(DecodePaddingMode::Indifferent);
21const URL_SAFE_DECODER: GeneralPurpose = GeneralPurpose::new(&alphabet::URL_SAFE, DECODE_CONFIG);
22const STANDARD_DECODER: GeneralPurpose = GeneralPurpose::new(&alphabet::STANDARD, DECODE_CONFIG);
23
24#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
25pub struct Cursor {
26    pub field: String,
27    pub value: CursorValue,
28    pub direction: CursorDirection,
29}
30
31#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
32#[serde(rename_all = "lowercase")]
33pub enum CursorDirection {
34    After,
35    Before,
36}
37
38/// The value a cursor points at.
39///
40/// Serialized as a bare JSON number or string, except [`CursorValue::Uuid`],
41/// which is serialized as `{"uuid": "..."}` so that the variant survives an
42/// encode/decode round trip and PostgreSQL still receives the `::uuid` cast.
43/// Cursors issued by older versions (a bare string for UUIDs) decode as
44/// [`CursorValue::String`].
45#[derive(Clone, Debug, PartialEq)]
46pub enum CursorValue {
47    String(String),
48    Int(i64),
49    Float(f64),
50    /// UUID value stored as string, will be cast to UUID in SQL
51    Uuid(String),
52}
53
54impl Serialize for CursorValue {
55    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
56        match self {
57            CursorValue::String(s) => serializer.serialize_str(s),
58            CursorValue::Int(i) => serializer.serialize_i64(*i),
59            CursorValue::Float(f) => serializer.serialize_f64(*f),
60            CursorValue::Uuid(u) => {
61                let mut map = serializer.serialize_map(Some(1))?;
62                map.serialize_entry("uuid", u)?;
63                map.end()
64            }
65        }
66    }
67}
68
69impl<'de> Deserialize<'de> for CursorValue {
70    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
71        #[derive(Deserialize)]
72        #[serde(untagged)]
73        enum Raw {
74            Int(i64),
75            Float(f64),
76            String(String),
77            Uuid { uuid: String },
78        }
79
80        Ok(match Raw::deserialize(deserializer)? {
81            Raw::Int(i) => CursorValue::Int(i),
82            Raw::Float(f) => CursorValue::Float(f),
83            Raw::String(s) => CursorValue::String(s),
84            Raw::Uuid { uuid } => CursorValue::Uuid(uuid),
85        })
86    }
87}
88
89impl CursorValue {
90    /// Convert a JSON value, typically one field of a serialized row, into a
91    /// cursor value.
92    ///
93    /// `template` is an existing cursor value on the same field; when given, string
94    /// values keep its `String`/`Uuid` variant and integers keep a `Float` variant.
95    /// Without a template, strings shaped like a UUID become [`CursorValue::Uuid`].
96    /// Booleans, nulls, arrays, and objects cannot be cursor values.
97    pub fn from_json(value: &Value, template: Option<&CursorValue>) -> Option<Self> {
98        match value {
99            Value::Number(n) => {
100                if let Some(i) = n.as_i64() {
101                    if matches!(template, Some(CursorValue::Float(_))) {
102                        Some(CursorValue::Float(i as f64))
103                    } else {
104                        Some(CursorValue::Int(i))
105                    }
106                } else {
107                    n.as_f64().map(CursorValue::Float)
108                }
109            }
110            Value::String(s) => match template {
111                Some(CursorValue::Uuid(_)) => Some(CursorValue::Uuid(s.clone())),
112                Some(CursorValue::String(_)) => Some(CursorValue::String(s.clone())),
113                _ if looks_like_uuid(s) => Some(CursorValue::Uuid(s.clone())),
114                _ => Some(CursorValue::String(s.clone())),
115            },
116            _ => None,
117        }
118    }
119}
120
121fn looks_like_uuid(s: &str) -> bool {
122    if s.len() != 36 {
123        return false;
124    }
125    s.bytes().enumerate().all(|(i, b)| match i {
126        8 | 13 | 18 | 23 => b == b'-',
127        _ => b.is_ascii_hexdigit(),
128    })
129}
130
131impl Cursor {
132    pub fn new(field: String, value: CursorValue, direction: CursorDirection) -> Self {
133        Self {
134            field,
135            value,
136            direction,
137        }
138    }
139
140    pub fn encode(&self) -> Result<String, String> {
141        let json = serde_json::to_string(self).map_err(|e| e.to_string())?;
142        Ok(ENCODER.encode(json.as_bytes()))
143    }
144
145    pub fn decode(encoded: &str) -> Result<Self, String> {
146        let encoded = encoded.trim();
147        let decoded = URL_SAFE_DECODER
148            .decode(encoded)
149            .or_else(|_| STANDARD_DECODER.decode(encoded))
150            .map_err(|e| e.to_string())?;
151        let json = String::from_utf8(decoded).map_err(|e| e.to_string())?;
152        serde_json::from_str(&json).map_err(|e| e.to_string())
153    }
154
155    /// Read the cursor field out of a serialized row.
156    ///
157    /// The row is serialized with serde_json and `field` is looked up on the
158    /// resulting object. A qualified name such as `users.id` falls back to its last
159    /// segment (`id`), so cursors on table-qualified columns still resolve. See
160    /// [`CursorValue::from_json`] for how `template` shapes the value.
161    pub fn value_from_row<T: Serialize>(
162        field: &str,
163        row: &T,
164        template: Option<&CursorValue>,
165    ) -> Result<CursorValue, String> {
166        let json = serde_json::to_value(row).map_err(|e| e.to_string())?;
167        let obj = json
168            .as_object()
169            .ok_or_else(|| "row did not serialize to a JSON object".to_string())?;
170        let raw = obj
171            .get(field)
172            .or_else(|| field.rsplit('.').next().and_then(|short| obj.get(short)))
173            .ok_or_else(|| format!("cursor field '{}' not found in row", field))?;
174        CursorValue::from_json(raw, template)
175            .ok_or_else(|| format!("cursor field '{}' is not a number or string", field))
176    }
177
178    /// Build a cursor anchored at `row`, for example to hand out a `next_cursor`
179    /// from the last row of an offset page:
180    ///
181    /// ```
182    /// use paginator_utils::{Cursor, CursorDirection, CursorValue};
183    /// use serde::Serialize;
184    ///
185    /// #[derive(Serialize)]
186    /// struct User { id: i64, name: String }
187    ///
188    /// let last = User { id: 42, name: "Ada".into() };
189    /// let next = Cursor::from_row("id", &last, CursorDirection::After).unwrap();
190    /// assert_eq!(next.value, CursorValue::Int(42));
191    /// ```
192    pub fn from_row<T: Serialize>(
193        field: impl Into<String>,
194        row: &T,
195        direction: CursorDirection,
196    ) -> Result<Self, String> {
197        let field = field.into();
198        let value = Self::value_from_row(&field, row, None)?;
199        Ok(Self::new(field, value, direction))
200    }
201
202    /// A cursor on the same field as `self`, anchored at `row`. The value keeps
203    /// the variant of `self.value`, so a `Uuid` cursor stays a `Uuid` cursor.
204    pub fn at_row<T: Serialize>(
205        &self,
206        row: &T,
207        direction: CursorDirection,
208    ) -> Result<Self, String> {
209        let value = Self::value_from_row(&self.field, row, Some(&self.value))?;
210        Ok(Self::new(self.field.clone(), value, direction))
211    }
212}
213
214/// How a request carrying a cursor is executed.
215///
216/// Keyset pagination selects the rows on the far side of the cursor and takes the
217/// nearest `per_page` of them. For an `After` cursor that is the natural sort
218/// order. For a `Before` cursor the query runs in the *reversed* order so that
219/// `LIMIT` picks the rows closest to the cursor, and the page is flipped back into
220/// the caller's order afterwards (see [`PaginatorResponseMeta::from_cursor_page`]).
221///
222/// [`PaginatorResponseMeta::from_cursor_page`]: crate::PaginatorResponseMeta::from_cursor_page
223#[derive(Clone, Debug, PartialEq)]
224pub struct KeysetPlan<'a> {
225    pub cursor: &'a Cursor,
226    /// The order the caller wants rows in: `sort_direction`, ascending by default.
227    pub sort: SortDirection,
228    /// The order to put in the query. Equal to `sort` for `After` cursors and
229    /// reversed for `Before` cursors.
230    pub query_sort: SortDirection,
231}
232
233impl<'a> KeysetPlan<'a> {
234    pub fn field(&self) -> &'a str {
235        &self.cursor.field
236    }
237
238    pub fn value(&self) -> &'a CursorValue {
239        &self.cursor.value
240    }
241
242    /// The comparison that selects rows on the far side of the cursor in query
243    /// order: `>` when the query sorts ascending, `<` when descending.
244    pub fn operator(&self) -> &'static str {
245        match self.query_sort {
246            SortDirection::Asc => ">",
247            SortDirection::Desc => "<",
248        }
249    }
250
251    /// Whether the fetched rows must be reversed to restore the caller's order.
252    pub fn reverse_rows(&self) -> bool {
253        self.cursor.direction == CursorDirection::Before
254    }
255}
256
257impl PaginationParams {
258    /// Resolve how to execute this request's cursor, if it has one.
259    ///
260    /// Errors when `sort_by` names a different field than the cursor: keyset
261    /// pagination is only correct when rows are ordered by the cursor field.
262    pub fn keyset_plan(&self) -> Result<Option<KeysetPlan<'_>>, String> {
263        let Some(cursor) = &self.cursor else {
264            return Ok(None);
265        };
266        if let Some(sort_by) = &self.sort_by {
267            if sort_by != &cursor.field {
268                return Err(format!(
269                    "cursor field '{}' does not match sort_by '{}': keyset pagination must sort by the cursor field",
270                    cursor.field, sort_by
271                ));
272            }
273        }
274        let sort = self.sort_direction.unwrap_or(SortDirection::Asc);
275        let query_sort = match cursor.direction {
276            CursorDirection::After => sort,
277            CursorDirection::Before => sort.reversed(),
278        };
279        Ok(Some(KeysetPlan {
280            cursor,
281            sort,
282            query_sort,
283        }))
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use serde_json::json;
291
292    #[test]
293    fn test_cursor_encode_decode_string() {
294        let cursor = Cursor::new(
295            "id".to_string(),
296            CursorValue::String("abc123".to_string()),
297            CursorDirection::After,
298        );
299        let encoded = cursor.encode().unwrap();
300        let decoded = Cursor::decode(&encoded).unwrap();
301        assert_eq!(cursor, decoded);
302    }
303
304    #[test]
305    fn test_cursor_encode_decode_int() {
306        let cursor = Cursor::new(
307            "id".to_string(),
308            CursorValue::Int(12345),
309            CursorDirection::Before,
310        );
311        let encoded = cursor.encode().unwrap();
312        let decoded = Cursor::decode(&encoded).unwrap();
313        assert_eq!(cursor, decoded);
314    }
315
316    #[test]
317    fn test_cursor_encode_decode_float() {
318        let cursor = Cursor::new(
319            "timestamp".to_string(),
320            CursorValue::Float(1234567890.123),
321            CursorDirection::After,
322        );
323        let encoded = cursor.encode().unwrap();
324        let decoded = Cursor::decode(&encoded).unwrap();
325        assert_eq!(cursor, decoded);
326    }
327
328    #[test]
329    fn uuid_variant_survives_a_round_trip() {
330        let cursor = Cursor::new(
331            "id".to_string(),
332            CursorValue::Uuid("550e8400-e29b-41d4-a716-446655440000".to_string()),
333            CursorDirection::After,
334        );
335        let json = serde_json::to_string(&cursor).unwrap();
336        assert!(json.contains(r#""value":{"uuid":"550e8400"#), "{json}");
337        let decoded = Cursor::decode(&cursor.encode().unwrap()).unwrap();
338        assert_eq!(decoded, cursor);
339
340        // Older cursors carried the UUID as a bare string; they still decode.
341        let legacy: Cursor =
342            serde_json::from_str(r#"{"field":"id","value":"abc","direction":"after"}"#).unwrap();
343        assert_eq!(legacy.value, CursorValue::String("abc".into()));
344        let float: CursorValue = serde_json::from_str("2.5").unwrap();
345        assert_eq!(float, CursorValue::Float(2.5));
346        let int: CursorValue = serde_json::from_str("7").unwrap();
347        assert_eq!(int, CursorValue::Int(7));
348    }
349
350    #[test]
351    fn encoded_cursor_is_url_safe() {
352        // A payload whose standard base64 form contains '+', '/' and '=' padding.
353        let cursor = Cursor::new(
354            "created_at".to_string(),
355            CursorValue::String("2024-01-01T00:00:00Z??>>".to_string()),
356            CursorDirection::After,
357        );
358        let encoded = cursor.encode().unwrap();
359        assert!(
360            encoded
361                .chars()
362                .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
363            "not url-safe: {encoded}"
364        );
365        assert_eq!(Cursor::decode(&encoded).unwrap(), cursor);
366    }
367
368    #[test]
369    fn decodes_legacy_standard_base64_cursors() {
370        let cursor = Cursor::new(
371            "id".to_string(),
372            CursorValue::Int(7),
373            CursorDirection::After,
374        );
375        let json = serde_json::to_string(&cursor).unwrap();
376        let legacy = base64::engine::general_purpose::STANDARD.encode(json.as_bytes());
377        assert!(
378            legacy.ends_with('='),
379            "test needs a padded payload: {legacy}"
380        );
381        assert_eq!(Cursor::decode(&legacy).unwrap(), cursor);
382        assert_eq!(Cursor::decode(&format!(" {legacy}\n")).unwrap(), cursor);
383    }
384
385    #[test]
386    fn rejects_garbage() {
387        assert!(Cursor::decode("not-a-valid-cursor").is_err());
388        assert!(Cursor::decode("").is_err());
389    }
390
391    #[test]
392    fn value_from_json_infers_types() {
393        assert_eq!(
394            CursorValue::from_json(&json!(5), None),
395            Some(CursorValue::Int(5))
396        );
397        assert_eq!(
398            CursorValue::from_json(&json!(5), Some(&CursorValue::Float(0.0))),
399            Some(CursorValue::Float(5.0))
400        );
401        assert_eq!(
402            CursorValue::from_json(&json!(1.5), None),
403            Some(CursorValue::Float(1.5))
404        );
405        assert_eq!(
406            CursorValue::from_json(&json!("abc"), None),
407            Some(CursorValue::String("abc".into()))
408        );
409        let uuid = "550e8400-e29b-41d4-a716-446655440000";
410        assert_eq!(
411            CursorValue::from_json(&json!(uuid), None),
412            Some(CursorValue::Uuid(uuid.into()))
413        );
414        assert_eq!(
415            CursorValue::from_json(&json!(uuid), Some(&CursorValue::String(String::new()))),
416            Some(CursorValue::String(uuid.into()))
417        );
418        assert_eq!(
419            CursorValue::from_json(&json!("x"), Some(&CursorValue::Uuid(String::new()))),
420            Some(CursorValue::Uuid("x".into()))
421        );
422        assert_eq!(CursorValue::from_json(&json!(true), None), None);
423        assert_eq!(CursorValue::from_json(&json!(null), None), None);
424        assert_eq!(CursorValue::from_json(&json!([1]), None), None);
425    }
426
427    #[derive(Serialize)]
428    struct Row {
429        id: i64,
430        name: String,
431    }
432
433    #[test]
434    fn from_row_reads_plain_and_qualified_fields() {
435        let row = Row {
436            id: 42,
437            name: "Ada".into(),
438        };
439        let c = Cursor::from_row("id", &row, CursorDirection::After).unwrap();
440        assert_eq!(c.field, "id");
441        assert_eq!(c.value, CursorValue::Int(42));
442        assert_eq!(c.direction, CursorDirection::After);
443
444        let c = Cursor::from_row("users.name", &row, CursorDirection::Before).unwrap();
445        assert_eq!(c.field, "users.name");
446        assert_eq!(c.value, CursorValue::String("Ada".into()));
447
448        let err = Cursor::from_row("missing", &row, CursorDirection::After).unwrap_err();
449        assert!(err.contains("missing"), "{err}");
450        assert!(Cursor::from_row("id", &42, CursorDirection::After).is_err());
451    }
452
453    #[test]
454    fn at_row_keeps_value_variant() {
455        #[derive(Serialize)]
456        struct R {
457            id: String,
458        }
459        let row = R { id: "abc".into() };
460        let seed = Cursor::new(
461            "id".into(),
462            CursorValue::Uuid("seed".into()),
463            CursorDirection::After,
464        );
465        let next = seed.at_row(&row, CursorDirection::Before).unwrap();
466        assert_eq!(next.value, CursorValue::Uuid("abc".into()));
467        assert_eq!(next.direction, CursorDirection::Before);
468    }
469
470    fn params_with(cursor: Cursor, sort_direction: Option<SortDirection>) -> PaginationParams {
471        PaginationParams {
472            cursor: Some(cursor),
473            sort_direction,
474            ..Default::default()
475        }
476    }
477
478    #[test]
479    fn keyset_plan_resolves_operator_and_order() {
480        let after = Cursor::new("id".into(), CursorValue::Int(5), CursorDirection::After);
481        let before = Cursor::new("id".into(), CursorValue::Int(5), CursorDirection::Before);
482
483        let p = params_with(after.clone(), None);
484        let plan = p.keyset_plan().unwrap().unwrap();
485        assert_eq!(plan.query_sort, SortDirection::Asc);
486        assert_eq!(plan.operator(), ">");
487        assert!(!plan.reverse_rows());
488
489        let p = params_with(after, Some(SortDirection::Desc));
490        let plan = p.keyset_plan().unwrap().unwrap();
491        assert_eq!(plan.query_sort, SortDirection::Desc);
492        assert_eq!(plan.operator(), "<");
493
494        let p = params_with(before.clone(), Some(SortDirection::Asc));
495        let plan = p.keyset_plan().unwrap().unwrap();
496        assert_eq!(plan.sort, SortDirection::Asc);
497        assert_eq!(plan.query_sort, SortDirection::Desc);
498        assert_eq!(plan.operator(), "<");
499        assert!(plan.reverse_rows());
500
501        let p = params_with(before, Some(SortDirection::Desc));
502        let plan = p.keyset_plan().unwrap().unwrap();
503        assert_eq!(plan.query_sort, SortDirection::Asc);
504        assert_eq!(plan.operator(), ">");
505    }
506
507    #[test]
508    fn keyset_plan_requires_matching_sort_field() {
509        let cursor = Cursor::new("id".into(), CursorValue::Int(5), CursorDirection::After);
510        let mut params = params_with(cursor, None);
511        params.sort_by = Some("id".into());
512        assert!(params.keyset_plan().unwrap().is_some());
513
514        params.sort_by = Some("name".into());
515        let err = params.keyset_plan().unwrap_err();
516        assert!(err.contains("does not match"), "{err}");
517
518        assert!(PaginationParams::default().keyset_plan().unwrap().is_none());
519    }
520}