Skip to main content

tablo_core/table/
column.rs

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