feat(search): add Meilisearch-backed SearchClient (index, search, remove)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
//! Full-text search over catalogue objects, backed by Meilisearch.
|
||||
|
||||
use db::Db;
|
||||
use domain::{CatalogueObject, ObjectId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Errors from the search subsystem.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SearchError {
|
||||
#[error(transparent)]
|
||||
Meili(#[from] meilisearch_sdk::errors::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Db(#[from] sqlx::Error),
|
||||
|
||||
#[error("invalid object id in index: {0}")]
|
||||
BadId(String),
|
||||
}
|
||||
|
||||
/// The indexed shape of a catalogue object.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SearchDocument {
|
||||
pub id: String,
|
||||
pub object_number: String,
|
||||
pub object_name: String,
|
||||
pub brief_description: Option<String>,
|
||||
pub current_owner: Option<String>,
|
||||
pub recorder: Option<String>,
|
||||
/// Filterable: "draft" | "internal" | "public".
|
||||
pub visibility: String,
|
||||
/// Flexible field values flattened to searchable text.
|
||||
pub fields_text: Vec<String>,
|
||||
}
|
||||
|
||||
/// A Meilisearch-backed search client scoped to one index.
|
||||
pub struct SearchClient {
|
||||
client: meilisearch_sdk::client::Client,
|
||||
index_uid: String,
|
||||
}
|
||||
|
||||
impl SearchClient {
|
||||
pub fn connect(url: &str, api_key: &str, index_uid: &str) -> Result<Self, SearchError> {
|
||||
let client = meilisearch_sdk::client::Client::new(url, Some(api_key))?;
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
index_uid: index_uid.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn ensure_index(&self) -> Result<(), SearchError> {
|
||||
self.client
|
||||
.create_index(&self.index_uid, Some("id"))
|
||||
.await?
|
||||
.wait_for_completion(&self.client, None, None)
|
||||
.await?;
|
||||
|
||||
self.client
|
||||
.index(&self.index_uid)
|
||||
.set_filterable_attributes(["visibility"])
|
||||
.await?
|
||||
.wait_for_completion(&self.client, None, None)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn index_object(&self, doc: &SearchDocument) -> Result<(), SearchError> {
|
||||
self.client
|
||||
.index(&self.index_uid)
|
||||
.add_or_replace(std::slice::from_ref(doc), Some("id"))
|
||||
.await?
|
||||
.wait_for_completion(&self.client, None, None)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn remove_object(&self, id: ObjectId) -> Result<(), SearchError> {
|
||||
self.client
|
||||
.index(&self.index_uid)
|
||||
.delete_document(id.to_string())
|
||||
.await?
|
||||
.wait_for_completion(&self.client, None, None)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn search(&self, query: &str) -> Result<Vec<ObjectId>, SearchError> {
|
||||
let index = self.client.index(&self.index_uid);
|
||||
|
||||
let results = index
|
||||
.search()
|
||||
.with_query(query)
|
||||
.build()
|
||||
.execute::<SearchDocument>()
|
||||
.await?;
|
||||
|
||||
results
|
||||
.hits
|
||||
.into_iter()
|
||||
.map(|hit| {
|
||||
hit.result
|
||||
.id
|
||||
.parse::<ObjectId>()
|
||||
.map_err(|_| SearchError::BadId(hit.result.id))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Rebuild the whole index from the database. (build_document is filled in Task 2.)
|
||||
pub async fn reindex_all(&self, db: &Db) -> Result<(), SearchError> {
|
||||
let index = self.client.index(&self.index_uid);
|
||||
|
||||
index
|
||||
.delete_all_documents()
|
||||
.await?
|
||||
.wait_for_completion(&self.client, None, None)
|
||||
.await?;
|
||||
|
||||
let objects = db::catalog::list_objects(db.pool()).await?;
|
||||
|
||||
let mut docs = Vec::with_capacity(objects.len());
|
||||
|
||||
for object in &objects {
|
||||
docs.push(build_document(db, object).await?);
|
||||
}
|
||||
|
||||
if !docs.is_empty() {
|
||||
index
|
||||
.add_or_replace(&docs, Some("id"))
|
||||
.await?
|
||||
.wait_for_completion(&self.client, None, None)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a SearchDocument from an object (implemented in Task 2).
|
||||
#[allow(clippy::unimplemented)] // Task 2
|
||||
pub async fn build_document(
|
||||
_db: &Db,
|
||||
_object: &CatalogueObject,
|
||||
) -> Result<SearchDocument, SearchError> {
|
||||
unimplemented!("implemented in Task 2")
|
||||
}
|
||||
Reference in New Issue
Block a user