Skip to main content

tablo_core/table/
mod.rs

1//! A resource's list view: the [`Table`] builder and its columns and filters, the URL state a list
2//! reads, query planning (`filter_expr`/`search_expr`/`order_bys_for`), and the page a table loads.
3//!
4//! Rendering lives in `render`, CSV export in `export`.
5
6use std::{marker::PhantomData, num::NonZeroUsize, sync::Arc};
7
8use toasty::stmt::{Expr, List, OrderByExpr};
9
10use self::{column::BoxColumn, filter::BoxFilter};
11use crate::{DeclarationErrorKind, Lens, form::FormScalar, schema::ResolvedLens};
12
13mod column;
14mod export;
15mod filter;
16mod page;
17mod render;
18mod state;
19
20pub use self::{
21    column::{
22        BooleanColumn, Column, ColumnWidth, ComputedColumn, Includes, IntoColumns, TextColumn,
23        contains_expr,
24    },
25    filter::{
26        DateFilter, Filter, FilterInput, IntoFilters, QueryFilter, SelectFilter, TernaryFilter,
27    },
28    page::TablePage,
29    state::{Cursor, Sort, TableState},
30};
31pub(crate) use self::{
32    page::{Past, row_exists_past},
33    render::TABLE_CARD_CLASS,
34    state::{
35        ACTION_ROUTE_PARAM, ACTIONS_ROUTE_SEGMENT, BULK_DELETE_ROUTE_SEGMENT, CREATE_ROUTE_SEGMENT,
36        DASH_ROUTE_SEGMENT, DELETE_ROUTE_SEGMENT, EDIT_ROUTE_SEGMENT, RECORD_ROUTE_PARAM,
37        RETURN_PARAM, TableSignals, create_page_url, query_of, request_query, with_return,
38    },
39};
40
41/// Group-label projection reads a row's group off one model instance.
42pub(crate) type GroupKey<M> = Arc<dyn Fn(&M) -> String + Send + Sync>;
43
44/// Per-record action policy reads which row actions one model instance allows.
45pub(crate) type RowPolicy<M> = Arc<dyn Fn(&M) -> RowActions + Send + Sync>;
46
47/// Which row actions one record may use.
48///
49/// A denied action emits no link, and a row denied `delete` renders no bulk checkbox.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub(crate) struct RowActions {
52    /// Whether the row renders its `View` link.
53    pub view: bool,
54    /// Whether the row renders its `Edit` link.
55    pub edit: bool,
56    /// Whether the row renders its `Delete` link and an enabled bulk checkbox.
57    pub delete: bool,
58}
59
60impl RowActions {
61    /// Every action allowed.
62    pub const ALL: Self = Self {
63        view: true,
64        edit: true,
65        delete: true,
66    };
67}
68
69/// A named grouping a `Table` renders.
70pub(crate) struct GroupDef<M> {
71    name: String,
72    key: GroupKey<M>,
73}
74
75impl<M> Clone for GroupDef<M> {
76    fn clone(&self) -> Self {
77        Self {
78            name: self.name.clone(),
79            key: Arc::clone(&self.key),
80        }
81    }
82}
83
84/// The action chrome a resource declares.
85#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
86pub(crate) struct TableChrome {
87    /// Whether the row renders a Delete action (which also enables bulk).
88    pub(crate) delete: bool,
89    /// Whether the row renders an Edit action.
90    pub(crate) edit: bool,
91    /// Whether the row renders a View action.
92    pub(crate) view: bool,
93    /// Whether the table renders the resource's custom actions.
94    pub(crate) actions: bool,
95}
96
97/// One custom [`Action`](crate::resource::Action) as a table renders it.
98pub(crate) struct TableAction<M> {
99    pub(crate) name: &'static str,
100    pub(crate) label: String,
101    pub(crate) row: bool,
102    pub(crate) bulk: bool,
103    pub(crate) confirm: bool,
104    pub(crate) allowed: Arc<dyn Fn(&M) -> bool + Send + Sync>,
105}
106
107impl<M> Clone for TableAction<M> {
108    fn clone(&self) -> Self {
109        Self {
110            name: self.name,
111            label: self.label.clone(),
112            row: self.row,
113            bulk: self.bulk,
114            confirm: self.confirm,
115            allowed: Arc::clone(&self.allowed),
116        }
117    }
118}
119
120/// The page size of a table that declares none with [`Table::paginate`].
121pub const DEFAULT_PAGE_SIZE: NonZeroUsize = NonZeroUsize::new(25).unwrap();
122
123/// Table description of a `Resource`'s list view declaring columns and how they map to queries.
124pub struct Table<M> {
125    columns: Vec<BoxColumn<M>>,
126    /// Misdeclarations a builder recorded ([`Self::declaration_errors`]).
127    misdeclared: Vec<DeclarationErrorKind>,
128    filters: Vec<BoxFilter<M>>,
129    group_by: Option<GroupDef<M>>,
130    /// A row's key: its record's primary key, as its action URLs carry it.
131    key: fn(&M) -> String,
132    /// Whether a row's key resolves as a URL id; a composite key does not, so its rows carry no
133    /// action.
134    addressable: bool,
135    row_policy: Option<RowPolicy<M>>,
136    page_size: NonZeroUsize,
137    hide_search: bool,
138    hide_filter_bar: bool,
139    delete_prefix: Option<String>,
140    edit_prefix: Option<String>,
141    view_prefix: Option<String>,
142    bulk_delete: bool,
143    /// The custom actions and the list URL their routes hang off.
144    custom_actions: Vec<TableAction<M>>,
145    actions_prefix: Option<String>,
146    live_search: bool,
147    /// Whether the table draws its own card.
148    framed: bool,
149    /// Where a write this table's row and bulk actions start lands.
150    return_to: Option<String>,
151    _marker: PhantomData<M>,
152}
153
154/// A copy sharing every column, filter and projection.
155impl<M> Clone for Table<M> {
156    fn clone(&self) -> Self {
157        Self {
158            columns: self.columns.clone(),
159            misdeclared: self.misdeclared.clone(),
160            filters: self.filters.clone(),
161            group_by: self.group_by.clone(),
162            key: self.key,
163            addressable: self.addressable,
164            row_policy: self.row_policy.clone(),
165            page_size: self.page_size,
166            hide_search: self.hide_search,
167            hide_filter_bar: self.hide_filter_bar,
168            delete_prefix: self.delete_prefix.clone(),
169            edit_prefix: self.edit_prefix.clone(),
170            view_prefix: self.view_prefix.clone(),
171            bulk_delete: self.bulk_delete,
172            custom_actions: self.custom_actions.clone(),
173            actions_prefix: self.actions_prefix.clone(),
174            live_search: self.live_search,
175            framed: self.framed,
176            return_to: self.return_to.clone(),
177            _marker: PhantomData,
178        }
179    }
180}
181
182impl<M> std::fmt::Debug for Table<M> {
183    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
184        f.debug_struct("Table")
185            .field(
186                "columns",
187                &self.columns.iter().map(|c| c.name()).collect::<Vec<_>>(),
188            )
189            .field("filters", &self.filters.len())
190            .field("group_by", &self.group_by.is_some())
191            .field("row_policy", &self.row_policy.is_some())
192            .field("page_size", &self.page_size)
193            .field("hide_search", &self.hide_search)
194            .field("hide_filter_bar", &self.hide_filter_bar)
195            .field("delete_prefix", &self.delete_prefix)
196            .field("edit_prefix", &self.edit_prefix)
197            .field("view_prefix", &self.view_prefix)
198            .field("bulk_delete", &self.bulk_delete)
199            .field(
200                "custom_actions",
201                &self
202                    .custom_actions
203                    .iter()
204                    .map(|a| a.name)
205                    .collect::<Vec<_>>(),
206            )
207            .field("live_search", &self.live_search)
208            .field("framed", &self.framed)
209            .field("return_to", &self.return_to)
210            .finish()
211    }
212}
213
214impl<M> Table<M> {
215    /// Declare a table of `cols`.
216    ///
217    /// Each row is keyed by its record's primary key, which the row's action URLs carry.
218    pub fn new(cols: impl IntoColumns<M>) -> Self
219    where
220        M: toasty::schema::Model + toasty::stmt::IntoExpr<M>,
221    {
222        Self {
223            columns: cols.into_columns(),
224            misdeclared: Vec::new(),
225            filters: Vec::new(),
226            group_by: None,
227            key: crate::toasty_compat::pk::pk_text::<M>,
228            addressable: !crate::toasty_compat::pk::pk_is_composite::<M>(),
229            row_policy: None,
230            page_size: DEFAULT_PAGE_SIZE,
231            hide_search: false,
232            hide_filter_bar: false,
233            delete_prefix: None,
234            edit_prefix: None,
235            view_prefix: None,
236            bulk_delete: false,
237            custom_actions: Vec::new(),
238            actions_prefix: None,
239            live_search: false,
240            framed: true,
241            return_to: None,
242            _marker: PhantomData,
243        }
244    }
245
246    /// Append `column` after the declared ones.
247    pub fn column(mut self, column: impl Column<M> + 'static) -> Self {
248        self.columns.push(Arc::new(column));
249        self
250    }
251
252    /// Declare the per-record action policy.
253    pub(crate) fn row_actions(
254        mut self,
255        policy: impl Fn(&M) -> RowActions + Send + Sync + 'static,
256    ) -> Self {
257        self.row_policy = Some(Arc::new(policy));
258        self
259    }
260
261    /// Declare filters.
262    pub fn filters(mut self, filters: impl IntoFilters<M>) -> Self
263    where
264        M: toasty::schema::Model,
265    {
266        let filters = filters.into_filters();
267        self.filters = filters;
268        self
269    }
270
271    /// Include every relation this table's columns declare, once each.
272    pub(crate) fn include_relations(
273        &self,
274        mut query: toasty::stmt::Query<List<M>>,
275    ) -> toasty::stmt::Query<List<M>>
276    where
277        M: toasty::schema::Model,
278    {
279        let mut seen: Vec<toasty_core::stmt::Include> = Vec::new();
280        for include in self.columns.iter().flat_map(|c| c.includes().into_vec()) {
281            if !seen.contains(&include) {
282                query = query.include(include.clone());
283                seen.push(include);
284            }
285        }
286        query
287    }
288
289    /// Filter predicate for the current `TableState`.
290    pub(crate) fn filter_expr(&self, state: &TableState) -> Option<Expr<bool>>
291    where
292        M: toasty::schema::Model,
293    {
294        let mut exprs = Vec::new();
295        for f in &self.filters {
296            if let Some(v) = state.filters.get(f.name())
297                && let Some(e) = f.to_expr(v)
298            {
299                exprs.push(e);
300            }
301        }
302        if exprs.is_empty() {
303            None
304        } else {
305            let mut iter = exprs.into_iter();
306            let first = iter.next().unwrap();
307            Some(iter.fold(first, |acc, e| acc.and(e)))
308        }
309    }
310
311    /// Requested filters that produce no predicate.
312    pub(crate) fn unapplied_filters(&self, state: &TableState) -> Vec<(String, String)>
313    where
314        M: toasty::schema::Model,
315    {
316        let mut out = Vec::new();
317        for (key, value) in &state.filters {
318            match self.filters.iter().find(|f| f.name() == key) {
319                None => out.push((format!("{key}:{value}"), "unknown filter".to_string())),
320                Some(f) if f.to_expr(value).is_none() && !f.is_noop_value(value) => {
321                    out.push((format!("{key}:{value}"), "invalid value".to_string()))
322                }
323                Some(_) => {}
324            }
325        }
326        if state.filters_dropped {
327            out.push((
328                "dropped filters".to_string(),
329                format!(
330                    "more than {}, over {} bytes, or the retired filters= form",
331                    state::MAX_FILTERS,
332                    state::MAX_FILTER_LEN
333                ),
334            ));
335        }
336        out.sort();
337        out
338    }
339
340    /// Offer grouping the page's rows by the field `lens` reads, under the field's name.
341    pub fn group_by<T>(mut self, lens: Lens<M, T>) -> Self
342    where
343        M: toasty::schema::Model + Send + Sync + 'static,
344        T: FormScalar + Send + Sync + 'static,
345    {
346        let binding = ResolvedLens::of(lens.path().clone());
347        self.misdeclared.extend(binding.misdeclared);
348        self.group_by = Some(GroupDef {
349            name: binding.name,
350            key: Arc::new(move |record| lens.read(record).to_form()),
351        });
352        self
353    }
354
355    /// The declared grouping iff `state.group_by` names it.
356    fn effective_group_key(&self, state: &TableState) -> Option<GroupKey<M>> {
357        match (&self.group_by, &state.group_by) {
358            (Some(def), Some(want)) if def.name == *want => Some(def.key.clone()),
359            _ => None,
360        }
361    }
362
363    /// Normalize `state.group_by` against the declared grouping.
364    pub(crate) fn normalize_state(&self, state: &TableState) -> TableState {
365        let mut out = state.clone();
366        if self.group_by.as_ref().map(|def| def.name.as_str()) != out.group_by.as_deref() {
367            out.group_by = None;
368        }
369        out
370    }
371
372    /// Set the page size.
373    pub fn paginate(mut self, per_page: usize) -> Self {
374        match NonZeroUsize::new(per_page) {
375            Some(size) => self.page_size = size,
376            None => self.misdeclared.push(DeclarationErrorKind::ZeroPageSize),
377        }
378        self
379    }
380
381    /// What is wrong with this declaration.
382    pub fn declaration_errors(&self) -> Vec<DeclarationErrorKind> {
383        let mut errors = self.misdeclared.clone();
384        if self.columns.is_empty() {
385            errors.push(DeclarationErrorKind::NoColumns);
386        }
387        let mut seen = std::collections::HashSet::with_capacity(self.columns.len());
388        for column in &self.columns {
389            match column.misdeclared() {
390                Some(error) => errors.push(error),
391                None if !seen.insert(column.name()) => {
392                    errors.push(DeclarationErrorKind::DuplicateColumn {
393                        name: column.name().to_string(),
394                    });
395                }
396                None => {}
397            }
398        }
399        let mut seen = std::collections::HashSet::with_capacity(self.filters.len());
400        for filter in &self.filters {
401            match filter.misdeclared() {
402                Some(error) => errors.push(error),
403                None if !seen.insert(filter.name()) => {
404                    errors.push(DeclarationErrorKind::DuplicateFilter {
405                        name: filter.name().to_string(),
406                    });
407                }
408                None => {}
409            }
410        }
411        errors
412    }
413
414    /// The page size.
415    pub fn page_size(&self) -> usize {
416        self.page_size.get()
417    }
418
419    /// The key of `record`'s row: its primary key's URL id.
420    pub(crate) fn key_of(&self, record: &M) -> String {
421        (self.key)(record)
422    }
423
424    /// Which row actions `record` allows.
425    pub(crate) fn actions_for(&self, record: &M) -> RowActions {
426        self.row_policy
427            .as_ref()
428            .map_or(RowActions::ALL, |policy| policy(record))
429    }
430
431    /// Render no search toolbar in the table.
432    pub fn hide_search(mut self) -> Self {
433        self.hide_search = true;
434        self
435    }
436
437    /// Render no filter bar in the table.
438    pub fn hide_filter_bar(mut self) -> Self {
439        self.hide_filter_bar = true;
440        self
441    }
442
443    /// Render the table without its own card.
444    pub(crate) fn unframed(mut self) -> Self {
445        self.framed = false;
446        self
447    }
448
449    /// Enable keystroke-live search via the `table_search` shard.
450    pub fn live_search(mut self) -> Self {
451        self.live_search = true;
452        self
453    }
454
455    /// Enable row-level `Delete` action.
456    pub(crate) fn with_delete(mut self, prefix: String) -> Self {
457        if self.addressable {
458            self.delete_prefix = Some(prefix);
459        }
460        self
461    }
462
463    /// Enable row-level `Edit` action.
464    pub(crate) fn with_edit(mut self, prefix: String) -> Self {
465        if self.addressable {
466            self.edit_prefix = Some(prefix);
467        }
468        self
469    }
470
471    /// Enable the row-level `View` action.
472    pub(crate) fn with_view(mut self, prefix: String) -> Self {
473        if self.addressable {
474            self.view_prefix = Some(prefix);
475        }
476        self
477    }
478
479    /// Enable bulk selection with `BulkDelete` action.
480    pub(crate) fn with_bulk_delete(mut self, enabled: bool) -> Self {
481        self.bulk_delete = enabled;
482        self
483    }
484
485    /// Send the writes this table starts back to `url`.
486    pub(crate) fn returning_to(mut self, url: String) -> Self {
487        self.return_to = Some(url);
488        self
489    }
490
491    fn action_url(&self, url: String) -> String {
492        match &self.return_to {
493            Some(target) => with_return(&url, target),
494            None => url,
495        }
496    }
497
498    /// Wire the resource's custom actions.
499    pub(crate) fn with_custom_actions(
500        mut self,
501        prefix: String,
502        actions: Vec<TableAction<M>>,
503    ) -> Self {
504        if self.addressable {
505            self.actions_prefix = Some(prefix);
506            self.custom_actions = actions;
507        }
508        self
509    }
510
511    fn row_custom_actions(&self) -> impl Iterator<Item = &TableAction<M>> {
512        self.custom_actions
513            .iter()
514            .filter(|a| a.row && self.actions_prefix.is_some())
515    }
516
517    fn bulk_custom_actions(&self) -> impl Iterator<Item = &TableAction<M>> {
518        self.custom_actions
519            .iter()
520            .filter(|a| a.bulk && self.actions_prefix.is_some())
521    }
522
523    fn bulk_delete_enabled(&self) -> bool {
524        self.bulk_delete && self.delete_prefix.is_some()
525    }
526
527    fn bulk_enabled(&self) -> bool {
528        self.bulk_delete_enabled() || self.bulk_custom_actions().next().is_some()
529    }
530
531    /// Global search predicate across searchable columns.
532    pub(crate) fn search_expr(&self, term: &str) -> Option<Expr<bool>>
533    where
534        M: toasty::schema::Model,
535    {
536        let t = term.trim();
537        if t.is_empty() {
538            return None;
539        }
540        let mut exprs = self.columns.iter().filter_map(|c| c.search_expr(t));
541        let first = exprs.next()?;
542        Some(exprs.fold(first, |acc, e| acc.or(e)))
543    }
544
545    /// First sortable column's order_by (tokio-rs/toasty#1142).
546    pub(crate) fn order_by(&self, descending: bool) -> Option<OrderByExpr>
547    where
548        M: toasty::schema::Model,
549    {
550        self.columns.iter().find_map(|c| c.order_by(descending))
551    }
552
553    /// Order-bys over the model's primary key.
554    fn pk_order_bys() -> Vec<OrderByExpr>
555    where
556        M: toasty::schema::Model,
557    {
558        let app_model = M::schema();
559        let Some(root) = app_model.as_root() else {
560            debug_assert!(
561                false,
562                "pk_order_bys: {} is not a root model; deterministic pagination needs its primary key",
563                std::any::type_name::<M>()
564            );
565            return Vec::new();
566        };
567        root.primary_key
568            .fields
569            .iter()
570            .map(|fid| M::path_field::<toasty::stmt::Value>(fid.index).asc())
571            .collect()
572    }
573
574    /// Resolve the full query ordering for a request.
575    pub(crate) fn order_bys_for(&self, state: &TableState) -> Vec<OrderByExpr>
576    where
577        M: toasty::schema::Model,
578    {
579        if let Some(sort) = &state.sort
580            && let Some(col) = self
581                .columns
582                .iter()
583                .find(|c| c.is_sortable() && c.name() == sort.column)
584            && let Some(ord) = col.order_by(sort.descending)
585        {
586            return vec![ord];
587        }
588        let out: Vec<OrderByExpr> = self.order_by(false).into_iter().collect();
589        if out.is_empty() {
590            return Self::pk_order_bys();
591        }
592        out
593    }
594
595    /// Apply this table's declaration to `query`.
596    pub(crate) fn apply_declaration(
597        &self,
598        mut query: toasty::stmt::Query<List<M>>,
599        state: &TableState,
600    ) -> toasty::stmt::Query<List<M>>
601    where
602        M: toasty::schema::Model,
603    {
604        if let Some(term) = &state.search
605            && let Some(expr) = self.search_expr(term)
606        {
607            query = query.filter(expr);
608        }
609        if let Some(expr) = self.filter_expr(state) {
610            query = query.filter(expr);
611        }
612        for ord in self.order_bys_for(state) {
613            query = query.order_by(ord);
614        }
615        query
616    }
617
618    /// Whether the search toolbar renders.
619    pub(crate) fn search_enabled(&self) -> bool
620    where
621        M: toasty::schema::Model,
622    {
623        !self.hide_search && self.columns.iter().any(|c| c.is_searchable())
624    }
625
626    /// Whether this table renders the keystroke-live search host.
627    pub(crate) fn is_live_search(&self) -> bool {
628        self.live_search
629    }
630
631    /// Whether the filter bar renders inside the table.
632    pub(crate) fn filter_bar_enabled(&self) -> bool {
633        !self.hide_filter_bar && !self.filters.is_empty()
634    }
635}
636
637#[cfg(test)]
638mod tests;