Skip to main content

adminx_search/
lib.rs

1// adminx-search/src/lib.rs
2//
3// Full-text search for adminx, powered by the standalone `searchez` crate.
4// Register it and any resource that declares `search_fields()` is indexed
5// automatically — create/update sync the index, delete removes the record — and
6// its list page grows a search box. Leave it out and adminx behaves exactly as
7// before, issuing no extra queries.
8//
9// The design keeps `searchez` independent: adminx-core exposes a neutral
10// `Indexer` seam, and this crate is the thin adapter that drives a
11// `searchez::Backend` behind it. searchez has no idea adminx exists; swap its
12// backend (in-memory, Meilisearch, ...) without touching adminx.
13//
14// ## Startup order
15//
16// ```ignore
17// adminx_seaorm::init(&db_url).await?;         // 1. storage
18// adminx_search::init_memory();                // 2. register a backend (dev/small)
19// configure_auth(AuthConfig { /* ... */ });    // 3. auth
20// register_resource(Box::new(PostResource));   // declares search_fields()
21// adminx_search::reindex(&PostResource, rows).await?;  // 4. optional backfill
22// ```
23//
24// A resource opts in by returning its searchable columns:
25//
26// ```ignore
27// fn search_fields(&self) -> Vec<&'static str> { vec!["title", "body"] }
28// ```
29//
30// ## Backends
31//
32// [`init_memory`] uses searchez's BM25 in-memory engine — no external service,
33// great for development and small datasets. For production, enable the
34// `meilisearch` feature and register [`searchez::MeilisearchBackend`] via
35// [`init`]. Nothing about your resources changes.
36
37use adminx_core::search::Indexer;
38use adminx_core::storage::StorageError;
39use async_trait::async_trait;
40use searchez::{Backend, Document, IndexDoc, Query};
41use serde_json::{Map, Value};
42
43/// Drives a [`searchez::Backend`] behind adminx-core's [`Indexer`] seam. The
44/// whole integration is these three methods mapping one neutral vocabulary onto
45/// the other.
46pub struct SearchezIndexer {
47    backend: Box<dyn Backend>,
48}
49
50impl SearchezIndexer {
51    pub fn new(backend: impl Backend + 'static) -> Self {
52        Self {
53            backend: Box::new(backend),
54        }
55    }
56}
57
58#[async_trait]
59impl Indexer for SearchezIndexer {
60    async fn index(
61        &self,
62        index: &str,
63        id: &str,
64        document: Map<String, Value>,
65    ) -> Result<(), StorageError> {
66        self.backend
67            .upsert(
68                index,
69                vec![IndexDoc {
70                    id: id.to_string(),
71                    document: Document::from(document),
72                }],
73            )
74            .await
75            .map_err(to_storage_err)
76    }
77
78    async fn remove(&self, index: &str, id: &str) -> Result<(), StorageError> {
79        self.backend
80            .delete(index, &[id.to_string()])
81            .await
82            .map_err(to_storage_err)
83    }
84
85    async fn search(
86        &self,
87        index: &str,
88        query: &str,
89        limit: usize,
90    ) -> Result<Vec<String>, StorageError> {
91        let hits = self
92            .backend
93            .search(index, &Query::text(query).limit(limit))
94            .await
95            .map_err(to_storage_err)?;
96        Ok(hits.into_iter().map(|h| h.id).collect())
97    }
98}
99
100/// Register a search backend with adminx-core. Use [`init_memory`] for the
101/// batteries-included path, or this to supply a configured backend (e.g.
102/// `searchez::MeilisearchBackend`).
103pub fn init(backend: impl Backend + 'static) {
104    adminx_core::set_indexer(Box::new(SearchezIndexer::new(backend)));
105    tracing::info!("adminx-search: full-text search enabled");
106}
107
108/// Register searchez's in-memory BM25 backend. No external service — ideal for
109/// development and small single-process datasets. Non-persistent, so run
110/// [`reindex`] at startup (or after a restart) to repopulate it from the
111/// database.
112pub fn init_memory() {
113    init(searchez::MemoryBackend::new());
114}
115
116/// Backfill a resource's index from a set of already-loaded rows. Each row is
117/// mapped through the resource's `search_fields()` and its primary key. Run this
118/// to seed a new index, after switching backends, or on startup for the
119/// non-persistent in-memory backend.
120///
121/// Returns the number of rows indexed.
122pub async fn reindex<R: adminx_core::resource::Resource + ?Sized>(
123    resource: &R,
124    rows: &[Value],
125) -> Result<usize, StorageError> {
126    let Some(indexer) = adminx_core::search::indexer() else {
127        return Ok(0);
128    };
129    let fields = resource.search_fields();
130    let pk = resource.primary_key();
131    let mut n = 0;
132    for row in rows {
133        let Some(id) = row.get(pk).map(value_to_id) else {
134            continue;
135        };
136        let doc = adminx_core::search::document_for(row, &fields);
137        indexer.index(resource.base_path(), &id, doc).await?;
138        n += 1;
139    }
140    tracing::info!("adminx-search: reindexed {n} row(s) into `{}`", resource.base_path());
141    Ok(n)
142}
143
144/// A primary-key value as a string, however the backend typed it.
145fn value_to_id(v: &Value) -> String {
146    match v {
147        Value::String(s) => s.clone(),
148        Value::Number(n) => n.to_string(),
149        other => other.to_string(),
150    }
151}
152
153fn to_storage_err(e: searchez::SearchError) -> StorageError {
154    StorageError::Backend(format!("search: {e}"))
155}