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