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