Skip to main content

tablo_core/table/
state.rs

1//! List state: [`TableState`], [`Cursor`], [`Sort`], and the URL codec.
2//!
3//! The URL query spells list state; [`TableState::from_query`] parses it and one
4//! encoder projects every link.
5
6use std::{borrow::Cow, collections::BTreeMap};
7
8use topcoat::context::Cx;
9
10use super::filter::FilterValue;
11use crate::{schema::clamp_query_term, topcoat_compat::href};
12
13/// The selection token for `key` on the bulk wire: the key percent-encoded between the commas that
14/// delimit it, so a key holding the wire's separator stays one token.
15pub(crate) fn bulk_token(key: &str) -> String {
16    format!(",{},", href::encode_query_value(key))
17}
18
19/// The key a [`bulk_token`]'s body spells: the inverse of the encoding it applies.
20///
21/// Each valid escape decodes, and a `%` that starts no valid escape stays as written. A body
22/// whose decoded bytes are not UTF-8 reads as written, whole, so a posted id holding no escape
23/// keeps its literal text.
24pub(crate) fn bulk_key(body: &str) -> String {
25    href::decode_query_value(body)
26}
27
28/// A column and a direction to sort a table by: parsed from `?sort=<column>&dir=asc|desc`, or
29/// declared as the table's [default](super::Table::default_sort).
30#[derive(Debug, Clone, PartialEq, Eq, Hash)]
31pub struct Sort {
32    /// The app-level field name of the column (matches `TextColumn::name`).
33    pub column: String,
34    /// `true` for `dir=desc`.
35    pub descending: bool,
36}
37
38impl Sort {
39    /// Sort by `column`, smallest first.
40    pub fn asc(column: impl Into<String>) -> Self {
41        Self {
42            column: column.into(),
43            descending: false,
44        }
45    }
46
47    /// Sort by `column`, largest first.
48    pub fn desc(column: impl Into<String>) -> Self {
49        Self {
50            column: column.into(),
51            descending: true,
52        }
53    }
54}
55
56/// A pagination cursor: the page after, or the page before, an encoded row.
57///
58/// Conflicting cursors parse as none and render the first page.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum Cursor {
61    /// `?after=` — the page after the encoded row.
62    After(String),
63    /// `?before=` — the page before the encoded row.
64    Before(String),
65}
66
67/// Request-scoped table state, parsed from the list's URL query.
68#[derive(Debug, Clone, Default, PartialEq, Eq)]
69pub struct TableState {
70    /// The prefix every parameter of this table carries, or `None` for a page's
71    /// own list.
72    pub prefix: Option<String>,
73    /// `?q=` — trimmed and clamped to `MAX_QUERY_TERM` chars; `None` when
74    /// absent or blank.
75    pub search: Option<String>,
76    /// `?sort=` + `?dir=` — `None` when absent or blank.
77    pub sort: Option<Sort>,
78    /// `?after=` or `?before=`, which a [`Pagination::Cursor`](super::Pagination::Cursor) table
79    /// reads.
80    pub cursor: Option<Cursor>,
81    /// `?page=`, from 1, which a numbered or simple table reads; `None` when absent or not a
82    /// positive number.
83    pub page: Option<u32>,
84    /// `?per_page=`, honored when the table offers it; `None` when absent or not a positive
85    /// number.
86    pub per_page: Option<u32>,
87    /// The active filters, by name: `?f.<name>=<value>`, repeated for each
88    /// value, and `?f.<name>.<part>=<value>` for each part. The parse keeps
89    /// no filter holding no value and no part.
90    pub filters: BTreeMap<String, FilterValue>,
91    /// Filter parameters the parse dropped.
92    pub filters_dropped: bool,
93    /// `?group_by=` — field name to group by (in-memory, `count` summarizer).
94    pub group_by: Option<String>,
95}
96
97/// The URL parameters one table link projects.
98#[derive(Default)]
99struct UrlProjection<'a> {
100    /// `?q=` search term.
101    search: Option<&'a str>,
102    /// `?sort=` column and `?dir=` value.
103    sort: Option<(&'a str, &'a str)>,
104    /// The `?f.<name>=` filters.
105    filters: bool,
106    /// `?group_by=` column.
107    group_by: Option<&'a str>,
108    /// `?after=` or `?before=`.
109    cursor: Option<&'a Cursor>,
110    /// `?page=` page number.
111    page: Option<u32>,
112    /// `?per_page=` page size.
113    per_page: Option<u32>,
114}
115
116/// Caps applied filters per query.
117pub(crate) const MAX_FILTERS: usize = 32;
118
119/// Caps the values and parts one filter holds.
120pub(crate) const MAX_FILTER_VALUES: usize = 64;
121
122/// Caps filter name, part and value length in bytes.
123pub(crate) const MAX_FILTER_LEN: usize = 256;
124
125/// The prefix that names a filter parameter: `?f.status=published`.
126const FILTER_PREFIX: &str = "f.";
127
128impl TableState {
129    /// Parse the state from the request in `cx`: [`Self::from_query`] over
130    /// the request URI's query. Renders without a request context (e.g. unit
131    /// tests) get neutral state.
132    pub fn from_cx(cx: &Cx) -> Self {
133        Self::from_query(&request_query(cx))
134    }
135
136    /// [`Self::from_query`] for the table whose parameters carry `prefix`:
137    /// only the parameters spelled `{prefix}.{name}` are read, as `name`, and
138    /// the parsed state carries the prefix so its links spell the same names.
139    pub fn from_query_prefixed(query: &str, prefix: &str) -> Self {
140        let dotted = format!("{prefix}.");
141        let own = form_urlencoded::parse(query.as_bytes()).filter_map(|(name, value)| {
142            name.strip_prefix(dotted.as_str())
143                .map(|name| (Cow::Owned(name.to_string()), value))
144        });
145        Self {
146            prefix: Some(prefix.to_string()),
147            ..Self::from_pairs(own)
148        }
149    }
150
151    /// The URL parameter this table spells `name` as: `name` itself, or
152    /// `{prefix}.{name}` for a prefixed table.
153    pub(crate) fn param(&self, name: &str) -> String {
154        match &self.prefix {
155            Some(prefix) => format!("{prefix}.{name}"),
156            None => name.to_string(),
157        }
158    }
159
160    /// The URL parameter the filter `name` travels as: `f.{name}`, prefixed
161    /// like every other parameter.
162    pub(crate) fn filter_param(&self, name: &str) -> String {
163        self.param(&format!("{FILTER_PREFIX}{name}"))
164    }
165
166    /// The `f.{name}` and `f.{name}.{part}` parameters `value` spells, in the order the filter
167    /// holds them, prefixed like every other parameter.
168    fn filter_pairs<'a>(
169        &'a self,
170        name: &'a str,
171        value: &'a FilterValue,
172    ) -> impl Iterator<Item = (String, &'a str)> + 'a {
173        let param = self.filter_param(name);
174        let values = value
175            .values()
176            .iter()
177            .map(move |held| (param.clone(), held.as_str()));
178        let parts = value
179            .parts()
180            .map(move |(part, held)| (self.filter_param(&format!("{name}.{part}")), held));
181        values.chain(parts)
182    }
183
184    /// How a link spells `name`'s value: the parameters it travels as, encoded like every other
185    /// parameter.
186    pub(crate) fn spell_filter(&self, name: &str, value: &FilterValue) -> String {
187        href::encode_query(&self.filter_pairs(name, value).collect::<Vec<_>>())
188    }
189
190    /// Parse the state from a URL query (without the leading `?`): the one
191    /// parser behind the page and its exports.
192    ///
193    /// A blank or unknown query parses as neutral state rather than failing
194    /// the request. A duplicate key keeps its first occurrence, except a
195    /// filter's value: each `f.<name>` occurrence adds one, in order, and a
196    /// repeated value or part keeps its first occurrence. A blank or dropped
197    /// occurrence adds nothing (a dropped one flags [`Self::filters_dropped`]).
198    /// `q` is trimmed and clamped to `MAX_QUERY_TERM`, `dir` is trimmed before
199    /// comparing, and at most `MAX_FILTERS` filters of at most
200    /// `MAX_FILTER_VALUES` values and parts, each name, part and value at most
201    /// `MAX_FILTER_LEN` bytes, apply. A cursor token is checked later, when it
202    /// decodes.
203    ///
204    /// A rerun parses a client-owned query, so the parse is linear in its
205    /// length: only the known keys are remembered.
206    pub fn from_query(query: &str) -> Self {
207        Self::from_pairs(form_urlencoded::parse(query.as_bytes()))
208    }
209
210    /// The parse behind [`Self::from_query`] and [`Self::from_query_prefixed`],
211    /// over the query's decoded pairs with any table prefix already stripped.
212    fn from_pairs<'q>(pairs: impl Iterator<Item = (Cow<'q, str>, Cow<'q, str>)>) -> Self {
213        let mut state = Self::default();
214        let (mut sort, mut dir, mut after, mut before) = (None, None, None, None);
215        let mut seen = [false; 8];
216        for (key, value) in pairs {
217            if let Some(filter) = key.strip_prefix(FILTER_PREFIX) {
218                state.add_filter(filter, value.trim());
219                continue;
220            }
221            let slot = match key.as_ref() {
222                "q" => 0,
223                "sort" => 1,
224                "dir" => 2,
225                "after" => 3,
226                "before" => 4,
227                "group_by" => 5,
228                "page" => 6,
229                "per_page" => 7,
230                // The retired single-parameter filter spelling: a saved link
231                // must warn (and its export refuse), not list everything.
232                "filters" => {
233                    state.filters_dropped |= !value.trim().is_empty();
234                    continue;
235                }
236                _ => continue,
237            };
238            if std::mem::replace(&mut seen[slot], true) {
239                continue;
240            }
241            let non_empty = || Some(value.trim().to_string()).filter(|v| !v.is_empty());
242            let positive = || value.trim().parse::<u32>().ok().filter(|n| *n > 0);
243            match slot {
244                0 => state.search = Some(clamp_query_term(&value)).filter(|t| !t.is_empty()),
245                1 => sort = non_empty(),
246                2 => dir = Some(value.trim() == "desc"),
247                3 => after = non_empty(),
248                4 => before = non_empty(),
249                5 => state.group_by = non_empty(),
250                6 => state.page = positive(),
251                _ => state.per_page = positive(),
252            }
253        }
254        state.sort = sort.map(|column| Sort {
255            column,
256            descending: dir.unwrap_or(false),
257        });
258        state.cursor = match (after, before) {
259            (Some(token), None) => Some(Cursor::After(token)),
260            (None, Some(token)) => Some(Cursor::Before(token)),
261            _ => None,
262        };
263        state
264    }
265
266    /// Add one `f.{filter}={value}` occurrence, `filter` naming the filter
267    /// and, after its first `.`, the part.
268    fn add_filter(&mut self, filter: &str, value: &str) {
269        let (name, part) = match filter.split_once('.') {
270            Some((name, part)) => (name, Some(part)),
271            None => (filter, None),
272        };
273        let held = self.filters.get(name);
274        if name.is_empty()
275            || part == Some("")
276            || value.is_empty()
277            || held.is_some_and(|held| held.holds(part, value))
278        {
279            return;
280        }
281        let room = match held {
282            Some(held) => held.len() < MAX_FILTER_VALUES,
283            None => self.filters.len() < MAX_FILTERS,
284        };
285        if !room
286            || [name, part.unwrap_or_default(), value]
287                .iter()
288                .any(|s| s.len() > MAX_FILTER_LEN)
289        {
290            self.filters_dropped = true;
291            return;
292        }
293        let held = self.filters.entry(name.to_string()).or_default();
294        match part {
295            Some(part) => held.insert_part(part, value),
296            None => held.push(value),
297        }
298    }
299
300    /// The state's full query: every parameter [`Self::list_url`] carries,
301    /// without the path or the leading `?`.
302    pub(crate) fn query(&self) -> String {
303        self.project(self.full())
304    }
305
306    /// URL projection: `TableState` owns the table's URL vocabulary.
307    /// Callers ask for a user intent, never a parameter list, so adding a
308    /// parameter cannot silently drop it from half the links.
309    ///
310    /// One private encoder ([`Self::project`]) holds the vocabulary in
311    /// canonical order `q, sort, dir, f.*, group_by, per_page, page, after|before`. Only a
312    /// filter's values depend on the order, and each filter writes them in
313    /// the order it holds them.
314    ///
315    /// Expects `group_by` pre-normalized: render seams normalize through
316    /// [`Table::normalize_state`](crate::table::Table::normalize_state), so
317    /// the projection echoes `state.group_by` as-is.
318    ///
319    /// Full state, including the cursor: the retry link for failures that
320    /// keep their evidence.
321    pub(crate) fn list_url(&self, path: &str) -> String {
322        href::with_query(path, &self.query())
323    }
324
325    /// Drops `q` (and the position in its result set); keeps the filters.
326    pub(crate) fn without_search(&self, path: &str) -> String {
327        let projection = UrlProjection {
328            search: None,
329            ..self.first_page_projection()
330        };
331        href::with_query(path, &self.project(projection))
332    }
333
334    /// Drops the filters (and the position in their result set); keeps the
335    /// search term.
336    pub(crate) fn without_filters(&self, path: &str) -> String {
337        let projection = UrlProjection {
338            filters: false,
339            ..self.first_page_projection()
340        };
341        href::with_query(path, &self.project(projection))
342    }
343
344    /// Drops the cursor and the page number; keeps everything else, the page size included.
345    /// Back-to-first-page and the cursor-failure retry link.
346    pub(crate) fn first_page(&self, path: &str) -> String {
347        href::with_query(path, &self.project(self.first_page_projection()))
348    }
349
350    /// Full state but the cursor, the page number kept: where a write returns the reader. A
351    /// cursor can name a row the write removed; a page number past the end reads as the last.
352    pub(crate) fn without_cursor(&self, path: &str) -> String {
353        let projection = UrlProjection {
354            cursor: None,
355            ..self.full()
356        };
357        href::with_query(path, &self.project(projection))
358    }
359
360    /// Full state with `cursor` in place of the current position: a cursor pager's links.
361    pub(crate) fn with_cursor(&self, path: &str, cursor: &Cursor) -> String {
362        let projection = UrlProjection {
363            cursor: Some(cursor),
364            ..self.first_page_projection()
365        };
366        href::with_query(path, &self.project(projection))
367    }
368
369    /// Full state at page `page`: a numbered pager's links. Page 1 spells no parameter.
370    pub(crate) fn with_page(&self, path: &str, page: u32) -> String {
371        let projection = UrlProjection {
372            page: Some(page).filter(|page| *page > 1),
373            ..self.first_page_projection()
374        };
375        href::with_query(path, &self.project(projection))
376    }
377
378    /// Replaces `sort`/`dir`, drops the position: a new ordering is a new
379    /// result set.
380    pub(crate) fn sorted_by(&self, path: &str, column: &str, descending: bool) -> String {
381        let projection = UrlProjection {
382            sort: Some((column, if descending { "desc" } else { "asc" })),
383            ..self.first_page_projection()
384        };
385        href::with_query(path, &self.project(projection))
386    }
387
388    /// The parameters a form rerunning this state at its first page carries, besides its own
389    /// fields: every one but `skip`, the page and the cursor.
390    pub(crate) fn hidden_pairs(&self, skip: &str) -> Vec<(String, String)> {
391        let skip = self.param(skip);
392        form_urlencoded::parse(self.project(self.first_page_projection()).as_bytes())
393            .filter(|(name, _)| *name != skip)
394            .map(|(name, value)| (name.into_owned(), value.into_owned()))
395            .collect()
396    }
397
398    /// `?sort=` column + `?dir=` value for the projection.
399    fn sort_pair(&self) -> Option<(&str, &str)> {
400        self.sort
401            .as_ref()
402            .map(|s| (s.column.as_str(), if s.descending { "desc" } else { "asc" }))
403    }
404
405    /// The projection that keeps every link parameter.
406    fn full(&self) -> UrlProjection<'_> {
407        UrlProjection {
408            search: self.search.as_deref(),
409            sort: self.sort_pair(),
410            filters: true,
411            group_by: self.group_by.as_deref(),
412            cursor: self.cursor.as_ref(),
413            page: self.page,
414            per_page: self.per_page,
415        }
416    }
417
418    /// The projection that keeps every parameter but the position: the result set's first page.
419    fn first_page_projection(&self) -> UrlProjection<'_> {
420        UrlProjection {
421            cursor: None,
422            page: None,
423            ..self.full()
424        }
425    }
426
427    /// The one encoder: every table link's parameter vocabulary lives here.
428    ///
429    /// The exhaustive destructure fails compilation when a field is added to
430    /// `TableState`, forcing the author to decide where it projects.
431    fn project(&self, projection: UrlProjection<'_>) -> String {
432        let TableState {
433            prefix: _,
434            search: _,
435            sort: _,
436            cursor: _,
437            page: _,
438            per_page: _,
439            filters: _,
440            filters_dropped: _,
441            group_by: _,
442        } = self;
443        let filters = projection
444            .filters
445            .then_some(&self.filters)
446            .into_iter()
447            .flatten()
448            .flat_map(|(name, value)| self.filter_pairs(name, value))
449            .map(|(key, value)| (key, Some(value)));
450        let cursor = match projection.cursor {
451            Some(Cursor::After(token)) => ("after", Some(token.as_str())),
452            Some(Cursor::Before(token)) => ("before", Some(token.as_str())),
453            None => ("after", None),
454        };
455        let per_page = projection.per_page.map(|n| n.to_string());
456        let page = projection.page.map(|n| n.to_string());
457        let pairs: Vec<(String, &str)> = [
458            (self.param("q"), projection.search),
459            (
460                self.param("sort"),
461                projection.sort.map(|(column, _)| column),
462            ),
463            (self.param("dir"), projection.sort.map(|(_, dir)| dir)),
464        ]
465        .into_iter()
466        .chain(filters)
467        .chain([
468            (self.param("group_by"), projection.group_by),
469            (self.param("per_page"), per_page.as_deref()),
470            (self.param("page"), page.as_deref()),
471            (self.param(cursor.0), cursor.1),
472        ])
473        .filter_map(|(key, value)| value.map(|value| (key, value)))
474        .collect();
475        href::encode_query(&pairs)
476    }
477}
478
479/// FNV-1a (32-bit): stable across runs and Rust versions, no dependency.
480/// Used only to disambiguate DOM ids, never for anything security-relevant.
481fn fnv1a_32(s: &str) -> u32 {
482    let mut hash: u32 = 0x811c_9dc5;
483    for byte in s.bytes() {
484        hash ^= u32::from(byte);
485        hash = hash.wrapping_mul(0x0100_0193);
486    }
487    hash
488}
489
490/// Stable DOM id for a table row: Topcoat's morph (topcoat#392) follows
491/// elements by `id` across reruns, so reorderable row content needs one in
492/// addition to the keyed-diff `key:`. Derived from the row key (stable for
493/// the record, unlike a loop index), sanitized to an HTML-safe token plus a
494/// short hash: distinct keys (`Ada Lovelace`, `Ada-Lovelace`) can sanitize to
495/// the same token, and duplicate DOM ids would make the morph follow one row.
496/// `scope` names the table, distinct per table on the page: two tables can hold one key.
497pub(crate) fn row_dom_id(scope: &str, key: &str) -> String {
498    dom_id(&format!("row-{scope}"), key)
499}
500
501/// Stable DOM id for a page-local group header.
502///
503/// Same contract as [`row_dom_id`]: the header is injected, removed and moved
504/// as the page is re-sorted, so the in-place morph needs an id derived from the
505/// group label it belongs to rather than from its position in the page.
506pub(crate) fn group_header_dom_id(scope: &str, label: &str) -> String {
507    dom_id(&format!("group-{scope}"), label)
508}
509
510/// The one sanitizer behind both ids: `{prefix}-{token}-{hash}`.
511fn dom_id(prefix: &str, key: &str) -> String {
512    let mut out = String::with_capacity(prefix.len() + key.len() + 14);
513    out.push_str(prefix);
514    out.push('-');
515    for c in key.chars() {
516        if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | ':' | '.') {
517            out.push(c);
518        } else {
519            out.push('-');
520        }
521    }
522    out.push_str(&format!("-{:08x}", fnv1a_32(key)));
523    out
524}
525
526/// The request's URL query, without the leading `?`; empty without a
527/// request context.
528pub(crate) fn request_query(cx: &Cx) -> String {
529    topcoat::context::try_request_context::<http::request::Parts>(cx)
530        .and_then(|parts| parts.uri.query().map(str::to_string))
531        .unwrap_or_default()
532}
533
534/// The query part of a URL this module built: what follows the `?`, or empty.
535pub(crate) fn query_of(url: &str) -> &str {
536    url.split_once('?').map_or("", |(_, query)| query)
537}
538
539#[cfg(test)]
540mod tests;