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
463/// An `enum` field with a declared variant order (`- enum: status` +
464/// `variants: [...]`). It is a `keyword` in every respect — the value ops and
465/// (via [`keyword`](Self::keyword)) the full keyword surface all apply — but its
466/// `.asc()` / `.desc()` sort by the **declared order**, not alphabetically.
467///
468/// The order is prebaked into the index: the field carries a `.sort` subfield
469/// whose normalizer maps each variant to its rank, so a bare
470/// `Ticket::status().asc()` is order-correct with no script. A value not in the
471/// declared set sorts after the known variants.
472///
473/// A bare `enum` with no declared `variants` is a plain [`Keyword`] instead
474/// (the derive only emits `Enum` when an order is declared).
475#[derive(Debug, Clone)]
476pub struct Enum<S = Root, Sub = WithSubfields> {
477    path: String,
478    _marker: PhantomData<fn() -> (S, Sub)>,
479}
480
481impl<S, Sub> Enum<S, Sub> {
482    fn handle(path: impl Into<String>) -> Self {
483        Self {
484            path: path.into(),
485            _marker: PhantomData,
486        }
487    }
488
489    /// The underlying [`Keyword`] handle — the full exact-string surface
490    /// (`eq`/`any_of`/`prefix`/`wildcard`/`regexp`/`fuzzy`/`exists`, plus the
491    /// subfield accessors when `Sub = WithSubfields`). The enum adds only the
492    /// order-aware sort on top.
493    pub fn keyword(&self) -> Keyword<S, Sub> {
494        Keyword::handle(self.path.clone())
495    }
496
497    /// Exact match. Accepts a `String`/`&str` or a `#[derive(FlussoValue)]`
498    /// keyword enum/newtype — matched against its serde string form.
499    pub fn eq(&self, value: impl FlussoValue<kind::Keyword>) -> TermQuery<S> {
500        self.keyword().eq(value)
501    }
502
503    /// Match any of the given values.
504    pub fn any_of(
505        &self,
506        values: impl IntoIterator<Item = impl FlussoValue<kind::Keyword>>,
507    ) -> TermsQuery<S> {
508        self.keyword().any_of(values)
509    }
510
511    /// The field has a non-null value.
512    pub fn exists(&self) -> Query<S> {
513        self.keyword().exists()
514    }
515}
516
517impl<S> Enum<S, WithSubfields> {
518    /// A handle for an enum whose keyword carries flusso's auto subfields.
519    pub fn at(path: impl Into<String>) -> Self {
520        Self::handle(path)
521    }
522}
523
524impl<S> Enum<S, NoSubfields> {
525    /// A handle for an enum whose keyword has no auto subfields.
526    pub fn leaf(path: impl Into<String>) -> Self {
527        Self::handle(path)
528    }
529}
530
531/// The declared order sorts on the prebaked `.sort` subfield (a rank-mapped
532/// keyword), nesting-aware like any field sort.
533impl<S: FlussoDocument, Sub> Sortable for Enum<S, Sub> {
534    fn asc(&self) -> Sort {
535        Sort::field::<S>(
536            &format!("{}.{ENUM_SORT_SUBFIELD}", self.path),
537            SortOrder::Asc,
538        )
539    }
540    fn desc(&self) -> Sort {
541        Sort::field::<S>(
542            &format!("{}.{ENUM_SORT_SUBFIELD}", self.path),
543            SortOrder::Desc,
544        )
545    }
546}
547
548/// The subfield the OpenSearch sink prebakes an ordered enum's rank into; sorting
549/// an [`Enum`] targets it (kept in sync with the sink's `ENUM_SORT_SUBFIELD`).
550const ENUM_SORT_SUBFIELD: &str = "sort";
551
552impl<S> Keyword<S, MapKey> {
553    /// A handle for one key of a dynamic-key `map` (see [`MapKey`]). The value
554    /// ops apply; the subfield accessors and direct sort do not.
555    pub(crate) fn map_key(path: impl Into<String>) -> Self {
556        Self::handle(path)
557    }
558}
559
560impl<S> Keyword<S, WithSubfields> {
561    pub fn at(path: impl Into<String>) -> Self {
562        Self::handle(path)
563    }
564
565    /// The full-text `.text` subfield flusso auto-creates on a `keyword` field
566    /// (analyzed with `flusso_code`), so a keyword is still searchable in a
567    /// search box. Only in scope when the field carries auto subfields.
568    pub fn text(&self) -> Text<S, NoSubfields> {
569        Text::leaf(format!("{}.text", self.path))
570    }
571
572    /// The case/accent-insensitive `.keyword_lowercase` subfield flusso
573    /// auto-creates — for case-insensitive exact match and sort. Only in scope
574    /// when the field carries auto subfields.
575    pub fn keyword_lowercase(&self) -> Keyword<S, NoSubfields> {
576        Keyword::leaf(format!("{}.keyword_lowercase", self.path))
577    }
578}
579
580impl<S> Keyword<S, NoSubfields> {
581    /// Construct a handle for a field known to have no auto subfields (a
582    /// subfield leaf, or a field the derive resolved as un-subfielded).
583    pub fn leaf(path: impl Into<String>) -> Self {
584        Self::handle(path)
585    }
586}
587
588/// A `match`-family clause (`match`, `match_phrase`, `match_phrase_prefix`,
589/// `match_bool_prefix`): the analyzed `query` value plus whichever options the
590/// kind supports, all written under the field as an object. The `kind`
591/// selects the wrapper and which setters are meaningful; unset options are
592/// simply omitted.
593#[derive(Debug, Clone)]
594pub struct MatchQuery<S = Root> {
595    wrapper: &'static str,
596    path: String,
597    value: String,
598    opts: Map<String, Value>,
599    common: Common,
600    _scope: PhantomData<fn() -> S>,
601}
602
603impl<S> MatchQuery<S> {
604    fn new(wrapper: &'static str, path: &str, value: String) -> Self {
605        Self {
606            wrapper,
607            path: path.to_string(),
608            value,
609            opts: Map::new(),
610            common: Common::default(),
611            _scope: PhantomData,
612        }
613    }
614
615    fn set(mut self, key: &str, value: Value) -> Self {
616        self.opts.insert(key.to_string(), value);
617        self
618    }
619
620    /// Edit distance for analyzed terms ([`Fuzziness::Auto`] is the usual choice).
621    #[must_use]
622    pub fn fuzziness(self, fuzziness: Fuzziness) -> Self {
623        self.set("fuzziness", fuzziness.to_value())
624    }
625
626    /// Combine analyzed terms with [`Operator::And`] or [`Operator::Or`]
627    /// (default `Or`).
628    #[must_use]
629    pub fn operator(self, operator: Operator) -> Self {
630        self.set("operator", Value::String(operator.as_str().to_string()))
631    }
632
633    /// How many of the analyzed terms must match
634    /// (e.g. `2`, `MinimumShouldMatch::percent(75)`).
635    #[must_use]
636    pub fn minimum_should_match(self, value: impl Into<MinimumShouldMatch>) -> Self {
637        self.set("minimum_should_match", value.into().to_value())
638    }
639
640    /// Leading characters that must match exactly (fuzzy/prefix matching).
641    #[must_use]
642    pub fn prefix_length(self, prefix_length: u32) -> Self {
643        self.set("prefix_length", Value::from(prefix_length))
644    }
645
646    /// Cap on terms a prefix / fuzzy term expands into.
647    #[must_use]
648    pub fn max_expansions(self, max_expansions: u32) -> Self {
649        self.set("max_expansions", Value::from(max_expansions))
650    }
651
652    /// Override the search analyzer for this clause.
653    #[must_use]
654    pub fn analyzer(self, analyzer: impl Into<String>) -> Self {
655        self.set("analyzer", Value::String(analyzer.into()))
656    }
657
658    /// Phrase `slop` — allowed positional gap (phrase / phrase-prefix).
659    #[must_use]
660    pub fn slop(self, slop: u32) -> Self {
661        self.set("slop", Value::from(slop))
662    }
663
664    /// Behavior when analysis yields no terms ([`ZeroTermsQuery::None`] or
665    /// [`ZeroTermsQuery::All`]).
666    #[must_use]
667    pub fn zero_terms_query(self, value: ZeroTermsQuery) -> Self {
668        self.set(
669            "zero_terms_query",
670            Value::String(value.as_str().to_string()),
671        )
672    }
673
674    /// Ignore format errors (e.g. analyzing text for a numeric subfield).
675    #[must_use]
676    pub fn lenient(self, lenient: bool) -> Self {
677        self.set("lenient", Value::Bool(lenient))
678    }
679
680    common_opts!(common);
681}
682
683impl<S> AsQuery<S> for MatchQuery<S> {
684    fn into_query(self) -> Option<Query<S>> {
685        let mut opts = self.opts;
686        self.common.write(&mut opts);
687        Some(keyed_value_query(
688            self.wrapper,
689            &self.path,
690            "query",
691            Value::String(self.value),
692            opts,
693        ))
694    }
695}
696
697/// An analyzed full-text field (`text`, `identifier`). No exact `eq`. `Sub` is
698/// a [`WithSubfields`]/[`NoSubfields`] type-state marker gating the subfield
699/// accessors (and the `any_of` / `asc` sugar built on them).
700#[derive(Debug, Clone)]
701pub struct Text<S = Root, Sub = WithSubfields> {
702    path: String,
703    boost: Option<f32>,
704    _marker: PhantomData<fn() -> (S, Sub)>,
705}
706
707impl<S, Sub> Text<S, Sub> {
708    fn handle(path: impl Into<String>) -> Self {
709        Self {
710            path: path.into(),
711            boost: None,
712            _marker: PhantomData,
713        }
714    }
715
716    /// Weight this field for [`multi_match`] (`field^weight`). Has no effect on
717    /// this handle's own `matches` / `match_phrase` clauses, which carry their
718    /// own `boost`.
719    #[must_use]
720    pub fn boosted(mut self, weight: f32) -> Self {
721        self.boost = Some(weight);
722        self
723    }
724
725    /// The field's path as listed in a [`multi_match`] `fields` array —
726    /// `field^weight` when [`boosted`](Self::boosted), else the bare path.
727    pub(crate) fn field_spec(&self) -> String {
728        match self.boost {
729            Some(weight) => format!("{}^{weight}", self.path),
730            None => self.path.clone(),
731        }
732    }
733
734    /// Analyzed match.
735    pub fn matches(&self, value: impl Into<String>) -> MatchQuery<S> {
736        MatchQuery::new("match", &self.path, value.into())
737    }
738
739    /// Analyzed phrase match (terms in order).
740    pub fn match_phrase(&self, value: impl Into<String>) -> MatchQuery<S> {
741        MatchQuery::new("match_phrase", &self.path, value.into())
742    }
743
744    /// Analyzed phrase-prefix match (search-as-you-type).
745    pub fn match_phrase_prefix(&self, value: impl Into<String>) -> MatchQuery<S> {
746        MatchQuery::new("match_phrase_prefix", &self.path, value.into())
747    }
748
749    /// Bool-prefix match — every term a `term` except the last, which is a
750    /// prefix (the other half of search-as-you-type).
751    pub fn match_bool_prefix(&self, value: impl Into<String>) -> MatchQuery<S> {
752        MatchQuery::new("match_bool_prefix", &self.path, value.into())
753    }
754
755    /// Analyzed match tolerant of typos — sugar for
756    /// `matches(v).fuzziness(Fuzziness::Auto)`.
757    pub fn matches_fuzzy(&self, value: impl Into<String>) -> MatchQuery<S> {
758        self.matches(value).fuzziness(Fuzziness::Auto)
759    }
760
761    /// The field has a non-null value.
762    pub fn exists(&self) -> Query<S> {
763        exists_q(&self.path)
764    }
765}
766
767impl<S> Text<S, WithSubfields> {
768    pub fn at(path: impl Into<String>) -> Self {
769        Self::handle(path)
770    }
771
772    /// Exact match against **any** of the given values, on the auto `.keyword`
773    /// subfield. A `terms` query on the analyzed field would match raw tokens,
774    /// which is rarely intended; this targets the exact subfield instead. Only
775    /// in scope when the field carries auto subfields.
776    pub fn any_of(
777        &self,
778        values: impl IntoIterator<Item = impl FlussoValue<kind::Keyword>>,
779    ) -> TermsQuery<S> {
780        self.keyword().any_of(values)
781    }
782
783    /// The exact `.keyword` subfield flusso auto-creates on a `text` field —
784    /// for exact `eq` / `any_of`, `wildcard`, `prefix`, and exact sort. (A
785    /// wildcard belongs here, not on the analyzed handle, which matches tokens
786    /// not the whole value.) Only in scope when the field carries auto subfields.
787    pub fn keyword(&self) -> Keyword<S, NoSubfields> {
788        Keyword::leaf(format!("{}.keyword", self.path))
789    }
790
791    /// The case/accent-insensitive `.keyword_lowercase` subfield — for
792    /// case-insensitive exact match and sort. Only in scope when the field
793    /// carries auto subfields.
794    pub fn keyword_lowercase(&self) -> Keyword<S, NoSubfields> {
795        Keyword::leaf(format!("{}.keyword_lowercase", self.path))
796    }
797}
798
799/// Sorting a `text` field goes through its case/accent-insensitive
800/// `.keyword_lowercase` subfield (the analyzed field itself isn't sortable), so
801/// it's [`Sortable`] only when the field carries auto subfields.
802impl<S: FlussoDocument> Sortable for Text<S, WithSubfields> {
803    fn asc(&self) -> Sort {
804        self.keyword_lowercase().asc()
805    }
806    fn desc(&self) -> Sort {
807        self.keyword_lowercase().desc()
808    }
809}
810
811impl<S> Text<S, NoSubfields> {
812    /// Construct a handle for a field known to have no auto subfields (a
813    /// subfield leaf, or a field the derive resolved as un-subfielded).
814    pub fn leaf(path: impl Into<String>) -> Self {
815        Self::handle(path)
816    }
817}
818
819impl<S> Text<S, MapKey> {
820    /// A handle for one key of a dynamic-key `map` (see [`MapKey`]). The
821    /// match/`exists` ops apply; the subfield accessors and direct sort do not.
822    pub(crate) fn map_key(path: impl Into<String>) -> Self {
823        Self::handle(path)
824    }
825}
826
827/// A cross-field full-text query over several [`Text`] fields in the same scope.
828/// Returns a [`MultiMatchQuery`] builder; weight individual fields with
829/// [`Text::boosted`].
830pub fn multi_match<S, Sub>(
831    query: impl Into<String>,
832    fields: impl IntoIterator<Item = Text<S, Sub>>,
833) -> MultiMatchQuery<S> {
834    MultiMatchQuery {
835        query: query.into(),
836        fields: fields.into_iter().map(|f| f.field_spec()).collect(),
837        opts: Map::new(),
838        common: Common::default(),
839        _scope: PhantomData,
840    }
841}
842
843/// A `multi_match` clause: one analyzed `query` over several `fields`, with the
844/// `type` / `operator` / `fuzziness` / `tie_breaker` / `minimum_should_match`
845/// options plus `boost` / `name`.
846#[derive(Debug, Clone)]
847pub struct MultiMatchQuery<S = Root> {
848    query: String,
849    fields: Vec<String>,
850    opts: Map<String, Value>,
851    common: Common,
852    _scope: PhantomData<fn() -> S>,
853}
854
855impl<S> MultiMatchQuery<S> {
856    fn set(mut self, key: &str, value: Value) -> Self {
857        self.opts.insert(key.to_string(), value);
858        self
859    }
860
861    /// The scoring [`MultiMatchType`] (default `BestFields`).
862    #[must_use]
863    pub fn match_type(self, match_type: MultiMatchType) -> Self {
864        self.set("type", Value::String(match_type.as_str().to_string()))
865    }
866
867    /// Combine analyzed terms with [`Operator::And`] or [`Operator::Or`].
868    #[must_use]
869    pub fn operator(self, operator: Operator) -> Self {
870        self.set("operator", Value::String(operator.as_str().to_string()))
871    }
872
873    /// Edit distance ([`Fuzziness::Auto`] is the usual choice).
874    #[must_use]
875    pub fn fuzziness(self, fuzziness: Fuzziness) -> Self {
876        self.set("fuzziness", fuzziness.to_value())
877    }
878
879    /// `tie_breaker` for `best_fields` — how much non-winning fields contribute.
880    #[must_use]
881    pub fn tie_breaker(self, tie_breaker: f32) -> Self {
882        self.set("tie_breaker", Value::from(tie_breaker))
883    }
884
885    /// How many of the analyzed terms must match
886    /// (e.g. `2`, `MinimumShouldMatch::percent(75)`).
887    #[must_use]
888    pub fn minimum_should_match(self, value: impl Into<MinimumShouldMatch>) -> Self {
889        self.set("minimum_should_match", value.into().to_value())
890    }
891
892    common_opts!(common);
893}
894
895impl<S> AsQuery<S> for MultiMatchQuery<S> {
896    fn into_query(self) -> Option<Query<S>> {
897        let mut body = self.opts;
898        body.insert("query".to_string(), Value::String(self.query));
899        body.insert(
900            "fields".to_string(),
901            Value::Array(self.fields.into_iter().map(Value::String).collect()),
902        );
903        self.common.write(&mut body);
904        Some(wrap("multi_match", body))
905    }
906}