From 922d2641dbac1cb2623ac748a66825f58df27e6f Mon Sep 17 00:00:00 2001 From: Lukas Kalbertodt Date: Thu, 12 Dec 2024 12:54:17 +0100 Subject: [PATCH 01/16] Replace manual `Serialize` impl of `MultiSearchQuery` with derive --- src/search.rs | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/src/search.rs b/src/search.rs index abc4befc..09899a82 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; @@ -611,26 +611,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> { From efd9dfb895f8c1fca663a362b9e75e6230df310f Mon Sep 17 00:00:00 2001 From: Lukas Kalbertodt Date: Thu, 12 Dec 2024 13:41:56 +0100 Subject: [PATCH 02/16] Add federated multi search API Fixes #609 --- src/client.rs | 31 ++++++++++++++++++ src/search.rs | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 117 insertions(+), 1 deletion(-) 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 09899a82..0c6dbae9 100644 --- a/src/search.rs +++ b/src/search.rs @@ -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)] @@ -624,7 +627,6 @@ pub struct MultiSearchQuery<'a, 'b, Http: HttpClient = DefaultHttpClient> { pub queries: Vec>, } - #[allow(missing_docs)] impl<'a, 'b, Http: HttpClient> MultiSearchQuery<'a, 'b, Http> { #[must_use] @@ -642,6 +644,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( @@ -655,6 +668,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::{ From 70f258f9f8c9877a9b52863989d4b2b5c17cb1cb Mon Sep 17 00:00:00 2001 From: Lukas Kalbertodt Date: Mon, 16 Dec 2024 12:43:58 +0100 Subject: [PATCH 03/16] Add `federation_options` to `SearchQuery` --- src/search.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/search.rs b/src/search.rs index 0c6dbae9..6b6588f7 100644 --- a/src/search.rs +++ b/src/search.rs @@ -364,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)] @@ -396,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> { @@ -603,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() } From 9c176e8ce81708bc97561c3f7025a5cbf29f563a Mon Sep 17 00:00:00 2001 From: Lukas Kalbertodt Date: Thu, 12 Jun 2025 14:20:27 +0200 Subject: [PATCH 04/16] Make `#[meilisearch_test]` support multiple indices This is useful for testing federated stuff. --- meilisearch-test-macro/README.md | 1 + meilisearch-test-macro/src/lib.rs | 89 ++++++++++++++++++------------- 2 files changed, 54 insertions(+), 36 deletions(-) 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"); From a826b64dec0be85eac9660ae34403b1b57abf4c7 Mon Sep 17 00:00:00 2001 From: Lukas Kalbertodt Date: Thu, 12 Jun 2025 14:21:14 +0200 Subject: [PATCH 05/16] Add unit test for federated multi search --- src/search.rs | 122 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/src/search.rs b/src/search.rs index 6b6588f7..d865a8ec 100644 --- a/src/search.rs +++ b/src/search.rs @@ -819,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?; @@ -841,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); From be7a7ff8ba3b6732f2a4b42f7fae1ff4d77e2f21 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Fri, 11 Jul 2025 17:37:04 +0200 Subject: [PATCH 06/16] Fix errors --- src/search.rs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/search.rs b/src/search.rs index 537f582b..30de70de 100644 --- a/src/search.rs +++ b/src/search.rs @@ -55,19 +55,25 @@ pub struct SearchResult { /// The full result. #[serde(flatten)] pub result: T, + /// The formatted result. - #[serde(rename = "_formatted")] + #[serde(rename = "_formatted", skip_serializing_if = "Option::is_none")] pub formatted_result: Option>, + /// The object that contains information about the matches. - #[serde(rename = "_matchesPosition")] + #[serde(rename = "_matchesPosition", skip_serializing_if = "Option::is_none")] pub matches_position: Option>>, + /// The relevancy score of the match. - #[serde(rename = "_rankingScore")] + #[serde(rename = "_rankingScore", skip_serializing_if = "Option::is_none")] pub ranking_score: Option, - #[serde(rename = "_rankingScoreDetails")] + + /// A detailed global ranking score field + #[serde(rename = "_rankingScoreDetails", skip_serializing_if = "Option::is_none")] pub ranking_score_details: Option>, + /// Only returned for federated multi search. - #[serde(rename = "_federation")] + #[serde(rename = "_federation", skip_serializing_if = "Option::is_none")] pub federation: Option, } @@ -832,7 +838,7 @@ pub struct FederatedMultiSearchResponse { } /// Returned for each hit in `_federation` when doing federated multi search. -#[derive(Debug, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct FederationHitInfo { pub index_uid: String, @@ -1243,6 +1249,7 @@ mod tests { number: 90, value: S("Harry Potter and the Deathly Hallows"), nested: Nested { child: S("tenth") }, + _vectors: None, }) ); assert_eq!( From 81d2dedccfd1f245602fe4019ad8b7d5887bcb75 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Fri, 11 Jul 2025 17:41:46 +0200 Subject: [PATCH 07/16] Add doc --- src/search.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/search.rs b/src/search.rs index 30de70de..d00f7a70 100644 --- a/src/search.rs +++ b/src/search.rs @@ -743,6 +743,7 @@ impl<'a, 'b, Http: HttpClient> MultiSearchQuery<'a, 'b, Http> { queries: Vec::new(), } } + pub fn with_search_query( &mut self, mut search_query: SearchQuery<'b, Http>, @@ -751,6 +752,7 @@ 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, @@ -770,6 +772,7 @@ impl<'a, 'b, Http: HttpClient> MultiSearchQuery<'a, 'b, Http> { self.client.execute_multi_search_query::(self).await } } + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct MultiSearchResponse { pub results: Vec>, @@ -791,12 +794,19 @@ pub struct FederatedMultiSearchQuery<'a, 'b, Http: HttpClient = DefaultHttpClien #[derive(Debug, Serialize, Clone, Default)] #[serde(rename_all = "camelCase")] pub struct FederationOptions { + /// Number of documents to skip #[serde(skip_serializing_if = "Option::is_none")] pub offset: Option, + + /// Maximum number of documents returned #[serde(skip_serializing_if = "Option::is_none")] pub limit: Option, + + /// Display facet information for the specified indexes #[serde(skip_serializing_if = "Option::is_none")] pub facets_by_index: Option>>, + + /// Display facet information for the specified indexes #[serde(skip_serializing_if = "Option::is_none")] pub merge_facets: Option, } From 9bca8cc5f0fcdc463b939ea1695e02d130131ee6 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Fri, 11 Jul 2025 18:10:31 +0200 Subject: [PATCH 08/16] Remove useless allow --- src/search.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/search.rs b/src/search.rs index d00f7a70..89441ca1 100644 --- a/src/search.rs +++ b/src/search.rs @@ -811,7 +811,6 @@ pub struct FederationOptions { 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( From fdaef5a07c6e063e90423244f75c851dcfaee560 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Fri, 11 Jul 2025 18:10:42 +0200 Subject: [PATCH 09/16] Update a lot of stuff that changed --- src/search.rs | 37 +++++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/src/search.rs b/src/search.rs index 89441ca1..d7e38477 100644 --- a/src/search.rs +++ b/src/search.rs @@ -1,5 +1,5 @@ use crate::{ - client::Client, errors::Error, indexes::Index, request::HttpClient, DefaultHttpClient, + client::Client, errors::{Error, MeilisearchError}, indexes::Index, request::HttpClient, DefaultHttpClient, }; use either::Either; use serde::{de::DeserializeOwned, Deserialize, Serialize, Serializer}; @@ -822,6 +822,12 @@ impl<'a, Http: HttpClient> FederatedMultiSearchQuery<'a, '_, Http> { } } +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ComputedFacets { + pub distribution: HashMap>, + pub stats: HashMap, +} + /// Returned by federated multi search. #[derive(Debug, Deserialize, Clone)] #[serde(rename_all = "camelCase")] @@ -829,30 +835,45 @@ 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, + + /// [Data for facets present in the search results](https://www.meilisearch.com/docs/reference/api/multi_search#facetsbyindex) + pub facets_by_index: Option, + + /// [Distribution of the given facets](https://www.meilisearch.com/docs/reference/api/multi_search#mergefacets) + pub facet_distribution: Option>>, + + /// [The numeric `min` and `max` values per facet](https://www.meilisearch.com/docs/reference/api/multi_search#mergefacets) + pub facet_stats: Option>, + + /// Indicates which remote requests failed and why + pub remote_errors: Option>, } /// Returned for each hit in `_federation` when doing federated multi search. #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct FederationHitInfo { + /// Index of origin for this document pub index_uid: String, + + /// Array index number of the query in the request’s queries array pub queries_position: usize, - // TOOD: not mentioned in the docs, is that optional? + + /// Remote instance of origin for this document + pub remote: Option, + + /// The product of the _rankingScore of the hit and the weight of the query of origin. pub weighted_ranking_score: f32, } From a5f8e45a08869bed91377ebd12110b93ec1d740a Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Fri, 11 Jul 2025 18:17:20 +0200 Subject: [PATCH 10/16] Improve API --- src/search.rs | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/search.rs b/src/search.rs index d7e38477..5612a502 100644 --- a/src/search.rs +++ b/src/search.rs @@ -700,15 +700,6 @@ impl<'a, Http: HttpClient> SearchQuery<'a, Http> { 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() } @@ -753,7 +744,18 @@ impl<'a, 'b, Http: HttpClient> MultiSearchQuery<'a, 'b, Http> { self } - /// Adds the `federation` parameter, making the search a federated search. + pub fn with_search_query_and_options( + &mut self, + mut search_query: SearchQuery<'b, Http>, + options: QueryFederationOptions, + ) -> &mut MultiSearchQuery<'a, 'b, Http> { + search_query.with_index_uid(); + search_query.federation_options = Some(options); + self.queries.push(search_query); + self + } + + /// Adds the `federation` parameter, turning the search into a federated search. pub fn with_federation( self, federation: FederationOptions, From 2c540a1301718cc711627d46799a41290905d2f4 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Fri, 11 Jul 2025 18:20:35 +0200 Subject: [PATCH 11/16] Format --- src/search.rs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/search.rs b/src/search.rs index 5612a502..a4e43d85 100644 --- a/src/search.rs +++ b/src/search.rs @@ -1,5 +1,9 @@ use crate::{ - client::Client, errors::{Error, MeilisearchError}, indexes::Index, request::HttpClient, DefaultHttpClient, + client::Client, + errors::{Error, MeilisearchError}, + indexes::Index, + request::HttpClient, + DefaultHttpClient, }; use either::Either; use serde::{de::DeserializeOwned, Deserialize, Serialize, Serializer}; @@ -69,7 +73,10 @@ pub struct SearchResult { pub ranking_score: Option, /// A detailed global ranking score field - #[serde(rename = "_rankingScoreDetails", skip_serializing_if = "Option::is_none")] + #[serde( + rename = "_rankingScoreDetails", + skip_serializing_if = "Option::is_none" + )] pub ranking_score_details: Option>, /// Only returned for federated multi search. @@ -382,7 +389,7 @@ pub struct SearchQuery<'a, Http: HttpClient> { #[serde(skip_serializing_if = "Option::is_none")] pub(crate) index_uid: Option<&'a str>, - + /// Configures Meilisearch to return search results based on a query’s meaning and context. #[serde(skip_serializing_if = "Option::is_none")] pub hybrid: Option>, @@ -744,7 +751,7 @@ impl<'a, 'b, Http: HttpClient> MultiSearchQuery<'a, 'b, Http> { self } - pub fn with_search_query_and_options( + pub fn with_search_query_and_options( &mut self, mut search_query: SearchQuery<'b, Http>, options: QueryFederationOptions, @@ -807,7 +814,7 @@ pub struct FederationOptions { /// Display facet information for the specified indexes #[serde(skip_serializing_if = "Option::is_none")] pub facets_by_index: Option>>, - + /// Display facet information for the specified indexes #[serde(skip_serializing_if = "Option::is_none")] pub merge_facets: Option, @@ -878,7 +885,7 @@ pub struct FederationHitInfo { /// The product of the _rankingScore of the hit and the weight of the query of origin. pub weighted_ranking_score: f32, } - + /// A struct representing a facet-search query. /// /// You can add search parameters using the builder syntax. From 0107e0371134b653bcacad332bce6ab22bc32c1d Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Fri, 11 Jul 2025 18:33:12 +0200 Subject: [PATCH 12/16] Improve test --- src/search.rs | 84 ++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 60 insertions(+), 24 deletions(-) diff --git a/src/search.rs b/src/search.rs index a4e43d85..69336a18 100644 --- a/src/search.rs +++ b/src/search.rs @@ -751,6 +751,19 @@ impl<'a, 'b, Http: HttpClient> MultiSearchQuery<'a, 'b, Http> { self } + pub fn with_search_query_and_weight( + &mut self, + mut search_query: SearchQuery<'b, Http>, + weight: f32, + ) -> &mut MultiSearchQuery<'a, 'b, Http> { + search_query.with_index_uid(); + search_query.federation_options = Some(QueryFederationOptions { + weight: Some(weight), + }); + self.queries.push(search_query); + self + } + pub fn with_search_query_and_options( &mut self, mut search_query: SearchQuery<'b, Http>, @@ -1248,41 +1261,34 @@ mod tests { #[meilisearch_test] async fn test_federated_multi_search( client: Client, - index_a: Index, - index_b: Index, + test_index: Index, + video_index: Index, ) -> Result<(), Error> { - setup_test_index(&client, &index_a).await?; - setup_test_video_index(&client, &index_b).await?; + setup_test_index(&client, &test_index).await?; + setup_test_video_index(&client, &video_index).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(); + let query_test_index = SearchQuery::new(&test_index).with_query("death").build(); + let query_video_index = SearchQuery::new(&video_index).with_query("death").build(); #[derive(Debug, Serialize, Deserialize, PartialEq)] #[serde(untagged)] enum AnyDocument { - IndexA(Document), - IndexB(VideoDocument), + Document(Document), + VideoDocument(VideoDocument), } + // Search with big weight on the test index 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()); + multi_query.with_search_query_and_weight(query_test_index.clone(), 999.0); + multi_query.with_search_query(query_video_index.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 { + response.hits[0].result, + AnyDocument::Document(Document { id: 9, kind: "title".into(), number: 90, @@ -1292,8 +1298,8 @@ mod tests { }) ); assert_eq!( - hit_b.result, - AnyDocument::IndexB(VideoDocument { + response.hits[1].result, + AnyDocument::VideoDocument(VideoDocument { id: 3, title: S("Harry Potter and the Deathly Hallows"), description: None, @@ -1301,10 +1307,40 @@ mod tests { }) ); + // Search with big weight on the video index + let mut multi_query = client.multi_search(); + multi_query.with_search_query(query_test_index.clone()); + multi_query.with_search_query_and_weight(query_video_index.clone(), 999.0); + let response = multi_query + .with_federation(FederationOptions::default()) + .execute::() + .await?; + assert_eq!(response.hits.len(), 2); + assert_eq!( + response.hits[0].result, + AnyDocument::VideoDocument(VideoDocument { + id: 3, + title: S("Harry Potter and the Deathly Hallows"), + description: None, + duration: 7654, + }) + ); + assert_eq!( + response.hits[1].result, + AnyDocument::Document(Document { + id: 9, + kind: "title".into(), + number: 90, + value: S("Harry Potter and the Deathly Hallows"), + nested: Nested { child: S("tenth") }, + _vectors: None, + }) + ); + // 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()); + multi_query.with_search_query(query_test_index.clone()); + multi_query.with_search_query(query_video_index.clone()); let response = multi_query .with_federation(FederationOptions { limit: Some(1), From 94e9140e7f2695fe1ad94626b2c46ad290d3bfff Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Fri, 11 Jul 2025 18:33:16 +0200 Subject: [PATCH 13/16] Grammar --- meilisearch-test-macro/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/meilisearch-test-macro/README.md b/meilisearch-test-macro/README.md index c99cedef..bf019b46 100644 --- a/meilisearch-test-macro/README.md +++ b/meilisearch-test-macro/README.md @@ -68,7 +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. + You can include multiple `Index` parameter to automatically create multiple indexes. 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. From c62228c7a7dbee9bd404db0a09851f068975202f Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Fri, 11 Jul 2025 18:48:50 +0200 Subject: [PATCH 14/16] Try to improve code coverage --- .github/workflows/tests.yml | 4 ++-- src/search.rs | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 677e0794..80b2bb98 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -107,8 +107,8 @@ jobs: # Generate separate reports for tests and doctests, and combine them. run: | set -euo pipefail - cargo llvm-cov --no-report --all-features --workspace - cargo llvm-cov --no-report --doc --all-features --workspace + cargo llvm-cov --no-report --all-features --workspace --exclude 'meilisearch-test-macro' + cargo llvm-cov --no-report --doc --all-features --workspace --exclude 'meilisearch-test-macro' cargo llvm-cov report --doctests --codecov --output-path codecov.json - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v5 diff --git a/src/search.rs b/src/search.rs index 69336a18..4c164486 100644 --- a/src/search.rs +++ b/src/search.rs @@ -753,15 +753,15 @@ impl<'a, 'b, Http: HttpClient> MultiSearchQuery<'a, 'b, Http> { pub fn with_search_query_and_weight( &mut self, - mut search_query: SearchQuery<'b, Http>, + search_query: SearchQuery<'b, Http>, weight: f32, ) -> &mut MultiSearchQuery<'a, 'b, Http> { - search_query.with_index_uid(); - search_query.federation_options = Some(QueryFederationOptions { - weight: Some(weight), - }); - self.queries.push(search_query); - self + self.with_search_query_and_options( + search_query, + QueryFederationOptions { + weight: Some(weight), + }, + ) } pub fn with_search_query_and_options( From 65162257670703904117e2fb23b52115a35edbec Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Fri, 11 Jul 2025 18:59:27 +0200 Subject: [PATCH 15/16] Fix merge_facets --- src/search.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/search.rs b/src/search.rs index 4c164486..b4e659bf 100644 --- a/src/search.rs +++ b/src/search.rs @@ -811,6 +811,13 @@ pub struct FederatedMultiSearchQuery<'a, 'b, Http: HttpClient = DefaultHttpClien pub federation: Option, } +#[derive(Debug, Serialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct MergeFacets { + #[serde(skip_serializing_if = "Option::is_none")] + pub max_values_per_facet: 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)] @@ -828,9 +835,9 @@ pub struct FederationOptions { #[serde(skip_serializing_if = "Option::is_none")] pub facets_by_index: Option>>, - /// Display facet information for the specified indexes + /// Request to merge the facets to enforce a maximum number of values per facet. #[serde(skip_serializing_if = "Option::is_none")] - pub merge_facets: Option, + pub merge_facets: Option, } impl<'a, Http: HttpClient> FederatedMultiSearchQuery<'a, '_, Http> { From 1101fa0267d22a2f1de9bc9ce4bc233a2c5a97d1 Mon Sep 17 00:00:00 2001 From: Mubelotix Date: Fri, 11 Jul 2025 19:22:36 +0200 Subject: [PATCH 16/16] Fix shitty tests interacting with each other --- src/features.rs | 52 ++++++++++--------------------------------------- 1 file changed, 10 insertions(+), 42 deletions(-) diff --git a/src/features.rs b/src/features.rs index 26e8ca0b..d6ee7e34 100644 --- a/src/features.rs +++ b/src/features.rs @@ -69,10 +69,10 @@ impl<'a, Http: HttpClient> ExperimentalFeatures<'a, Http> { /// # let MEILISEARCH_URL = option_env!("MEILISEARCH_URL").unwrap_or("http://localhost:7700"); /// # let MEILISEARCH_API_KEY = option_env!("MEILISEARCH_API_KEY").unwrap_or("masterKey"); /// # let client = Client::new(MEILISEARCH_URL, Some(MEILISEARCH_API_KEY)).unwrap(); - /// tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { - /// let features = ExperimentalFeatures::new(&client); - /// features.get().await.unwrap(); - /// }); + /// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { + /// let features = ExperimentalFeatures::new(&client); + /// features.get().await.unwrap(); + /// # }); /// ``` pub async fn get(&self) -> Result { self.client @@ -148,52 +148,20 @@ mod tests { use meilisearch_test_macro::meilisearch_test; #[meilisearch_test] - async fn test_experimental_features_set_metrics(client: Client) { + async fn test_experimental_features(client: Client) { let mut features = ExperimentalFeatures::new(&client); features.set_metrics(true); - let _ = features.update().await.unwrap(); - - let res = features.get().await.unwrap(); - assert!(res.metrics) - } - - #[meilisearch_test] - async fn test_experimental_features_set_logs_route(client: Client) { - let mut features = ExperimentalFeatures::new(&client); features.set_logs_route(true); - let _ = features.update().await.unwrap(); - - let res = features.get().await.unwrap(); - assert!(res.logs_route) - } - - #[meilisearch_test] - async fn test_experimental_features_set_contains_filter(client: Client) { - let mut features = ExperimentalFeatures::new(&client); features.set_contains_filter(true); - let _ = features.update().await.unwrap(); - - let res = features.get().await.unwrap(); - assert!(res.contains_filter) - } - - #[meilisearch_test] - async fn test_experimental_features_set_network(client: Client) { - let mut features = ExperimentalFeatures::new(&client); features.set_network(true); - let _ = features.update().await.unwrap(); - - let res = features.get().await.unwrap(); - assert!(res.network) - } - - #[meilisearch_test] - async fn test_experimental_features_set_edit_documents_by_function(client: Client) { - let mut features = ExperimentalFeatures::new(&client); features.set_edit_documents_by_function(true); let _ = features.update().await.unwrap(); let res = features.get().await.unwrap(); - assert!(res.edit_documents_by_function) + assert!(res.metrics); + assert!(res.logs_route); + assert!(res.contains_filter); + assert!(res.network); + assert!(res.edit_documents_by_function); } }