Skip to main content

siderite_core/pagination/
mod.rs

1//! Page-number pagination over HTTP: [`PageParams`] reads and checks
2//! `?page=N&per_page=M`, and [`Page`] is the response body.
3//!
4//! [`Page`] is built from the ORM's [`orm::Page`](siderite_orm::Page)
5//! (`QuerySet::paginate` is unchanged: one `COUNT` and one fetch), so
6//! `total` and `items` come from two statements. Without a transaction
7//! that pins a snapshot, rows written between them can make `total` and
8//! the items disagree, and an unordered queryset can return a row on two
9//! pages or on none; order by a unique key for stable pages.
10//!
11//! [`CursorParams`] and [`CursorPage`] page by cursor instead (`?after=` or
12//! `?before=` and `?limit=`): one statement, no total, and rows that exist
13//! for the whole iteration are returned exactly once.
14
15mod cursor;
16
17pub use cursor::{CursorPage, CursorParams};
18
19use serde::ser::SerializeStruct;
20use serde::{Deserialize, Serialize, Serializer};
21use serde_json::{Number, Value, json};
22use siderite_orm::{Model, OrmError, QuerySet};
23use siderite_validation::dump::DumpSerialize;
24use siderite_validation::model::{Constraint, check};
25use siderite_validation::{Dump, DumpError, DumpOptions, Schema};
26use siderite_validation::{SchemaObject, SchemaRegistry};
27use siderite_validation::{Validate, ValidationContext};
28
29/// The result of [`PageParams::fetch`].
30pub type Fetched<M> = Result<Page<M>, OrmError>;
31
32/// `?page=N&per_page=M`: a 1-based page and a page size from 1 to `MAX`,
33/// `DEFAULT` when absent.
34///
35/// Out-of-range or non-numeric values are rejected with 422 before the
36/// handler runs, and the documented query parameters state the bounds.
37/// Use it as `Query<PageParams>` (at most 100, default 20) or choose the
38/// bounds: `Query<PageParams<500, 50>>`.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
40pub struct PageParams<const MAX: u64 = 100, const DEFAULT: u64 = 20> {
41    /// 1-based page number.
42    #[serde(default)]
43    pub page: Option<u64>,
44    /// Items per page.
45    #[serde(default)]
46    pub per_page: Option<u64>,
47}
48
49impl<const MAX: u64, const DEFAULT: u64> PageParams<MAX, DEFAULT> {
50    /// Checked once per instantiation, at compile time.
51    const BOUNDS: () = assert!(
52        1 <= DEFAULT && DEFAULT <= MAX,
53        "PageParams needs 1 <= DEFAULT <= MAX"
54    );
55
56    /// The requested page, 1 when absent.
57    pub fn page(&self) -> u64 {
58        self.page.unwrap_or(1)
59    }
60
61    /// The requested page size, `DEFAULT` when absent.
62    pub fn per_page(&self) -> u64 {
63        let () = Self::BOUNDS;
64        self.per_page.unwrap_or(DEFAULT)
65    }
66
67    /// Fetch this page of `queryset` (one count, one fetch).
68    ///
69    /// # Errors
70    /// Capability or backend errors of the queryset.
71    pub async fn fetch<M: Model>(&self, queryset: QuerySet<M>) -> Fetched<M> {
72        let page = queryset.paginate(self.page(), self.per_page()).await?;
73        Ok(page.into())
74    }
75}
76
77impl<const MAX: u64, const D: u64> Validate for PageParams<MAX, D> {
78    fn prepare(input: &mut Value, ctx: &mut ValidationContext) {
79        let Value::Object(fields) = input else {
80            return;
81        };
82        let one = Number::from(1_u64);
83        let max = Number::from(MAX);
84        for (key, upper) in [("page", None), ("per_page", Some(&max))] {
85            let Some(value) = fields.get_mut(key) else {
86                continue;
87            };
88            ctx.at(key, |ctx| {
89                let before = ctx.error_count();
90                Option::<u64>::prepare(value, ctx);
91                if ctx.error_count() > before {
92                    return;
93                }
94                check(value, Constraint::Ge(&one), ctx);
95                if let Some(upper) = upper {
96                    check(value, Constraint::Le(upper), ctx);
97                }
98            });
99        }
100    }
101}
102
103impl<const MAX: u64, const D: u64> Schema for PageParams<MAX, D> {
104    fn schema(_: &mut SchemaRegistry) -> SchemaObject {
105        let properties = json!({
106            "page": {
107                "type": "integer",
108                "minimum": 1,
109                "default": 1,
110                "description": "1-based page number.",
111            },
112            "per_page": {
113                "type": "integer",
114                "minimum": 1,
115                "maximum": MAX,
116                "default": D,
117                "description": "Items per page.",
118            },
119        });
120        SchemaObject::of_type("object").with("properties", properties)
121    }
122}
123
124/// One page of `T` as a response body.
125///
126/// Its schema is inlined rather than named, so pages of different item
127/// types never collide in `components.schemas`.
128#[derive(Debug, Clone, PartialEq, Serialize)]
129pub struct Page<T> {
130    /// The items of this page.
131    pub items: Vec<T>,
132    /// Items over all pages, counted before the page was fetched.
133    pub total: u64,
134    /// 1-based number of this page.
135    pub page: u64,
136    /// Requested page size.
137    pub per_page: u64,
138    /// Number of pages for `total` items.
139    pub total_pages: u64,
140}
141
142impl<T> Page<T> {
143    /// The same page with each item converted by `f`.
144    pub fn map<U>(self, f: impl FnMut(T) -> U) -> Page<U> {
145        Page {
146            items: self.items.into_iter().map(f).collect(),
147            total: self.total,
148            page: self.page,
149            per_page: self.per_page,
150            total_pages: self.total_pages,
151        }
152    }
153
154    /// The same page with each item converted by `f`, stopping at the
155    /// first error.
156    ///
157    /// # Errors
158    /// The first error `f` returns.
159    pub fn try_map<U, E, F>(self, f: F) -> Result<Page<U>, E>
160    where
161        F: FnMut(T) -> Result<U, E>,
162    {
163        let items = self.items.into_iter().map(f).collect::<Result<_, _>>()?;
164        Ok(Page {
165            items,
166            total: self.total,
167            page: self.page,
168            per_page: self.per_page,
169            total_pages: self.total_pages,
170        })
171    }
172}
173
174impl<M> From<siderite_orm::Page<M>> for Page<M> {
175    fn from(page: siderite_orm::Page<M>) -> Self {
176        let total_pages = page.total_pages();
177        Self {
178            items: page.items,
179            total: page.total,
180            page: page.page,
181            per_page: page.per_page,
182            total_pages,
183        }
184    }
185}
186
187impl<T: Dump> Dump for Page<T> {
188    fn serialize_dump<S: Serializer>(
189        &self,
190        serializer: S,
191        opts: &DumpOptions,
192    ) -> Result<S::Ok, S::Error> {
193        let mut out = serializer.serialize_struct("Page", 5)?;
194        out.serialize_field("items", &DumpSerialize(&self.items[..], opts))?;
195        out.serialize_field("total", &self.total)?;
196        out.serialize_field("page", &self.page)?;
197        out.serialize_field("per_page", &self.per_page)?;
198        out.serialize_field("total_pages", &self.total_pages)?;
199        out.end()
200    }
201
202    fn dump(&self, opts: &DumpOptions) -> Result<Value, DumpError> {
203        Ok(serde_json::to_value(DumpSerialize(self, opts))?)
204    }
205}
206
207impl<T: Schema + 'static> Schema for Page<T> {
208    fn schema(registry: &mut SchemaRegistry) -> SchemaObject {
209        let items = registry.subschema::<Vec<T>>().into_value();
210        let count = registry.subschema::<u64>().into_value();
211        let properties = json!({
212            "items": items,
213            "total": count,
214            "page": count,
215            "per_page": count,
216            "total_pages": count,
217        });
218        let required = ["items", "total", "page", "per_page", "total_pages"];
219        SchemaObject::of_type("object")
220            .with("properties", properties)
221            .with("required", json!(required))
222    }
223}
224
225#[cfg(test)]
226mod cursor_tests;
227#[cfg(test)]
228mod tests;