adminx_core/search.rs
1// adminx-core/src/search.rs
2//
3// The search seam. A resource can declare `search_fields()`; default CRUD then
4// keeps a search index in sync — index on create/update, remove on delete —
5// through a pluggable backend registered once. With no indexer registered
6// nothing is indexed and no extra query is issued, so the cost is zero until a
7// crate like `adminx-search` opts in.
8//
9// Core never names a search engine. `adminx-search` implements this trait over
10// the standalone `searchez` crate (in-memory, Meilisearch, ...); nothing here
11// depends on it.
12
13use crate::request::ReqCtx;
14use crate::storage::StorageError;
15use async_trait::async_trait;
16use once_cell::sync::OnceCell;
17use serde_json::{Map, Value};
18
19/// A pluggable search index. Keyed by `index` (a resource's `base_path()`), so
20/// one backend serves every searchable resource. Async — it does I/O, once per
21/// mutation, not per request.
22#[async_trait]
23pub trait Indexer: Send + Sync {
24 /// Insert or replace the document for one record.
25 async fn index(
26 &self,
27 index: &str,
28 id: &str,
29 document: Map<String, Value>,
30 ) -> Result<(), StorageError>;
31
32 /// Remove a record from the index.
33 async fn remove(&self, index: &str, id: &str) -> Result<(), StorageError>;
34
35 /// Full-text search, returning matching record ids in rank order (best
36 /// first). The caller hydrates the rows from storage.
37 async fn search(
38 &self,
39 index: &str,
40 query: &str,
41 limit: usize,
42 ) -> Result<Vec<String>, StorageError>;
43}
44
45static INDEXER: OnceCell<Box<dyn Indexer>> = OnceCell::new();
46
47/// Register the global search backend. Set-once, matching the other seams.
48pub fn set_indexer(indexer: Box<dyn Indexer>) {
49 if INDEXER.set(indexer).is_err() {
50 tracing::warn!("adminx indexer already initialized; ignoring reset");
51 }
52}
53
54/// The registered indexer, if any.
55pub fn indexer() -> Option<&'static dyn Indexer> {
56 INDEXER.get().map(|b| b.as_ref())
57}
58
59/// Whether search is on. Default CRUD checks this before spending a read to
60/// build a document, so an unindexed app issues exactly the queries it did
61/// before this module existed.
62pub fn is_enabled() -> bool {
63 INDEXER.get().is_some()
64}
65
66/// Project a stored row to the document to index: just the resource's declared
67/// `search_fields` (plus nothing else — the id is carried separately). Missing
68/// fields are simply absent.
69pub fn document_for(row: &Value, fields: &[&str]) -> Map<String, Value> {
70 let mut doc = Map::new();
71 if let Value::Object(map) = row {
72 for f in fields {
73 if let Some(v) = map.get(*f) {
74 doc.insert((*f).to_string(), v.clone());
75 }
76 }
77 }
78 doc
79}
80
81/// Index (or re-index) a record after a write. Best-effort: an indexing failure
82/// is logged, never propagated — a search backend hiccup must not fail the write
83/// the user asked for. The index catches up on the next write or a reindex.
84pub async fn index_record(index: &str, id: &str, document: Map<String, Value>) {
85 if let Some(indexer) = indexer() {
86 if let Err(e) = indexer.index(index, id, document).await {
87 tracing::error!("adminx: failed to index {index}/{id}: {e}");
88 }
89 }
90}
91
92/// Remove a record from the index after a delete. Best-effort, same rationale as
93/// [`index_record`].
94pub async fn remove_record(index: &str, id: &str) {
95 if let Some(indexer) = indexer() {
96 if let Err(e) = indexer.remove(index, id).await {
97 tracing::error!("adminx: failed to de-index {index}/{id}: {e}");
98 }
99 }
100}
101
102/// Search an index, returning matching ids in rank order. Returns empty (never
103/// errors to the caller) so a list page renders even when the backend is down.
104pub async fn search_ids(index: &str, query: &str, limit: usize) -> Vec<String> {
105 let Some(indexer) = indexer() else {
106 return Vec::new();
107 };
108 match indexer.search(index, query, limit).await {
109 Ok(ids) => ids,
110 Err(e) => {
111 tracing::error!("adminx: search on {index} failed: {e}");
112 Vec::new()
113 }
114 }
115}
116
117/// The `q` full-text term from a request query string, trimmed; `None` when
118/// absent or blank. The list page uses this to decide between a search and the
119/// normal paginated listing.
120pub fn query_term(ctx: &ReqCtx) -> Option<String> {
121 let params: std::collections::HashMap<String, String> =
122 serde_urlencoded::from_str(&ctx.query).unwrap_or_default();
123 params
124 .get("q")
125 .map(|s| s.trim().to_string())
126 .filter(|s| !s.is_empty())
127}