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}