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::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}