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// ---- Keyword ---------------------------------------------------------------
25
26/// An exact, aggregatable string field (`keyword`, `enum`, `uuid`).
27#[derive(Debug, Clone)]
28pub struct Keyword<S = Root> {
29    path: String,
30    _scope: PhantomData<fn() -> S>,
31}
32
33impl<S> Keyword<S> {
34    /// Build a handle for the field at `path`.
35    pub fn at(path: impl Into<String>) -> Self {
36        Self {
37            path: path.into(),
38            _scope: PhantomData,
39        }
40    }
41
42    /// Exact match. Accepts a `String`/`&str`, or any `#[derive(FlussoValue)]`
43    /// keyword enum/newtype — matched against its serde string form
44    /// (`Account::tier().eq(AccountTier::Pro)`).
45    pub fn eq(&self, value: impl FlussoValue<kind::Keyword> + serde::Serialize) -> Query<S> {
46        single("term", &self.path, keyword_term(&value))
47    }
48
49    /// Match any of the given values (`String`/`&str` or keyword `FlussoValue` types).
50    pub fn in_(
51        &self,
52        values: impl IntoIterator<Item = impl FlussoValue<kind::Keyword> + serde::Serialize>,
53    ) -> Query<S> {
54        let array = values.into_iter().map(|v| keyword_term(&v)).collect();
55        single("terms", &self.path, Value::Array(array))
56    }
57
58    /// Prefix match.
59    pub fn prefix(&self, value: impl Into<String>) -> Query<S> {
60        single("prefix", &self.path, Value::String(value.into()))
61    }
62
63    /// Wildcard match — `?` matches one character, `*` matches any run.
64    pub fn wildcard(&self, pattern: impl Into<String>) -> Query<S> {
65        single("wildcard", &self.path, Value::String(pattern.into()))
66    }
67
68    /// Regular-expression match (Lucene regex syntax, anchored to the whole term).
69    pub fn regexp(&self, pattern: impl Into<String>) -> Query<S> {
70        single("regexp", &self.path, Value::String(pattern.into()))
71    }
72
73    /// Fuzzy term match — tolerates typos within the default `AUTO` distance.
74    pub fn fuzzy(&self, value: impl Into<String>) -> Query<S> {
75        single("fuzzy", &self.path, Value::String(value.into()))
76    }
77
78    /// The field has a non-null value.
79    pub fn exists(&self) -> Query<S> {
80        exists_q(&self.path)
81    }
82
83    /// Sort ascending on this field.
84    pub fn asc(&self) -> Sort {
85        Sort::new(&self.path, SortOrder::Asc)
86    }
87
88    /// Sort descending on this field.
89    pub fn desc(&self) -> Sort {
90        Sort::new(&self.path, SortOrder::Desc)
91    }
92}
93
94// ---- Text ------------------------------------------------------------------
95
96/// An analyzed full-text field (`text`, `identifier`). No exact `eq`.
97#[derive(Debug, Clone)]
98pub struct Text<S = Root> {
99    path: String,
100    _scope: PhantomData<fn() -> S>,
101}
102
103impl<S> Text<S> {
104    /// Build a handle for the field at `path`.
105    pub fn at(path: impl Into<String>) -> Self {
106        Self {
107            path: path.into(),
108            _scope: PhantomData,
109        }
110    }
111
112    /// Analyzed match.
113    pub fn matches(&self, value: impl Into<String>) -> Query<S> {
114        single("match", &self.path, Value::String(value.into()))
115    }
116
117    /// Analyzed phrase match (terms in order).
118    pub fn match_phrase(&self, value: impl Into<String>) -> Query<S> {
119        single("match_phrase", &self.path, Value::String(value.into()))
120    }
121
122    /// Analyzed phrase-prefix match (search-as-you-type).
123    pub fn match_phrase_prefix(&self, value: impl Into<String>) -> Query<S> {
124        single(
125            "match_phrase_prefix",
126            &self.path,
127            Value::String(value.into()),
128        )
129    }
130
131    /// Analyzed match tolerant of typos — a `match` with `fuzziness: AUTO`.
132    pub fn matches_fuzzy(&self, value: impl Into<String>) -> Query<S> {
133        let mut params = Map::new();
134        params.insert("query".to_string(), Value::String(value.into()));
135        params.insert("fuzziness".to_string(), Value::String("AUTO".to_string()));
136        single("match", &self.path, Value::Object(params))
137    }
138
139    /// The field has a non-null value.
140    pub fn exists(&self) -> Query<S> {
141        exists_q(&self.path)
142    }
143}
144
145/// A cross-field full-text query over several [`Text`] fields in the same scope.
146pub fn multi_match<S>(
147    query: impl Into<String>,
148    fields: impl IntoIterator<Item = Text<S>>,
149) -> Query<S> {
150    let paths = fields
151        .into_iter()
152        .map(|field| Value::String(field.path))
153        .collect();
154    let mut body = Map::new();
155    body.insert("query".to_string(), Value::String(query.into()));
156    body.insert("fields".to_string(), Value::Array(paths));
157    let mut outer = Map::new();
158    outer.insert("multi_match".to_string(), Value::Object(body));
159    Query::leaf(Value::Object(outer))
160}