use crate::json::OValue;
use std::collections::HashSet;
use std::future::Future;
pub const MAX_PAGES: u64 = 100_000;
pub const MAX_BYTES: usize = 512 * 1024 * 1024;
pub const MAX_ITEMS: u64 = 1_000_000;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Meta {
pub last: Option<bool>,
pub total_pages: Option<u64>,
pub total_elements: Option<u64>,
pub size: Option<u64>,
}
fn count(value: Option<&OValue>) -> Option<u64> {
match value {
Some(OValue::Number(number)) => crate::walk::total_of(number.as_f64()),
_ => None,
}
}
fn first_count(value: &OValue, keys: &[&str]) -> Option<u64> {
keys.iter().find_map(|key| count(value.get(key)))
}
pub fn rows_of(page: &OValue) -> Vec<OValue> {
page.get("content")
.and_then(OValue::as_array)
.unwrap_or_default()
.to_vec()
}
pub fn meta_of(page: &OValue) -> Meta {
let pageable = page.get("pageable");
let pageable_size =
pageable.and_then(|pageable| first_count(pageable, &["pageSize", "page_size"]));
Meta {
last: match page.get("last") {
Some(OValue::Bool(last)) => Some(*last),
_ => None,
},
total_pages: first_count(page, &["totalPages", "total_pages"]),
total_elements: first_count(page, &["totalElements", "total_elements"]),
size: count(page.get("size"))
.or(pageable_size)
.filter(|size| *size >= 1),
}
}
pub fn status_numbers(page: &OValue, returned: usize) -> (u64, u64, Option<u64>) {
let pageable = page.get("pageable");
let number = pageable
.and_then(|pageable| first_count(pageable, &["pageNumber", "page_number"]))
.unwrap_or(0);
let size = pageable
.and_then(|pageable| first_count(pageable, &["pageSize", "page_size"]))
.unwrap_or(returned as u64);
let total = first_count(page, &["totalElements", "total_elements"]);
(number, size, total)
}
#[derive(Debug)]
pub struct Walked {
pub rows: Vec<OValue>,
pub stopped_at_max: bool,
}
fn pagination_error() -> scm::Error {
scm::Error::new(scm::ErrorKind::Pagination)
}
fn id_of(row: &OValue) -> Option<&str> {
row.get("id")
.and_then(OValue::as_str)
.filter(|id| !id.is_empty())
}
pub async fn walk<F, Fut>(size: u32, max: u64, fetch: F) -> Result<Walked, scm::Error>
where
F: FnMut(u64, u32) -> Fut,
Fut: Future<Output = Result<OValue, scm::Error>>,
{
walk_bounded(size, max, MAX_BYTES, fetch).await
}
async fn walk_bounded<F, Fut>(
size: u32,
max: u64,
max_bytes: usize,
mut fetch: F,
) -> Result<Walked, scm::Error>
where
F: FnMut(u64, u32) -> Fut,
Fut: Future<Output = Result<OValue, scm::Error>>,
{
let user_cap = max != 0 && max <= MAX_ITEMS;
let cap = usize::try_from(if user_cap { max } else { MAX_ITEMS }).unwrap_or(usize::MAX);
let mut rows: Vec<OValue> = Vec::new();
let mut bytes = 0usize;
let mut seen: HashSet<String> = HashSet::new();
for page in 0..MAX_PAGES {
let answer = fetch(page, size).await?;
let content = rows_of(&answer);
let meta = meta_of(&answer);
let page_size = meta.size.unwrap_or(u64::from(size));
let last = match (meta.last, meta.total_pages) {
(Some(last), _) => last,
(None, Some(total)) => page + 1 >= total,
(None, None) => (content.len() as u64) < page_size,
};
if content.is_empty() && !last {
return Err(pagination_error());
}
for row in content {
if let Some(id) = id_of(&row)
&& !seen.insert(id.to_owned())
{
return Err(pagination_error());
}
if rows.len() == cap {
return stop_at_cap(rows, user_cap);
}
bytes =
bytes.saturating_add(crate::json::to_compact(&row).map_or(0, |text| text.len()));
if bytes > max_bytes {
return Err(crate::pagination::incomplete_walk());
}
rows.push(row);
}
if last {
return Ok(Walked {
rows,
stopped_at_max: false,
});
}
if rows.len() == cap {
return stop_at_cap(rows, user_cap);
}
}
Err(crate::pagination::incomplete_walk())
}
fn stop_at_cap(rows: Vec<OValue>, user_cap: bool) -> Result<Walked, scm::Error> {
if user_cap {
Ok(Walked {
rows,
stopped_at_max: true,
})
} else {
Err(crate::pagination::incomplete_walk())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
fn run<T>(future: impl Future<Output = T>) -> T {
tokio::runtime::Builder::new_current_thread()
.build()
.unwrap()
.block_on(future)
}
fn page(text: &str) -> OValue {
crate::json::parse(text).unwrap()
}
fn service<'a>(
count: u64,
cap: u64,
style: &'a str,
calls: &'a RefCell<Vec<(u64, u32)>>,
) -> impl FnMut(u64, u32) -> std::future::Ready<Result<OValue, scm::Error>> + 'a {
move |number, size| {
calls.borrow_mut().push((number, size));
let size_used = u64::from(size).min(cap);
let start = number * size_used;
let rows: Vec<String> = (start..count.min(start + size_used))
.map(|n| format!(r#"{{"id":"r{n}"}}"#))
.collect();
let total_pages = count.div_ceil(size_used);
let last = number + 1 >= total_pages;
let content = rows.join(",");
let text = match style {
"camel" => format!(
r#"{{"content":[{content}],"last":{last},"totalPages":{total_pages},"totalElements":{count},"size":{size_used}}}"#
),
"snake" => format!(
r#"{{"content":[{content}],"total_pages":{total_pages},"total_elements":{count},"pageable":{{"page_number":{number},"page_size":{size_used}}}}}"#
),
_ => format!(r#"{{"content":[{content}]}}"#),
};
std::future::ready(Ok(page(&text)))
}
}
fn ids(walked: &Walked) -> Vec<String> {
walked
.rows
.iter()
.map(|row| row.get("id").and_then(OValue::as_str).unwrap().to_owned())
.collect()
}
#[test]
fn walks_by_last_by_total_pages_and_by_a_short_page() {
for style in ["camel", "snake", "bare"] {
let calls = RefCell::new(Vec::new());
let walked = run(walk(2, 0, service(5, 100, style, &calls))).unwrap();
assert_eq!(ids(&walked), ["r0", "r1", "r2", "r3", "r4"], "{style}");
assert!(!walked.stopped_at_max);
assert_eq!(*calls.borrow(), [(0, 2), (1, 2), (2, 2)], "{style}");
}
let calls = RefCell::new(Vec::new());
let walked = run(walk(2, 0, service(4, 100, "bare", &calls))).unwrap();
assert_eq!(walked.rows.len(), 4);
assert_eq!(calls.borrow().len(), 3);
}
#[test]
fn a_capped_page_size_continues_the_walk() {
for style in ["camel", "snake"] {
let calls = RefCell::new(Vec::new());
let walked = run(walk(100, 0, service(120, 50, style, &calls))).unwrap();
assert_eq!(walked.rows.len(), 120, "{style}");
assert_eq!(*calls.borrow(), [(0, 100), (1, 100), (2, 100)], "{style}");
}
}
#[test]
fn an_empty_page_that_is_not_the_last_is_a_pagination_error() {
let pages = RefCell::new(vec![
page(r#"{"content":[{"id":"a"}],"last":false,"size":1}"#),
page(r#"{"content":[],"last":false,"size":1}"#),
]);
let error = run(walk(1, 0, |_, _| {
std::future::ready(Ok(pages.borrow_mut().remove(0)))
}))
.unwrap_err();
assert_eq!(error.kind(), scm::ErrorKind::Pagination);
let error = run(walk(1, 0, |number, _| {
let text = if number == 0 {
r#"{"content":[{"id":"a"}],"totalPages":3,"size":1}"#
} else {
r#"{"content":[],"totalPages":3,"size":1}"#
};
std::future::ready(Ok(page(text)))
}))
.unwrap_err();
assert_eq!(error.kind(), scm::ErrorKind::Pagination);
let walked = run(walk(5, 0, |_, _| {
std::future::ready(Ok(page(r#"{"content":[],"last":true}"#)))
}))
.unwrap();
assert!(walked.rows.is_empty());
}
#[test]
fn a_repeated_id_is_a_pagination_error() {
let calls = RefCell::new(0);
let error = run(walk(2, 0, |_, _| {
*calls.borrow_mut() += 1;
std::future::ready(Ok(page(
r#"{"content":[{"id":"a"},{"id":"b"}],"totalPages":3,"size":2}"#,
)))
}))
.unwrap_err();
assert_eq!(error.kind(), scm::ErrorKind::Pagination);
assert_eq!(*calls.borrow(), 2, "stops at the repeat");
let walked = run(walk(2, 0, |number, _| {
let text = if number == 0 {
r#"{"content":[{"name":"x"},{"id":""}],"totalPages":2,"size":2}"#
} else {
r#"{"content":[{"name":"x"}],"totalPages":2,"size":2}"#
};
std::future::ready(Ok(page(text)))
}))
.unwrap();
assert_eq!(walked.rows.len(), 3);
}
#[test]
fn max_stops_with_a_flag_and_the_safety_bounds_fail() {
let calls = RefCell::new(Vec::new());
let walked = run(walk(2, 3, service(9, 100, "camel", &calls))).unwrap();
assert_eq!(ids(&walked), ["r0", "r1", "r2"]);
assert!(walked.stopped_at_max);
let calls = RefCell::new(Vec::new());
let walked = run(walk(2, 4, service(4, 100, "camel", &calls))).unwrap();
assert_eq!(walked.rows.len(), 4);
assert!(!walked.stopped_at_max, "exactly the cap and nothing more");
let calls = RefCell::new(Vec::new());
let error = run(walk_bounded(2, 0, 20, service(4, 100, "camel", &calls))).unwrap_err();
assert_eq!(error.kind(), scm::ErrorKind::Limit);
}
#[test]
fn metadata_in_both_spellings() {
let camel = page(
r#"{"content":[],"last":true,"totalPages":2,"totalElements":30,"size":20,"pageable":{"pageNumber":1,"pageSize":20}}"#,
);
assert_eq!(
meta_of(&camel),
Meta {
last: Some(true),
total_pages: Some(2),
total_elements: Some(30),
size: Some(20)
}
);
assert_eq!(status_numbers(&camel, 0), (1, 20, Some(30)));
let snake = page(
r#"{"content":[{"id":"a"}],"total_pages":2,"total_elements":3,"pageable":{"page_number":0,"page_size":2}}"#,
);
assert_eq!(
meta_of(&snake),
Meta {
last: None,
total_pages: Some(2),
total_elements: Some(3),
size: Some(2)
}
);
assert_eq!(status_numbers(&snake, 1), (0, 2, Some(3)));
let bare = page(r#"{"content":[{"id":"a"}],"size":0,"totalElements":-1}"#);
assert_eq!(meta_of(&bare), Meta::default());
assert_eq!(status_numbers(&bare, 1), (0, 1, None));
}
}