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