Skip to content

Commit fbbf782

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

3 files changed

Lines changed: 494 additions & 0 deletions

File tree

src/fields.rs

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

0 commit comments

Comments
 (0)