Skip to main content

flusso_query/handles/
string.rs

1//! String field handles: the exact [`Keyword`] and the analyzed [`Text`], plus
2//! the cross-field [`multi_match`].
3
4use std::marker::PhantomData;
5
6use serde_json::{Map, Value};
7
8use super::{FlussoValue, Sort, SortOrder, exists_q, kind, single};
9use crate::query::{Query, Root};
10
11/// The keyword term for a value, taken from its serde serialization — so a
12/// `#[derive(FlussoValue)]` enum/newtype matches exactly the string it stores
13/// in the document. `String`/`&str` pass straight through; the non-string
14/// fallback only fires for a hand-written [`trait@FlussoValue`] impl that breaks the
15/// "serializes to a string" contract the derive enforces.
16fn keyword_term(value: &impl serde::Serialize) -> Value {
17    match serde_json::to_value(value) {
18        Ok(Value::String(string)) => Value::String(string),
19        Ok(other) => Value::String(other.to_string()),
20        Err(_) => Value::String(String::new()),
21    }
22}
23
24/// An exact, aggregatable string field (`keyword`, `enum`, `uuid`).
25#[derive(Debug, Clone)]
26pub struct Keyword<S = Root> {
27    path: String,
28    _scope: PhantomData<fn() -> S>,
29}
30
31impl<S> Keyword<S> {
32    pub fn at(path: impl Into<String>) -> Self {
33        Self {
34            path: path.into(),
35            _scope: PhantomData,
36        }
37    }
38
39    /// Exact match. Accepts a `String`/`&str`, or any `#[derive(FlussoValue)]`
40    /// keyword enum/newtype — matched against its serde string form
41    /// (`Account::tier().eq(AccountTier::Pro)`).
42    pub fn eq(&self, value: impl FlussoValue<kind::Keyword> + serde::Serialize) -> Query<S> {
43        single("term", &self.path, keyword_term(&value))
44    }
45
46    /// Match any of the given values (`String`/`&str` or keyword `FlussoValue` types).
47    pub fn in_(
48        &self,
49        values: impl IntoIterator<Item = impl FlussoValue<kind::Keyword> + serde::Serialize>,
50    ) -> Query<S> {
51        let array = values.into_iter().map(|v| keyword_term(&v)).collect();
52        single("terms", &self.path, Value::Array(array))
53    }
54
55    /// Prefix match.
56    pub fn prefix(&self, value: impl Into<String>) -> Query<S> {
57        single("prefix", &self.path, Value::String(value.into()))
58    }
59
60    /// Wildcard match — `?` matches one character, `*` matches any run.
61    pub fn wildcard(&self, pattern: impl Into<String>) -> Query<S> {
62        single("wildcard", &self.path, Value::String(pattern.into()))
63    }
64
65    /// Regular-expression match (Lucene regex syntax, anchored to the whole term).
66    pub fn regexp(&self, pattern: impl Into<String>) -> Query<S> {
67        single("regexp", &self.path, Value::String(pattern.into()))
68    }
69
70    /// Fuzzy term match — tolerates typos within the default `AUTO` distance.
71    pub fn fuzzy(&self, value: impl Into<String>) -> Query<S> {
72        single("fuzzy", &self.path, Value::String(value.into()))
73    }
74
75    /// The field has a non-null value.
76    pub fn exists(&self) -> Query<S> {
77        exists_q(&self.path)
78    }
79
80    pub fn asc(&self) -> Sort {
81        Sort::new(&self.path, SortOrder::Asc)
82    }
83
84    pub fn desc(&self) -> Sort {
85        Sort::new(&self.path, SortOrder::Desc)
86    }
87}
88
89/// An analyzed full-text field (`text`, `identifier`). No exact `eq`.
90#[derive(Debug, Clone)]
91pub struct Text<S = Root> {
92    path: String,
93    _scope: PhantomData<fn() -> S>,
94}
95
96impl<S> Text<S> {
97    pub fn at(path: impl Into<String>) -> Self {
98        Self {
99            path: path.into(),
100            _scope: PhantomData,
101        }
102    }
103
104    /// Analyzed match.
105    pub fn matches(&self, value: impl Into<String>) -> Query<S> {
106        single("match", &self.path, Value::String(value.into()))
107    }
108
109    /// Analyzed phrase match (terms in order).
110    pub fn match_phrase(&self, value: impl Into<String>) -> Query<S> {
111        single("match_phrase", &self.path, Value::String(value.into()))
112    }
113
114    /// Analyzed phrase-prefix match (search-as-you-type).
115    pub fn match_phrase_prefix(&self, value: impl Into<String>) -> Query<S> {
116        single(
117            "match_phrase_prefix",
118            &self.path,
119            Value::String(value.into()),
120        )
121    }
122
123    /// Analyzed match tolerant of typos — a `match` with `fuzziness: AUTO`.
124    pub fn matches_fuzzy(&self, value: impl Into<String>) -> Query<S> {
125        let mut params = Map::new();
126        params.insert("query".to_string(), Value::String(value.into()));
127        params.insert("fuzziness".to_string(), Value::String("AUTO".to_string()));
128        single("match", &self.path, Value::Object(params))
129    }
130
131    /// The field has a non-null value.
132    pub fn exists(&self) -> Query<S> {
133        exists_q(&self.path)
134    }
135}
136
137/// A cross-field full-text query over several [`Text`] fields in the same scope.
138pub fn multi_match<S>(
139    query: impl Into<String>,
140    fields: impl IntoIterator<Item = Text<S>>,
141) -> Query<S> {
142    let paths = fields
143        .into_iter()
144        .map(|field| Value::String(field.path))
145        .collect();
146    let mut body = Map::new();
147    body.insert("query".to_string(), Value::String(query.into()));
148    body.insert("fields".to_string(), Value::Array(paths));
149    let mut outer = Map::new();
150    outer.insert("multi_match".to_string(), Value::Object(body));
151    Query::leaf(Value::Object(outer))
152}