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