From dc903989f72324db458644df23199d548cff08bd Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Tue, 2 Jun 2026 11:43:53 +0200 Subject: [PATCH] feat(search): add Meilisearch-backed SearchClient (index, search, remove) Co-Authored-By: Claude Sonnet 4.6 --- Cargo.toml | 3 +- crates/search/Cargo.toml | 20 +++++ crates/search/src/lib.rs | 149 ++++++++++++++++++++++++++++++++++ crates/search/tests/search.rs | 52 ++++++++++++ 4 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 crates/search/Cargo.toml create mode 100644 crates/search/src/lib.rs create mode 100644 crates/search/tests/search.rs diff --git a/Cargo.toml b/Cargo.toml index 1522aa4..fdb73ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["crates/domain", "crates/db", "crates/api", "crates/server"] +members = ["crates/domain", "crates/db", "crates/api", "crates/server", "crates/search"] [workspace.package] edition = "2024" @@ -23,3 +23,4 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } tower = { version = "0.5", features = ["util"] } http-body-util = "0.1" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +meilisearch-sdk = "0.33" diff --git a/crates/search/Cargo.toml b/crates/search/Cargo.toml new file mode 100644 index 0000000..858a735 --- /dev/null +++ b/crates/search/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "search" +version = "0.0.0" +edition.workspace = true +rust-version.workspace = true + +[dependencies] +meilisearch-sdk.workspace = true +serde = { workspace = true } +thiserror.workspace = true +domain = { path = "../domain" } +db = { path = "../db" } +sqlx.workspace = true + +[dev-dependencies] +tokio.workspace = true +uuid.workspace = true +serde_json.workspace = true +sqlx.workspace = true +domain = { path = "../domain" } diff --git a/crates/search/src/lib.rs b/crates/search/src/lib.rs new file mode 100644 index 0000000..6dc4471 --- /dev/null +++ b/crates/search/src/lib.rs @@ -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, + pub current_owner: Option, + pub recorder: Option, + /// Filterable: "draft" | "internal" | "public". + pub visibility: String, + /// Flexible field values flattened to searchable text. + pub fields_text: Vec, +} + +/// 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 { + 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, SearchError> { + let index = self.client.index(&self.index_uid); + + let results = index + .search() + .with_query(query) + .build() + .execute::() + .await?; + + results + .hits + .into_iter() + .map(|hit| { + hit.result + .id + .parse::() + .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 { + unimplemented!("implemented in Task 2") +} diff --git a/crates/search/tests/search.rs b/crates/search/tests/search.rs new file mode 100644 index 0000000..f120037 --- /dev/null +++ b/crates/search/tests/search.rs @@ -0,0 +1,52 @@ +use search::{SearchClient, SearchDocument}; + +fn meili() -> (String, String) { + ( + std::env::var("MEILI_URL").expect("MEILI_URL must be set"), + std::env::var("MEILI_MASTER_KEY").expect("MEILI_MASTER_KEY must be set"), + ) +} + +fn unique_index() -> String { + format!("objects_test_{}", uuid::Uuid::new_v4().simple()) +} + +fn doc(id: &str, object_name: &str, fields_text: &[&str]) -> SearchDocument { + SearchDocument { + id: id.to_string(), + object_number: format!("N-{id}"), + object_name: object_name.to_string(), + brief_description: None, + current_owner: None, + recorder: None, + visibility: "draft".to_string(), + fields_text: fields_text.iter().map(|s| s.to_string()).collect(), + } +} + +#[tokio::test] +async fn index_search_and_remove() { + let (url, key) = meili(); + let client = SearchClient::connect(&url, &key, &unique_index()).unwrap(); + client.ensure_index().await.unwrap(); + + let vase = domain::ObjectId::new(); + let chair = domain::ObjectId::new(); + client + .index_object(&doc(&vase.to_string(), "vase", &["wood", "trä"])) + .await + .unwrap(); + client + .index_object(&doc(&chair.to_string(), "chair", &["oak"])) + .await + .unwrap(); + + let hits = client.search("wood").await.unwrap(); + assert_eq!(hits, vec![vase]); + + let hits = client.search("chair").await.unwrap(); + assert_eq!(hits, vec![chair]); + + client.remove_object(vase).await.unwrap(); + assert!(client.search("wood").await.unwrap().is_empty()); +}