Skip to main content

tablo_core/table/
column.rs

1//! Table and detail columns: the [`Column`] trait, the built-in [`TextColumn`],
2//! [`ComputedColumn`], [`RelationColumn`], [`CountColumn`], [`BooleanColumn`], [`FileColumn`] and
3//! [`EmbeddedColumn`], and the [`IntoColumns`] seam.
4
5use std::{borrow::Cow, sync::Arc};
6
7use toasty::stmt::{Expr, List, OrderByExpr, Query};
8use topcoat::{context::Cx, icon::icon, view::*};
9
10use crate::{
11    Lens,
12    form::FormScalar,
13    schema::{Binding, FieldResolver},
14};
15
16mod embedded;
17mod relation;
18mod repeater;
19
20pub use embedded::EmbeddedColumn;
21pub use relation::{CountColumn, RelationColumn, RelationLens, ToOneRelation};
22pub use repeater::RepeaterColumn;
23
24/// One column of a table or a [`Detail`](crate::Detail) declares its label, its value read off
25/// the record, and its query predicates.
26///
27/// ```rust
28/// # #[derive(Debug, Clone, toasty::Model)]
29/// # struct User { #[key] #[auto] id: uuid::Uuid, name: String }
30/// # use tablo_core::extend::Column;
31/// # use topcoat::context::Cx;
32/// struct Initials;
33///
34/// impl Column<User> for Initials {
35///     fn name(&self) -> &str {
36///         "initials"
37///     }
38///     fn label(&self) -> &str {
39///         "Initials"
40///     }
41///     fn text(&self, _cx: &Cx, row: &User) -> String {
42///         row.name
43///             .split_whitespace()
44///             .filter_map(|w| w.chars().next())
45///             .collect()
46///     }
47/// }
48/// ```
49///
50/// Only [`name`](Self::name), [`label`](Self::label) and
51/// [`text`](Self::text) are required.
52pub trait Column<M>: Send + Sync {
53    /// The column's identifier, distinct within its table.
54    fn name(&self) -> &str;
55
56    /// The header text.
57    fn label(&self) -> &str;
58
59    /// The row's value as plain text.
60    fn text(&self, cx: &Cx, row: &M) -> String;
61
62    /// The row's table cell.
63    fn cell<'a>(&self, cx: &'a Cx, row: &M) -> BoxView<'a> {
64        crate::schema::value_cell(cx, &self.text(cx, row))
65    }
66
67    /// The record's entry on a detail page: the [`label`](Self::label) over the
68    /// [`cell`](Self::cell).
69    fn entry<'a>(&self, cx: &'a Cx, row: &M) -> BoxView<'a> {
70        crate::schema::read_only(cx, self.label(), self.cell(cx, row))
71    }
72
73    /// The width the column claims.
74    fn column_width(&self) -> ColumnWidth {
75        ColumnWidth::Narrow
76    }
77
78    /// Whether the column joins the table's search.
79    fn is_searchable(&self) -> bool {
80        false
81    }
82
83    /// The predicate a search for `term` adds.
84    fn search_expr(&self, _term: &str) -> Option<Expr<bool>> {
85        None
86    }
87
88    /// Whether the header links to a sort on this column.
89    fn is_sortable(&self) -> bool {
90        false
91    }
92
93    /// The ordering a sort on this column applies.
94    fn order_by(&self, _descending: bool) -> Option<OrderByExpr> {
95        None
96    }
97
98    /// The relations [`text`](Self::text), [`cell`](Self::cell) and [`entry`](Self::entry) read.
99    fn includes(&self) -> Includes<M> {
100        Includes::new()
101    }
102
103    /// What is wrong with this column's declaration.
104    #[doc(hidden)]
105    fn misdeclared(&self) -> Option<crate::DeclarationErrorKind> {
106        None
107    }
108
109    /// The source the column labels its records by, when the context's panel cannot load from it.
110    #[doc(hidden)]
111    fn unavailable_source(&self, _cx: &Cx) -> Option<&'static str> {
112        None
113    }
114
115    /// Bind an embedded path through `resolver`'s app schema.
116    #[doc(hidden)]
117    fn bind(&self, _resolver: &FieldResolver) {}
118}
119
120/// The relations a [`Column`] reads off its row.
121pub struct Includes<M>(
122    Vec<crate::toasty_compat::UntypedInclude>,
123    std::marker::PhantomData<fn() -> M>,
124);
125
126impl<M> Default for Includes<M> {
127    fn default() -> Self {
128        Self(Vec::new(), std::marker::PhantomData)
129    }
130}
131
132impl<M> Clone for Includes<M> {
133    fn clone(&self) -> Self {
134        Self(self.0.clone(), std::marker::PhantomData)
135    }
136}
137
138impl<M> std::fmt::Debug for Includes<M> {
139    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140        f.debug_tuple("Includes").field(&self.0).finish()
141    }
142}
143
144impl<M> Includes<M> {
145    /// No relation.
146    pub fn new() -> Self {
147        Self::default()
148    }
149
150    /// Add `relation`.
151    pub fn with<T>(mut self, relation: impl Into<toasty::stmt::Include<M, T>>) -> Self {
152        let include: crate::toasty_compat::UntypedInclude = relation.into().into();
153        if !self.0.contains(&include) {
154            self.0.push(include);
155        }
156        self
157    }
158
159    /// The relations, in the order they were added.
160    pub(crate) fn into_vec(self) -> Vec<crate::toasty_compat::UntypedInclude> {
161        self.0
162    }
163
164    pub fn len(&self) -> usize {
165        self.0.len()
166    }
167
168    pub fn is_empty(&self) -> bool {
169        self.0.is_empty()
170    }
171}
172
173/// Include in `query` every relation `columns` declare, once each.
174/// One [`UnregisteredLabelSource`](DeclarationErrorKind::UnregisteredLabelSource) per column
175/// labelling its records by a source `cx`'s panel cannot load from.
176pub(crate) fn unavailable_sources<'c, M: 'c>(
177    cx: &Cx,
178    columns: impl IntoIterator<Item = &'c dyn Column<M>>,
179) -> Vec<crate::DeclarationErrorKind> {
180    columns
181        .into_iter()
182        .filter_map(|column| {
183            column.unavailable_source(cx).map(|source| {
184                crate::DeclarationErrorKind::UnregisteredLabelSource {
185                    column: column.name().to_string(),
186                    source,
187                }
188            })
189        })
190        .collect()
191}
192
193pub(crate) fn include_relations<'c, M>(
194    mut query: Query<List<M>>,
195    columns: impl IntoIterator<Item = &'c BoxColumn<M>>,
196) -> Query<List<M>>
197where
198    M: toasty::schema::Model + 'c,
199{
200    let mut seen: Vec<crate::toasty_compat::UntypedInclude> = Vec::new();
201    for include in columns.into_iter().flat_map(|c| c.includes().into_vec()) {
202        if !seen.contains(&include) {
203            query = query.include(include.clone());
204            seen.push(include);
205        }
206    }
207    query
208}
209
210/// The share of the table a [`ColumnWidth::Narrow`] column claims, in whole
211/// percent.
212pub(crate) const NARROW_DEFAULT_PERCENT: u8 = 10;
213
214/// The width a [`Column`] claims in the table's fixed layout.
215#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
216pub enum ColumnWidth {
217    /// Take a share of whatever the declared columns leave.
218    #[default]
219    Wide,
220    /// A share of the table for a status, boolean, date or count cell.
221    Narrow,
222    /// An explicit length in whole rem.
223    Rem(u8),
224    /// An explicit share of the table in whole percent.
225    Percent(u8),
226}
227
228impl ColumnWidth {
229    /// The share of the table this column claims as a kind default, in whole percent.
230    pub(crate) fn default_percent(self) -> Option<u8> {
231        match self {
232            Self::Narrow => Some(NARROW_DEFAULT_PERCENT),
233            Self::Wide | Self::Rem(_) | Self::Percent(_) => None,
234        }
235    }
236
237    /// The `style` attribute value an explicit declaration emits.
238    pub(crate) fn explicit_css(self) -> Option<Cow<'static, str>> {
239        match self {
240            Self::Rem(rem) => Some(Cow::Owned(format!("width: {rem}rem"))),
241            Self::Percent(percent) => Some(Cow::Owned(format!("width: {percent}%"))),
242            Self::Wide | Self::Narrow => None,
243        }
244    }
245}
246
247/// A column of one field, rendered as text and bound through a [`Lens`] for sorting and search.
248///
249/// ```rust
250/// # #[derive(Debug, Clone, toasty::Model)]
251/// # struct User {
252/// #     #[key] #[auto] id: uuid::Uuid,
253/// #     name: String,
254/// #     email: String,
255/// #     age: i64,
256/// # }
257/// tablo_core::TextColumn::new(tablo_core::lens!(User.name))
258///     .searchable()
259///     .sortable();
260/// tablo_core::TextColumn::new(tablo_core::lens!(User.age))
261///     .sortable()
262///     .format(|age| format!("{age} years"));
263/// ```
264///
265/// The cell is the value's [`FormScalar::to_label`], an [`Options`](crate::Options) enum's label
266/// or any other type's form spelling, unless [`format`](Self::format) says otherwise. Only a
267/// string field is [`searchable`](Self::searchable):
268///
269/// ```compile_fail
270/// # #[derive(Debug, Clone, toasty::Model)]
271/// # struct User { #[key] #[auto] id: uuid::Uuid, age: i64 }
272/// # fn main() {
273/// tablo_core::TextColumn::new(tablo_core::lens!(User.age)).searchable();
274/// # }
275/// ```
276pub struct TextColumn<M, T> {
277    lens: Lens<M, T>,
278    binding: Binding,
279    /// The declared label, over the binding's.
280    label: Option<String>,
281    format: Arc<dyn Fn(&T) -> String + Send + Sync>,
282    /// The `LIKE` predicate for a search pattern, when [`searchable`](Self::searchable).
283    search: Option<SearchFn>,
284    sortable: bool,
285    /// The width this column claims in the table's fixed layout.
286    width: ColumnWidth,
287}
288
289/// A searchable column's predicate for an escaped `LIKE` pattern.
290type SearchFn = Arc<dyn Fn(String) -> Expr<bool> + Send + Sync>;
291
292/// The escape character the search pattern declares to `LIKE`.
293pub(crate) const LIKE_ESCAPE: char = '\\';
294
295/// Wrap `term` as a `LIKE` pattern matching it anywhere in the column.
296pub(crate) fn escape_like_pattern(term: &str) -> String {
297    let mut pattern = String::with_capacity(term.len() + 2);
298    pattern.push('%');
299    for c in term.chars() {
300        if c == LIKE_ESCAPE || c == '%' || c == '_' {
301            pattern.push(LIKE_ESCAPE);
302        }
303        pattern.push(c);
304    }
305    pattern.push('%');
306    pattern
307}
308
309/// Escaped substring predicate for `lens` matching `term`.
310pub fn contains_expr<M, T>(lens: &Lens<M, T>, term: &str) -> Option<Expr<bool>>
311where
312    M: toasty::schema::Model,
313    T: toasty::schema::Field<Inner = String>,
314{
315    let trimmed = term.trim();
316    (!trimmed.is_empty()).then(|| {
317        lens.path()
318            .clone()
319            .like_with_escape(escape_like_pattern(trimmed), LIKE_ESCAPE)
320    })
321}
322
323impl<M, T> TextColumn<M, T>
324where
325    M: toasty::schema::Model,
326{
327    /// Bind a column to the field `lens` reads, which must be one field of the model.
328    pub fn new(lens: Lens<M, T>) -> Self
329    where
330        T: FormScalar + Send + Sync + 'static,
331    {
332        let binding = Binding::of(&lens.path().clone());
333        Self {
334            lens,
335            binding,
336            label: None,
337            format: Arc::new(T::to_label),
338            search: None,
339            sortable: false,
340            width: ColumnWidth::Wide,
341        }
342    }
343
344    /// Render the field's value through `format`.
345    pub fn format(mut self, format: impl Fn(&T) -> String + Send + Sync + 'static) -> Self {
346        self.format = Arc::new(format);
347        self
348    }
349
350    /// Make the header a sort link.
351    pub fn sortable(mut self) -> Self {
352        self.sortable = true;
353        self
354    }
355
356    /// Replace the label the field's name gives it.
357    pub fn label(mut self, label: impl Into<String>) -> Self {
358        self.label = Some(label.into());
359        self
360    }
361
362    /// Declare this column's width.
363    pub fn width(mut self, width: ColumnWidth) -> Self {
364        self.width = width;
365        self
366    }
367}
368
369impl<M, T> TextColumn<M, T>
370where
371    M: toasty::schema::Model + Send + Sync + 'static,
372    T: toasty::schema::Field<Inner = String> + Send + Sync + 'static,
373{
374    /// Join the table's search with a substring match on this string field.
375    pub fn searchable(mut self) -> Self {
376        let path = self.lens.path().clone();
377        self.search = Some(Arc::new(move |pattern| {
378            path.clone().like_with_escape(pattern, LIKE_ESCAPE)
379        }));
380        self
381    }
382}
383
384impl<M, T> Column<M> for TextColumn<M, T>
385where
386    M: toasty::schema::Model + Send + Sync + 'static,
387    T: Send + Sync + 'static,
388{
389    fn name(&self) -> &str {
390        self.binding.name()
391    }
392
393    fn label(&self) -> &str {
394        self.label.as_deref().unwrap_or(self.binding.label())
395    }
396
397    fn text(&self, _cx: &Cx, row: &M) -> String {
398        (self.format)(self.lens.read(row))
399    }
400
401    fn column_width(&self) -> ColumnWidth {
402        self.width
403    }
404
405    fn is_searchable(&self) -> bool {
406        self.search.is_some()
407    }
408
409    /// A portable, escaped substring match.
410    fn search_expr(&self, term: &str) -> Option<Expr<bool>> {
411        let t = term.trim();
412        let search = self.search.as_ref()?;
413        (!t.is_empty()).then(|| search(escape_like_pattern(t)))
414    }
415
416    fn is_sortable(&self) -> bool {
417        self.sortable
418    }
419
420    fn order_by(&self, descending: bool) -> Option<OrderByExpr> {
421        self.sortable.then(|| {
422            let path = self.lens.path().clone();
423            if descending { path.desc() } else { path.asc() }
424        })
425    }
426
427    fn misdeclared(&self) -> Option<crate::DeclarationErrorKind> {
428        self.binding.misdeclared()
429    }
430
431    fn bind(&self, resolver: &FieldResolver) {
432        self.binding.bind(resolver);
433    }
434}
435
436impl<M, T> Clone for TextColumn<M, T> {
437    fn clone(&self) -> Self {
438        Self {
439            lens: self.lens.clone(),
440            binding: self.binding.clone(),
441            label: self.label.clone(),
442            format: Arc::clone(&self.format),
443            search: self.search.clone(),
444            sortable: self.sortable,
445            width: self.width,
446        }
447    }
448}
449
450impl<M, T> std::fmt::Debug for TextColumn<M, T> {
451    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
452        f.debug_struct("TextColumn")
453            .field("name", &self.binding.name())
454            .field(
455                "label",
456                &self.label.as_deref().unwrap_or(self.binding.label()),
457            )
458            .field("searchable", &self.search.is_some())
459            .field("sortable", &self.sortable)
460            .field("width", &self.width)
461            .finish_non_exhaustive()
462    }
463}
464
465/// A display-only column rendering any text from the row, with no query predicate.
466///
467/// ```rust
468/// # #[derive(Debug, Clone, toasty::Model)]
469/// # struct Post { #[key] #[auto] id: uuid::Uuid, body: String }
470/// tablo_core::ComputedColumn::new("Words", |p: &Post| {
471///     p.body.split_whitespace().count().to_string()
472/// });
473/// ```
474///
475/// A closure that reads a relation declares it with [`include`](Self::include), so the page
476/// loads it; one relation's record or count is a [`RelationColumn`] or a [`CountColumn`], which
477/// declare their own.
478///
479/// It maps to no column, so it neither searches nor sorts:
480///
481/// ```compile_fail
482/// # #[derive(Debug, Clone, toasty::Model)]
483/// # struct User { #[key] #[auto] id: uuid::Uuid, name: String }
484/// # fn main() {
485/// tablo_core::ComputedColumn::new("Name", |u: &User| u.name.clone()).sortable();
486/// # }
487/// ```
488pub struct ComputedColumn<M> {
489    name: String,
490    label: String,
491    project: Arc<dyn Fn(&M) -> String + Send + Sync>,
492    width: ColumnWidth,
493    /// Relations the projection reads.
494    includes: Includes<M>,
495}
496
497impl<M> ComputedColumn<M>
498where
499    M: toasty::schema::Model,
500{
501    /// Declare a column headed `label` rendering `project(row)`.
502    pub fn new(
503        label: impl Into<String>,
504        project: impl Fn(&M) -> String + Send + Sync + 'static,
505    ) -> Self {
506        let label = label.into();
507        Self {
508            name: label.to_lowercase(),
509            label,
510            project: Arc::new(project),
511            width: ColumnWidth::Narrow,
512            includes: Includes::new(),
513        }
514    }
515
516    /// Declare a relation the projection reads, so the table loads it.
517    pub fn include<T>(mut self, relation: impl Into<toasty::stmt::Include<M, T>>) -> Self {
518        self.includes = self.includes.with(relation);
519        self
520    }
521
522    /// Declare this column's width.
523    pub fn width(mut self, width: ColumnWidth) -> Self {
524        self.width = width;
525        self
526    }
527}
528
529impl<M> Column<M> for ComputedColumn<M>
530where
531    M: toasty::schema::Model + Send + Sync + 'static,
532{
533    fn name(&self) -> &str {
534        &self.name
535    }
536
537    fn label(&self) -> &str {
538        &self.label
539    }
540
541    fn text(&self, _cx: &Cx, row: &M) -> String {
542        (self.project)(row)
543    }
544
545    fn column_width(&self) -> ColumnWidth {
546        self.width
547    }
548
549    fn includes(&self) -> Includes<M> {
550        self.includes.clone()
551    }
552}
553
554impl<M> Clone for ComputedColumn<M> {
555    fn clone(&self) -> Self {
556        Self {
557            name: self.name.clone(),
558            label: self.label.clone(),
559            project: Arc::clone(&self.project),
560            width: self.width,
561            includes: self.includes.clone(),
562        }
563    }
564}
565
566impl<M> std::fmt::Debug for ComputedColumn<M> {
567    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
568        f.debug_struct("ComputedColumn")
569            .field("name", &self.name)
570            .field("label", &self.label)
571            .field("width", &self.width)
572            .field("includes", &self.includes.len())
573            .finish_non_exhaustive()
574    }
575}
576
577/// A column of a `bool` field rendered as an icon.
578///
579/// ```rust
580/// # #[derive(Debug, Clone, toasty::Model)]
581/// # struct Post { #[key] #[auto] id: uuid::Uuid, featured: bool }
582/// tablo_core::BooleanColumn::new(tablo_core::lens!(Post.featured)).sortable();
583/// ```
584pub struct BooleanColumn<M> {
585    lens: Lens<M, bool>,
586    binding: Binding,
587    /// The declared label, over the binding's.
588    label: Option<String>,
589    sortable: bool,
590    labels: (String, String),
591    width: ColumnWidth,
592}
593
594impl<M> BooleanColumn<M>
595where
596    M: toasty::schema::Model,
597{
598    /// Bind the column to the `bool` field `lens` reads.
599    pub fn new(lens: Lens<M, bool>) -> Self {
600        let binding = Binding::of(&lens.path().clone());
601        Self {
602            lens,
603            binding,
604            label: None,
605            sortable: false,
606            labels: ("Yes".to_string(), "No".to_string()),
607            width: ColumnWidth::Narrow,
608        }
609    }
610
611    /// Make the header a sort link.
612    pub fn sortable(mut self) -> Self {
613        self.sortable = true;
614        self
615    }
616
617    /// Replace the label the field's name gives it.
618    pub fn label(mut self, label: impl Into<String>) -> Self {
619        self.label = Some(label.into());
620        self
621    }
622
623    /// Declare this column's width.
624    pub fn width(mut self, width: ColumnWidth) -> Self {
625        self.width = width;
626        self
627    }
628
629    /// The words for `true` and `false`.
630    pub fn labels(mut self, yes: impl Into<String>, no: impl Into<String>) -> Self {
631        self.labels = (yes.into(), no.into());
632        self
633    }
634}
635
636impl<M> Column<M> for BooleanColumn<M>
637where
638    M: toasty::schema::Model + Send + Sync + 'static,
639{
640    fn name(&self) -> &str {
641        self.binding.name()
642    }
643
644    fn label(&self) -> &str {
645        self.label.as_deref().unwrap_or(self.binding.label())
646    }
647
648    fn text(&self, _cx: &Cx, row: &M) -> String {
649        if *self.lens.read(row) {
650            self.labels.0.clone()
651        } else {
652            self.labels.1.clone()
653        }
654    }
655
656    fn column_width(&self) -> ColumnWidth {
657        self.width
658    }
659
660    fn cell<'a>(&self, cx: &'a Cx, row: &M) -> BoxView<'a> {
661        let value = *self.lens.read(row);
662        let text = self.text(cx, row);
663        let (data, class) = if value {
664            (tablo_ui::icons::CIRCLE_CHECK, "size-4 text-primary")
665        } else {
666            (tablo_ui::icons::X, "size-4 text-muted-foreground")
667        };
668        view! {
669            cx =>
670            <span class="inline-flex items-center" data-boolean=(value.to_string())>
671                icon(
672                    data: data,
673                    attrs: attributes! { class=(class) aria-hidden="true" }
674                )
675                <span class="sr-only">(text)</span>
676            </span>
677        }
678        .boxed()
679    }
680
681    fn is_sortable(&self) -> bool {
682        self.sortable
683    }
684
685    fn order_by(&self, descending: bool) -> Option<OrderByExpr> {
686        self.sortable.then(|| {
687            let path = self.lens.path().clone();
688            if descending { path.desc() } else { path.asc() }
689        })
690    }
691
692    fn misdeclared(&self) -> Option<crate::DeclarationErrorKind> {
693        self.binding.misdeclared()
694    }
695
696    fn bind(&self, resolver: &FieldResolver) {
697        self.binding.bind(resolver);
698    }
699}
700
701impl<M> Clone for BooleanColumn<M> {
702    fn clone(&self) -> Self {
703        Self {
704            lens: self.lens.clone(),
705            binding: self.binding.clone(),
706            label: self.label.clone(),
707            sortable: self.sortable,
708            labels: self.labels.clone(),
709            width: self.width,
710        }
711    }
712}
713
714impl<M> std::fmt::Debug for BooleanColumn<M> {
715    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
716        f.debug_struct("BooleanColumn")
717            .field("name", &self.binding.name())
718            .field(
719                "label",
720                &self.label.as_deref().unwrap_or(self.binding.label()),
721            )
722            .field("sortable", &self.sortable)
723            .field("width", &self.width)
724            .finish_non_exhaustive()
725    }
726}
727
728/// A column of a `String` field holding an uploaded file's path, rendered as a link to the file.
729///
730/// ```rust
731/// # #[derive(Debug, Clone, toasty::Model)]
732/// # struct Doc { #[key] #[auto] id: uuid::Uuid, path: String }
733/// tablo_core::FileColumn::new(tablo_core::lens!(Doc.path));
734/// ```
735///
736/// The cell links a rooted path or an absolute `http(s)` URL, the same rule a
737/// [`Field::file`](crate::Field::file) control applies, and shows any other value as text.
738pub struct FileColumn<M> {
739    lens: Lens<M, String>,
740    binding: Binding,
741    /// The declared label, over the binding's.
742    label: Option<String>,
743    width: ColumnWidth,
744}
745
746impl<M> FileColumn<M>
747where
748    M: toasty::schema::Model,
749{
750    /// Bind the column to the `String` field `lens` reads.
751    pub fn new(lens: Lens<M, String>) -> Self {
752        let binding = Binding::of(&lens.path().clone());
753        Self {
754            lens,
755            binding,
756            label: None,
757            width: ColumnWidth::Wide,
758        }
759    }
760
761    /// Replace the label the field's name gives it.
762    pub fn label(mut self, label: impl Into<String>) -> Self {
763        self.label = Some(label.into());
764        self
765    }
766
767    /// Declare this column's width.
768    pub fn width(mut self, width: ColumnWidth) -> Self {
769        self.width = width;
770        self
771    }
772}
773
774impl<M> Column<M> for FileColumn<M>
775where
776    M: toasty::schema::Model + Send + Sync + 'static,
777{
778    fn name(&self) -> &str {
779        self.binding.name()
780    }
781
782    fn label(&self) -> &str {
783        self.label.as_deref().unwrap_or(self.binding.label())
784    }
785
786    fn text(&self, _cx: &Cx, row: &M) -> String {
787        self.lens.read(row).clone()
788    }
789
790    fn cell<'a>(&self, cx: &'a Cx, row: &M) -> BoxView<'a> {
791        let path = self.lens.read(row);
792        if path.trim().is_empty() {
793            return crate::schema::value_cell(cx, path);
794        }
795        crate::schema::stored_upload(cx, path)
796    }
797
798    fn column_width(&self) -> ColumnWidth {
799        self.width
800    }
801
802    fn misdeclared(&self) -> Option<crate::DeclarationErrorKind> {
803        self.binding.misdeclared()
804    }
805
806    fn bind(&self, resolver: &FieldResolver) {
807        self.binding.bind(resolver);
808    }
809}
810
811impl<M> Clone for FileColumn<M> {
812    fn clone(&self) -> Self {
813        Self {
814            lens: self.lens.clone(),
815            binding: self.binding.clone(),
816            label: self.label.clone(),
817            width: self.width,
818        }
819    }
820}
821
822impl<M> std::fmt::Debug for FileColumn<M> {
823    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
824        f.debug_struct("FileColumn")
825            .field("name", &self.binding.name())
826            .field(
827                "label",
828                &self.label.as_deref().unwrap_or(self.binding.label()),
829            )
830            .field("width", &self.width)
831            .finish_non_exhaustive()
832    }
833}
834
835/// A table's columns, as the table stores them.
836pub(crate) type BoxColumn<M> = Arc<dyn Column<M>>;
837
838/// Convert a single built-in column, a tuple of any [`Column`]s, a `Vec` or slice
839/// of boxed columns, or `()` for none yet, into a table's column list.
840///
841/// [`Table::column`](super::Table::column) appends past it.
842pub trait IntoColumns<M> {
843    #[doc(hidden)]
844    fn into_columns(self) -> Vec<BoxColumn<M>>;
845}
846
847impl<M, T> IntoColumns<M> for TextColumn<M, T>
848where
849    M: toasty::schema::Model + Send + Sync + 'static,
850    T: Send + Sync + 'static,
851{
852    fn into_columns(self) -> Vec<BoxColumn<M>> {
853        vec![Arc::new(self)]
854    }
855}
856
857impl<M> IntoColumns<M> for ComputedColumn<M>
858where
859    M: toasty::schema::Model + Send + Sync + 'static,
860{
861    fn into_columns(self) -> Vec<BoxColumn<M>> {
862        vec![Arc::new(self)]
863    }
864}
865
866impl<M> IntoColumns<M> for RelationColumn<M>
867where
868    M: toasty::schema::Model + Send + Sync + 'static,
869{
870    fn into_columns(self) -> Vec<BoxColumn<M>> {
871        vec![Arc::new(self)]
872    }
873}
874
875impl<M> IntoColumns<M> for CountColumn<M>
876where
877    M: toasty::schema::Model + Send + Sync + 'static,
878{
879    fn into_columns(self) -> Vec<BoxColumn<M>> {
880        vec![Arc::new(self)]
881    }
882}
883
884impl<M> IntoColumns<M> for BooleanColumn<M>
885where
886    M: toasty::schema::Model + Send + Sync + 'static,
887{
888    fn into_columns(self) -> Vec<BoxColumn<M>> {
889        vec![Arc::new(self)]
890    }
891}
892
893impl<M> IntoColumns<M> for FileColumn<M>
894where
895    M: toasty::schema::Model + Send + Sync + 'static,
896{
897    fn into_columns(self) -> Vec<BoxColumn<M>> {
898        vec![Arc::new(self)]
899    }
900}
901
902impl<M, T> IntoColumns<M> for EmbeddedColumn<M, T>
903where
904    M: toasty::schema::Model + Send + Sync + 'static,
905    T: crate::EmbeddedForm + Send + Sync + 'static,
906{
907    fn into_columns(self) -> Vec<BoxColumn<M>> {
908        vec![Arc::new(self)]
909    }
910}
911
912impl<M, T> IntoColumns<M> for RepeaterColumn<M, T>
913where
914    M: toasty::schema::Model + Send + Sync + 'static,
915    T: crate::RepeaterItem,
916{
917    fn into_columns(self) -> Vec<BoxColumn<M>> {
918        vec![Arc::new(self)]
919    }
920}
921
922impl<M> IntoColumns<M> for () {
923    fn into_columns(self) -> Vec<BoxColumn<M>> {
924        Vec::new()
925    }
926}
927
928impl<M> IntoColumns<M> for Vec<BoxColumn<M>> {
929    fn into_columns(self) -> Vec<BoxColumn<M>> {
930        self
931    }
932}
933
934impl<M> IntoColumns<M> for &[BoxColumn<M>] {
935    fn into_columns(self) -> Vec<BoxColumn<M>> {
936        self.to_vec()
937    }
938}
939
940/// The tuple impls of [`IntoColumns`], one arity per invocation.
941macro_rules! into_columns_tuples {
942    ($($T:ident => $v:ident),+ $(,)?) => {
943        impl<M, $($T),+> IntoColumns<M> for ($($T,)+)
944        where
945            $($T: Column<M> + 'static,)+
946        {
947            fn into_columns(self) -> Vec<BoxColumn<M>> {
948                let ($($v,)+) = self;
949                vec![$(Arc::new($v) as BoxColumn<M>,)+]
950            }
951        }
952    };
953}
954
955into_columns_tuples!(A => a);
956into_columns_tuples!(A => a, B => b);
957into_columns_tuples!(A => a, B => b, C => c);
958into_columns_tuples!(A => a, B => b, C => c, D => d);
959into_columns_tuples!(A => a, B => b, C => c, D => d, E => e);
960into_columns_tuples!(A => a, B => b, C => c, D => d, E => e, F => f);
961into_columns_tuples!(A => a, B => b, C => c, D => d, E => e, F => f, G => g);
962into_columns_tuples!(
963    A => a, B => b, C => c, D => d, E => e, F => f, G => g, H => h
964);
965into_columns_tuples!(
966    A => a, B => b, C => c, D => d, E => e, F => f, G => g, H => h, I => i
967);
968into_columns_tuples!(
969    A => a, B => b, C => c, D => d, E => e, F => f, G => g, H => h, I => i, J => j
970);
971into_columns_tuples!(
972    A => a, B => b, C => c, D => d, E => e, F => f, G => g, H => h, I => i, J => j, K => k
973);
974into_columns_tuples!(
975    A => a, B => b, C => c, D => d, E => e, F => f, G => g, H => h, I => i, J => j, K => k, L => l
976);
977
978#[cfg(test)]
979mod tests;