tablo_core/table/page.rs
1//! One executed page of a [`Table`]: [`TablePage`] and its loader.
2//!
3//! The table is the declaration and its pure planning
4//! ([`Table::apply_declaration`](super::Table)); this module runs it against
5//! the database.
6
7use std::num::NonZeroUsize;
8
9use toasty::stmt::{List, Query, Value};
10use topcoat::{Result, context::Cx};
11
12use super::{Cursor, Table, TableState};
13
14/// The page size of a table that declares none with [`Table::paginate`].
15pub const DEFAULT_PAGE_SIZE: NonZeroUsize = NonZeroUsize::new(25).unwrap();
16
17/// The page sizes a table offers a reader by default, beside its own [`Table::paginate`] size.
18pub const DEFAULT_PER_PAGE_OPTIONS: [usize; 4] = [10, 25, 50, 100];
19
20/// How a table pages through its rows.
21///
22/// ```rust
23/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
24/// # struct Event { #[key] #[auto] id: uuid::Uuid, name: String }
25/// use tablo_core::{Pagination, Table, TextColumn, lens};
26///
27/// // A log too large to count on every page.
28/// Table::new(TextColumn::new(lens!(Event.name))).pagination(Pagination::Cursor);
29/// ```
30#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
31pub enum Pagination {
32 /// Numbered pages, `?page=3`: "Showing 51–75 of 312", the page links, and Previous and
33 /// Next. Each page counts the result set, one more query.
34 #[default]
35 Pages,
36 /// Previous and Next by page number, without a count: "Page 3".
37 Simple,
38 /// Previous and Next by row cursor, `?after=…`: neither counts nor skips rows, so it pages
39 /// a large or fast-changing table without drifting.
40 Cursor,
41}
42
43/// One executed page of rows for `WiredTable::render`.
44///
45/// Build it from toasty's `Page` via [`Self::from_toasty_page`], which
46/// URL-encodes the engine cursors; a `Vec<M>` converts directly into a page
47/// with no neighbors. An absent cursor simply means no Previous/Next link is
48/// rendered — the chrome never invents pages.
49#[derive(Debug, Clone)]
50pub struct TablePage<M> {
51 /// The rows of this page.
52 pub rows: Vec<M>,
53 /// Encoded cursor for the next page (`?after=`), when one exists.
54 pub next_cursor: Option<String>,
55 /// Encoded cursor for the previous page (`?before=`), when one exists.
56 pub prev_cursor: Option<String>,
57 /// Where a numbered or simple page sits; `None` for a cursor page.
58 pub position: Option<PagePosition>,
59}
60
61/// Where a page numbered by `?page=` sits in its result set.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub struct PagePosition {
64 /// The page, from 1.
65 pub page: u32,
66 /// The rows a full page holds.
67 pub per_page: usize,
68 /// The rows the result set holds; `None` for a [`Pagination::Simple`] page, which counts
69 /// nothing.
70 pub total: Option<u64>,
71 /// Whether a page follows this one.
72 pub has_next: bool,
73}
74
75impl PagePosition {
76 /// The number of pages, at least one; `None` without a total.
77 pub fn last_page(&self) -> Option<u32> {
78 let per_page = u64::try_from(self.per_page).ok()?.max(1);
79 let pages = self.total?.div_ceil(per_page).max(1);
80 Some(u32::try_from(pages).unwrap_or(u32::MAX))
81 }
82}
83
84impl<M> From<Vec<M>> for TablePage<M> {
85 fn from(rows: Vec<M>) -> Self {
86 Self {
87 rows,
88 next_cursor: None,
89 prev_cursor: None,
90 position: None,
91 }
92 }
93}
94
95impl<M: toasty::schema::Model> TablePage<M> {
96 /// Wrap a toasty cursor-pagination result, encoding its cursors for URLs.
97 ///
98 /// # Errors
99 ///
100 /// Errors when a cursor contains a value the URL codec cannot represent.
101 pub fn from_toasty_page(page: toasty::stmt::Page<M>) -> Result<Self> {
102 Ok(Self {
103 rows: page.items,
104 next_cursor: page
105 .next_cursor
106 .as_ref()
107 .map(crate::toasty_compat::cursor::encode)
108 .transpose()?,
109 prev_cursor: page
110 .prev_cursor
111 .as_ref()
112 .map(crate::toasty_compat::cursor::encode)
113 .transpose()?,
114 position: None,
115 })
116 }
117}
118
119impl<M> TablePage<M>
120where
121 M: toasty::schema::Model + Send + Sync + 'static,
122{
123 /// Load the page of `table` that `state` names: `query` with the table's
124 /// search, filters and ordering applied (but a filter naming records the user may not see),
125 /// the relations its columns declare included and checked against what their sources show
126 /// the user, and the table's [`Pagination`] at the page size `state` reads at.
127 ///
128 /// `query` is the caller's scope. The panel's list passes the resource's
129 /// [`scoped_query`](crate::resource::scoped_query); a page that owns its
130 /// table passes its own, with the table from
131 /// [`panel::wired_table`](crate::panel::wired_table) when it renders the
132 /// resource's action chrome.
133 ///
134 /// # Errors
135 ///
136 /// A malformed cursor, a cursor the engine rejects for this
137 /// ordering (both take the cursor-stripped retry contract), or a database
138 /// failure.
139 pub async fn load(
140 cx: &Cx,
141 table: &Table<M>,
142 query: Query<List<M>>,
143 state: &TableState,
144 ) -> Result<Self> {
145 let refused = table.refused_filters(cx, state).await?;
146 let page = match table.pagination_mode() {
147 Pagination::Pages => Self::load_numbered(cx, table, query, state, &refused, true).await,
148 Pagination::Simple => {
149 Self::load_numbered(cx, table, query, state, &refused, false).await
150 }
151 Pagination::Cursor => Self::load_cursor(cx, table, query, state, &refused).await,
152 }?;
153 table
154 .restrict_relations(cx, &page.rows, &mut crate::db::db(cx)?)
155 .await?;
156 Ok(page)
157 }
158
159 /// Load the page `?page=` names by offset, counting the result set when `counted`.
160 ///
161 /// Counted, a page past the last one reads as the last, so a stale link or a narrowed search
162 /// lands on rows rather than on an empty page. Uncounted, it shows the empty state, which
163 /// links the first page.
164 async fn load_numbered(
165 cx: &Cx,
166 table: &Table<M>,
167 query: Query<List<M>>,
168 state: &TableState,
169 refused: &[String],
170 counted: bool,
171 ) -> Result<Self> {
172 let mut db = crate::db::db(cx)?;
173 let narrowed = table.narrow(cx, &db, query, state, refused);
174 let per_page = table.page_size_for(state);
175 let total = if counted {
176 Some(
177 narrowed
178 .clone()
179 .count()
180 .exec(&mut db)
181 .await
182 .map_err(crate::error::unavailable)?,
183 )
184 } else {
185 None
186 };
187 // `TableState` is public, so a page of 0 can reach here: it reads as the first.
188 let asked = state.page.unwrap_or(1).max(1);
189 let page = match total {
190 Some(total) => {
191 let position = PagePosition {
192 page: asked,
193 per_page,
194 total: Some(total),
195 has_next: false,
196 };
197 asked.min(position.last_page().unwrap_or(1))
198 }
199 None => asked,
200 };
201 let offset = usize::try_from(page - 1)
202 .unwrap_or(usize::MAX)
203 .saturating_mul(per_page);
204 // A simple page fetches one row more than it shows: that row is the next page's.
205 let fetch = if counted { per_page } else { per_page + 1 };
206 let mut ordered = narrowed;
207 ordered.set_order_by(toasty::stmt::OrderBy {
208 exprs: table.offset_order_bys_for(state),
209 });
210 let mut rows = table
211 .include_relations(ordered)
212 .limit(fetch)
213 .offset(offset)
214 .exec(&mut db)
215 .await
216 .map_err(crate::error::unavailable)?;
217 let has_next = match total {
218 Some(total) => u64::try_from(offset + rows.len()).is_ok_and(|seen| seen < total),
219 None => rows.len() > per_page,
220 };
221 rows.truncate(per_page);
222 Ok(Self {
223 rows,
224 next_cursor: None,
225 prev_cursor: None,
226 position: Some(PagePosition {
227 page,
228 per_page,
229 total,
230 has_next,
231 }),
232 })
233 }
234
235 /// Load the page `?after=` or `?before=` names by cursor.
236 async fn load_cursor(
237 cx: &Cx,
238 table: &Table<M>,
239 query: Query<List<M>>,
240 state: &TableState,
241 refused: &[String],
242 ) -> Result<Self> {
243 // The declaration becomes predicates and an ordering through the one
244 // shared routine — the export loader applies the same one to its own
245 // seed. The cursor probes below reuse that filtered and ordered query
246 // without the columns' includes: they only ask whether a row exists.
247 let mut db = crate::db::db(cx)?;
248 let base_query = table.apply_declaration(cx, &db, query, state, refused);
249 let query = table.include_relations(base_query.clone());
250 let per_page = table.page_size_for(state);
251 let mut paginated = toasty::stmt::Paginate::new(query, per_page);
252 // Toasty cursor pagination takes exactly one cursor, and the state
253 // holds at most one.
254 match &state.cursor {
255 Some(Cursor::After(token)) => {
256 paginated = paginated.after(crate::toasty_compat::cursor::decode(token)?);
257 }
258 Some(Cursor::Before(token)) => {
259 paginated = paginated.before(crate::toasty_compat::cursor::decode(token)?);
260 }
261 None => {}
262 }
263 let loaded = paginated
264 .exec(&mut db)
265 .await
266 .map_err(|error| reject_cursor(error.into(), state))?;
267 let mut page = TablePage::from_toasty_page(loaded)?;
268 // Cursor-existence probes, one per landing direction:
269 // the engine sets `next_cursor`/`prev_cursor` optimistically,
270 // so a page sitting exactly at a boundary carries a phantom
271 // cursor without validation. Each direction probes only the
272 // edge that can lie:
273 // - forward/first landing: prev is exact (absent on the first page; otherwise the page we
274 // came from exists), next may be phantom at the end boundary → probe next on full pages.
275 // A short page cannot have a next page.
276 // - backward landing: next is exact (the page we came from follows), prev may be phantom
277 // when the fetch lands on the first page → probe prev whenever one is reported.
278 //
279 // Deliberately NOT a `LIMIT per_page+1` fold: the engine
280 // derives `next_cursor` from the last *fetched* row, so
281 // trimming the extra row would anchor the next link past it —
282 // every `(per_page+1)`th row would vanish from forward walks.
283 // The probes keep the main fetch's cursors (which point at
284 // displayed rows) as the link anchors.
285 //
286 // Residual (same as ever): a concurrent delete landing between
287 // the main fetch and the click can still void a validated
288 // cursor — that degrades to the void-window recovery link
289 // never to silently skipped rows.
290 if matches!(state.cursor, Some(Cursor::Before(_))) {
291 if let Some(cursor) = page.prev_cursor.clone() {
292 let past = Past::Before(crate::toasty_compat::cursor::decode(&cursor)?);
293 if !row_exists_past(&mut db, base_query, past)
294 .await
295 .map_err(crate::error::unavailable)?
296 {
297 page.prev_cursor = None;
298 }
299 }
300 } else if page.rows.len() == per_page {
301 if let Some(cursor) = page.next_cursor.clone() {
302 let past = Past::After(crate::toasty_compat::cursor::decode(&cursor)?);
303 if !row_exists_past(&mut db, base_query, past)
304 .await
305 .map_err(crate::error::unavailable)?
306 {
307 page.next_cursor = None;
308 }
309 }
310 } else {
311 // Short page → no next, keep prev as-is (has_previous already correct).
312 page.next_cursor = None;
313 }
314 Ok(page)
315 }
316}
317
318/// Which side of a cursor a probe looks past.
319pub(crate) enum Past {
320 /// Rows after the cursor, in the query's order.
321 After(Value),
322 /// Rows before the cursor.
323 Before(Value),
324}
325
326/// Whether `query` has a row past the cursor.
327///
328/// Toasty reports a cursor whenever a page comes back full, so a page that
329/// ends exactly at the table's edge carries one with nothing behind it.
330/// The list loader validates the cursor its links carry with
331/// this, and the export asks it whether rows remain past its row cap.
332pub(crate) async fn row_exists_past<M>(
333 db: &mut toasty::Db,
334 query: Query<List<M>>,
335 past: Past,
336) -> toasty::Result<bool>
337where
338 M: toasty::schema::Model + Send + Sync + 'static,
339{
340 let probe = toasty::stmt::Paginate::new(query, 1);
341 let probe = match past {
342 Past::After(cursor) => probe.after(cursor),
343 Past::Before(cursor) => probe.before(cursor),
344 };
345 Ok(!probe.exec(db).await?.items.is_empty())
346}
347
348/// Attribute a failed paginated fetch to the request's cursor.
349///
350/// A token cut from a different ordering decodes but the engine refuses the
351/// statement (`invalid_statement`: its field count no longer matches the
352/// query's `ORDER BY`). No other statement this paginated loader builds carries
353/// that error while the request names a cursor. Such a failure is the cursor's,
354/// so it takes the cursor-stripped retry contract instead of re-requesting the
355/// identical URL forever; every other failure is the database's, and takes the
356/// opaque mapping that keeps the driver's text in the log.
357fn reject_cursor(error: topcoat::Error, state: &TableState) -> topcoat::Error {
358 let cursored = state.cursor.is_some();
359 let rejected = error
360 .downcast_ref::<toasty::Error>()
361 .is_some_and(toasty::Error::is_invalid_statement);
362 if cursored && rejected {
363 crate::toasty_compat::cursor::rejected(&error)
364 } else {
365 crate::error::unavailable(error)
366 }
367}