Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement federated multi search #625

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,21 @@ impl<Http: HttpClient> Client<Http> {
.await
}

pub async fn execute_federated_multi_search_query<
T: 'static + DeserializeOwned + Send + Sync,
>(
&self,
body: &FederatedMultiSearchQuery<'_, '_, Http>,
) -> Result<FederatedMultiSearchResponse<T>, Error> {
self.http_client
.request::<(), &FederatedMultiSearchQuery<Http>, FederatedMultiSearchResponse<T>>(
&format!("{}/multi-search", &self.host),
Method::Post { body, query: () },
200,
)
.await
}

/// Make multiple search requests.
///
/// # Example
Expand Down Expand Up @@ -170,6 +185,22 @@ impl<Http: HttpClient> Client<Http> {
/// # 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<String, serde_json::Value>` 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<Http> {
MultiSearchQuery::new(self)
Expand Down
130 changes: 113 additions & 17 deletions src/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -55,6 +55,9 @@ pub struct SearchResult<T> {
pub ranking_score: Option<f64>,
#[serde(rename = "_rankingScoreDetails")]
pub ranking_score_details: Option<Map<String, Value>>,
/// Only returned for federated multi search.
#[serde(rename = "_federation")]
pub federation: Option<FederationHitInfo>,
}

#[derive(Deserialize, Debug, Clone)]
Expand Down Expand Up @@ -350,6 +353,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<QueryFederationOptions>,
}

#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct QueryFederationOptions {
#[serde(skip_serializing_if = "Option::is_none")]
pub weight: Option<f32>,
}

#[allow(missing_docs)]
Expand Down Expand Up @@ -382,6 +395,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> {
Expand Down Expand Up @@ -589,6 +603,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()
}
Expand All @@ -600,27 +622,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<Http>,
// 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<SearchQuery<'b, Http>>,
}

impl<Http: HttpClient> Serialize for MultiSearchQuery<'_, '_, Http> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
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]
Expand All @@ -638,6 +652,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<T: 'static + DeserializeOwned + Send + Sync>(
Expand All @@ -651,6 +676,77 @@ pub struct MultiSearchResponse<T> {
pub results: Vec<SearchResults<T>>,
}

#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct FederatedMultiSearchQuery<'a, 'b, Http: HttpClient = DefaultHttpClient> {
#[serde(skip_serializing)]
client: &'a Client<Http>,
#[serde(bound(serialize = ""))]
pub queries: Vec<SearchQuery<'b, Http>>,
pub federation: Option<FederationOptions>,
}

/// 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<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub limit: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub facets_by_index: Option<HashMap<String, Vec<String>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub merge_facets: Option<bool>,
}

#[allow(missing_docs)]
impl<'a, 'b, Http: HttpClient> FederatedMultiSearchQuery<'a, 'b, Http> {
/// Execute the query and fetch the results.
pub async fn execute<T: 'static + DeserializeOwned + Send + Sync>(
&'a self,
) -> Result<FederatedMultiSearchResponse<T>, Error> {
self.client
.execute_federated_multi_search_query::<T>(self)
.await
}
}

/// Returned by federated multi search.
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct FederatedMultiSearchResponse<T> {
/// Merged results of the query.
pub hits: Vec<SearchResult<T>>,

// 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<HashMap<String, HashMap<String, usize>>>,
/// facet stats of the numerical facets requested in the `facet` search parameter.
pub facet_stats: Option<HashMap<String, FacetStats>>,
/// 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::{
Expand Down
Loading