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 pub const MAX_SIZE: u64 = 100;
23 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 #[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 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}