Skip to main content

sofie_db/search/
engine.rs

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