searchez 1.0.0

A searchable-model layer for Rust: make a type searchable, keep the index in sync, and search with real relevance ranking — over a pluggable backend, with a batteries-included in-memory engine that needs no external service.
Documentation
// searchez/src/meilisearch.rs
//
// A production backend backed by a Meilisearch server. Enabled by the
// `meilisearch` feature. searchez's `Searchable` models, index/search calls and
// hydration code are unchanged — only the backend swaps.
//
// Two Meilisearch specifics worth knowing:
//   * Every document needs a primary-key field. searchez ids are strings, so we
//     inject the id into each document under `SEARCHEZ_ID` and declare that the
//     primary key. Meilisearch primary-key values must match `^[a-zA-Z0-9_-]+$`,
//     so ids should avoid `/`, `.` and spaces.
//   * Filtering requires the fields to be declared *filterable* on the index
//     first (a Meilisearch requirement, not searchez's). Call
//     [`MeilisearchBackend::set_filterable_attributes`] once at setup for any
//     field you filter on, or filtered searches will error.

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};

/// The document field searchez uses as Meilisearch's primary key.
const SEARCHEZ_ID: &str = "searchez_id";

/// A [`Backend`] talking to a Meilisearch server.
pub struct MeilisearchBackend {
    client: Client,
}

impl MeilisearchBackend {
    /// Connect to a Meilisearch server. `api_key` is the master or a scoped key
    /// (`None` only for a keyless dev server).
    ///
    /// ```ignore
    /// let backend = MeilisearchBackend::new("http://localhost:7700", Some("masterKey"))?;
    /// let engine = searchez::SearchEngine::new(backend);
    /// ```
    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 })
    }

    /// Declare which fields of an index may be filtered on. Meilisearch rejects a
    /// filter on any field not listed here, so call this once at setup for every
    /// field you pass to [`Query::filter`](crate::Query::filter).
    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)?;
        // Wait so the setting is live before the caller searches.
        task.wait_for_completion(&self.client, None, None)
            .await
            .map_err(map_err)?;
        Ok(())
    }
}

/// Merge the searchez id into the document as the primary key, and hand back a
/// plain JSON object Meilisearch can store.
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)
}

/// Turn searchez filters into a Meilisearch filter expression: `field = value`
/// clauses ANDed together. Strings are quoted; numbers and booleans are bare.
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 "))
}

/// Reconstruct a searchez [`Hit`] from a returned Meilisearch document: lift the
/// injected id back out, and hand the rest of the fields back as the document.
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)?;
        // Wait for indexing so a subsequent search sees the write — matching the
        // synchronous semantics of the in-memory backend.
        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)
            // So `ranking_score` is populated on each hit.
            .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}"))
}