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}