Skip to main content

modo_db/
pagination.rs

1use sea_orm::sea_query::IntoValueTuple;
2use sea_orm::{
3    ConnectionTrait, DbErr, EntityTrait, FromQueryResult, IntoIdentity, QuerySelect, Select,
4};
5use serde::{Deserialize, Serialize};
6
7// ── Offset / Page Pagination ────────────────────────────────────────────────
8
9/// Query-string parameters for offset-based pagination.
10///
11/// Page is 1-indexed (default 1). `per_page` is clamped to `[1, 100]`.
12#[derive(Debug, Clone, Deserialize)]
13#[serde(default)]
14pub struct PageParams {
15    pub page: u64,
16    pub per_page: u64,
17}
18
19impl Default for PageParams {
20    fn default() -> Self {
21        Self {
22            page: 1,
23            per_page: 20,
24        }
25    }
26}
27
28impl PageParams {
29    fn clamped(&self) -> (u64, u64) {
30        (self.page.max(1), self.per_page.clamp(1, 100))
31    }
32}
33
34/// Paginated response for offset-based pagination.
35#[derive(Debug, Clone, Serialize)]
36pub struct PageResult<T> {
37    pub data: Vec<T>,
38    pub page: u64,
39    pub per_page: u64,
40    pub has_next: bool,
41    pub has_prev: bool,
42}
43
44impl<T> PageResult<T> {
45    /// Transform every item in `data` via `f`.
46    pub fn map<U>(self, f: impl FnMut(T) -> U) -> PageResult<U> {
47        PageResult {
48            data: self.data.into_iter().map(f).collect(),
49            page: self.page,
50            per_page: self.per_page,
51            has_next: self.has_next,
52            has_prev: self.has_prev,
53        }
54    }
55}
56
57/// Run an offset-based paginated query.
58///
59/// Uses the **limit + 1** trick to detect `has_next` without a COUNT query.
60pub async fn paginate<E, M>(
61    query: Select<E>,
62    db: &impl ConnectionTrait,
63    params: &PageParams,
64) -> Result<PageResult<M>, DbErr>
65where
66    E: EntityTrait<Model = M>,
67    M: FromQueryResult + Send + Sync,
68{
69    let (page, per_page) = params.clamped();
70    let offset = (page - 1) * per_page;
71
72    let mut rows = query.offset(offset).limit(per_page + 1).all(db).await?;
73
74    let has_next = rows.len() as u64 > per_page;
75    if has_next {
76        rows.truncate(per_page as usize);
77    }
78
79    Ok(PageResult {
80        data: rows,
81        page,
82        per_page,
83        has_next,
84        has_prev: page > 1,
85    })
86}
87
88// ── Cursor Pagination ───────────────────────────────────────────────────────
89
90/// Query-string parameters for cursor-based pagination.
91///
92/// `V` is the cursor value type — defaults to `String` (for ULID/NanoID keys).
93/// For integer PKs use `CursorParams<i64>`.
94///
95/// `per_page` is clamped to `[1, 100]`. If both `after` and `before` are set,
96/// `after` takes precedence.
97#[derive(Debug, Clone, Deserialize)]
98pub struct CursorParams<V = String> {
99    #[serde(default)]
100    pub per_page: Option<u64>,
101    #[serde(default)]
102    pub after: Option<V>,
103    #[serde(default)]
104    pub before: Option<V>,
105}
106
107impl<V> Default for CursorParams<V> {
108    fn default() -> Self {
109        Self {
110            per_page: None,
111            after: None,
112            before: None,
113        }
114    }
115}
116
117impl<V> CursorParams<V> {
118    fn clamped_per_page(&self) -> u64 {
119        self.per_page.unwrap_or(20).clamp(1, 100)
120    }
121}
122
123/// Paginated response for cursor-based pagination.
124///
125/// Cursor values are always strings in the response (for JSON serialization).
126/// Use `next_cursor` with `?after=` and `prev_cursor` with `?before=` to
127/// navigate forward and backward respectively.
128#[derive(Debug, Clone, Serialize)]
129pub struct CursorResult<T> {
130    pub data: Vec<T>,
131    pub per_page: u64,
132    pub next_cursor: Option<String>,
133    pub prev_cursor: Option<String>,
134    pub has_next: bool,
135    pub has_prev: bool,
136}
137
138impl<T> CursorResult<T> {
139    /// Transform every item in `data` via `f`.
140    pub fn map<U>(self, f: impl FnMut(T) -> U) -> CursorResult<U> {
141        CursorResult {
142            data: self.data.into_iter().map(f).collect(),
143            per_page: self.per_page,
144            next_cursor: self.next_cursor,
145            prev_cursor: self.prev_cursor,
146            has_next: self.has_next,
147            has_prev: self.has_prev,
148        }
149    }
150}
151
152/// Run a cursor-based paginated query.
153///
154/// Uses SeaORM's cursor API with the **limit + 1** trick to detect boundaries
155/// without an extra COUNT query.
156///
157/// - `cursor_column` — the column to paginate on (e.g. `Column::Id`).
158/// - `cursor_fn` — extracts the cursor string from a model instance.
159///
160/// The cursor value type `V` (from `CursorParams<V>`) must match the column's
161/// database type. Use `CursorParams` (default `String`) for string columns
162/// (ULID, NanoID) or `CursorParams<i64>` for integer columns.
163pub async fn paginate_cursor<E, M, C, V, F>(
164    query: Select<E>,
165    cursor_column: C,
166    cursor_fn: F,
167    db: &impl ConnectionTrait,
168    params: &CursorParams<V>,
169) -> Result<CursorResult<M>, DbErr>
170where
171    E: EntityTrait<Model = M>,
172    M: FromQueryResult + Send + Sync,
173    C: IntoIdentity,
174    V: IntoValueTuple + Clone,
175    F: Fn(&M) -> String,
176{
177    let per_page = params.clamped_per_page();
178    let mut cursor = query.cursor_by(cursor_column);
179
180    // `after` wins when both are set.
181    let is_backward = params.after.is_none() && params.before.is_some();
182
183    if let Some(ref after) = params.after {
184        cursor.after(after.clone());
185        cursor.first(per_page + 1);
186    } else if let Some(ref before) = params.before {
187        cursor.before(before.clone());
188        cursor.last(per_page + 1);
189    } else {
190        cursor.first(per_page + 1);
191    }
192
193    let mut rows = cursor.all(db).await?;
194
195    let (has_next, has_prev);
196
197    if is_backward {
198        has_prev = rows.len() as u64 > per_page;
199        has_next = !rows.is_empty();
200        if has_prev {
201            rows.remove(0);
202        }
203    } else {
204        has_next = rows.len() as u64 > per_page;
205        has_prev = params.after.is_some();
206        if has_next {
207            rows.truncate(per_page as usize);
208        }
209    }
210
211    let next_cursor = if has_next {
212        rows.last().map(&cursor_fn)
213    } else {
214        None
215    };
216    let prev_cursor = if has_prev {
217        rows.first().map(&cursor_fn)
218    } else {
219        None
220    };
221
222    Ok(CursorResult {
223        data: rows,
224        per_page,
225        next_cursor,
226        prev_cursor,
227        has_next,
228        has_prev,
229    })
230}