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
10const ENCODER: GeneralPurpose = GeneralPurpose::new(
13 &alphabet::URL_SAFE,
14 GeneralPurposeConfig::new().with_encode_padding(false),
15);
16
17const 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#[derive(Clone, Debug, PartialEq)]
46pub enum CursorValue {
47 String(String),
48 Int(i64),
49 Float(f64),
50 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 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 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 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 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#[derive(Clone, Debug, PartialEq)]
224pub struct KeysetPlan<'a> {
225 pub cursor: &'a Cursor,
226 pub sort: SortDirection,
228 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 pub fn operator(&self) -> &'static str {
245 match self.query_sort {
246 SortDirection::Asc => ">",
247 SortDirection::Desc => "<",
248 }
249 }
250
251 pub fn reverse_rows(&self) -> bool {
253 self.cursor.direction == CursorDirection::Before
254 }
255}
256
257impl PaginationParams {
258 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 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 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}