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//!
4//! Every operator returns a small per-query builder ([`TermQuery`],
5//! [`WildcardQuery`], [`MatchQuery`], …) carrying that query's options plus the
6//! universal `boost` / `name`. Builders render lazily through
7//! [`AsQuery`], so they drop straight into a clause — with no
8//! options (the DSL shorthand) or with them (the expanded object form).
9
10use std::marker::PhantomData;
11
12use serde_json::{Map, Value};
13
14use super::{
15    Common, FlussoValue, Sort, SortOrder, TermsQuery, common_opts, exists_q, keyed_value_query,
16    kind, wrap,
17};
18use crate::query::{AsQuery, Query, Root};
19
20/// The keyword term for a value, taken from its serde serialization — so a
21/// `#[derive(FlussoValue)]` enum/newtype matches exactly the string it stores
22/// in the document. `String`/`&str` pass straight through; the non-string
23/// fallback only fires for a hand-written [`trait@FlussoValue`] impl that breaks the
24/// "serializes to a string" contract the derive enforces.
25fn keyword_term(value: &impl serde::Serialize) -> Value {
26    match serde_json::to_value(value) {
27        Ok(Value::String(string)) => Value::String(string),
28        Ok(other) => Value::String(other.to_string()),
29        Err(_) => Value::String(String::new()),
30    }
31}
32
33/// An exact-match (`term`) clause on a string field, with optional
34/// `case_insensitive` plus the universal `boost` / `name`.
35#[derive(Debug, Clone)]
36pub struct TermQuery<S = Root> {
37    path: String,
38    value: Value,
39    case_insensitive: Option<bool>,
40    common: Common,
41    _scope: PhantomData<fn() -> S>,
42}
43
44impl<S> TermQuery<S> {
45    fn new(path: &str, value: Value) -> Self {
46        Self {
47            path: path.to_string(),
48            value,
49            case_insensitive: None,
50            common: Common::default(),
51            _scope: PhantomData,
52        }
53    }
54
55    /// Match regardless of case.
56    #[must_use]
57    pub fn case_insensitive(mut self) -> Self {
58        self.case_insensitive = Some(true);
59        self
60    }
61
62    common_opts!(common);
63}
64
65impl<S> AsQuery<S> for TermQuery<S> {
66    fn into_query(self) -> Option<Query<S>> {
67        let mut opts = Map::new();
68        if let Some(ci) = self.case_insensitive {
69            opts.insert("case_insensitive".to_string(), Value::Bool(ci));
70        }
71        self.common.write(&mut opts);
72        Some(keyed_value_query(
73            "term", &self.path, "value", self.value, opts,
74        ))
75    }
76}
77
78/// A `prefix` clause, with `case_insensitive` / `rewrite` plus `boost` / `name`.
79#[derive(Debug, Clone)]
80pub struct PrefixQuery<S = Root> {
81    path: String,
82    value: String,
83    case_insensitive: Option<bool>,
84    rewrite: Option<String>,
85    common: Common,
86    _scope: PhantomData<fn() -> S>,
87}
88
89impl<S> PrefixQuery<S> {
90    fn new(path: &str, value: String) -> Self {
91        Self {
92            path: path.to_string(),
93            value,
94            case_insensitive: None,
95            rewrite: None,
96            common: Common::default(),
97            _scope: PhantomData,
98        }
99    }
100
101    /// Match regardless of case.
102    #[must_use]
103    pub fn case_insensitive(mut self) -> Self {
104        self.case_insensitive = Some(true);
105        self
106    }
107
108    /// The multi-term `rewrite` method (e.g. `"constant_score"`).
109    #[must_use]
110    pub fn rewrite(mut self, rewrite: impl Into<String>) -> Self {
111        self.rewrite = Some(rewrite.into());
112        self
113    }
114
115    common_opts!(common);
116}
117
118impl<S> AsQuery<S> for PrefixQuery<S> {
119    fn into_query(self) -> Option<Query<S>> {
120        let mut opts = Map::new();
121        if let Some(ci) = self.case_insensitive {
122            opts.insert("case_insensitive".to_string(), Value::Bool(ci));
123        }
124        if let Some(rewrite) = self.rewrite {
125            opts.insert("rewrite".to_string(), Value::String(rewrite));
126        }
127        self.common.write(&mut opts);
128        Some(keyed_value_query(
129            "prefix",
130            &self.path,
131            "value",
132            Value::String(self.value),
133            opts,
134        ))
135    }
136}
137
138/// A `wildcard` clause, with `case_insensitive` / `rewrite` plus `boost` / `name`.
139#[derive(Debug, Clone)]
140pub struct WildcardQuery<S = Root> {
141    path: String,
142    value: String,
143    case_insensitive: Option<bool>,
144    rewrite: Option<String>,
145    common: Common,
146    _scope: PhantomData<fn() -> S>,
147}
148
149impl<S> WildcardQuery<S> {
150    fn new(path: &str, value: String) -> Self {
151        Self {
152            path: path.to_string(),
153            value,
154            case_insensitive: None,
155            rewrite: None,
156            common: Common::default(),
157            _scope: PhantomData,
158        }
159    }
160
161    /// Match regardless of case.
162    #[must_use]
163    pub fn case_insensitive(mut self) -> Self {
164        self.case_insensitive = Some(true);
165        self
166    }
167
168    /// The multi-term `rewrite` method.
169    #[must_use]
170    pub fn rewrite(mut self, rewrite: impl Into<String>) -> Self {
171        self.rewrite = Some(rewrite.into());
172        self
173    }
174
175    common_opts!(common);
176}
177
178impl<S> AsQuery<S> for WildcardQuery<S> {
179    fn into_query(self) -> Option<Query<S>> {
180        let mut opts = Map::new();
181        if let Some(ci) = self.case_insensitive {
182            opts.insert("case_insensitive".to_string(), Value::Bool(ci));
183        }
184        if let Some(rewrite) = self.rewrite {
185            opts.insert("rewrite".to_string(), Value::String(rewrite));
186        }
187        self.common.write(&mut opts);
188        Some(keyed_value_query(
189            "wildcard",
190            &self.path,
191            "value",
192            Value::String(self.value),
193            opts,
194        ))
195    }
196}
197
198/// A `regexp` clause, with `case_insensitive` / `flags` /
199/// `max_determinized_states` plus `boost` / `name`.
200#[derive(Debug, Clone)]
201pub struct RegexpQuery<S = Root> {
202    path: String,
203    value: String,
204    case_insensitive: Option<bool>,
205    flags: Option<String>,
206    max_determinized_states: Option<u32>,
207    common: Common,
208    _scope: PhantomData<fn() -> S>,
209}
210
211impl<S> RegexpQuery<S> {
212    fn new(path: &str, value: String) -> Self {
213        Self {
214            path: path.to_string(),
215            value,
216            case_insensitive: None,
217            flags: None,
218            max_determinized_states: None,
219            common: Common::default(),
220            _scope: PhantomData,
221        }
222    }
223
224    /// Match regardless of case.
225    #[must_use]
226    pub fn case_insensitive(mut self) -> Self {
227        self.case_insensitive = Some(true);
228        self
229    }
230
231    /// Enabled Lucene regex operators (e.g. `"INTERSECTION|COMPLEMENT|EMPTY"`).
232    #[must_use]
233    pub fn flags(mut self, flags: impl Into<String>) -> Self {
234        self.flags = Some(flags.into());
235        self
236    }
237
238    /// Cap on the automaton size compiled from the pattern.
239    #[must_use]
240    pub fn max_determinized_states(mut self, max: u32) -> Self {
241        self.max_determinized_states = Some(max);
242        self
243    }
244
245    common_opts!(common);
246}
247
248impl<S> AsQuery<S> for RegexpQuery<S> {
249    fn into_query(self) -> Option<Query<S>> {
250        let mut opts = Map::new();
251        if let Some(ci) = self.case_insensitive {
252            opts.insert("case_insensitive".to_string(), Value::Bool(ci));
253        }
254        if let Some(flags) = self.flags {
255            opts.insert("flags".to_string(), Value::String(flags));
256        }
257        if let Some(max) = self.max_determinized_states {
258            opts.insert("max_determinized_states".to_string(), Value::from(max));
259        }
260        self.common.write(&mut opts);
261        Some(keyed_value_query(
262            "regexp",
263            &self.path,
264            "value",
265            Value::String(self.value),
266            opts,
267        ))
268    }
269}
270
271/// A `fuzzy` clause, with `fuzziness` / `prefix_length` / `max_expansions` /
272/// `transpositions` plus `boost` / `name`.
273#[derive(Debug, Clone)]
274pub struct FuzzyQuery<S = Root> {
275    path: String,
276    value: String,
277    fuzziness: Option<String>,
278    prefix_length: Option<u32>,
279    max_expansions: Option<u32>,
280    transpositions: Option<bool>,
281    common: Common,
282    _scope: PhantomData<fn() -> S>,
283}
284
285impl<S> FuzzyQuery<S> {
286    fn new(path: &str, value: String) -> Self {
287        Self {
288            path: path.to_string(),
289            value,
290            fuzziness: None,
291            prefix_length: None,
292            max_expansions: None,
293            transpositions: None,
294            common: Common::default(),
295            _scope: PhantomData,
296        }
297    }
298
299    /// Maximum edit distance — `"AUTO"` (the default) or an integer-as-string.
300    #[must_use]
301    pub fn fuzziness(mut self, fuzziness: impl Into<String>) -> Self {
302        self.fuzziness = Some(fuzziness.into());
303        self
304    }
305
306    /// Leading characters that must match exactly.
307    #[must_use]
308    pub fn prefix_length(mut self, prefix_length: u32) -> Self {
309        self.prefix_length = Some(prefix_length);
310        self
311    }
312
313    /// Cap on the variations the term expands into.
314    #[must_use]
315    pub fn max_expansions(mut self, max_expansions: u32) -> Self {
316        self.max_expansions = Some(max_expansions);
317        self
318    }
319
320    /// Whether adjacent-character transpositions count as one edit.
321    #[must_use]
322    pub fn transpositions(mut self, transpositions: bool) -> Self {
323        self.transpositions = Some(transpositions);
324        self
325    }
326
327    common_opts!(common);
328}
329
330impl<S> AsQuery<S> for FuzzyQuery<S> {
331    fn into_query(self) -> Option<Query<S>> {
332        let mut opts = Map::new();
333        if let Some(fuzziness) = self.fuzziness {
334            opts.insert("fuzziness".to_string(), Value::String(fuzziness));
335        }
336        if let Some(prefix_length) = self.prefix_length {
337            opts.insert("prefix_length".to_string(), Value::from(prefix_length));
338        }
339        if let Some(max_expansions) = self.max_expansions {
340            opts.insert("max_expansions".to_string(), Value::from(max_expansions));
341        }
342        if let Some(transpositions) = self.transpositions {
343            opts.insert("transpositions".to_string(), Value::Bool(transpositions));
344        }
345        self.common.write(&mut opts);
346        Some(keyed_value_query(
347            "fuzzy",
348            &self.path,
349            "value",
350            Value::String(self.value),
351            opts,
352        ))
353    }
354}
355
356/// An exact, aggregatable string field (`keyword`, `enum`, `uuid`).
357#[derive(Debug, Clone)]
358pub struct Keyword<S = Root> {
359    path: String,
360    _scope: PhantomData<fn() -> S>,
361}
362
363impl<S> Keyword<S> {
364    pub fn at(path: impl Into<String>) -> Self {
365        Self {
366            path: path.into(),
367            _scope: PhantomData,
368        }
369    }
370
371    /// Exact match. Accepts a `String`/`&str`, or any `#[derive(FlussoValue)]`
372    /// keyword enum/newtype — matched against its serde string form
373    /// (`Account::tier().eq(AccountTier::Pro)`).
374    pub fn eq(&self, value: impl FlussoValue<kind::Keyword> + serde::Serialize) -> TermQuery<S> {
375        TermQuery::new(&self.path, keyword_term(&value))
376    }
377
378    /// Match any of the given values (`String`/`&str` or keyword `FlussoValue` types).
379    pub fn in_(
380        &self,
381        values: impl IntoIterator<Item = impl FlussoValue<kind::Keyword> + serde::Serialize>,
382    ) -> TermsQuery<S> {
383        let array = values.into_iter().map(|v| keyword_term(&v)).collect();
384        TermsQuery::new(&self.path, array)
385    }
386
387    /// Prefix match.
388    pub fn prefix(&self, value: impl Into<String>) -> PrefixQuery<S> {
389        PrefixQuery::new(&self.path, value.into())
390    }
391
392    /// Wildcard match — `?` matches one character, `*` matches any run.
393    pub fn wildcard(&self, pattern: impl Into<String>) -> WildcardQuery<S> {
394        WildcardQuery::new(&self.path, pattern.into())
395    }
396
397    /// Regular-expression match (Lucene regex syntax, anchored to the whole term).
398    pub fn regexp(&self, pattern: impl Into<String>) -> RegexpQuery<S> {
399        RegexpQuery::new(&self.path, pattern.into())
400    }
401
402    /// Fuzzy term match — tolerates typos within the default `AUTO` distance.
403    pub fn fuzzy(&self, value: impl Into<String>) -> FuzzyQuery<S> {
404        FuzzyQuery::new(&self.path, value.into())
405    }
406
407    /// The full-text `.text` subfield flusso auto-creates on a `keyword` field
408    /// (analyzed with `flusso_code`), so a keyword is still searchable in a
409    /// search box. Available only when the sink's `auto_subfields` is on (the
410    /// default) and the field defines no custom `fields`.
411    pub fn text(&self) -> Text<S> {
412        Text::at(format!("{}.text", self.path))
413    }
414
415    /// The case/accent-insensitive `.keyword_lowercase` subfield flusso
416    /// auto-creates — for case-insensitive exact match and sort. Available only
417    /// when the sink's `auto_subfields` is on (the default).
418    pub fn keyword_lowercase(&self) -> Keyword<S> {
419        Keyword::at(format!("{}.keyword_lowercase", self.path))
420    }
421
422    /// The field has a non-null value.
423    pub fn exists(&self) -> Query<S> {
424        exists_q(&self.path)
425    }
426
427    pub fn asc(&self) -> Sort {
428        Sort::new(&self.path, SortOrder::Asc)
429    }
430
431    pub fn desc(&self) -> Sort {
432        Sort::new(&self.path, SortOrder::Desc)
433    }
434}
435
436/// A `match`-family clause (`match`, `match_phrase`, `match_phrase_prefix`,
437/// `match_bool_prefix`): the analyzed `query` value plus whichever options the
438/// kind supports, all written under the field as an object. The `kind`
439/// selects the wrapper and which setters are meaningful; unset options are
440/// simply omitted.
441#[derive(Debug, Clone)]
442pub struct MatchQuery<S = Root> {
443    wrapper: &'static str,
444    path: String,
445    value: String,
446    opts: Map<String, Value>,
447    common: Common,
448    _scope: PhantomData<fn() -> S>,
449}
450
451impl<S> MatchQuery<S> {
452    fn new(wrapper: &'static str, path: &str, value: String) -> Self {
453        Self {
454            wrapper,
455            path: path.to_string(),
456            value,
457            opts: Map::new(),
458            common: Common::default(),
459            _scope: PhantomData,
460        }
461    }
462
463    fn set(mut self, key: &str, value: Value) -> Self {
464        self.opts.insert(key.to_string(), value);
465        self
466    }
467
468    /// Edit distance for analyzed terms — `"AUTO"` or an integer-as-string.
469    #[must_use]
470    pub fn fuzziness(self, fuzziness: impl Into<String>) -> Self {
471        self.set("fuzziness", Value::String(fuzziness.into()))
472    }
473
474    /// Combine analyzed terms with `"AND"` or `"OR"` (default `"OR"`).
475    #[must_use]
476    pub fn operator(self, operator: impl Into<String>) -> Self {
477        self.set("operator", Value::String(operator.into()))
478    }
479
480    /// How many of the analyzed terms must match (e.g. `"75%"`, `"2"`).
481    #[must_use]
482    pub fn minimum_should_match(self, value: impl Into<String>) -> Self {
483        self.set("minimum_should_match", Value::String(value.into()))
484    }
485
486    /// Leading characters that must match exactly (fuzzy/prefix matching).
487    #[must_use]
488    pub fn prefix_length(self, prefix_length: u32) -> Self {
489        self.set("prefix_length", Value::from(prefix_length))
490    }
491
492    /// Cap on terms a prefix / fuzzy term expands into.
493    #[must_use]
494    pub fn max_expansions(self, max_expansions: u32) -> Self {
495        self.set("max_expansions", Value::from(max_expansions))
496    }
497
498    /// Override the search analyzer for this clause.
499    #[must_use]
500    pub fn analyzer(self, analyzer: impl Into<String>) -> Self {
501        self.set("analyzer", Value::String(analyzer.into()))
502    }
503
504    /// Phrase `slop` — allowed positional gap (phrase / phrase-prefix).
505    #[must_use]
506    pub fn slop(self, slop: u32) -> Self {
507        self.set("slop", Value::from(slop))
508    }
509
510    /// Behavior when analysis yields no terms (`"none"` or `"all"`).
511    #[must_use]
512    pub fn zero_terms_query(self, value: impl Into<String>) -> Self {
513        self.set("zero_terms_query", Value::String(value.into()))
514    }
515
516    /// Ignore format errors (e.g. analyzing text for a numeric subfield).
517    #[must_use]
518    pub fn lenient(self, lenient: bool) -> Self {
519        self.set("lenient", Value::Bool(lenient))
520    }
521
522    common_opts!(common);
523}
524
525impl<S> AsQuery<S> for MatchQuery<S> {
526    fn into_query(self) -> Option<Query<S>> {
527        let mut opts = self.opts;
528        self.common.write(&mut opts);
529        Some(keyed_value_query(
530            self.wrapper,
531            &self.path,
532            "query",
533            Value::String(self.value),
534            opts,
535        ))
536    }
537}
538
539/// An analyzed full-text field (`text`, `identifier`). No exact `eq`.
540#[derive(Debug, Clone)]
541pub struct Text<S = Root> {
542    path: String,
543    boost: Option<f32>,
544    _scope: PhantomData<fn() -> S>,
545}
546
547impl<S> Text<S> {
548    pub fn at(path: impl Into<String>) -> Self {
549        Self {
550            path: path.into(),
551            boost: None,
552            _scope: PhantomData,
553        }
554    }
555
556    /// Weight this field for [`multi_match`] (`field^weight`). Has no effect on
557    /// this handle's own `matches` / `match_phrase` clauses, which carry their
558    /// own `boost`.
559    #[must_use]
560    pub fn boosted(mut self, weight: f32) -> Self {
561        self.boost = Some(weight);
562        self
563    }
564
565    /// The field's path as listed in a [`multi_match`] `fields` array —
566    /// `field^weight` when [`boosted`](Self::boosted), else the bare path.
567    pub(crate) fn field_spec(&self) -> String {
568        match self.boost {
569            Some(weight) => format!("{}^{weight}", self.path),
570            None => self.path.clone(),
571        }
572    }
573
574    /// Analyzed match.
575    pub fn matches(&self, value: impl Into<String>) -> MatchQuery<S> {
576        MatchQuery::new("match", &self.path, value.into())
577    }
578
579    /// Analyzed phrase match (terms in order).
580    pub fn match_phrase(&self, value: impl Into<String>) -> MatchQuery<S> {
581        MatchQuery::new("match_phrase", &self.path, value.into())
582    }
583
584    /// Analyzed phrase-prefix match (search-as-you-type).
585    pub fn match_phrase_prefix(&self, value: impl Into<String>) -> MatchQuery<S> {
586        MatchQuery::new("match_phrase_prefix", &self.path, value.into())
587    }
588
589    /// Bool-prefix match — every term a `term` except the last, which is a
590    /// prefix (the other half of search-as-you-type).
591    pub fn match_bool_prefix(&self, value: impl Into<String>) -> MatchQuery<S> {
592        MatchQuery::new("match_bool_prefix", &self.path, value.into())
593    }
594
595    /// Analyzed match tolerant of typos — sugar for `matches(v).fuzziness("AUTO")`.
596    pub fn matches_fuzzy(&self, value: impl Into<String>) -> MatchQuery<S> {
597        self.matches(value).fuzziness("AUTO")
598    }
599
600    /// The exact `.keyword` subfield flusso auto-creates on a `text` field —
601    /// for exact `eq` / `in_`, `wildcard`, `prefix`, and exact sort. (A wildcard
602    /// belongs here, not on the analyzed handle, which matches tokens not the
603    /// whole value.) Available only when the sink's `auto_subfields` is on (the
604    /// default) and the field defines no custom `fields`.
605    pub fn keyword(&self) -> Keyword<S> {
606        Keyword::at(format!("{}.keyword", self.path))
607    }
608
609    /// The case/accent-insensitive `.keyword_lowercase` subfield — for
610    /// case-insensitive exact match and sort. Available only when the sink's
611    /// `auto_subfields` is on (the default).
612    pub fn keyword_lowercase(&self) -> Keyword<S> {
613        Keyword::at(format!("{}.keyword_lowercase", self.path))
614    }
615
616    /// The field has a non-null value.
617    pub fn exists(&self) -> Query<S> {
618        exists_q(&self.path)
619    }
620}
621
622/// A cross-field full-text query over several [`Text`] fields in the same scope.
623/// Returns a [`MultiMatchQuery`] builder; weight individual fields with
624/// [`Text::boosted`].
625pub fn multi_match<S>(
626    query: impl Into<String>,
627    fields: impl IntoIterator<Item = Text<S>>,
628) -> MultiMatchQuery<S> {
629    MultiMatchQuery {
630        query: query.into(),
631        fields: fields.into_iter().map(|f| f.field_spec()).collect(),
632        opts: Map::new(),
633        common: Common::default(),
634        _scope: PhantomData,
635    }
636}
637
638/// A `multi_match` clause: one analyzed `query` over several `fields`, with the
639/// `type` / `operator` / `fuzziness` / `tie_breaker` / `minimum_should_match`
640/// options plus `boost` / `name`.
641#[derive(Debug, Clone)]
642pub struct MultiMatchQuery<S = Root> {
643    query: String,
644    fields: Vec<String>,
645    opts: Map<String, Value>,
646    common: Common,
647    _scope: PhantomData<fn() -> S>,
648}
649
650impl<S> MultiMatchQuery<S> {
651    fn set(mut self, key: &str, value: Value) -> Self {
652        self.opts.insert(key.to_string(), value);
653        self
654    }
655
656    /// The scoring `type`: `"best_fields"` / `"most_fields"` / `"cross_fields"`
657    /// / `"phrase"` / `"phrase_prefix"` / `"bool_prefix"`.
658    #[must_use]
659    pub fn match_type(self, match_type: impl Into<String>) -> Self {
660        self.set("type", Value::String(match_type.into()))
661    }
662
663    /// Combine analyzed terms with `"AND"` or `"OR"`.
664    #[must_use]
665    pub fn operator(self, operator: impl Into<String>) -> Self {
666        self.set("operator", Value::String(operator.into()))
667    }
668
669    /// Edit distance — `"AUTO"` or an integer-as-string.
670    #[must_use]
671    pub fn fuzziness(self, fuzziness: impl Into<String>) -> Self {
672        self.set("fuzziness", Value::String(fuzziness.into()))
673    }
674
675    /// `tie_breaker` for `best_fields` — how much non-winning fields contribute.
676    #[must_use]
677    pub fn tie_breaker(self, tie_breaker: f32) -> Self {
678        self.set("tie_breaker", Value::from(tie_breaker))
679    }
680
681    /// How many of the analyzed terms must match (e.g. `"75%"`, `"2"`).
682    #[must_use]
683    pub fn minimum_should_match(self, value: impl Into<String>) -> Self {
684        self.set("minimum_should_match", Value::String(value.into()))
685    }
686
687    common_opts!(common);
688}
689
690impl<S> AsQuery<S> for MultiMatchQuery<S> {
691    fn into_query(self) -> Option<Query<S>> {
692        let mut body = self.opts;
693        body.insert("query".to_string(), Value::String(self.query));
694        body.insert(
695            "fields".to_string(),
696            Value::Array(self.fields.into_iter().map(Value::String).collect()),
697        );
698        self.common.write(&mut body);
699        Some(wrap("multi_match", body))
700    }
701}