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