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, Fuzziness, MinimumShouldMatch, MultiMatchType, Operator, Sort, SortOrder,
16    TermsQuery, ZeroTermsQuery, common_opts, exists_q, keyed_value_query, 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<Value>,
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 ([`Fuzziness::Auto`] is the usual choice).
300    #[must_use]
301    pub fn fuzziness(mut self, fuzziness: Fuzziness) -> Self {
302        self.fuzziness = Some(fuzziness.to_value());
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(), 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/// Type-state marker: this `text`/`keyword` handle's field carries flusso's
357/// auto subfields, so its subfield accessors (`.keyword()` / `.text()` /
358/// `.keyword_lowercase()`) — and the sugar built on them (`Text::any_of`,
359/// `Text::asc`) — are in scope. The default for a hand-written handle; the
360/// derive stamps it on a field only when every OpenSearch sink has
361/// `auto_subfields` on and the field declares no custom `fields`.
362#[derive(Debug)]
363pub enum WithSubfields {}
364
365/// Type-state marker: this handle's field has **no** auto subfields, so the
366/// subfield accessors don't exist — calling one is a compile error, not a 400.
367/// The derive stamps it when subfields aren't provisioned; subfield leaves
368/// (`.keyword()`) also carry it, since a subfield has no further subfields.
369#[derive(Debug)]
370pub enum NoSubfields {}
371
372/// An exact, aggregatable string field (`keyword`, `enum`, `uuid`). `Sub` is a
373/// [`WithSubfields`]/[`NoSubfields`] type-state marker gating the subfield
374/// accessors.
375#[derive(Debug, Clone)]
376pub struct Keyword<S = Root, Sub = WithSubfields> {
377    path: String,
378    _marker: PhantomData<fn() -> (S, Sub)>,
379}
380
381impl<S, Sub> Keyword<S, Sub> {
382    fn handle(path: impl Into<String>) -> Self {
383        Self {
384            path: path.into(),
385            _marker: PhantomData,
386        }
387    }
388
389    /// Exact match. Accepts a `String`/`&str`, or any `#[derive(FlussoValue)]`
390    /// keyword enum/newtype — matched against its serde string form
391    /// (`Account::tier().eq(AccountTier::Pro)`).
392    pub fn eq(&self, value: impl FlussoValue<kind::Keyword> + serde::Serialize) -> TermQuery<S> {
393        TermQuery::new(&self.path, keyword_term(&value))
394    }
395
396    /// Match any of the given values (`String`/`&str` or keyword `FlussoValue` types).
397    pub fn any_of(
398        &self,
399        values: impl IntoIterator<Item = impl FlussoValue<kind::Keyword> + serde::Serialize>,
400    ) -> TermsQuery<S> {
401        let array = values.into_iter().map(|v| keyword_term(&v)).collect();
402        TermsQuery::new(&self.path, array)
403    }
404
405    /// Prefix match.
406    pub fn prefix(&self, value: impl Into<String>) -> PrefixQuery<S> {
407        PrefixQuery::new(&self.path, value.into())
408    }
409
410    /// Wildcard match — `?` matches one character, `*` matches any run.
411    pub fn wildcard(&self, pattern: impl Into<String>) -> WildcardQuery<S> {
412        WildcardQuery::new(&self.path, pattern.into())
413    }
414
415    /// Regular-expression match (Lucene regex syntax, anchored to the whole term).
416    pub fn regexp(&self, pattern: impl Into<String>) -> RegexpQuery<S> {
417        RegexpQuery::new(&self.path, pattern.into())
418    }
419
420    /// Fuzzy term match — tolerates typos within the default `AUTO` distance.
421    pub fn fuzzy(&self, value: impl Into<String>) -> FuzzyQuery<S> {
422        FuzzyQuery::new(&self.path, value.into())
423    }
424
425    /// The field has a non-null value.
426    pub fn exists(&self) -> Query<S> {
427        exists_q(&self.path)
428    }
429
430    pub fn asc(&self) -> Sort {
431        Sort::new(&self.path, SortOrder::Asc)
432    }
433
434    pub fn desc(&self) -> Sort {
435        Sort::new(&self.path, SortOrder::Desc)
436    }
437}
438
439impl<S> Keyword<S, WithSubfields> {
440    pub fn at(path: impl Into<String>) -> Self {
441        Self::handle(path)
442    }
443
444    /// The full-text `.text` subfield flusso auto-creates on a `keyword` field
445    /// (analyzed with `flusso_code`), so a keyword is still searchable in a
446    /// search box. Only in scope when the field carries auto subfields.
447    pub fn text(&self) -> Text<S, NoSubfields> {
448        Text::leaf(format!("{}.text", self.path))
449    }
450
451    /// The case/accent-insensitive `.keyword_lowercase` subfield flusso
452    /// auto-creates — for case-insensitive exact match and sort. Only in scope
453    /// when the field carries auto subfields.
454    pub fn keyword_lowercase(&self) -> Keyword<S, NoSubfields> {
455        Keyword::leaf(format!("{}.keyword_lowercase", self.path))
456    }
457}
458
459impl<S> Keyword<S, NoSubfields> {
460    /// Construct a handle for a field known to have no auto subfields (a
461    /// subfield leaf, or a field the derive resolved as un-subfielded).
462    pub fn leaf(path: impl Into<String>) -> Self {
463        Self::handle(path)
464    }
465}
466
467/// A `match`-family clause (`match`, `match_phrase`, `match_phrase_prefix`,
468/// `match_bool_prefix`): the analyzed `query` value plus whichever options the
469/// kind supports, all written under the field as an object. The `kind`
470/// selects the wrapper and which setters are meaningful; unset options are
471/// simply omitted.
472#[derive(Debug, Clone)]
473pub struct MatchQuery<S = Root> {
474    wrapper: &'static str,
475    path: String,
476    value: String,
477    opts: Map<String, Value>,
478    common: Common,
479    _scope: PhantomData<fn() -> S>,
480}
481
482impl<S> MatchQuery<S> {
483    fn new(wrapper: &'static str, path: &str, value: String) -> Self {
484        Self {
485            wrapper,
486            path: path.to_string(),
487            value,
488            opts: Map::new(),
489            common: Common::default(),
490            _scope: PhantomData,
491        }
492    }
493
494    fn set(mut self, key: &str, value: Value) -> Self {
495        self.opts.insert(key.to_string(), value);
496        self
497    }
498
499    /// Edit distance for analyzed terms ([`Fuzziness::Auto`] is the usual choice).
500    #[must_use]
501    pub fn fuzziness(self, fuzziness: Fuzziness) -> Self {
502        self.set("fuzziness", fuzziness.to_value())
503    }
504
505    /// Combine analyzed terms with [`Operator::And`] or [`Operator::Or`]
506    /// (default `Or`).
507    #[must_use]
508    pub fn operator(self, operator: Operator) -> Self {
509        self.set("operator", Value::String(operator.as_str().to_string()))
510    }
511
512    /// How many of the analyzed terms must match
513    /// (e.g. `2`, `MinimumShouldMatch::percent(75)`).
514    #[must_use]
515    pub fn minimum_should_match(self, value: impl Into<MinimumShouldMatch>) -> Self {
516        self.set("minimum_should_match", value.into().to_value())
517    }
518
519    /// Leading characters that must match exactly (fuzzy/prefix matching).
520    #[must_use]
521    pub fn prefix_length(self, prefix_length: u32) -> Self {
522        self.set("prefix_length", Value::from(prefix_length))
523    }
524
525    /// Cap on terms a prefix / fuzzy term expands into.
526    #[must_use]
527    pub fn max_expansions(self, max_expansions: u32) -> Self {
528        self.set("max_expansions", Value::from(max_expansions))
529    }
530
531    /// Override the search analyzer for this clause.
532    #[must_use]
533    pub fn analyzer(self, analyzer: impl Into<String>) -> Self {
534        self.set("analyzer", Value::String(analyzer.into()))
535    }
536
537    /// Phrase `slop` — allowed positional gap (phrase / phrase-prefix).
538    #[must_use]
539    pub fn slop(self, slop: u32) -> Self {
540        self.set("slop", Value::from(slop))
541    }
542
543    /// Behavior when analysis yields no terms ([`ZeroTermsQuery::None`] or
544    /// [`ZeroTermsQuery::All`]).
545    #[must_use]
546    pub fn zero_terms_query(self, value: ZeroTermsQuery) -> Self {
547        self.set(
548            "zero_terms_query",
549            Value::String(value.as_str().to_string()),
550        )
551    }
552
553    /// Ignore format errors (e.g. analyzing text for a numeric subfield).
554    #[must_use]
555    pub fn lenient(self, lenient: bool) -> Self {
556        self.set("lenient", Value::Bool(lenient))
557    }
558
559    common_opts!(common);
560}
561
562impl<S> AsQuery<S> for MatchQuery<S> {
563    fn into_query(self) -> Option<Query<S>> {
564        let mut opts = self.opts;
565        self.common.write(&mut opts);
566        Some(keyed_value_query(
567            self.wrapper,
568            &self.path,
569            "query",
570            Value::String(self.value),
571            opts,
572        ))
573    }
574}
575
576/// An analyzed full-text field (`text`, `identifier`). No exact `eq`. `Sub` is
577/// a [`WithSubfields`]/[`NoSubfields`] type-state marker gating the subfield
578/// accessors (and the `any_of` / `asc` sugar built on them).
579#[derive(Debug, Clone)]
580pub struct Text<S = Root, Sub = WithSubfields> {
581    path: String,
582    boost: Option<f32>,
583    _marker: PhantomData<fn() -> (S, Sub)>,
584}
585
586impl<S, Sub> Text<S, Sub> {
587    fn handle(path: impl Into<String>) -> Self {
588        Self {
589            path: path.into(),
590            boost: None,
591            _marker: PhantomData,
592        }
593    }
594
595    /// Weight this field for [`multi_match`] (`field^weight`). Has no effect on
596    /// this handle's own `matches` / `match_phrase` clauses, which carry their
597    /// own `boost`.
598    #[must_use]
599    pub fn boosted(mut self, weight: f32) -> Self {
600        self.boost = Some(weight);
601        self
602    }
603
604    /// The field's path as listed in a [`multi_match`] `fields` array —
605    /// `field^weight` when [`boosted`](Self::boosted), else the bare path.
606    pub(crate) fn field_spec(&self) -> String {
607        match self.boost {
608            Some(weight) => format!("{}^{weight}", self.path),
609            None => self.path.clone(),
610        }
611    }
612
613    /// Analyzed match.
614    pub fn matches(&self, value: impl Into<String>) -> MatchQuery<S> {
615        MatchQuery::new("match", &self.path, value.into())
616    }
617
618    /// Analyzed phrase match (terms in order).
619    pub fn match_phrase(&self, value: impl Into<String>) -> MatchQuery<S> {
620        MatchQuery::new("match_phrase", &self.path, value.into())
621    }
622
623    /// Analyzed phrase-prefix match (search-as-you-type).
624    pub fn match_phrase_prefix(&self, value: impl Into<String>) -> MatchQuery<S> {
625        MatchQuery::new("match_phrase_prefix", &self.path, value.into())
626    }
627
628    /// Bool-prefix match — every term a `term` except the last, which is a
629    /// prefix (the other half of search-as-you-type).
630    pub fn match_bool_prefix(&self, value: impl Into<String>) -> MatchQuery<S> {
631        MatchQuery::new("match_bool_prefix", &self.path, value.into())
632    }
633
634    /// Analyzed match tolerant of typos — sugar for
635    /// `matches(v).fuzziness(Fuzziness::Auto)`.
636    pub fn matches_fuzzy(&self, value: impl Into<String>) -> MatchQuery<S> {
637        self.matches(value).fuzziness(Fuzziness::Auto)
638    }
639
640    /// The field has a non-null value.
641    pub fn exists(&self) -> Query<S> {
642        exists_q(&self.path)
643    }
644}
645
646impl<S> Text<S, WithSubfields> {
647    pub fn at(path: impl Into<String>) -> Self {
648        Self::handle(path)
649    }
650
651    /// Exact match against **any** of the given values, on the auto `.keyword`
652    /// subfield. A `terms` query on the analyzed field would match raw tokens,
653    /// which is rarely intended; this targets the exact subfield instead. Only
654    /// in scope when the field carries auto subfields.
655    pub fn any_of(
656        &self,
657        values: impl IntoIterator<Item = impl FlussoValue<kind::Keyword> + serde::Serialize>,
658    ) -> TermsQuery<S> {
659        self.keyword().any_of(values)
660    }
661
662    /// The exact `.keyword` subfield flusso auto-creates on a `text` field —
663    /// for exact `eq` / `any_of`, `wildcard`, `prefix`, and exact sort. (A
664    /// wildcard belongs here, not on the analyzed handle, which matches tokens
665    /// not the whole value.) Only in scope when the field carries auto subfields.
666    pub fn keyword(&self) -> Keyword<S, NoSubfields> {
667        Keyword::leaf(format!("{}.keyword", self.path))
668    }
669
670    /// The case/accent-insensitive `.keyword_lowercase` subfield — for
671    /// case-insensitive exact match and sort. Only in scope when the field
672    /// carries auto subfields.
673    pub fn keyword_lowercase(&self) -> Keyword<S, NoSubfields> {
674        Keyword::leaf(format!("{}.keyword_lowercase", self.path))
675    }
676
677    /// Sort ascending — on the case/accent-insensitive `.keyword_lowercase`
678    /// subfield, since the analyzed field itself isn't sortable. Only in scope
679    /// when the field carries auto subfields.
680    pub fn asc(&self) -> Sort {
681        self.keyword_lowercase().asc()
682    }
683
684    /// Sort descending (on `.keyword_lowercase` — see [`asc`](Self::asc)).
685    pub fn desc(&self) -> Sort {
686        self.keyword_lowercase().desc()
687    }
688}
689
690impl<S> Text<S, NoSubfields> {
691    /// Construct a handle for a field known to have no auto subfields (a
692    /// subfield leaf, or a field the derive resolved as un-subfielded).
693    pub fn leaf(path: impl Into<String>) -> Self {
694        Self::handle(path)
695    }
696}
697
698/// A cross-field full-text query over several [`Text`] fields in the same scope.
699/// Returns a [`MultiMatchQuery`] builder; weight individual fields with
700/// [`Text::boosted`].
701pub fn multi_match<S, Sub>(
702    query: impl Into<String>,
703    fields: impl IntoIterator<Item = Text<S, Sub>>,
704) -> MultiMatchQuery<S> {
705    MultiMatchQuery {
706        query: query.into(),
707        fields: fields.into_iter().map(|f| f.field_spec()).collect(),
708        opts: Map::new(),
709        common: Common::default(),
710        _scope: PhantomData,
711    }
712}
713
714/// A `multi_match` clause: one analyzed `query` over several `fields`, with the
715/// `type` / `operator` / `fuzziness` / `tie_breaker` / `minimum_should_match`
716/// options plus `boost` / `name`.
717#[derive(Debug, Clone)]
718pub struct MultiMatchQuery<S = Root> {
719    query: String,
720    fields: Vec<String>,
721    opts: Map<String, Value>,
722    common: Common,
723    _scope: PhantomData<fn() -> S>,
724}
725
726impl<S> MultiMatchQuery<S> {
727    fn set(mut self, key: &str, value: Value) -> Self {
728        self.opts.insert(key.to_string(), value);
729        self
730    }
731
732    /// The scoring [`MultiMatchType`] (default `BestFields`).
733    #[must_use]
734    pub fn match_type(self, match_type: MultiMatchType) -> Self {
735        self.set("type", Value::String(match_type.as_str().to_string()))
736    }
737
738    /// Combine analyzed terms with [`Operator::And`] or [`Operator::Or`].
739    #[must_use]
740    pub fn operator(self, operator: Operator) -> Self {
741        self.set("operator", Value::String(operator.as_str().to_string()))
742    }
743
744    /// Edit distance ([`Fuzziness::Auto`] is the usual choice).
745    #[must_use]
746    pub fn fuzziness(self, fuzziness: Fuzziness) -> Self {
747        self.set("fuzziness", fuzziness.to_value())
748    }
749
750    /// `tie_breaker` for `best_fields` — how much non-winning fields contribute.
751    #[must_use]
752    pub fn tie_breaker(self, tie_breaker: f32) -> Self {
753        self.set("tie_breaker", Value::from(tie_breaker))
754    }
755
756    /// How many of the analyzed terms must match
757    /// (e.g. `2`, `MinimumShouldMatch::percent(75)`).
758    #[must_use]
759    pub fn minimum_should_match(self, value: impl Into<MinimumShouldMatch>) -> Self {
760        self.set("minimum_should_match", value.into().to_value())
761    }
762
763    common_opts!(common);
764}
765
766impl<S> AsQuery<S> for MultiMatchQuery<S> {
767    fn into_query(self) -> Option<Query<S>> {
768        let mut body = self.opts;
769        body.insert("query".to_string(), Value::String(self.query));
770        body.insert(
771            "fields".to_string(),
772            Value::Array(self.fields.into_iter().map(Value::String).collect()),
773        );
774        self.common.write(&mut body);
775        Some(wrap("multi_match", body))
776    }
777}