Skip to main content

sofie_db/search/
engine.rs

1use std::sync::Arc;
2
3use sofie_core::{Error, Result, SqlType, SqlValue, Uuid};
4
5use crate::condition::Kind;
6use crate::statement::Criteria;
7use crate::{BoxFuture, Db, Migrations, Read};
8
9/// Meilisearch's default `maxTotalHits`; a read filtering matches further sees at most these.
10pub const MATCHES_READ: usize = 1000;
11
12pub trait SearchEngine: Send + Sync + 'static {
13    /// Best first; with an owner, only that owner's.
14    fn matching<'a>(
15        &'a self,
16        index: &'a SearchIndex,
17        words: &'a str,
18        owner: Option<Uuid>,
19    ) -> BoxFuture<'a, Result<Vec<Uuid>>>;
20
21    /// Returns once the engine has taken them, which may be before they are found.
22    fn put<'a>(
23        &'a self,
24        index: &'a SearchIndex,
25        documents: &'a [Document],
26    ) -> BoxFuture<'a, Result<()>>;
27
28    fn remove<'a>(&'a self, index: &'a SearchIndex, ids: &'a [Uuid]) -> BoxFuture<'a, Result<()>>;
29}
30
31#[derive(Clone, Debug, PartialEq, Eq)]
32pub struct SearchIndex {
33    pub(crate) module: &'static str,
34    pub(crate) name: &'static str,
35    pub(crate) owner: Option<&'static str>,
36    pub(crate) hidden: Option<&'static str>,
37    pub(crate) fields: &'static [crate::SearchedField],
38}
39
40impl SearchIndex {
41    /// For an engine's own tests; the database's come from the aggregates' tables.
42    #[must_use]
43    pub const fn new(
44        module: &'static str,
45        name: &'static str,
46        owner: Option<&'static str>,
47    ) -> Self {
48        Self {
49            module,
50            name,
51            owner,
52            hidden: None,
53            fields: &[],
54        }
55    }
56
57    #[must_use]
58    pub fn uid(&self) -> String {
59        format!("{}_{}", self.module, self.name)
60    }
61
62    #[must_use]
63    pub const fn owned(&self) -> bool {
64        self.owner.is_some()
65    }
66}
67
68/// `text` is the record's search text, recomputed from the fields `Searched` names.
69#[derive(Clone, Debug, PartialEq, Eq)]
70pub struct Document {
71    pub id: Uuid,
72    pub text: String,
73    pub owner: Option<Uuid>,
74}
75
76pub(crate) struct Installed {
77    pub(crate) engine: Arc<dyn SearchEngine>,
78    pub(crate) indexes: Vec<SearchIndex>,
79}
80
81impl Installed {
82    pub(crate) fn of(db: &Db) -> Result<&Self> {
83        db.search.get().ok_or_else(|| {
84            Error::internal_message(
85                "no search engine is installed, so the database matches searches itself; set \
86                 SOFIE_SEARCH=meilisearch",
87            )
88        })
89    }
90
91    pub(crate) fn find(&self, module: &str, name: &str) -> Option<&SearchIndex> {
92        self.indexes
93            .iter()
94            .find(|index| index.module == module && index.name == name)
95    }
96
97    pub(crate) fn index(&self, module: &str, name: &str) -> Result<&SearchIndex> {
98        self.find(module, name).ok_or_else(|| {
99            Error::internal_message(format!(
100                "`{module}.{name}` is searched, but no module's migrations list it among \
101                     their aggregates; run `sofie gen`"
102            ))
103        })
104    }
105}
106
107/// A database keeps the first engine installed: the worker's may be the app's own.
108pub fn install_search(db: &Db, engine: Arc<dyn SearchEngine>, migrations: &[Migrations]) {
109    db.search.get_or_init(|| Installed {
110        engine,
111        indexes: migrations
112            .iter()
113            .flat_map(|migrations| migrations.aggregates)
114            .filter_map(crate::AggregateTable::search_index)
115            .collect(),
116    });
117}
118
119/// The database still filters the engine's ids by the read's other conditions, its owner's too.
120pub(crate) async fn matched(mut criteria: Criteria, read: &Read) -> Result<Criteria> {
121    let Some(installed) = read.db.search.get() else {
122        return Ok(criteria);
123    };
124    let mut searches = Vec::new();
125    for condition in &mut criteria.conditions {
126        condition.searches(true, &mut searches);
127    }
128    for (search, own_records) in searches {
129        let Kind::TextSearch {
130            words,
131            index: (module, name),
132            ..
133        } = &*search
134        else {
135            continue;
136        };
137        let index = installed.index(module, name)?;
138        let owner = read.owner().filter(|_| own_records && index.owned());
139        // As on Postgres, no words match nothing; an engine would answer every record.
140        let ids = if words.trim().is_empty() {
141            Vec::new()
142        } else {
143            installed.engine.matching(index, words, owner).await?
144        };
145        *search = Kind::In {
146            columns: vec!["id".to_owned()],
147            types: vec![SqlType::Uuid],
148            rows: ids.into_iter().map(|id| vec![SqlValue::Uuid(id)]).collect(),
149        };
150    }
151    Ok(criteria)
152}