use crate::backend::{Backend, IndexDoc};
use crate::document::Document;
use crate::error::{Result, SearchError};
use crate::query::{Hit, Query};
use async_trait::async_trait;
use meilisearch_sdk::client::Client;
use serde_json::{Map, Value};
const SEARCHEZ_ID: &str = "searchez_id";
pub struct MeilisearchBackend {
client: Client,
}
impl MeilisearchBackend {
pub fn new(url: impl Into<String>, api_key: Option<impl Into<String>>) -> Result<Self> {
let client = Client::new(url.into(), api_key.map(Into::into))
.map_err(|e| SearchError::Backend(format!("meilisearch connect: {e}")))?;
Ok(Self { client })
}
pub async fn set_filterable_attributes(
&self,
index: &str,
fields: &[&str],
) -> Result<()> {
let task = self
.client
.index(index)
.set_filterable_attributes(fields)
.await
.map_err(map_err)?;
task.wait_for_completion(&self.client, None, None)
.await
.map_err(map_err)?;
Ok(())
}
}
fn to_meili_doc(doc: &IndexDoc) -> Value {
let mut map: Map<String, Value> = doc.document.as_map().clone();
map.insert(SEARCHEZ_ID.to_string(), Value::String(doc.id.clone()));
Value::Object(map)
}
fn filter_expr(query: &Query) -> Option<String> {
if query.filters.is_empty() {
return None;
}
let clauses: Vec<String> = query
.filters
.iter()
.map(|f| {
let rhs = match &f.value {
Value::String(s) => format!("\"{}\"", s.replace('"', "\\\"")),
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
other => format!("\"{other}\""),
};
format!("{} = {}", f.field, rhs)
})
.collect();
Some(clauses.join(" AND "))
}
fn to_hit(mut map: Map<String, Value>, score: Option<f32>) -> Hit {
let id = map
.remove(SEARCHEZ_ID)
.map(|v| match v {
Value::String(s) => s,
other => other.to_string(),
})
.unwrap_or_default();
Hit {
id,
score: score.unwrap_or(0.0),
document: Document::from(Value::Object(map)),
}
}
#[async_trait]
impl Backend for MeilisearchBackend {
async fn upsert(&self, index: &str, docs: Vec<IndexDoc>) -> Result<()> {
if docs.is_empty() {
return Ok(());
}
let payload: Vec<Value> = docs.iter().map(to_meili_doc).collect();
let task = self
.client
.index(index)
.add_or_replace(&payload, Some(SEARCHEZ_ID))
.await
.map_err(map_err)?;
task.wait_for_completion(&self.client, None, None)
.await
.map_err(map_err)?;
Ok(())
}
async fn delete(&self, index: &str, ids: &[String]) -> Result<()> {
if ids.is_empty() {
return Ok(());
}
let task = self
.client
.index(index)
.delete_documents(ids)
.await
.map_err(map_err)?;
task.wait_for_completion(&self.client, None, None)
.await
.map_err(map_err)?;
Ok(())
}
async fn search(&self, index: &str, query: &Query) -> Result<Vec<Hit>> {
let idx = self.client.index(index);
let mut search = idx.search();
if let Some(text) = query.text.as_deref() {
search.with_query(text);
}
let filter = filter_expr(query);
if let Some(f) = filter.as_deref() {
search.with_filter(f);
}
search
.with_limit(query.effective_limit())
.with_offset(query.offset)
.with_show_ranking_score(true);
let results = search
.execute::<Map<String, Value>>()
.await
.map_err(map_err)?;
Ok(results
.hits
.into_iter()
.map(|h| to_hit(h.result, h.ranking_score.map(|s| s as f32)))
.collect())
}
async fn clear(&self, index: &str) -> Result<()> {
let task = self
.client
.index(index)
.delete_all_documents()
.await
.map_err(map_err)?;
task.wait_for_completion(&self.client, None, None)
.await
.map_err(map_err)?;
Ok(())
}
async fn count(&self, index: &str) -> Result<usize> {
let stats = self.client.index(index).get_stats().await.map_err(map_err)?;
Ok(stats.number_of_documents)
}
}
fn map_err(e: meilisearch_sdk::errors::Error) -> SearchError {
SearchError::Backend(format!("meilisearch: {e}"))
}