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}