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 toasty::stmt::{List, Query, Value};
8use topcoat::{Result, context::Cx};
9
10use super::{Cursor, Table, TableState};
11
12/// One executed page of rows for `WiredTable::render`.
13///
14/// Build it from toasty's `Page` via [`Self::from_toasty_page`], which
15/// URL-encodes the engine cursors; a `Vec<M>` converts directly into a page
16/// with no neighbors. An absent cursor simply means no Previous/Next link is
17/// rendered — the chrome never invents pages.
18#[derive(Debug, Clone)]
19pub struct TablePage<M> {
20    /// The rows of this page.
21    pub rows: Vec<M>,
22    /// Encoded cursor for the next page (`?after=`), when one exists.
23    pub next_cursor: Option<String>,
24    /// Encoded cursor for the previous page (`?before=`), when one exists.
25    pub prev_cursor: Option<String>,
26}
27
28impl<M> From<Vec<M>> for TablePage<M> {
29    fn from(rows: Vec<M>) -> Self {
30        Self {
31            rows,
32            next_cursor: None,
33            prev_cursor: None,
34        }
35    }
36}
37
38impl<M: toasty::schema::Model> TablePage<M> {
39    /// Wrap a toasty cursor-pagination result, encoding its cursors for URLs.
40    ///
41    /// # Errors
42    ///
43    /// Errors when a cursor contains a value the URL codec cannot represent
44    /// (see `crate::cursor`).
45    pub fn from_toasty_page(page: toasty::stmt::Page<M>) -> Result<Self> {
46        Ok(Self {
47            rows: page.items,
48            next_cursor: page
49                .next_cursor
50                .as_ref()
51                .map(crate::toasty_compat::cursor::encode)
52                .transpose()?,
53            prev_cursor: page
54                .prev_cursor
55                .as_ref()
56                .map(crate::toasty_compat::cursor::encode)
57                .transpose()?,
58        })
59    }
60}
61
62impl<M> TablePage<M>
63where
64    M: toasty::schema::Model + Send + Sync + 'static,
65{
66    /// Load the page of `table` that `state` names: `query` with the table's
67    /// search, filters and ordering applied, the relations its columns declare
68    /// included, and cursor pagination at its page size.
69    ///
70    /// `query` is the caller's scope. The panel's list passes the resource's
71    /// [`scoped_query`](crate::resource::scoped_query); a page that owns its
72    /// table passes its own, with the table from
73    /// [`panel::wired_table`](crate::panel::wired_table) when it renders the
74    /// resource's action chrome.
75    ///
76    /// # Errors
77    ///
78    /// A malformed cursor, a cursor the engine rejects for this
79    /// ordering (both take the cursor-stripped retry contract), or a database
80    /// failure.
81    pub async fn load(
82        cx: &Cx,
83        table: &Table<M>,
84        query: Query<List<M>>,
85        state: &TableState,
86    ) -> Result<Self> {
87        // The declaration becomes predicates and an ordering through the one
88        // shared routine — the export loader applies the same one to its own
89        // seed. The cursor probes below reuse that filtered and ordered query
90        // without the columns' includes: they only ask whether a row exists.
91        let base_query = table.apply_declaration(query, state);
92        let query = table.include_relations(base_query.clone());
93        let mut db = crate::db::db(cx);
94        let per_page = table.page_size();
95        let mut paginated = toasty::stmt::Paginate::new(query, per_page);
96        // Toasty cursor pagination takes exactly one cursor, and the state
97        // holds at most one.
98        match &state.cursor {
99            Some(Cursor::After(token)) => {
100                paginated = paginated.after(crate::toasty_compat::cursor::decode(token)?);
101            }
102            Some(Cursor::Before(token)) => {
103                paginated = paginated.before(crate::toasty_compat::cursor::decode(token)?);
104            }
105            None => {}
106        }
107        let loaded = paginated
108            .exec(&mut db)
109            .await
110            .map_err(|error| reject_cursor(error.into(), state))?;
111        let mut page = TablePage::from_toasty_page(loaded)?;
112        // Cursor-existence probes, one per landing direction:
113        // the engine sets `next_cursor`/`prev_cursor` optimistically,
114        // so a page sitting exactly at a boundary carries a phantom
115        // cursor without validation. Each direction probes only the
116        // edge that can lie:
117        // - forward/first landing: prev is exact (absent on the first page; otherwise the page we
118        //   came from exists), next may be phantom at the end boundary → probe next on full pages.
119        //   A short page cannot have a next page.
120        // - backward landing: next is exact (the page we came from follows), prev may be phantom
121        //   when the fetch lands on the first page → probe prev whenever one is reported.
122        //
123        // Deliberately NOT a `LIMIT per_page+1` fold: the engine
124        // derives `next_cursor` from the last *fetched* row, so
125        // trimming the extra row would anchor the next link past it —
126        // every `(per_page+1)`th row would vanish from forward walks.
127        // The probes keep the main fetch's cursors (which point at
128        // displayed rows) as the link anchors.
129        //
130        // Residual (same as ever): a concurrent delete landing between
131        // the main fetch and the click can still void a validated
132        // cursor — that degrades to the void-window recovery link
133        // never to silently skipped rows.
134        if matches!(state.cursor, Some(Cursor::Before(_))) {
135            if let Some(cursor) = page.prev_cursor.clone() {
136                let past = Past::Before(crate::toasty_compat::cursor::decode(&cursor)?);
137                if !row_exists_past(&mut db, base_query, past)
138                    .await
139                    .map_err(crate::error::unavailable)?
140                {
141                    page.prev_cursor = None;
142                }
143            }
144        } else if page.rows.len() == per_page {
145            if let Some(cursor) = page.next_cursor.clone() {
146                let past = Past::After(crate::toasty_compat::cursor::decode(&cursor)?);
147                if !row_exists_past(&mut db, base_query, past)
148                    .await
149                    .map_err(crate::error::unavailable)?
150                {
151                    page.next_cursor = None;
152                }
153            }
154        } else {
155            // Short page → no next, keep prev as-is (has_previous already correct).
156            page.next_cursor = None;
157        }
158        Ok(page)
159    }
160}
161
162/// Which side of a cursor a probe looks past.
163pub(crate) enum Past {
164    /// Rows after the cursor, in the query's order.
165    After(Value),
166    /// Rows before the cursor.
167    Before(Value),
168}
169
170/// Whether `query` has a row past the cursor.
171///
172/// Toasty reports a cursor whenever a page comes back full, so a page that
173/// ends exactly at the table's edge carries one with nothing behind it.
174/// The list loader validates the cursor its links carry with
175/// this, and the export asks it whether rows remain past its row cap.
176pub(crate) async fn row_exists_past<M>(
177    db: &mut toasty::Db,
178    query: Query<List<M>>,
179    past: Past,
180) -> toasty::Result<bool>
181where
182    M: toasty::schema::Model + Send + Sync + 'static,
183{
184    let probe = toasty::stmt::Paginate::new(query, 1);
185    let probe = match past {
186        Past::After(cursor) => probe.after(cursor),
187        Past::Before(cursor) => probe.before(cursor),
188    };
189    Ok(!probe.exec(db).await?.items.is_empty())
190}
191
192/// Attribute a failed paginated fetch to the request's cursor.
193///
194/// A token cut from a different ordering decodes but the engine refuses the
195/// statement (`invalid_statement`: its field count no longer matches the
196/// query's `ORDER BY`). No other statement this paginated loader builds carries
197/// that error while the request names a cursor. Such a failure is the cursor's,
198/// so it takes the cursor-stripped retry contract instead of re-requesting the
199/// identical URL forever; every other failure is the database's, and takes the
200/// opaque mapping that keeps the driver's text in the log.
201fn reject_cursor(error: topcoat::Error, state: &TableState) -> topcoat::Error {
202    let cursored = state.cursor.is_some();
203    let rejected = error
204        .downcast_ref::<toasty::Error>()
205        .is_some_and(toasty::Error::is_invalid_statement);
206    if cursored && rejected {
207        crate::toasty_compat::cursor::rejected(&error)
208    } else {
209        crate::error::unavailable(error)
210    }
211}