Skip to main content

hal_sdk/
query.rs

1/// A HAL search field usable for fine-grained (field-scoped) searches.
2///
3/// These map to the Solr *text* fields exposed by HAL. Use
4/// [`Field::name`] to get the raw field name, or pass a [`Field`] to
5/// [`SearchQuery::in_field`].
6#[derive(Clone, Copy, Debug, PartialEq, Eq)]
7pub enum Field {
8    /// The document title (`title_t`).
9    Title,
10    /// The author full names (`authFullName_t`).
11    Author,
12    /// The abstract (`abstract_t`).
13    Abstract,
14    /// The keywords (`keyword_t`).
15    Keyword,
16}
17
18impl Field {
19    /// The raw HAL/Solr field name.
20    pub fn name(self) -> &'static str {
21        match self {
22            Field::Title => "title_t",
23            Field::Author => "authFullName_t",
24            Field::Abstract => "abstract_t",
25            Field::Keyword => "keyword_t",
26        }
27    }
28}
29
30/// A builder for HAL search queries.
31///
32/// It maps directly onto the Solr query parameters used by the HAL API and
33/// covers every search mode listed in the HAL documentation:
34///
35/// * **basic search** — [`SearchQuery::basic`];
36/// * **search within a field** — [`SearchQuery::field`] / [`SearchQuery::in_field`];
37/// * **several terms within a field** — [`SearchQuery::field_terms`] (OR) /
38///   [`SearchQuery::field_all_terms`] (AND);
39/// * **proximity search** — [`SearchQuery::proximity`];
40/// * **field selection** (`fl`) — [`SearchQuery::fields`];
41/// * **pagination** — [`SearchQuery::rows`] / [`SearchQuery::start`] / [`SearchQuery::page`];
42/// * **facets** — [`SearchQuery::facet`].
43///
44/// The builder methods consume and return `self`, so they can be chained.
45#[derive(Clone, Debug)]
46pub struct SearchQuery {
47    q: String,
48    fields: Vec<String>,
49    rows: Option<u32>,
50    start: Option<u32>,
51    sort: Option<String>,
52    facet_fields: Vec<String>,
53}
54
55impl SearchQuery {
56    /// A basic search over all fields (the `q` parameter is used as-is).
57    ///
58    /// This is the exact behaviour of the original chapter-16 library.
59    #[must_use]
60    pub fn basic(query: impl Into<String>) -> Self {
61        SearchQuery {
62            q: query.into(),
63            fields: Vec::new(),
64            rows: None,
65            start: None,
66            sort: None,
67            facet_fields: Vec::new(),
68        }
69    }
70
71    /// Search for a value within a specific field, e.g. `field("title_t", "europe")`
72    /// produces `q=title_t:europe`.
73    #[must_use]
74    pub fn field(field: &str, value: &str) -> Self {
75        Self::basic(format!("{field}:{value}"))
76    }
77
78    /// Fine-grained search scoped to a typed [`Field`].
79    ///
80    /// A multi-word value is grouped so that every word is searched within the
81    /// field, e.g. `in_field(Field::Title, "open science")` produces
82    /// `q=title_t:(open science)`. An empty value falls back to a basic search.
83    #[must_use]
84    pub fn in_field(field: Field, value: &str) -> Self {
85        let words: Vec<&str> = value.split_whitespace().collect();
86        if words.is_empty() {
87            Self::basic(value.to_owned())
88        } else {
89            Self::field_terms(field.name(), words)
90        }
91    }
92
93    /// Search for several terms within a single field, matching **any** of them
94    /// (Solr `OR`), e.g. `field_terms("title_t", ["economic", "policy"])`
95    /// produces `q=title_t:(economic policy)`.
96    #[must_use]
97    pub fn field_terms<I, S>(field: &str, terms: I) -> Self
98    where
99        I: IntoIterator<Item = S>,
100        S: AsRef<str>,
101    {
102        let joined = terms
103            .into_iter()
104            .map(|t| t.as_ref().to_owned())
105            .collect::<Vec<_>>()
106            .join(" ");
107        Self::basic(format!("{field}:({joined})"))
108    }
109
110    /// Search for several terms within a single field, matching **all** of them
111    /// (Solr `AND`), e.g. `field_all_terms("title_t", ["economic", "policy"])`
112    /// produces `q=title_t:(economic AND policy)`.
113    #[must_use]
114    pub fn field_all_terms<I, S>(field: &str, terms: I) -> Self
115    where
116        I: IntoIterator<Item = S>,
117        S: AsRef<str>,
118    {
119        let joined = terms
120            .into_iter()
121            .map(|t| t.as_ref().to_owned())
122            .collect::<Vec<_>>()
123            .join(" AND ");
124        Self::basic(format!("{field}:({joined})"))
125    }
126
127    /// Proximity search: find `phrase` within `distance` words inside `field`,
128    /// e.g. `proximity("title_t", "economic policy", 3)` produces
129    /// `q=title_t:"economic policy"~3`.
130    #[must_use]
131    pub fn proximity(field: &str, phrase: &str, distance: u32) -> Self {
132        Self::basic(format!("{field}:\"{phrase}\"~{distance}"))
133    }
134
135    /// Restrict the returned fields (the Solr `fl` parameter).
136    #[must_use]
137    pub fn fields<I, S>(mut self, fields: I) -> Self
138    where
139        I: IntoIterator<Item = S>,
140        S: AsRef<str>,
141    {
142        self.fields = fields.into_iter().map(|f| f.as_ref().to_owned()).collect();
143        self
144    }
145
146    /// Set the number of rows (documents) to return.
147    #[must_use]
148    pub fn rows(mut self, rows: u32) -> Self {
149        self.rows = Some(rows);
150        self
151    }
152
153    /// Set the offset of the first returned document.
154    #[must_use]
155    pub fn start(mut self, start: u32) -> Self {
156        self.start = Some(start);
157        self
158    }
159
160    /// Convenience pagination: page `page` (0-based) with `per_page` results each.
161    #[must_use]
162    pub fn page(mut self, page: u32, per_page: u32) -> Self {
163        self.rows = Some(per_page);
164        self.start = Some(page * per_page);
165        self
166    }
167
168    /// Set the sort clause (the Solr `sort` parameter), e.g. `"producedDate_s desc"`.
169    #[must_use]
170    pub fn sort(mut self, sort: impl Into<String>) -> Self {
171        self.sort = Some(sort.into());
172        self
173    }
174
175    /// Add a facet field. Calling this at least once enables faceting.
176    #[must_use]
177    pub fn facet(mut self, field: impl Into<String>) -> Self {
178        self.facet_fields.push(field.into());
179        self
180    }
181
182    /// Serialise the query into HAL/Solr URL parameters.
183    ///
184    /// The output type is compatible with `reqwest`'s `.query(..)`.
185    #[must_use]
186    pub fn to_params(&self) -> Vec<(String, String)> {
187        let mut params = vec![
188            ("q".to_owned(), self.q.clone()),
189            ("wt".to_owned(), "json".to_owned()),
190        ];
191
192        if !self.fields.is_empty() {
193            params.push(("fl".to_owned(), self.fields.join(",")));
194        }
195        if let Some(rows) = self.rows {
196            params.push(("rows".to_owned(), rows.to_string()));
197        }
198        if let Some(start) = self.start {
199            params.push(("start".to_owned(), start.to_string()));
200        }
201        if let Some(sort) = &self.sort {
202            params.push(("sort".to_owned(), sort.clone()));
203        }
204        if !self.facet_fields.is_empty() {
205            params.push(("facet".to_owned(), "true".to_owned()));
206            for field in &self.facet_fields {
207                params.push(("facet.field".to_owned(), field.clone()));
208            }
209        }
210
211        params
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    fn param<'a>(params: &'a [(String, String)], key: &str) -> Option<&'a str> {
220        params
221            .iter()
222            .find(|(k, _)| k == key)
223            .map(|(_, v)| v.as_str())
224    }
225
226    #[test]
227    fn basic_query() {
228        let params = SearchQuery::basic("europe").to_params();
229        assert_eq!(param(&params, "q"), Some("europe"));
230        assert_eq!(param(&params, "wt"), Some("json"));
231    }
232
233    #[test]
234    fn field_query() {
235        let params = SearchQuery::field("title_t", "europe").to_params();
236        assert_eq!(param(&params, "q"), Some("title_t:europe"));
237    }
238
239    #[test]
240    fn field_terms_query() {
241        let params = SearchQuery::field_terms("title_t", ["economic", "policy"]).to_params();
242        assert_eq!(param(&params, "q"), Some("title_t:(economic policy)"));
243    }
244
245    #[test]
246    fn proximity_query() {
247        let params = SearchQuery::proximity("title_t", "economic policy", 3).to_params();
248        assert_eq!(param(&params, "q"), Some("title_t:\"economic policy\"~3"));
249    }
250
251    #[test]
252    fn pagination_and_fields() {
253        let params = SearchQuery::basic("europe")
254            .fields(["docid", "label_s"])
255            .page(2, 10)
256            .to_params();
257        assert_eq!(param(&params, "fl"), Some("docid,label_s"));
258        assert_eq!(param(&params, "rows"), Some("10"));
259        assert_eq!(param(&params, "start"), Some("20"));
260    }
261
262    #[test]
263    fn field_all_terms_uses_and() {
264        let params = SearchQuery::field_all_terms("title_t", ["economic", "policy"]).to_params();
265        assert_eq!(param(&params, "q"), Some("title_t:(economic AND policy)"));
266    }
267
268    #[test]
269    fn in_field_groups_words() {
270        let params = SearchQuery::in_field(Field::Title, "open science").to_params();
271        assert_eq!(param(&params, "q"), Some("title_t:(open science)"));
272
273        let single = SearchQuery::in_field(Field::Author, "dupont").to_params();
274        assert_eq!(param(&single, "q"), Some("authFullName_t:(dupont)"));
275    }
276
277    #[test]
278    fn facets_enabled() {
279        let params = SearchQuery::basic("europe").facet("docType_s").to_params();
280        assert_eq!(param(&params, "facet"), Some("true"));
281        assert_eq!(param(&params, "facet.field"), Some("docType_s"));
282    }
283}