feat(search): add Meilisearch-backed SearchClient (index, search, remove)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
members = ["crates/domain", "crates/db", "crates/api", "crates/server"]
|
members = ["crates/domain", "crates/db", "crates/api", "crates/server", "crates/search"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
@@ -23,3 +23,4 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
|||||||
tower = { version = "0.5", features = ["util"] }
|
tower = { version = "0.5", features = ["util"] }
|
||||||
http-body-util = "0.1"
|
http-body-util = "0.1"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
|
meilisearch-sdk = "0.33"
|
||||||
|
|||||||
@@ -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" }
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user