Skip to main content

sofie_db/page/
mod.rs

1#[cfg(feature = "listing")]
2mod listing;
3
4use serde::ser::SerializeStruct;
5use serde::{Serialize, Serializer};
6use sofie_core::listing::{HAS_NEXT, ITEMS, PAGE, SIZE};
7use sofie_core::show::Fields;
8use sofie_core::{Error, Result, Show, Shown};
9
10#[cfg(feature = "listing")]
11pub use self::listing::*;
12use crate::{Outcome, Query, Read, ReadOnly, Reply, Showing, Source};
13
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub struct Page {
16    number: u64,
17    size: u64,
18}
19
20impl Page {
21    /// Larger pages are refused, so a request cannot read a whole table.
22    pub const MAX_SIZE: u64 = 100;
23    /// A database reads and drops every row before the offset.
24    pub const MAX_OFFSET: u64 = 10_000;
25
26    pub fn new(number: u64, size: u64) -> Result<Self> {
27        if number == 0 {
28            return Err(Error::BadRequest("pages are numbered from 1".to_owned()));
29        }
30        if size == 0 || size > Self::MAX_SIZE {
31            return Err(Error::BadRequest(format!(
32                "a page holds 1 to {} records, not {size}",
33                Self::MAX_SIZE
34            )));
35        }
36        if (number - 1)
37            .checked_mul(size)
38            .is_none_or(|offset| offset > Self::MAX_OFFSET)
39        {
40            return Err(Error::BadRequest(format!(
41                "page {number} starts past the first {} records; narrow the filter",
42                Self::MAX_OFFSET
43            )));
44        }
45        Ok(Self { number, size })
46    }
47
48    #[must_use]
49    pub fn number(self) -> u64 {
50        self.number
51    }
52
53    #[must_use]
54    pub fn size(self) -> u64 {
55        self.size
56    }
57
58    pub(crate) fn offset(self) -> u64 {
59        (self.number - 1) * self.size
60    }
61}
62
63#[derive(Debug)]
64pub struct Paginated<T> {
65    items: Vec<T>,
66    page: Page,
67    has_next: bool,
68}
69
70impl<T> Paginated<T> {
71    /// A next page past `Page::MAX_OFFSET` would be refused, so none is offered.
72    #[must_use]
73    pub fn new(items: Vec<T>, page: Page, more: bool) -> Self {
74        Self {
75            items,
76            page,
77            has_next: more && Page::new(page.number + 1, page.size).is_ok(),
78        }
79    }
80
81    #[must_use]
82    pub fn items(&self) -> &[T] {
83        &self.items
84    }
85
86    #[must_use]
87    pub fn page(&self) -> Page {
88        self.page
89    }
90
91    #[must_use]
92    pub fn has_next(&self) -> bool {
93        self.has_next
94    }
95
96    #[must_use]
97    pub fn has_previous(&self) -> bool {
98        self.page.number > 1
99    }
100}
101
102impl<T: Serialize> Serialize for Paginated<T> {
103    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
104        let mut page = serializer.serialize_struct("Paginated", 4)?;
105        page.serialize_field(ITEMS, &self.items)?;
106        page.serialize_field(PAGE, &self.page.number)?;
107        page.serialize_field(SIZE, &self.page.size)?;
108        page.serialize_field(HAS_NEXT, &self.has_next)?;
109        page.end()
110    }
111}
112
113impl<T: Show> Fields for Paginated<T> {
114    fn name(&self, index: usize) -> Option<&str> {
115        [ITEMS, PAGE, SIZE, HAS_NEXT].get(index).copied()
116    }
117
118    fn get(&self, name: &str) -> Option<Shown<'_>> {
119        match name {
120            ITEMS => Some(self.items.show()),
121            PAGE => Some(self.page.number.show()),
122            SIZE => Some(self.page.size.show()),
123            HAS_NEXT => Some(Shown::Bool(self.has_next)),
124            _ => None,
125        }
126    }
127}
128
129impl<T: Show> Show for Paginated<T> {
130    fn show(&self) -> Shown<'_> {
131        Shown::Record(self)
132    }
133}
134
135impl<T: Show + Serialize + Send + Sync + 'static> Outcome for Paginated<T> {
136    fn reply(self) -> Result<Reply> {
137        Ok(Reply::Data(Box::new(Showing(self))))
138    }
139}
140
141impl<T: Show + Serialize + Send + Sync + 'static> ReadOnly for Paginated<T> {}
142
143impl<T: Source> Query<T> {
144    /// Reads one record past the page instead of counting; ties are ordered by id.
145    pub async fn paginate(self, read: &Read, page: Page) -> Result<Paginated<T>> {
146        let mut criteria = self.criteria;
147        if !criteria.sorts.iter().any(|sort| sort.column == "id") {
148            criteria.sorts.push(crate::statement::SortKey::id(false));
149        }
150        criteria.limit = Some(page.size() + 1);
151        criteria.offset = Some(page.offset());
152        let mut items = criteria.all(read).await?;
153        let size = usize::try_from(page.size()).expect("a page holds at most `Page::MAX_SIZE`");
154        let has_next = items.len() > size;
155        items.truncate(size);
156        Ok(Paginated::new(items, page, has_next))
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use sofie_core::Shown;
163
164    use super::*;
165
166    #[test]
167    fn a_page_starts_after_the_ones_before_it() {
168        assert_eq!(Page::new(3, 20).unwrap().offset(), 40);
169        assert_eq!(Page::new(1, 1).unwrap().offset(), 0);
170    }
171
172    #[test]
173    fn a_page_out_of_range_is_a_bad_request() {
174        for (number, size) in [(0, 10), (1, 0), (1, Page::MAX_SIZE + 1), (u64::MAX, 100)] {
175            assert!(
176                matches!(Page::new(number, size), Err(Error::BadRequest(_))),
177                "{number} {size}"
178            );
179        }
180    }
181
182    #[test]
183    fn a_page_past_the_deepest_offset_asks_to_narrow_the_filter() {
184        assert!(Page::new(101, 100).is_ok());
185
186        let deep = Page::new(102, 100);
187
188        assert!(
189            matches!(&deep, Err(Error::BadRequest(message)) if message.contains("filter")),
190            "{deep:?}"
191        );
192    }
193
194    #[test]
195    fn the_last_page_before_the_deepest_offset_links_to_no_next_one() {
196        let last = Paginated::new(vec![1], Page::new(101, 100).unwrap(), true);
197
198        assert!(!last.has_next());
199    }
200
201    #[test]
202    fn a_page_serializes_its_items_and_position() {
203        let page = Paginated::new(vec![1, 2], Page::new(2, 2).unwrap(), true);
204
205        assert_eq!(
206            serde_json::to_value(&page).unwrap(),
207            serde_json::json!({ "items": [1, 2], "page": 2, "size": 2, "has_next": true })
208        );
209        assert!(page.has_previous());
210    }
211
212    #[test]
213    fn a_page_of_shown_items_replies_them_typed_and_its_json_unchanged() {
214        let at = sofie_core::Instant::parse("2026-10-11T22:30:00Z").unwrap();
215
216        let reply = Paginated::new(vec![at], Page::new(1, 20).unwrap(), false)
217            .reply()
218            .unwrap();
219
220        let items = match reply.shown() {
221            Some(Shown::Record(page)) => page.get("items"),
222            _ => None,
223        };
224        let first = match items {
225            Some(Shown::List(items)) => items.item(0),
226            _ => None,
227        };
228        assert!(matches!(first, Some(Shown::Instant(shown)) if shown == at));
229        assert_eq!(
230            reply.to_json().unwrap(),
231            serde_json::json!({
232                "items": ["2026-10-11T22:30:00Z"], "page": 1, "size": 20, "has_next": false
233            })
234        );
235    }
236}