use crate::DbResult;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PageSpec {
pub limit: usize,
pub chunk: usize,
pub max_examined: Option<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StopReason {
Filled,
Exhausted,
BudgetExhausted,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChunkedPage<K, R> {
pub items: Vec<R>,
pub next: Option<K>,
pub stop: StopReason,
}
pub fn page_chunks<It, T, K, C, R>(
mut rows: It,
spec: PageSpec,
cursor: impl Fn(&T) -> K,
mut prepare: impl FnMut(&[T]) -> DbResult<C>,
mut project: impl FnMut(T, &C) -> DbResult<Option<R>>,
) -> DbResult<ChunkedPage<K, R>>
where
It: Iterator<Item = T>,
{
if spec.limit == 0 {
return Ok(ChunkedPage {
items: Vec::new(),
next: None,
stop: StopReason::Filled,
});
}
let chunk = spec.chunk.max(1);
let max = spec.max_examined.map(|budget| budget.max(1));
let mut items = Vec::with_capacity(spec.limit.min(1024));
let mut batch: Vec<T> = Vec::with_capacity(chunk.min(1024));
let mut examined = 0;
let mut last = None;
loop {
let allowed = max.map_or(chunk, |budget| chunk.min(budget - examined));
if allowed == 0 {
return Ok(ChunkedPage {
items,
next: last,
stop: StopReason::BudgetExhausted,
});
}
batch.clear();
batch.extend(rows.by_ref().take(allowed));
if batch.is_empty() {
return Ok(ChunkedPage {
items,
next: None,
stop: StopReason::Exhausted,
});
}
let context = prepare(&batch)?;
for row in batch.drain(..) {
last = Some(cursor(&row));
examined += 1;
if let Some(item) = project(row, &context)? {
items.push(item);
if items.len() == spec.limit {
return Ok(ChunkedPage {
items,
next: last,
stop: StopReason::Filled,
});
}
}
if max.is_some_and(|budget| examined == budget) {
return Ok(ChunkedPage {
items,
next: last,
stop: StopReason::BudgetExhausted,
});
}
}
}
}
#[cfg(test)]
mod tests {
use std::cell::RefCell;
use std::rc::Rc;
use crate::{DbError, DbResult};
use super::{PageSpec, StopReason, page_chunks};
#[test]
fn filling_mid_chunk_returns_the_filling_cursor_and_discards_prefetch() -> DbResult<()> {
let batches = Rc::new(RefCell::new(Vec::new()));
let batches_for_prepare = batches.clone();
let page = page_chunks(
1..=7,
PageSpec {
limit: 3,
chunk: 4,
max_examined: None,
},
|row| *row,
move |batch: &[i32]| {
batches_for_prepare.borrow_mut().push(batch.to_vec());
Ok(())
},
|row, &()| Ok(Some(row)),
)?;
assert_eq!(page.items, vec![1, 2, 3]);
assert_eq!(page.next, Some(3));
assert_eq!(page.stop, StopReason::Filled);
assert_eq!(&*batches.borrow(), &[vec![1, 2, 3, 4]]);
let next_batches = Rc::new(RefCell::new(Vec::new()));
let next_batches_for_prepare = next_batches.clone();
page_chunks(
4..=7,
PageSpec {
limit: 3,
chunk: 4,
max_examined: None,
},
|row| *row,
move |batch: &[i32]| {
next_batches_for_prepare.borrow_mut().push(batch.to_vec());
Ok(())
},
|row, &()| Ok(Some(row)),
)?;
assert_eq!(&*next_batches.borrow(), &[vec![4, 5, 6, 7]]);
Ok(())
}
#[test]
fn filling_on_the_final_row_keeps_a_cursor_for_an_empty_follow_up() -> DbResult<()> {
let page = page_chunks(
1..=3,
PageSpec {
limit: 3,
chunk: 4,
max_examined: None,
},
|row| *row,
|_| Ok(()),
|row, &()| Ok(Some(row)),
)?;
assert_eq!(page.items, vec![1, 2, 3]);
assert_eq!(page.next, Some(3));
assert_eq!(page.stop, StopReason::Filled);
let resumed = page_chunks(
[1, 2, 3].into_iter().filter(|row| *row > 3),
PageSpec {
limit: 3,
chunk: 4,
max_examined: None,
},
|row| *row,
|_| Ok(()),
|row, &()| Ok(Some(row)),
)?;
assert!(resumed.items.is_empty());
assert_eq!(resumed.next, None);
assert_eq!(resumed.stop, StopReason::Exhausted);
Ok(())
}
#[test]
fn all_rejected_rows_stop_at_the_examination_budget() -> DbResult<()> {
let page = page_chunks(
1..,
PageSpec {
limit: 3,
chunk: 2,
max_examined: Some(4),
},
|row| *row,
|_| Ok(()),
|_, &()| Ok(None::<i32>),
)?;
assert!(page.items.is_empty());
assert_eq!(page.next, Some(4));
assert_eq!(page.stop, StopReason::BudgetExhausted);
let resumed = page_chunks(
(1..=7).filter(|row| *row > page.next.expect("budget page has a cursor")),
PageSpec {
limit: 3,
chunk: 2,
max_examined: None,
},
|row| *row,
|_| Ok(()),
|row, &()| Ok(Some(row)),
)?;
assert_eq!(resumed.items, vec![5, 6, 7]);
assert_eq!(resumed.next, Some(7));
assert_eq!(resumed.stop, StopReason::Filled);
Ok(())
}
#[test]
fn rejected_first_chunk_tops_up_and_prepares_each_nonempty_batch() -> DbResult<()> {
let prepared = Rc::new(RefCell::new(Vec::new()));
let prepared_for_prepare = prepared.clone();
let page = page_chunks(
1..=5,
PageSpec {
limit: 2,
chunk: 2,
max_examined: None,
},
|row| *row,
move |batch: &[i32]| {
prepared_for_prepare.borrow_mut().push(batch.to_vec());
Ok(())
},
|row, &()| Ok((row >= 3).then_some(row)),
)?;
assert_eq!(page.items, vec![3, 4]);
assert_eq!(page.next, Some(4));
assert_eq!(page.stop, StopReason::Filled);
assert_eq!(&*prepared.borrow(), &[vec![1, 2], vec![3, 4]]);
Ok(())
}
#[test]
fn zero_chunk_and_zero_budget_are_normalized_to_one() -> DbResult<()> {
for spec in [
PageSpec {
limit: 1,
chunk: 0,
max_examined: None,
},
PageSpec {
limit: 1,
chunk: 4,
max_examined: Some(0),
},
] {
let page = page_chunks(1.., spec, |row| *row, |_| Ok(()), |row, &()| Ok(Some(row)))?;
assert_eq!(page.items, vec![1]);
assert_eq!(page.next, Some(1));
assert_eq!(page.stop, StopReason::Filled);
}
Ok(())
}
#[test]
fn zero_limit_does_not_touch_the_iterator_or_closures() -> DbResult<()> {
let rows = std::iter::from_fn(|| -> Option<i32> { panic!("iterator must not be called") });
let page = page_chunks(
rows,
PageSpec {
limit: 0,
chunk: 1,
max_examined: None,
},
|_| -> i32 { panic!("cursor must not be called") },
|_| -> DbResult<()> { panic!("prepare must not be called") },
|_, _: &()| -> DbResult<Option<i32>> { panic!("project must not be called") },
)?;
assert!(page.items.is_empty());
assert_eq!(page.next, None);
assert_eq!(page.stop, StopReason::Filled);
Ok(())
}
#[test]
fn short_input_is_exhausted_without_a_cursor() -> DbResult<()> {
let page = page_chunks(
1..=2,
PageSpec {
limit: 3,
chunk: 4,
max_examined: None,
},
|row| *row,
|_| Ok(()),
|row, &()| Ok(Some(row)),
)?;
assert_eq!(page.items, vec![1, 2]);
assert_eq!(page.next, None);
assert_eq!(page.stop, StopReason::Exhausted);
Ok(())
}
#[test]
fn prepare_and_project_errors_are_propagated_without_a_page() {
let prepare = page_chunks(
1..=1,
PageSpec {
limit: 1,
chunk: 1,
max_examined: None,
},
|row| *row,
|_| Err::<(), _>(DbError::Internal("prepare failed")),
|row, &()| Ok(Some(row)),
);
assert!(matches!(prepare, Err(DbError::Internal("prepare failed"))));
let project = page_chunks(
1..=1,
PageSpec {
limit: 1,
chunk: 1,
max_examined: None,
},
|row| *row,
|_| Ok(()),
|_, &()| Err::<Option<i32>, _>(DbError::Internal("project failed")),
);
assert!(matches!(project, Err(DbError::Internal("project failed"))));
}
#[test]
fn non_clone_rows_keep_their_cursor_before_projection() -> DbResult<()> {
struct Row {
key: u8,
value: u8,
}
let page = page_chunks(
vec![Row { key: 7, value: 11 }].into_iter(),
PageSpec {
limit: 1,
chunk: 1,
max_examined: None,
},
|row| row.key,
|_| Ok(()),
|row, &()| Ok(Some(row.value)),
)?;
assert_eq!(page.items, vec![11]);
assert_eq!(page.next, Some(7));
Ok(())
}
}