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}