Skip to main content

tablo_core/resource/
global_search.rs

1//! [`GlobalSearch`] and [`ResourceDef::global_search`]: how a resource's records show in its
2//! panel's global search, and the search of one resource's records for a request.
3
4use std::sync::Arc;
5
6use topcoat::{Result, context::Cx};
7
8use super::{Resource, ResourceDef, mounted};
9use crate::{
10    Lens,
11    form::FormScalar,
12    policy::Ability,
13    toasty_compat::model::{ModelPath, root_field},
14};
15
16/// How a resource's records show in its panel's global search, as
17/// [`ResourceDef::global_search`] declares it: the details listed under each result's title.
18///
19/// ```rust
20/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
21/// # struct User { #[key] #[auto] id: uuid::Uuid, name: String, email: String }
22/// # struct UserResource;
23/// # impl tablo_core::Resource for UserResource {
24/// #     type Model = User;
25/// #     type Form = tablo_core::NoForm<User>;
26/// #     fn declare() -> tablo_core::ResourceDef<Self> {
27/// use tablo_core::{GlobalSearch, ResourceDef, lens};
28///
29/// ResourceDef::new()
30///     .record_title(lens!(User.name))
31///     .global_search(GlobalSearch::new().detail("Email", lens!(User.email)))
32/// #     }
33/// # }
34/// ```
35pub struct GlobalSearch<M> {
36    details: Vec<Detail<M>>,
37}
38
39/// One labelled value under a result's title.
40struct Detail<M> {
41    label: crate::Text,
42    read: Arc<dyn Fn(&M) -> crate::Text + Send + Sync>,
43    /// The model's root field the value reads, which field access may hide.
44    field: Option<String>,
45}
46
47impl<M: toasty::schema::Model + Send + Sync + 'static> GlobalSearch<M> {
48    /// A result with its title alone.
49    pub fn new() -> Self {
50        Self::default()
51    }
52
53    /// Lists the value `lens` reads under each result's title, labelled `label`, after the
54    /// details already declared.
55    ///
56    /// A result leaves out an empty value, and every value of a field
57    /// [field access](ResourceDef::field_access) hides from the request.
58    #[must_use]
59    pub fn detail<T>(mut self, label: impl Into<crate::Text>, lens: Lens<M, T>) -> Self
60    where
61        T: FormScalar + Send + Sync + 'static,
62    {
63        let field = root_field::<M>(&ModelPath::of(lens.path()));
64        self.details.push(Detail {
65            label: label.into(),
66            read: Arc::new(move |record| lens.read(record).to_label()),
67            field,
68        });
69        self
70    }
71
72    /// The `(label, value)` of each detail of `record` the request may see.
73    fn details_of(&self, cx: &Cx, record: &M, hidden: &[String]) -> Vec<(crate::Text, String)> {
74        self.details
75            .iter()
76            .filter(|detail| {
77                detail
78                    .field
79                    .as_ref()
80                    .is_none_or(|field| !hidden.contains(field))
81            })
82            .map(|detail| (detail.label.clone(), (detail.read)(record).localize(cx)))
83            .filter(|(_, value)| !value.trim().is_empty())
84            .collect()
85    }
86}
87
88impl<M> Clone for GlobalSearch<M> {
89    fn clone(&self) -> Self {
90        Self {
91            details: self.details.clone(),
92        }
93    }
94}
95
96impl<M> Clone for Detail<M> {
97    fn clone(&self) -> Self {
98        Self {
99            label: self.label.clone(),
100            read: Arc::clone(&self.read),
101            field: self.field.clone(),
102        }
103    }
104}
105
106impl<M> Default for GlobalSearch<M> {
107    fn default() -> Self {
108        Self {
109            details: Vec::new(),
110        }
111    }
112}
113
114impl<M> std::fmt::Debug for GlobalSearch<M> {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        f.debug_struct("GlobalSearch")
117            .field(
118                "details",
119                &self
120                    .details
121                    .iter()
122                    .map(|detail| &detail.label)
123                    .collect::<Vec<_>>(),
124            )
125            .finish()
126    }
127}
128
129impl<R: Resource> ResourceDef<R> {
130    /// Declares how the resource's records show in the panel's global search: the details under
131    /// each result's title. Without it a result shows its title alone.
132    ///
133    /// The search box in the panel's topbar searches every resource the panel mounts unless the
134    /// resource opts out with [`without_global_search`](Self::without_global_search). It matches
135    /// a record when each word of the term is in one of the table's searchable columns or in the
136    /// [`record_title`](Self::record_title) column, which also titles each result. It lists only
137    /// the records the resource's list would, and none to a request the list refuses: one the
138    /// policy refuses `ViewAny`, or one without a tenant on a tenant-scoped resource. Each
139    /// result opens the record's detail page, or its edit page on a resource with an empty
140    /// [`detail`](Self::detail); a record the policy refuses `View`, or `Update` for the edit
141    /// page, is left out.
142    ///
143    /// See [`Panel::without_global_search`](crate::Panel::without_global_search) for the panel's
144    /// side.
145    #[must_use]
146    pub fn global_search(mut self, search: GlobalSearch<R::Model>) -> Self {
147        self.global_search = Some(search);
148        self
149    }
150
151    /// Leaves the resource's records out of the panel's global search.
152    #[must_use]
153    pub fn without_global_search(mut self) -> Self {
154        self.global_search = None;
155        self
156    }
157}
158
159/// One record a global search found: its title, its key, the page it opens, and its details.
160#[derive(Debug, Clone)]
161pub(crate) struct SearchHit {
162    pub(crate) title: String,
163    /// The record's key as its URLs spell it.
164    pub(crate) key: String,
165    pub(crate) opens: Opens,
166    pub(crate) details: Vec<(crate::Text, String)>,
167}
168
169/// The record page a result opens.
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171pub(crate) enum Opens {
172    Detail,
173    Edit,
174}
175
176/// How many records past the asked limit one batch of a search reads, so the records the policy
177/// refuses seldom cost a second batch.
178const BATCH_FACTOR: usize = 4;
179
180/// How many batches a search reads at most, which bounds its work when the policy refuses most of
181/// the matching records.
182const MAX_BATCHES: usize = 3;
183
184/// Whether the request may list `R`'s records, so its results may show: the policy allows
185/// `ViewAny`, and the request has a tenant when `R` is tenant-scoped.
186pub(crate) fn searchable<R: Resource>(cx: &Cx) -> bool {
187    mounted::<R>(cx).is_some_and(|mounted| lists(cx, &mounted))
188}
189
190/// Whether the request may list `mounted`'s records.
191fn lists<R: Resource>(cx: &Cx, mounted: &super::Mounted<R>) -> bool {
192    (!mounted.tenancy.is_scoped() || crate::tenancy::require_tenant(cx).is_ok())
193        && mounted.can(cx, Ability::ViewAny)
194}
195
196/// Up to `limit` of `R`'s records matching every one of `words` that the request may open, each
197/// with the page it opens; empty when the request may not list `R`, or `R` searches no column.
198///
199/// The policy decides per record which page it opens, so the search reads the matching records
200/// in batches until `limit` of them open, the records run out, or [`MAX_BATCHES`] batches are
201/// read.
202pub(crate) fn search<'a, R: Resource>(
203    cx: &'a Cx,
204    words: &'a [String],
205    limit: usize,
206) -> crate::BoxFuture<'a, Result<Vec<SearchHit>>> {
207    Box::pin(async move {
208        let Some(mounted) = mounted::<R>(cx) else {
209            return Ok(Vec::new());
210        };
211        let Some(search) = &mounted.global_search else {
212            return Ok(Vec::new());
213        };
214        if !lists(cx, &mounted) {
215            return Ok(Vec::new());
216        }
217        let Some(matched) = mounted.words_search_expr(cx, words) else {
218            return Ok(Vec::new());
219        };
220        let mut db = crate::db::db(cx)?;
221        let matched = crate::toasty_compat::case_insensitive_search(&db, matched);
222        let order = mounted.table_for(cx).first_order_by();
223        let mask = mounted.field_mask(cx);
224        let batch = limit.saturating_mul(BATCH_FACTOR);
225        let mut hits = Vec::new();
226        for read in 0..MAX_BATCHES {
227            let mut query = mounted.scoped_query(cx)?.filter(matched.clone());
228            if let Some(order) = order.clone() {
229                query = query.order_by(order);
230            }
231            let records = query
232                .limit(batch)
233                .offset(read * batch)
234                .exec(&mut db)
235                .await?;
236            hits.extend(records.iter().filter_map(|record| {
237                Some(SearchHit {
238                    opens: opens::<R>(cx, &mounted, record)?,
239                    key: mounted.table.key_of(record),
240                    title: mounted.record_title(
241                        cx,
242                        record,
243                        &crate::toasty_compat::pk::pk_text(record),
244                    ),
245                    details: search.details_of(cx, record, mask.hidden()),
246                })
247            }));
248            if hits.len() >= limit || records.len() < batch {
249                break;
250            }
251        }
252        hits.truncate(limit);
253        Ok(hits)
254    })
255}
256
257/// The page a result opens: the record's detail page, or its edit page on a resource without
258/// one; `None` when the record's key has no URL, or the policy refuses `View`, which both pages
259/// ask, or `Update` for the edit page.
260fn opens<R: Resource>(cx: &Cx, mounted: &super::Mounted<R>, record: &R::Model) -> Option<Opens> {
261    if !mounted.table.is_addressable() || !mounted.can(cx, Ability::View(record)) {
262        return None;
263    }
264    if mounted.viewed() {
265        Some(Opens::Detail)
266    } else if mounted.pages.edit && mounted.can(cx, Ability::Update(record)) {
267        Some(Opens::Edit)
268    } else {
269        None
270    }
271}