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;