diff --git a/meilisearch-test-macro/README.md b/meilisearch-test-macro/README.md index 1d794b69..c99cedef 100644 --- a/meilisearch-test-macro/README.md +++ b/meilisearch-test-macro/README.md @@ -68,6 +68,7 @@ There are a few rules, though: - `String`: It returns the name of the test. - `Client`: It creates a client like that: `Client::new("http://localhost:7700", "masterKey")`. - `Index`: It creates and deletes an index, as we've seen before. + You can include multiple `Index` parameter to automatically create multiple indices. 2. You only get what you asked for. That means if you don't ask for an index, no index will be created in meilisearch. So, if you are testing the creation of indexes, you can ask for a `Client` and a `String` and then create it yourself. diff --git a/meilisearch-test-macro/src/lib.rs b/meilisearch-test-macro/src/lib.rs index 28d4a440..c2325567 100644 --- a/meilisearch-test-macro/src/lib.rs +++ b/meilisearch-test-macro/src/lib.rs @@ -77,7 +77,6 @@ pub fn meilisearch_test(params: TokenStream, input: TokenStream) -> TokenStream let use_name = params .iter() .any(|param| matches!(param, Param::String | Param::Index)); - let use_index = params.contains(&Param::Index); // Now we are going to build the body of the outer function let mut outer_block: Vec = Vec::new(); @@ -106,59 +105,77 @@ pub fn meilisearch_test(params: TokenStream, input: TokenStream) -> TokenStream )); } + let index_var = |idx: usize| Ident::new(&format!("index_{idx}"), Span::call_site()); + // And finally if an index was asked, we delete it, and we (re)create it and wait until meilisearch confirm its creation. - if use_index { - outer_block.push(parse_quote!({ - let res = client - .delete_index(&name) - .await - .expect("Network issue while sending the delete index task") - .wait_for_completion(&client, None, None) - .await - .expect("Network issue while waiting for the index deletion"); - if res.is_failure() { - let error = res.unwrap_failure(); - assert_eq!( - error.error_code, - crate::errors::ErrorCode::IndexNotFound, - "{:?}", - error - ); - } - })); + for (i, param) in params.iter().enumerate() { + if !matches!(param, Param::Index) { + continue; + } + let var_name = index_var(i); outer_block.push(parse_quote!( - let index = client - .create_index(&name, None) - .await - .expect("Network issue while sending the create index task") - .wait_for_completion(&client, None, None) - .await - .expect("Network issue while waiting for the index creation") - .try_make_index(&client) - .expect("Could not create the index out of the create index task"); + let #var_name = { + let index_uid = format!("{name}_{}", #i); + let res = client + .delete_index(&index_uid) + .await + .expect("Network issue while sending the delete index task") + .wait_for_completion(&client, None, None) + .await + .expect("Network issue while waiting for the index deletion"); + + if res.is_failure() { + let error = res.unwrap_failure(); + assert_eq!( + error.error_code, + crate::errors::ErrorCode::IndexNotFound, + "{:?}", + error + ); + } + + client + .create_index(&index_uid, None) + .await + .expect("Network issue while sending the create index task") + .wait_for_completion(&client, None, None) + .await + .expect("Network issue while waiting for the index creation") + .try_make_index(&client) + .expect("Could not create the index out of the create index task") + }; )); } // Create a list of params separated by comma with the name we defined previously. - let params: Vec = params - .into_iter() - .map(|param| match param { + let args: Vec = params + .iter() + .enumerate() + .map(|(i, param)| match param { Param::Client => parse_quote!(client), - Param::Index => parse_quote!(index), + Param::Index => { + let var = index_var(i); + parse_quote!(#var) + } Param::String => parse_quote!(name), }) .collect(); // Now we can call the user code with our parameters :tada: outer_block.push(parse_quote!( - let result = #inner_ident(#(#params.clone()),*).await; + let result = #inner_ident(#(#args.clone()),*).await; )); // And right before the end, if an index was created and the tests successfully executed we delete it. - if use_index { + for (i, param) in params.iter().enumerate() { + if !matches!(param, Param::Index) { + continue; + } + + let var_name = index_var(i); outer_block.push(parse_quote!( - index + #var_name .delete() .await .expect("Network issue while sending the last delete index task"); diff --git a/src/client.rs b/src/client.rs index a574160e..6c024f01 100644 --- a/src/client.rs +++ b/src/client.rs @@ -128,6 +128,21 @@ impl Client { .await } + pub async fn execute_federated_multi_search_query< + T: 'static + DeserializeOwned + Send + Sync, + >( + &self, + body: &FederatedMultiSearchQuery<'_, '_, Http>, + ) -> Result, Error> { + self.http_client + .request::<(), &FederatedMultiSearchQuery, FederatedMultiSearchResponse>( + &format!("{}/multi-search", &self.host), + Method::Post { body, query: () }, + 200, + ) + .await + } + /// Make multiple search requests. /// /// # Example @@ -170,6 +185,22 @@ impl Client { /// # movies.delete().await.unwrap().wait_for_completion(&client, None, None).await.unwrap(); /// # }); /// ``` + /// + /// # Federated Search + /// + /// You can use [`MultiSearchQuery::with_federation`] to perform a [federated + /// search][1] where results from different indexes are merged and returned as + /// one list. + /// + /// When executing a federated query, the type parameter `T` is less clear, + /// as the documents in the different indexes potentially have different + /// fields and you might have one Rust type per index. In most cases, you + /// either want to create an enum with one variant per index and `#[serde + /// (untagged)]` attribute, or if you need more control, just pass + /// `serde_json::Map` and then deserialize that + /// into the appropriate target types later. + /// + /// [1]: https://www.meilisearch.com/docs/learn/multi_search/multi_search_vs_federated_search#what-is-federated-search #[must_use] pub fn multi_search(&self) -> MultiSearchQuery { MultiSearchQuery::new(self) diff --git a/src/search.rs b/src/search.rs index abc4befc..d865a8ec 100644 --- a/src/search.rs +++ b/src/search.rs @@ -2,7 +2,7 @@ use crate::{ client::Client, errors::Error, indexes::Index, request::HttpClient, DefaultHttpClient, }; use either::Either; -use serde::{de::DeserializeOwned, ser::SerializeStruct, Deserialize, Serialize, Serializer}; +use serde::{de::DeserializeOwned, Deserialize, Serialize, Serializer}; use serde_json::{Map, Value}; use std::collections::HashMap; @@ -66,6 +66,9 @@ pub struct SearchResult { pub ranking_score: Option, #[serde(rename = "_rankingScoreDetails")] pub ranking_score_details: Option>, + /// Only returned for federated multi search. + #[serde(rename = "_federation")] + pub federation: Option, } #[derive(Deserialize, Debug, Clone)] @@ -361,6 +364,16 @@ pub struct SearchQuery<'a, Http: HttpClient> { #[serde(skip_serializing_if = "Option::is_none")] pub(crate) index_uid: Option<&'a str>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) federation_options: Option, +} + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct QueryFederationOptions { + #[serde(skip_serializing_if = "Option::is_none")] + pub weight: Option, } #[allow(missing_docs)] @@ -393,6 +406,7 @@ impl<'a, Http: HttpClient> SearchQuery<'a, Http> { distinct: None, ranking_score_threshold: None, locales: None, + federation_options: None, } } pub fn with_query<'b>(&'b mut self, query: &'a str) -> &'b mut SearchQuery<'a, Http> { @@ -600,6 +614,14 @@ impl<'a, Http: HttpClient> SearchQuery<'a, Http> { self.locales = Some(locales); self } + /// Only usable in federated multi search queries. + pub fn with_federation_options<'b>( + &'b mut self, + federation_options: QueryFederationOptions, + ) -> &'b mut SearchQuery<'a, Http> { + self.federation_options = Some(federation_options); + self + } pub fn build(&mut self) -> SearchQuery<'a, Http> { self.clone() } @@ -611,27 +633,19 @@ impl<'a, Http: HttpClient> SearchQuery<'a, Http> { } } -// TODO: Make it works with the serde derive macro -// #[derive(Debug, Serialize, Clone)] -// #[serde(rename_all = "camelCase")] -#[derive(Debug, Clone)] +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] pub struct MultiSearchQuery<'a, 'b, Http: HttpClient = DefaultHttpClient> { - // #[serde(skip_serializing)] + #[serde(skip_serializing)] client: &'a Client, + // The weird `serialize = ""` is actually useful: without it, serde adds the + // bound `Http: Serialize` to the `Serialize` impl block, but that's not + // necessary. `SearchQuery` always implements `Serialize` (regardless of + // type parameter), so no bound is fine. + #[serde(bound(serialize = ""))] pub queries: Vec>, } -impl Serialize for MultiSearchQuery<'_, '_, Http> { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - let mut strukt = serializer.serialize_struct("MultiSearchQuery", 1)?; - strukt.serialize_field("queries", &self.queries)?; - strukt.end() - } -} - #[allow(missing_docs)] impl<'a, 'b, Http: HttpClient> MultiSearchQuery<'a, 'b, Http> { #[must_use] @@ -649,6 +663,17 @@ impl<'a, 'b, Http: HttpClient> MultiSearchQuery<'a, 'b, Http> { self.queries.push(search_query); self } + /// Adds the `federation` parameter, making the search a federated search. + pub fn with_federation( + self, + federation: FederationOptions, + ) -> FederatedMultiSearchQuery<'a, 'b, Http> { + FederatedMultiSearchQuery { + client: self.client, + queries: self.queries, + federation: Some(federation), + } + } /// Execute the query and fetch the results. pub async fn execute( @@ -662,6 +687,78 @@ pub struct MultiSearchResponse { pub results: Vec>, } +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct FederatedMultiSearchQuery<'a, 'b, Http: HttpClient = DefaultHttpClient> { + #[serde(skip_serializing)] + client: &'a Client, + #[serde(bound(serialize = ""))] + pub queries: Vec>, + #[serde(skip_serializing_if = "Option::is_none")] + pub federation: Option, +} + +/// The `federation` field of the multi search API. +/// See [the docs](https://www.meilisearch.com/docs/reference/api/multi_search#federation). +#[derive(Debug, Serialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct FederationOptions { + #[serde(skip_serializing_if = "Option::is_none")] + pub offset: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub facets_by_index: Option>>, + #[serde(skip_serializing_if = "Option::is_none")] + pub merge_facets: Option, +} + +#[allow(missing_docs)] +impl<'a, Http: HttpClient> FederatedMultiSearchQuery<'a, '_, Http> { + /// Execute the query and fetch the results. + pub async fn execute( + &'a self, + ) -> Result, Error> { + self.client + .execute_federated_multi_search_query::(self) + .await + } +} + +/// Returned by federated multi search. +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct FederatedMultiSearchResponse { + /// Merged results of the query. + pub hits: Vec>, + + // TODO: are offset, limit and estimated_total_hits really non-optional? In + // my tests they are always returned, but that's not a proof. + /// Number of documents skipped. + pub offset: usize, + /// Number of results returned. + pub limit: usize, + /// Estimated total number of matches. + pub estimated_total_hits: usize, + + /// Distribution of the given facets. + pub facet_distribution: Option>>, + /// facet stats of the numerical facets requested in the `facet` search parameter. + pub facet_stats: Option>, + /// Processing time of the query. + pub processing_time_ms: usize, +} + +/// Returned for each hit in `_federation` when doing federated multi search. +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct FederationHitInfo { + pub index_uid: String, + pub queries_position: usize, + // TOOD: not mentioned in the docs, is that optional? + pub weighted_ranking_score: f32, +} + #[cfg(test)] mod tests { use crate::{ @@ -722,6 +819,56 @@ mod tests { Ok(()) } + #[derive(Debug, Serialize, Deserialize, PartialEq)] + struct VideoDocument { + id: usize, + title: String, + description: Option, + duration: u32, + } + + async fn setup_test_video_index(client: &Client, index: &Index) -> Result<(), Error> { + let t0 = index + .add_documents( + &[ + VideoDocument { + id: 0, + title: S("Spring"), + description: Some(S("A Blender Open movie")), + duration: 123, + }, + VideoDocument { + id: 1, + title: S("Wing It!"), + description: None, + duration: 234, + }, + VideoDocument { + id: 2, + title: S("Coffee Run"), + description: Some(S("Directed by Hjalti Hjalmarsson")), + duration: 345, + }, + VideoDocument { + id: 3, + title: S("Harry Potter and the Deathly Hallows"), + description: None, + duration: 7654, + }, + ], + None, + ) + .await?; + let t1 = index.set_filterable_attributes(["duration"]).await?; + let t2 = index.set_sortable_attributes(["title"]).await?; + + t2.wait_for_completion(client, None, None).await?; + t1.wait_for_completion(client, None, None).await?; + t0.wait_for_completion(client, None, None).await?; + + Ok(()) + } + #[meilisearch_test] async fn test_multi_search(client: Client, index: Index) -> Result<(), Error> { setup_test_index(&client, &index).await?; @@ -744,6 +891,78 @@ mod tests { Ok(()) } + #[meilisearch_test] + async fn test_federated_multi_search( + client: Client, + index_a: Index, + index_b: Index, + ) -> Result<(), Error> { + setup_test_index(&client, &index_a).await?; + setup_test_video_index(&client, &index_b).await?; + + let query_death_a = SearchQuery::new(&index_a).with_query("death").build(); + let query_death_b = SearchQuery::new(&index_b).with_query("death").build(); + + #[derive(Debug, Serialize, Deserialize, PartialEq)] + #[serde(untagged)] + enum AnyDocument { + IndexA(Document), + IndexB(VideoDocument), + } + + let mut multi_query = client.multi_search(); + multi_query.with_search_query(query_death_a.clone()); + multi_query.with_search_query(query_death_b.clone()); + let response = multi_query + .with_federation(FederationOptions::default()) + .execute::() + .await?; + + assert_eq!(response.hits.len(), 2); + let pos_a = response + .hits + .iter() + .position(|hit| hit.federation.as_ref().unwrap().index_uid == index_a.uid) + .expect("No hit of index_a found"); + let hit_a = &response.hits[pos_a]; + let hit_b = &response.hits[if pos_a == 0 { 1 } else { 0 }]; + assert_eq!( + hit_a.result, + AnyDocument::IndexA(Document { + id: 9, + kind: "title".into(), + number: 90, + value: S("Harry Potter and the Deathly Hallows"), + nested: Nested { child: S("tenth") }, + }) + ); + assert_eq!( + hit_b.result, + AnyDocument::IndexB(VideoDocument { + id: 3, + title: S("Harry Potter and the Deathly Hallows"), + description: None, + duration: 7654, + }) + ); + + // Make sure federation options are applied + let mut multi_query = client.multi_search(); + multi_query.with_search_query(query_death_a.clone()); + multi_query.with_search_query(query_death_b.clone()); + let response = multi_query + .with_federation(FederationOptions { + limit: Some(1), + ..Default::default() + }) + .execute::() + .await?; + + assert_eq!(response.hits.len(), 1); + + Ok(()) + } + #[meilisearch_test] async fn test_query_builder(_client: Client, index: Index) -> Result<(), Error> { let mut query = SearchQuery::new(&index);