Skip to main content

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}