Skip to content

Commit d7baa91

Browse files
committed
feat: add /fields route
1 parent 656f989 commit d7baa91

3 files changed

Lines changed: 495 additions & 0 deletions

File tree

src/fields.rs

Lines changed: 383 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,383 @@
1+
use crate::errors::Error;
2+
use serde::{Deserialize, Serialize};
3+
use serde_json::{Map, Value};
4+
use std::collections::HashMap;
5+
6+
use crate::{indexes::Index, request::HttpClient};
7+
8+
/// An [`FieldsQuery`] containing filter and pagination parameters when looking up an index's fields.
9+
///
10+
/// # Example
11+
///
12+
/// ```
13+
/// # use serde::{Serialize, Deserialize};
14+
/// # use meilisearch_sdk::{client::*, indexes::*, fields::*};
15+
/// #
16+
/// # let MEILISEARCH_URL = option_env!("MEILISEARCH_URL").unwrap_or("http://localhost:7700");
17+
/// # let MEILISEARCH_API_KEY = option_env!("MEILISEARCH_API_KEY").unwrap_or("masterKey");
18+
/// #
19+
/// # #[derive(Serialize, Deserialize, Debug)]
20+
/// # struct Movie {
21+
/// # name: String,
22+
/// # description: String,
23+
/// # }
24+
///
25+
/// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async {
26+
/// # let client = Client::new(MEILISEARCH_URL, Some(MEILISEARCH_API_KEY)).unwrap();
27+
/// # let index = client
28+
/// # .create_index("fields_query", None)
29+
/// # .await
30+
/// # .unwrap()
31+
/// # .wait_for_completion(&client, None, None)
32+
/// # .await
33+
/// # .unwrap()
34+
/// # // Once the task finished, we try to create an `Index` out of it.
35+
/// # .try_make_index(&client)
36+
/// # .unwrap();
37+
/// # 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();
38+
/// let fields = FieldsQuery::new(&index)
39+
/// .with_offset(1)
40+
/// .execute()
41+
/// .await
42+
/// .unwrap();
43+
/// assert_eq!(fields.len(), 1);
44+
/// # index.delete().await.unwrap().wait_for_completion(&client, None, None).await.unwrap();
45+
/// # });
46+
/// ```
47+
#[derive(Debug, Serialize)]
48+
#[serde(rename_all = "camelCase")]
49+
pub struct FieldsQuery<'a, Http: HttpClient> {
50+
#[serde(skip_serializing)]
51+
pub index: &'a Index<Http>,
52+
/// The number of fields to skip.
53+
///
54+
/// If the value of the parameter `offset` is `n`, the `n` first fields will not be returned.
55+
///
56+
/// Example: If you want to skip the first field, set offset to `1`.
57+
#[serde(skip_serializing_if = "Option::is_none")]
58+
pub offset: Option<usize>,
59+
/// The maximum number of fields returned.
60+
///
61+
/// If the value of the parameter `limit` is `n`, there will never be more than `n` fields in the response.
62+
///
63+
/// Example: If you don't want to get more than two fields, set limit to `2`.
64+
///
65+
/// **Default: `20`**
66+
#[serde(skip_serializing_if = "Option::is_none")]
67+
pub limit: Option<usize>,
68+
/// [Filter](`FieldsQueryFilter`) for fields returned
69+
///
70+
/// All fields return must match **all** of the filter criteria
71+
#[serde(skip_serializing_if = "Option::is_none")]
72+
pub filter: Option<FieldsQueryFilter>,
73+
}
74+
75+
impl<'a, Http: HttpClient> FieldsQuery<'a, Http> {
76+
#[must_use]
77+
pub fn new(index: &Index<Http>) -> FieldsQuery<'_, Http> {
78+
FieldsQuery {
79+
index,
80+
offset: None,
81+
limit: None,
82+
filter: None,
83+
}
84+
}
85+
86+
/// Specify the number of fields to skip.
87+
pub fn with_offset(&mut self, offset: usize) -> &mut FieldsQuery<'a, Http> {
88+
self.offset = Some(offset);
89+
self
90+
}
91+
92+
/// Specify the maximum number of fields to return.
93+
pub fn with_limit(&mut self, limit: usize) -> &mut FieldsQuery<'a, Http> {
94+
self.limit = Some(limit);
95+
self
96+
}
97+
98+
/// Specify the [`FieldsQueryFilter`].
99+
///
100+
/// # Example
101+
///
102+
/// ```
103+
/// # use meilisearch_sdk::{client::*, indexes::*, fields::*};
104+
/// #
105+
/// # let MEILISEARCH_URL = option_env!("MEILISEARCH_URL").unwrap_or("http://localhost:7700");
106+
/// # let MEILISEARCH_API_KEY = option_env!("MEILISEARCH_API_KEY").unwrap_or("masterKey");
107+
/// #
108+
/// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async {
109+
/// # let client = Client::new(MEILISEARCH_URL, Some(MEILISEARCH_API_KEY)).unwrap();
110+
/// # let index = client
111+
/// # .create_index("fields_query_with_filter", None)
112+
/// # .await
113+
/// # .unwrap()
114+
/// # .wait_for_completion(&client, None, None)
115+
/// # .await
116+
/// # .unwrap()
117+
/// # // Once the task finished, we try to create an `Index` out of it
118+
/// # .try_make_index(&client)
119+
/// # .unwrap();
120+
/// let filter = FieldsQueryFilter::new().with_displayed(true);
121+
/// let mut fields = FieldsQuery::new(&index)
122+
/// .with_filter(filter)
123+
/// .execute().await.unwrap();
124+
///
125+
/// # index.delete().await.unwrap().wait_for_completion(&client, None, None).await.unwrap();
126+
/// # });
127+
/// ```
128+
pub fn with_filter(&mut self, filter: FieldsQueryFilter) -> &mut FieldsQuery<'a, Http> {
129+
self.filter = Some(filter);
130+
131+
self
132+
}
133+
134+
/// Get an index's fields.
135+
///
136+
/// # Example
137+
///
138+
/// ```
139+
/// # use meilisearch_sdk::{fields::FieldsQuery, client::Client};
140+
/// #
141+
/// # let MEILISEARCH_URL = option_env!("MEILISEARCH_URL").unwrap_or("http://localhost:7700");
142+
/// # let MEILISEARCH_API_KEY = option_env!("MEILISEARCH_API_KEY").unwrap_or("masterKey");
143+
/// #
144+
/// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async {
145+
/// # let client = Client::new(MEILISEARCH_URL, Some(MEILISEARCH_API_KEY)).unwrap();
146+
/// # let index = client
147+
/// # .create_index("fields_query_execute", None)
148+
/// # .await
149+
/// # .unwrap()
150+
/// # .wait_for_completion(&client, None, None)
151+
/// # .await
152+
/// # .unwrap()
153+
/// # // Once the task finished, we try to create an `Index` out of it
154+
/// # .try_make_index(&client)
155+
/// # .unwrap();
156+
/// let fields = FieldsQuery::new(&index)
157+
/// .with_limit(1)
158+
/// .execute()
159+
/// .await
160+
/// .unwrap();
161+
///
162+
/// # index.delete().await.unwrap().wait_for_completion(&client, None, None).await.unwrap();
163+
/// # });
164+
/// ```
165+
pub async fn execute(&self) -> Result<Vec<FieldInfo>, Error> {
166+
self.index.get_fields_with(self).await
167+
}
168+
}
169+
170+
#[derive(Debug, Serialize, Default)]
171+
#[serde(rename_all = "camelCase")]
172+
pub struct FieldsQueryFilter {
173+
#[serde(skip_serializing_if = "Option::is_none")]
174+
pub attribute_patterns: Option<Vec<String>>,
175+
#[serde(skip_serializing_if = "Option::is_none")]
176+
pub displayed: Option<bool>,
177+
#[serde(skip_serializing_if = "Option::is_none")]
178+
pub searchable: Option<bool>,
179+
#[serde(skip_serializing_if = "Option::is_none")]
180+
pub sortable: Option<bool>,
181+
#[serde(skip_serializing_if = "Option::is_none")]
182+
pub distinct: Option<bool>,
183+
#[serde(skip_serializing_if = "Option::is_none")]
184+
pub ranking_rule: Option<bool>,
185+
#[serde(skip_serializing_if = "Option::is_none")]
186+
pub filterable: Option<bool>,
187+
}
188+
189+
impl FieldsQueryFilter {
190+
pub fn new() -> Self {
191+
FieldsQueryFilter::default()
192+
}
193+
194+
/// Match fields using attribute patterns (supports wildcards: * for any characters), e.g.
195+
/// - `"cuisine.*"` matches `cuisine.type`, `cuisine.region`
196+
/// - `"user*"` matches `user_id`, username, `user_profile`
197+
/// - `"*_id"` matches all fields ending with `_id`
198+
/// # Example
199+
/// ```
200+
/// # use meilisearch_sdk::fields::*;
201+
/// let filter = FieldsQueryFilter::new()
202+
/// .with_attribute_patterns(vec!["cuisine.*", "*_id"]);
203+
/// ```
204+
pub fn with_attribute_patterns(
205+
mut self,
206+
attribute_patterns: impl IntoIterator<Item = impl AsRef<str>>,
207+
) -> Self {
208+
self.attribute_patterns = Some(
209+
attribute_patterns
210+
.into_iter()
211+
.map(|v| v.as_ref().to_string())
212+
.collect(),
213+
);
214+
215+
self
216+
}
217+
218+
/// Filter by whether fields are displayed in search results
219+
///
220+
/// `true` = only displayed fields, `false` = only hidden fields
221+
pub fn with_displayed(mut self, displayed: bool) -> Self {
222+
self.displayed = Some(displayed);
223+
224+
self
225+
}
226+
227+
/// Filter by whether fields are searchable (indexed for full-text search)
228+
///
229+
/// `true` = only searchable fields, `false` = only non-searchable fields
230+
pub fn with_searchable(mut self, searchable: bool) -> Self {
231+
self.searchable = Some(searchable);
232+
233+
self
234+
}
235+
236+
/// Filter by whether fields can be used for sorting results
237+
///
238+
/// `true` = only sortable fields, `false` = only non-sortable fields
239+
pub fn with_sortable(mut self, sortable: bool) -> Self {
240+
self.sortable = Some(sortable);
241+
242+
self
243+
}
244+
245+
/// Filter by whether the field is used as the distinct attribute
246+
///
247+
/// `true` = only the distinct field, `false` = only non-distinct fields
248+
pub fn with_distinct(mut self, distint: bool) -> Self {
249+
self.distinct = Some(distint);
250+
251+
self
252+
}
253+
254+
/// Filter by whether the field is used in ranking rules
255+
///
256+
/// `true` = only fields used in ranking, `false` = only fields not used in ranking
257+
pub fn with_ranking_rule(mut self, ranking_rule: bool) -> Self {
258+
self.ranking_rule = Some(ranking_rule);
259+
260+
self
261+
}
262+
263+
/// Filter by whether the field can be used for filtering/faceting
264+
///
265+
/// `true` = only filterable fields, `false` = only non-filterable fields
266+
pub fn with_filterable(mut self, filterable: bool) -> Self {
267+
self.filterable = Some(filterable);
268+
269+
self
270+
}
271+
}
272+
273+
#[derive(Debug, Clone, Deserialize)]
274+
#[serde(rename_all = "camelCase")]
275+
pub struct FieldInfo {
276+
/// The name of the field
277+
pub name: String,
278+
279+
/// contains `enabled` key indicating if field is displayed
280+
pub displayed: HashMap<String, bool>,
281+
282+
/// contains `enabled` key indicating if this field is searchable
283+
pub searchable: HashMap<String, bool>,
284+
285+
/// contains `enabled` key indicating if this field is sortable
286+
pub sortable: HashMap<String, bool>,
287+
288+
/// contains `enabled` key indicating if this field is distinct
289+
pub distinct: HashMap<String, bool>,
290+
291+
/// contains `enabled` key indicating if this field is used in ranking rules.
292+
/// If enabled, also contains 'order' with value 'asc' or 'desc'
293+
pub ranking_rule: Map<String, Value>,
294+
295+
/// contains `enabled` key indicating if field is filterable,
296+
/// and the following filter settings:
297+
/// - sortBy: Sort order for facet values (e.g., 'alpha')
298+
/// - facetSearch: Whether facet search is enabled
299+
/// - equality: Whether equality filtering is enabled
300+
/// - comparison: Whether comparison filtering is enabled
301+
pub filterable: Map<String, Value>,
302+
303+
/// Contains 'locales' key with locales array
304+
/// e.g. `{"locales": ["en", "fr"]}`
305+
pub localized: HashMap<String, Vec<String>>,
306+
}
307+
308+
#[cfg(test)]
309+
mod tests {
310+
use crate::client::Client;
311+
312+
use super::*;
313+
314+
use meilisearch_test_macro::meilisearch_test;
315+
use serde_json::json;
316+
317+
#[meilisearch_test]
318+
async fn test_fields_query(client: Client, index: Index) -> Result<(), Error> {
319+
let document_with_5_fields = json!({
320+
"id": 1,
321+
"name": "doggo",
322+
"field3": "value",
323+
"field4": "value",
324+
"field5": "value"
325+
});
326+
327+
index
328+
.add_documents(&[document_with_5_fields], None)
329+
.await
330+
.unwrap()
331+
.wait_for_completion(&client, None, None)
332+
.await
333+
.unwrap();
334+
335+
let fields_result = index.get_fields().await;
336+
337+
assert!(fields_result.is_ok_and(|fields| fields.len() == 5));
338+
339+
Ok(())
340+
}
341+
342+
#[meilisearch_test]
343+
async fn test_get_fields_with_filter(client: Client, index: Index) -> Result<(), Error> {
344+
let document_with_7_fields = json!({
345+
"id": 1,
346+
"field1": "value",
347+
"field2": "value",
348+
"field3": "value",
349+
"field4": "value",
350+
"field5": "value",
351+
"field6": "value",
352+
});
353+
354+
index
355+
.add_documents(&[document_with_7_fields], None)
356+
.await
357+
.unwrap()
358+
.wait_for_completion(&client, None, None)
359+
.await
360+
.unwrap();
361+
362+
let fields_result = FieldsQuery::new(&index)
363+
.with_offset(1)
364+
.with_limit(6)
365+
.with_filter(
366+
FieldsQueryFilter::new()
367+
.with_attribute_patterns(["field*"])
368+
.with_displayed(true)
369+
.with_searchable(true)
370+
.with_sortable(false)
371+
.with_distinct(false)
372+
.with_ranking_rule(false)
373+
.with_filterable(false),
374+
)
375+
.execute()
376+
.await;
377+
378+
//skipped 1/6 fields with offset = 1
379+
assert!(fields_result.is_ok_and(|fields| fields.len() == 5));
380+
381+
Ok(())
382+
}
383+
}

0 commit comments

Comments
 (0)