use std::sync::Arc;
use topcoat::{Result, context::Cx};
use super::{Resource, ResourceDef, mounted};
use crate::{
Lens,
form::FormScalar,
policy::Ability,
toasty_compat::model::{ModelPath, root_field},
};
pub struct GlobalSearch<M> {
details: Vec<Detail<M>>,
}
struct Detail<M> {
label: crate::Text,
read: Arc<dyn Fn(&M) -> crate::Text + Send + Sync>,
field: Option<String>,
}
impl<M: toasty::schema::Model + Send + Sync + 'static> GlobalSearch<M> {
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn detail<T>(mut self, label: impl Into<crate::Text>, lens: Lens<M, T>) -> Self
where
T: FormScalar + Send + Sync + 'static,
{
let field = root_field::<M>(&ModelPath::of(lens.path()));
self.details.push(Detail {
label: label.into(),
read: Arc::new(move |record| lens.read(record).to_label()),
field,
});
self
}
fn details_of(&self, cx: &Cx, record: &M, hidden: &[String]) -> Vec<(crate::Text, String)> {
self.details
.iter()
.filter(|detail| {
detail
.field
.as_ref()
.is_none_or(|field| !hidden.contains(field))
})
.map(|detail| (detail.label.clone(), (detail.read)(record).localize(cx)))
.filter(|(_, value)| !value.trim().is_empty())
.collect()
}
}
impl<M> Clone for GlobalSearch<M> {
fn clone(&self) -> Self {
Self {
details: self.details.clone(),
}
}
}
impl<M> Clone for Detail<M> {
fn clone(&self) -> Self {
Self {
label: self.label.clone(),
read: Arc::clone(&self.read),
field: self.field.clone(),
}
}
}
impl<M> Default for GlobalSearch<M> {
fn default() -> Self {
Self {
details: Vec::new(),
}
}
}
impl<M> std::fmt::Debug for GlobalSearch<M> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GlobalSearch")
.field(
"details",
&self
.details
.iter()
.map(|detail| &detail.label)
.collect::<Vec<_>>(),
)
.finish()
}
}
impl<R: Resource> ResourceDef<R> {
#[must_use]
pub fn global_search(mut self, search: GlobalSearch<R::Model>) -> Self {
self.global_search = Some(search);
self
}
#[must_use]
pub fn without_global_search(mut self) -> Self {
self.global_search = None;
self
}
}
#[derive(Debug, Clone)]
pub(crate) struct SearchHit {
pub(crate) title: String,
pub(crate) key: String,
pub(crate) opens: Opens,
pub(crate) details: Vec<(crate::Text, String)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Opens {
Detail,
Edit,
}
const BATCH_FACTOR: usize = 4;
const MAX_BATCHES: usize = 3;
pub(crate) fn searchable<R: Resource>(cx: &Cx) -> bool {
mounted::<R>(cx).is_some_and(|mounted| lists(cx, &mounted))
}
fn lists<R: Resource>(cx: &Cx, mounted: &super::Mounted<R>) -> bool {
(!mounted.tenancy.is_scoped() || crate::tenancy::require_tenant(cx).is_ok())
&& mounted.can(cx, Ability::ViewAny)
}
pub(crate) fn search<'a, R: Resource>(
cx: &'a Cx,
words: &'a [String],
limit: usize,
) -> crate::BoxFuture<'a, Result<Vec<SearchHit>>> {
Box::pin(async move {
let Some(mounted) = mounted::<R>(cx) else {
return Ok(Vec::new());
};
let Some(search) = &mounted.global_search else {
return Ok(Vec::new());
};
if !lists(cx, &mounted) {
return Ok(Vec::new());
}
let Some(matched) = mounted.words_search_expr(cx, words) else {
return Ok(Vec::new());
};
let mut db = crate::db::db(cx)?;
let matched = crate::toasty_compat::case_insensitive_search(&db, matched);
let order = mounted.table_for(cx).first_order_by();
let mask = mounted.field_mask(cx);
let batch = limit.saturating_mul(BATCH_FACTOR);
let mut hits = Vec::new();
for read in 0..MAX_BATCHES {
let mut query = mounted.scoped_query(cx)?.filter(matched.clone());
if let Some(order) = order.clone() {
query = query.order_by(order);
}
let records = query
.limit(batch)
.offset(read * batch)
.exec(&mut db)
.await?;
hits.extend(records.iter().filter_map(|record| {
Some(SearchHit {
opens: opens::<R>(cx, &mounted, record)?,
key: mounted.table.key_of(record),
title: mounted.record_title(
cx,
record,
&crate::toasty_compat::pk::pk_text(record),
),
details: search.details_of(cx, record, mask.hidden()),
})
}));
if hits.len() >= limit || records.len() < batch {
break;
}
}
hits.truncate(limit);
Ok(hits)
})
}
fn opens<R: Resource>(cx: &Cx, mounted: &super::Mounted<R>, record: &R::Model) -> Option<Opens> {
if !mounted.table.is_addressable() || !mounted.can(cx, Ability::View(record)) {
return None;
}
if mounted.viewed() {
Some(Opens::Detail)
} else if mounted.pages.edit && mounted.can(cx, Ability::Update(record)) {
Some(Opens::Edit)
} else {
None
}
}