diff --git a/src/fields.rs b/src/fields.rs new file mode 100644 index 00000000..f1e9c3f1 --- /dev/null +++ b/src/fields.rs @@ -0,0 +1,392 @@ +use crate::errors::Error; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use std::collections::HashMap; + +use crate::{indexes::Index, request::HttpClient}; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FieldResult { + /// The name of the field + pub name: String, + + /// contains `enabled` key indicating if field is displayed + pub displayed: HashMap, + + /// contains `enabled` key indicating if this field is searchable + pub searchable: HashMap, + + /// contains `enabled` key indicating if this field is sortable + pub sortable: HashMap, + + /// contains `enabled` key indicating if this field is distinct + pub distinct: HashMap, + + /// contains `enabled` key indicating if this field is used in ranking rules. + /// If enabled, also contains 'order' with value 'asc' or 'desc' + pub ranking_rule: Map, + + /// contains `enabled` key indicating if field is filterable, + /// and the following filter settings: + /// - sortBy: Sort order for facet values (e.g., 'alpha') + /// - facetSearch: Whether facet search is enabled + /// - equality: Whether equality filtering is enabled + /// - comparison: Whether comparison filtering is enabled + pub filterable: Map, + + /// Contains 'locales' key with locales array + /// e.g. `{"locales": ["en", "fr"]}` + pub localized: HashMap>, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FieldsResult { + pub results: Vec, + pub offset: u32, + pub limit: u32, + pub total: u32, +} + +/// An [`FieldsQuery`] containing filter and pagination parameters when looking up an index's fields. +/// +/// # Example +/// +/// ``` +/// # use serde::{Serialize, Deserialize}; +/// # use meilisearch_sdk::{client::*, indexes::*, fields::*}; +/// # +/// # let MEILISEARCH_URL = option_env!("MEILISEARCH_URL").unwrap_or("http://localhost:7700"); +/// # let MEILISEARCH_API_KEY = option_env!("MEILISEARCH_API_KEY").unwrap_or("masterKey"); +/// # +/// # #[derive(Serialize, Deserialize, Debug)] +/// # struct Movie { +/// # name: String, +/// # description: String, +/// # } +/// +/// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { +/// # let client = Client::new(MEILISEARCH_URL, Some(MEILISEARCH_API_KEY)).unwrap(); +/// # let index = client +/// # .create_index("fields_query", None) +/// # .await +/// # .unwrap() +/// # .wait_for_completion(&client, None, None) +/// # .await +/// # .unwrap() +/// # // Once the task finished, we try to create an `Index` out of it. +/// # .try_make_index(&client) +/// # .unwrap(); +/// # index.add_or_replace(&[Movie{name:String::from("Interstellar"), description:String::from("Interstellar chronicles the adventures of a group of explorers who make use of a newly discovered wormhole to surpass the limitations on human space travel and conquer the vast distances involved in an interstellar voyage.")}], Some("name")).await.unwrap().wait_for_completion(&client, None, None).await.unwrap(); +/// let fields = FieldsQuery::new(&index) +/// .with_offset(1) +/// .execute() +/// .await +/// .unwrap(); +/// assert_eq!(fields.results.len(), 1); +/// # index.delete().await.unwrap().wait_for_completion(&client, None, None).await.unwrap(); +/// # }); +/// ``` +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FieldsQuery<'a, Http: HttpClient> { + #[serde(skip_serializing)] + pub index: &'a Index, + /// The number of fields to skip. + /// + /// If the value of the parameter `offset` is `n`, the `n` first fields will not be returned. + /// + /// Example: If you want to skip the first field, set offset to `1`. + #[serde(skip_serializing_if = "Option::is_none")] + pub offset: Option, + /// The maximum number of fields returned. + /// + /// If the value of the parameter `limit` is `n`, there will never be more than `n` fields in the response. + /// + /// Example: If you don't want to get more than two fields, set limit to `2`. + /// + /// **Default: `20`** + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + /// [Filter](`FieldsQueryFilter`) for fields returned + /// + /// All fields return must match **all** of the filter criteria + #[serde(skip_serializing_if = "Option::is_none")] + pub filter: Option, +} + +impl<'a, Http: HttpClient> FieldsQuery<'a, Http> { + #[must_use] + pub fn new(index: &Index) -> FieldsQuery<'_, Http> { + FieldsQuery { + index, + offset: None, + limit: None, + filter: None, + } + } + + /// Specify the number of fields to skip. + pub fn with_offset(&mut self, offset: usize) -> &mut FieldsQuery<'a, Http> { + self.offset = Some(offset); + self + } + + /// Specify the maximum number of fields to return. + pub fn with_limit(&mut self, limit: usize) -> &mut FieldsQuery<'a, Http> { + self.limit = Some(limit); + self + } + + /// Specify the [`FieldsQueryFilter`]. + /// + /// # Example + /// + /// ``` + /// # use meilisearch_sdk::{client::*, indexes::*, fields::*}; + /// # + /// # let MEILISEARCH_URL = option_env!("MEILISEARCH_URL").unwrap_or("http://localhost:7700"); + /// # let MEILISEARCH_API_KEY = option_env!("MEILISEARCH_API_KEY").unwrap_or("masterKey"); + /// # + /// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { + /// # let client = Client::new(MEILISEARCH_URL, Some(MEILISEARCH_API_KEY)).unwrap(); + /// # let index = client + /// # .create_index("fields_query_with_filter", None) + /// # .await + /// # .unwrap() + /// # .wait_for_completion(&client, None, None) + /// # .await + /// # .unwrap() + /// # // Once the task finished, we try to create an `Index` out of it + /// # .try_make_index(&client) + /// # .unwrap(); + /// let filter = FieldsQueryFilter::new().with_displayed(true); + /// let mut fields = FieldsQuery::new(&index) + /// .with_filter(filter) + /// .execute().await.unwrap(); + /// + /// # index.delete().await.unwrap().wait_for_completion(&client, None, None).await.unwrap(); + /// # }); + /// ``` + pub fn with_filter(&mut self, filter: FieldsQueryFilter) -> &mut FieldsQuery<'a, Http> { + self.filter = Some(filter); + + self + } + + /// Get an index's fields. + /// + /// # Example + /// + /// ``` + /// # use meilisearch_sdk::{fields::FieldsQuery, client::Client}; + /// # + /// # let MEILISEARCH_URL = option_env!("MEILISEARCH_URL").unwrap_or("http://localhost:7700"); + /// # let MEILISEARCH_API_KEY = option_env!("MEILISEARCH_API_KEY").unwrap_or("masterKey"); + /// # + /// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { + /// # let client = Client::new(MEILISEARCH_URL, Some(MEILISEARCH_API_KEY)).unwrap(); + /// # let index = client + /// # .create_index("fields_query_execute", None) + /// # .await + /// # .unwrap() + /// # .wait_for_completion(&client, None, None) + /// # .await + /// # .unwrap() + /// # // Once the task finished, we try to create an `Index` out of it + /// # .try_make_index(&client) + /// # .unwrap(); + /// let fields = FieldsQuery::new(&index) + /// .with_limit(1) + /// .execute() + /// .await + /// .unwrap(); + /// + /// # index.delete().await.unwrap().wait_for_completion(&client, None, None).await.unwrap(); + /// # }); + /// ``` + pub async fn execute(&self) -> Result { + self.index.get_fields_with(self).await + } +} + +#[derive(Debug, Serialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct FieldsQueryFilter { + #[serde(skip_serializing_if = "Option::is_none")] + pub attribute_patterns: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub displayed: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub searchable: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sortable: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub distinct: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ranking_rule: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub filterable: Option, +} + +impl FieldsQueryFilter { + pub fn new() -> Self { + FieldsQueryFilter::default() + } + + /// Match fields using attribute patterns (supports wildcards: * for any characters), e.g. + /// - `"cuisine.*"` matches `cuisine.type`, `cuisine.region` + /// - `"user*"` matches `user_id`, username, `user_profile` + /// - `"*_id"` matches all fields ending with `_id` + /// # Example + /// ``` + /// # use meilisearch_sdk::fields::*; + /// let filter = FieldsQueryFilter::new() + /// .with_attribute_patterns(vec!["cuisine.*", "*_id"]); + /// ``` + pub fn with_attribute_patterns( + mut self, + attribute_patterns: impl IntoIterator>, + ) -> Self { + self.attribute_patterns = Some( + attribute_patterns + .into_iter() + .map(|v| v.as_ref().to_string()) + .collect(), + ); + + self + } + + /// Filter by whether fields are displayed in search results + /// + /// `true` = only displayed fields, `false` = only hidden fields + pub fn with_displayed(mut self, displayed: bool) -> Self { + self.displayed = Some(displayed); + + self + } + + /// Filter by whether fields are searchable (indexed for full-text search) + /// + /// `true` = only searchable fields, `false` = only non-searchable fields + pub fn with_searchable(mut self, searchable: bool) -> Self { + self.searchable = Some(searchable); + + self + } + + /// Filter by whether fields can be used for sorting results + /// + /// `true` = only sortable fields, `false` = only non-sortable fields + pub fn with_sortable(mut self, sortable: bool) -> Self { + self.sortable = Some(sortable); + + self + } + + /// Filter by whether the field is used as the distinct attribute + /// + /// `true` = only the distinct field, `false` = only non-distinct fields + pub fn with_distinct(mut self, distinct: bool) -> Self { + self.distinct = Some(distinct); + + self + } + + /// Filter by whether the field is used in ranking rules + /// + /// `true` = only fields used in ranking, `false` = only fields not used in ranking + pub fn with_ranking_rule(mut self, ranking_rule: bool) -> Self { + self.ranking_rule = Some(ranking_rule); + + self + } + + /// Filter by whether the field can be used for filtering/faceting + /// + /// `true` = only filterable fields, `false` = only non-filterable fields + pub fn with_filterable(mut self, filterable: bool) -> Self { + self.filterable = Some(filterable); + + self + } +} + +#[cfg(test)] +mod tests { + use crate::client::Client; + + use super::*; + + use meilisearch_test_macro::meilisearch_test; + use serde_json::json; + + #[meilisearch_test] + async fn test_fields_query(client: Client, index: Index) -> Result<(), Error> { + let document_with_5_fields = json!({ + "id": 1, + "name": "doggo", + "field3": "value", + "field4": "value", + "field5": "value" + }); + + index + .add_documents(&[document_with_5_fields], None) + .await + .unwrap() + .wait_for_completion(&client, None, None) + .await + .unwrap(); + + let fields_result = index.get_fields().await; + + assert!(fields_result.is_ok_and(|fields| fields.results.len() == 5)); + + Ok(()) + } + + #[meilisearch_test] + async fn test_get_fields_with_filter(client: Client, index: Index) -> Result<(), Error> { + let document_with_7_fields = json!({ + "id": 1, + "field1": "value", + "field2": "value", + "field3": "value", + "field4": "value", + "field5": "value", + "field6": "value", + }); + + index + .add_documents(&[document_with_7_fields], None) + .await + .unwrap() + .wait_for_completion(&client, None, None) + .await + .unwrap(); + + let fields_result = FieldsQuery::new(&index) + .with_offset(1) + .with_limit(6) + .with_filter( + FieldsQueryFilter::new() + .with_attribute_patterns(["field*"]) + .with_displayed(true) + .with_searchable(true) + .with_sortable(false) + .with_distinct(false) + .with_ranking_rule(false) + .with_filterable(false), + ) + .execute() + .await; + + //skipped 1/6 fields with offset = 1 + assert!(fields_result.is_ok_and(|fields| fields.results.len() == 5)); + + Ok(()) + } +} diff --git a/src/indexes.rs b/src/indexes.rs index d30e2736..ae77db05 100644 --- a/src/indexes.rs +++ b/src/indexes.rs @@ -2,6 +2,7 @@ use crate::{ client::Client, documents::{DocumentDeletionQuery, DocumentQuery, DocumentsQuery, DocumentsResults}, errors::{Error, MeilisearchCommunicationError, MeilisearchError, MEILISEARCH_VERSION_HINT}, + fields::{FieldsQuery, FieldsResult}, request::*, search::*, similar::*, @@ -1772,6 +1773,88 @@ impl Index { ) -> SimilarQuery<'a, Http> { SimilarQuery::new(self, document_id, index_name) } + + /// Get detailed metadata of fields in this index + /// + /// # Example + /// + /// ``` + /// # use serde::{Serialize, Deserialize}; + /// # use meilisearch_sdk::{client::*, indexes::*}; + /// # + /// # let MEILISEARCH_URL = option_env!("MEILISEARCH_URL").unwrap_or("http://localhost:7700"); + /// # let MEILISEARCH_API_KEY = option_env!("MEILISEARCH_API_KEY").unwrap_or("masterKey"); + /// # + /// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { + /// # let client = Client::new(MEILISEARCH_URL, Some(MEILISEARCH_API_KEY)).unwrap(); + /// # let index = client.create_index("get_fields", None).await.unwrap().wait_for_completion(&client, None, None).await.unwrap().try_make_index(&client).unwrap(); + /// let fields = index.get_fields().await.unwrap(); + /// + /// # index.delete().await.unwrap().wait_for_completion(&client, None, None).await.unwrap(); + /// # }); + /// ``` + pub async fn get_fields(&self) -> Result { + self.client + .http_client + .request::<(), serde_json::Value, FieldsResult>( + &format!("{}/indexes/{}/fields", self.client.host, self.uid), + Method::Post { + body: serde_json::json!({}), + query: (), + }, + 200, + ) + .await + } + + /// Get fields in this index, specifying parameters via [`FieldsQuery`] + /// + /// # Example + /// + /// ``` + /// # use serde::{Serialize, Deserialize}; + /// # use meilisearch_sdk::{client::*, indexes::*, fields::*}; + /// # + /// # let MEILISEARCH_URL = option_env!("MEILISEARCH_URL").unwrap_or("http://localhost:7700"); + /// # let MEILISEARCH_API_KEY = option_env!("MEILISEARCH_API_KEY").unwrap_or("masterKey"); + /// # + /// # #[derive(Serialize, Deserialize, Debug)] + /// # struct Movie { + /// # name: String, + /// # description: String, + /// # } + /// + /// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { + /// # let client = Client::new(MEILISEARCH_URL, Some(MEILISEARCH_API_KEY)).unwrap(); + /// # let index = client + /// # .create_index("get_fields_with", None) + /// # .await + /// # .unwrap() + /// # .wait_for_completion(&client, None, None) + /// # .await + /// # .unwrap() + /// # // Once the task finished, we try to create an `Index` out of it. + /// # .try_make_index(&client) + /// # .unwrap(); + /// # index.add_or_replace(&[Movie{name:String::from("Interstellar"), description:String::from("Interstellar chronicles the adventures of a group of explorers who make use of a newly discovered wormhole to surpass the limitations on human space travel and conquer the vast distances involved in an interstellar voyage.")}], Some("name")).await.unwrap().wait_for_completion(&client, None, None).await.unwrap(); + /// let mut query = FieldsQuery::new(&index); + /// let fields = index.get_fields_with(query.with_offset(1)).await.unwrap(); + /// # index.delete().await.unwrap().wait_for_completion(&client, None, None).await.unwrap(); + /// # }); + /// ``` + pub async fn get_fields_with( + &self, + body: &FieldsQuery<'_, Http>, + ) -> Result { + self.client + .http_client + .request::<(), &FieldsQuery, FieldsResult>( + &format!("{}/indexes/{}/fields", self.client.host, self.uid), + Method::Post { body, query: () }, + 200, + ) + .await + } } impl AsRef for Index { @@ -2502,4 +2585,31 @@ mod tests { assert!(task.is_success()); Ok(()) } + + #[meilisearch_test] + async fn test_get_fields_with(client: Client, index: Index) -> Result<(), Error> { + let document_with_5_fields = json!({ + "id": 1, + "name": "doggo", + "field3": "value", + "field4": "value", + "field5": "value" + }); + + index + .add_documents(&[document_with_5_fields], None) + .await + .unwrap() + .wait_for_completion(&client, None, None) + .await + .unwrap(); + + let mut query = FieldsQuery::new(&index); + let query = query.with_limit(4); + let fields_result = index.get_fields_with(query).await; + + assert!(fields_result.is_ok_and(|fields| fields.results.len() == 4)); + + Ok(()) + } } diff --git a/src/lib.rs b/src/lib.rs index eb0d182f..db2801f7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -244,6 +244,8 @@ pub mod dumps; pub mod errors; /// Module related to runtime and instance features. pub mod features; +/// Module related to [fields queries](https://github.com/meilisearch/meilisearch/pull/6082) +pub mod fields; /// Module containing the Index struct. pub mod indexes; /// Module containing the [`Key`](key::Key) struct.