Skip to main content

luci/query/
ast.rs

1//! Query expression types — the output of JSON parsing and input to execution.
2//!
3//! See [[query-dsl]] and [[architecture-query-execution#Step 3]].
4
5/// Universal query expression — the top level of every search request.
6///
7/// `Scoring` variants produce per-document scores via the Scorer pipeline.
8/// `Ranking` variants compose result sets (fusion, RRF).
9/// Type-safe nesting: Bool clauses accept only `ScoringExpression`,
10/// Fusion sources accept `QueryExpression` (enabling nested fusion).
11///
12/// See [[feature-rrf-retrievers]].
13#[derive(Clone, Debug, PartialEq)]
14pub enum QueryExpression {
15    /// Per-document scoring query (BM25, kNN, bool, etc.).
16    Scoring(ScoringExpression),
17    /// Result-set composition (fusion, RRF).
18    Ranking(RankingExpression),
19}
20
21/// Result-set composition expressions.
22///
23/// See [[feature-rrf-retrievers#RankingExpression]].
24#[derive(Clone, Debug, PartialEq)]
25pub enum RankingExpression {
26    /// Fuse N query sources using a configurable method.
27    ///
28    /// Each source is executed independently, then results are combined
29    /// using the specified fusion method. Sources can be scoring queries
30    /// or nested fusion expressions.
31    Fusion {
32        sources: Vec<QueryExpression>,
33        method: FusionMethod,
34        /// Rank constant k for RRF: `score(d) = Σ 1/(k + rank)`. Default: 60.
35        rank_constant: f32,
36        /// Number of candidates per source before fusion.
37        /// Default: search size.
38        rank_window_size: Option<usize>,
39        /// Per-source weights for score-based fusion methods.
40        /// Default: equal weights (1.0 each).
41        weights: Option<Vec<f32>>,
42    },
43}
44
45/// Fusion method for combining multiple ranked result lists.
46///
47/// See [[feature-rrf-retrievers#FusionMethod]].
48#[derive(Clone, Debug, PartialEq)]
49pub enum FusionMethod {
50    /// Reciprocal Rank Fusion — rank-based, ignores actual scores.
51    /// `score(d) = Σ weight_i / (rank_constant + rank_i(d))`
52    ReciprocalRank,
53    /// Weighted sum of scores.
54    /// `score(d) = Σ weight_i * score_i(d)`
55    Sum,
56    /// Weighted arithmetic mean.
57    ArithmeticMean,
58    /// Weighted harmonic mean.
59    HarmonicMean,
60    /// Weighted geometric mean.
61    GeometricMean,
62}
63
64impl QueryExpression {
65    /// Extract the scoring expression, if this is a Scoring variant.
66    pub fn as_scoring(&self) -> Option<&ScoringExpression> {
67        match self {
68            QueryExpression::Scoring(s) => Some(s),
69            QueryExpression::Ranking(_) => None,
70        }
71    }
72
73    /// Collect all scoring expressions from this query tree.
74    /// For Scoring: returns the single expression.
75    /// For Ranking(Fusion): recursively collects from all sources.
76    pub fn scoring_expressions(&self) -> Vec<&ScoringExpression> {
77        match self {
78            QueryExpression::Scoring(s) => vec![s],
79            QueryExpression::Ranking(RankingExpression::Fusion { sources, .. }) => sources
80                .iter()
81                .flat_map(|s| s.scoring_expressions())
82                .collect(),
83        }
84    }
85}
86
87/// Configuration for inner_hits on nested queries.
88/// See [[feature-search-inner-hits]].
89#[derive(Clone, Debug, PartialEq)]
90pub struct InnerHitsConfig {
91    pub name: Option<String>,
92    pub size: usize,
93    pub from: usize,
94}
95
96/// Typed query expression. Each variant maps to an ES-compatible query type.
97#[derive(Clone, Debug, PartialEq)]
98pub enum ScoringExpression {
99    /// Exact term match on a field (no analysis).
100    Term { field: String, value: String },
101
102    /// Match any of a set of exact values.
103    Terms { field: String, values: Vec<String> },
104
105    /// Full-text match: analyze query text, build bool of term queries.
106    Match {
107        field: String,
108        query: String,
109        analyzer: Option<String>,
110    },
111
112    /// Phrase match: terms must appear in order at consecutive positions.
113    MatchPhrase {
114        field: String,
115        query: String,
116        analyzer: Option<String>,
117    },
118
119    /// Match bool prefix: analyzes text, all tokens become term queries
120    /// except the last which becomes a prefix query. Rewrites to bool/should.
121    MatchBoolPrefix {
122        field: String,
123        query: String,
124        analyzer: Option<String>,
125    },
126
127    /// Boolean combination of sub-queries.
128    ///
129    /// `minimum_should_match` requires at least N should clauses to match.
130    /// Default behavior: if must/filter present and msm is None, should
131    /// clauses are optional (boost only). If no must/filter and msm is
132    /// None, at least 1 should must match.
133    ///
134    /// See [[feature-minimum-should-match]].
135    Bool {
136        must: Vec<ScoringExpression>,
137        should: Vec<ScoringExpression>,
138        must_not: Vec<ScoringExpression>,
139        filter: Vec<ScoringExpression>,
140        minimum_should_match: Option<u32>,
141    },
142
143    /// Disjunction max: return the score from the best-matching sub-query,
144    /// plus tie_breaker * sum of other matching scores.
145    DisMax {
146        queries: Vec<ScoringExpression>,
147        tie_breaker: f32,
148    },
149
150    /// Field exists (has a non-null value).
151    Exists { field: String },
152
153    /// Prefix match on a field's terms.
154    Prefix { field: String, value: String },
155
156    /// Wrap a query in filter context with a constant score.
157    ConstantScore {
158        query: Box<ScoringExpression>,
159        boost: f32,
160    },
161
162    /// Nested query: search within nested document arrays.
163    Nested {
164        path: String,
165        query: Box<ScoringExpression>,
166        inner_hits: Option<InnerHitsConfig>,
167    },
168
169    /// Geographic distance query.
170    GeoDistance {
171        field: String,
172        lat: f64,
173        lon: f64,
174        distance: String, // e.g. "10km"
175    },
176
177    /// Geographic bounding box query.
178    GeoBoundingBox {
179        field: String,
180        top_left_lat: f64,
181        top_left_lon: f64,
182        bottom_right_lat: f64,
183        bottom_right_lon: f64,
184    },
185
186    /// Geographic shape query with spatial relation.
187    ///
188    /// See [[feature-geo-shape]] and [[de-9im]].
189    GeoShape {
190        field: String,
191        shape: GeoShapeValue,
192        relation: SpatialRelation,
193    },
194
195    /// Numeric range query on a field.
196    Range {
197        field: String,
198        gte: Option<f64>,
199        gt: Option<f64>,
200        lte: Option<f64>,
201        lt: Option<f64>,
202    },
203
204    /// Boost wrapper: multiplies inner query's score by a factor.
205    Boost {
206        query: Box<ScoringExpression>,
207        boost: f32,
208    },
209
210    /// Script score: compiled expression-based custom scoring.
211    ScriptScore {
212        query: Box<ScoringExpression>,
213        script: String,
214        params: std::collections::HashMap<String, f64>,
215    },
216
217    /// Function score: modify query scores with scoring functions.
218    FunctionScore {
219        query: Box<ScoringExpression>,
220        functions: Vec<ScoreFunction>,
221        score_mode: FunctionScoreMode,
222        boost_mode: FunctionBoostMode,
223    },
224
225    /// Boosting: positive query with score demotion for negative matches.
226    Boosting {
227        positive: Box<ScoringExpression>,
228        negative: Box<ScoringExpression>,
229        negative_boost: f32,
230    },
231
232    /// Fuzzy match: terms within edit distance.
233    Fuzzy {
234        field: String,
235        value: String,
236        fuzziness: u32,
237    },
238
239    /// Regexp match on term dictionary.
240    Regexp { field: String, value: String },
241
242    /// Wildcard match: * (any chars) and ? (single char).
243    Wildcard { field: String, value: String },
244
245    /// Multi-field match: search the same query across multiple fields.
246    /// Defaults to `best_fields` (dis_max with tie_breaker=0.0).
247    MultiMatch {
248        fields: Vec<String>,
249        query: String,
250        analyzer: Option<String>,
251        tie_breaker: f32,
252    },
253
254    /// Span-typed query (position-aware). The inner
255    /// [`SpanExpression`] enumerates the concrete span variants; this
256    /// single wrapper parallels [`QueryExpression::Scoring`] wrapping
257    /// a `ScoringExpression` — one concept, one representation.
258    Span(SpanExpression),
259
260    /// Approximate k-nearest-neighbor search on a dense_vector field.
261    ///
262    /// Results are pre-materialized at bind time via HNSW graph traversal.
263    /// The scorer iterates a small sorted array of (doc_id, score) pairs.
264    /// Score conversion is metric-specific (see [`crate::vector::distance_to_score`]).
265    ///
266    /// See [[feature-knn-query-type]].
267    Knn {
268        field: String,
269        query_vector: Vec<f32>,
270        k: usize,
271        num_candidates: usize,
272        /// Minimum score threshold. Results with score < threshold are excluded.
273        threshold: Option<f32>,
274    },
275
276    /// Match all documents.
277    MatchAll,
278
279    /// Match no documents.
280    MatchNone,
281}
282
283/// Span-typed expression — the subset of queries that yield
284/// `(doc, start, end)` span tuples and can be composed by span
285/// operators (`SpanFirst`, `SpanNot`, `SpanNear`).
286///
287/// The parser produces this type for `span_first.match` and
288/// `span_not.include / exclude`, so the AST makes it unrepresentable
289/// to place a non-span query inside a span composition operator —
290/// the mistake would require constructing an invalid enum variant,
291/// which doesn't type-check. This replaces runtime dispatch and
292/// default-trait-error rejection in the engine.
293#[derive(Clone, Debug, PartialEq)]
294pub enum SpanExpression {
295    /// Single term with position awareness.
296    SpanTerm { field: String, value: String },
297    /// Terms within slop positions, optionally ordered.
298    SpanNear {
299        field: String,
300        terms: Vec<String>,
301        slop: u32,
302        in_order: bool,
303    },
304    /// Include spans minus exclude spans (doc-level overlap in Luci).
305    SpanNot {
306        include: Box<SpanExpression>,
307        exclude: Box<SpanExpression>,
308    },
309    /// Spans ending at position ≤ end.
310    SpanFirst {
311        query: Box<SpanExpression>,
312        end: u32,
313    },
314}
315
316/// A scoring function for function_score queries.
317#[derive(Clone, Debug, PartialEq)]
318pub enum ScoreFunction {
319    /// Multiply score by a constant weight.
320    Weight(f32),
321    /// Score based on a numeric field's value.
322    FieldValueFactor {
323        field: String,
324        factor: f32,
325        modifier: FieldValueModifier,
326        missing: f64,
327    },
328    /// Random score (deterministic per seed + doc).
329    RandomScore { seed: u64 },
330}
331
332/// Modifier for field_value_factor.
333#[derive(Clone, Debug, PartialEq)]
334pub enum FieldValueModifier {
335    None,
336    Log1p,
337    Log2p,
338    Ln1p,
339    Ln2p,
340    Sqrt,
341    Square,
342    Reciprocal,
343}
344
345/// How to combine multiple function scores.
346#[derive(Clone, Debug, PartialEq)]
347pub enum FunctionScoreMode {
348    Multiply,
349    Sum,
350    Avg,
351    First,
352    Max,
353    Min,
354}
355
356/// How to combine the function result with the query score.
357#[derive(Clone, Debug, PartialEq)]
358pub enum FunctionBoostMode {
359    Multiply,
360    Replace,
361    Sum,
362    Avg,
363    Max,
364    Min,
365}
366
367/// Spatial relation for geo_shape queries.
368///
369/// Supports the full [[ogc-simple-features]] / [[de-9im]] predicate set.
370#[derive(Clone, Debug, PartialEq)]
371pub enum SpatialRelation {
372    /// Query and document shapes share any area or boundary (default).
373    Intersects,
374    /// Document shape is entirely inside the query shape.
375    Within,
376    /// Document shape entirely contains the query shape.
377    Contains,
378    /// Query and document shapes share no area or boundary.
379    Disjoint,
380    /// Shapes meet at boundary but don't overlap interiors.
381    Touches,
382    /// Interiors intersect but neither contains the other.
383    Crosses,
384    /// Shared interior area, same dimension, neither contains the other.
385    Overlaps,
386    /// Geometries are topologically identical.
387    Equals,
388    /// Like Contains but includes boundary contact.
389    Covers,
390    /// Like Within but includes boundary contact.
391    CoveredBy,
392    /// Interior of document shape contains all of query shape (strict).
393    ContainsProperly,
394}
395
396/// A parsed GeoJSON shape for use in geo_shape queries.
397#[derive(Clone, Debug, PartialEq)]
398pub struct GeoShapeValue {
399    /// The raw GeoJSON shape object (type + coordinates).
400    pub json: serde_json::Value,
401}
402
403impl ScoringExpression {
404    /// Convenience constructor for a bool query.
405    pub fn bool_query(
406        must: Vec<ScoringExpression>,
407        should: Vec<ScoringExpression>,
408        must_not: Vec<ScoringExpression>,
409        filter: Vec<ScoringExpression>,
410    ) -> Self {
411        Self::Bool {
412            must,
413            should,
414            must_not,
415            filter,
416            minimum_should_match: None,
417        }
418    }
419}
420
421impl super::Query for ScoringExpression {
422    /// Bind this query expression to index statistics, producing a BoundQuery.
423    ///
424    /// Recursively compiles the expression tree into executable form,
425    /// binding to the Searcher's index-level statistics (IDF, avg field
426    /// lengths, etc.). Each variant directly constructs the concrete query
427    /// struct and binds it, bypassing the `ast_to_query` indirection.
428    /// Sub-queries bind recursively via `ScoringExpression::bind`.
429    fn bind(
430        &self,
431        searcher: &crate::search::searcher::Searcher,
432        score_mode: crate::core::ScoreMode,
433    ) -> crate::core::Result<Box<dyn super::BoundQuery>> {
434        match self {
435            // --- Simple term-level queries ---
436            ScoringExpression::Term { field, value } => crate::query::term::TermQuery {
437                field: field.clone(),
438                value: value.clone(),
439            }
440            .bind(searcher, score_mode),
441
442            ScoringExpression::Terms { field, values } => {
443                // Rewrite to Bool { should: [Term * N] }
444                let should: Vec<ScoringExpression> = values
445                    .iter()
446                    .map(|v| ScoringExpression::Term {
447                        field: field.clone(),
448                        value: v.clone(),
449                    })
450                    .collect();
451                ScoringExpression::Bool {
452                    must: vec![],
453                    should,
454                    must_not: vec![],
455                    filter: vec![],
456                    minimum_should_match: None,
457                }
458                .bind(searcher, score_mode)
459            }
460
461            // --- Full-text queries ---
462            ScoringExpression::Match {
463                field,
464                query,
465                analyzer,
466            } => crate::query::match_query::MatchQuery {
467                field: field.clone(),
468                query_text: query.clone(),
469                analyzer: analyzer.clone(),
470            }
471            .bind(searcher, score_mode),
472
473            ScoringExpression::MatchPhrase {
474                field,
475                query,
476                analyzer,
477            } => crate::query::phrase::MatchPhraseQuery {
478                field: field.clone(),
479                query_text: query.clone(),
480                analyzer: analyzer.clone(),
481            }
482            .bind(searcher, score_mode),
483
484            ScoringExpression::MatchBoolPrefix {
485                field,
486                query,
487                analyzer,
488            } => {
489                // Rewrite: analyze text using the searcher's analyzer
490                // registry (must respect custom and field-configured
491                // analyzers), all tokens -> term queries, last ->
492                // prefix query, wrap in Bool/should.
493                // See [[investigation-20260405-06-match-bool-prefix-analyzer]].
494                let analyzer_name = searcher.resolve_search_analyzer(field, analyzer.as_deref());
495                let analyzers = searcher.analyzers();
496                let analyzer_impl = analyzers.get(analyzer_name);
497                let analyzed = analyzer_impl.analyze(query);
498                let tokens: Vec<String> = analyzed.into_iter().map(|t| t.text).collect();
499                if tokens.is_empty() {
500                    crate::query::convert::MatchNoneQuery.bind(searcher, score_mode)
501                } else {
502                    let mut should: Vec<ScoringExpression> = Vec::new();
503                    for (i, token) in tokens.iter().enumerate() {
504                        if i == tokens.len() - 1 {
505                            should.push(ScoringExpression::Prefix {
506                                field: field.clone(),
507                                value: token.clone(),
508                            });
509                        } else {
510                            should.push(ScoringExpression::Term {
511                                field: field.clone(),
512                                value: token.clone(),
513                            });
514                        }
515                    }
516                    ScoringExpression::Bool {
517                        must: vec![],
518                        should,
519                        must_not: vec![],
520                        filter: vec![],
521                        minimum_should_match: None,
522                    }
523                    .bind(searcher, score_mode)
524                }
525            }
526
527            ScoringExpression::MultiMatch {
528                fields,
529                query,
530                analyzer,
531                tie_breaker,
532            } => {
533                // Rewrite to DisMax (best_fields). With tie_breaker=0.0
534                // (default), score = max(field_scores). With tie_breaker=1.0
535                // (most_fields), score = sum(field_scores).
536                // See [[investigation-20260405-04-multi-match-wrong-rewrite]].
537                let queries: Vec<ScoringExpression> = fields
538                    .iter()
539                    .map(|f| ScoringExpression::Match {
540                        field: f.clone(),
541                        query: query.clone(),
542                        analyzer: analyzer.clone(),
543                    })
544                    .collect();
545                ScoringExpression::DisMax {
546                    queries,
547                    tie_breaker: *tie_breaker,
548                }
549                .bind(searcher, score_mode)
550            }
551
552            // --- Compound queries (recursive) ---
553            ScoringExpression::Bool {
554                must,
555                should,
556                must_not,
557                filter,
558                minimum_should_match,
559            } => crate::query::boolean::BoolQuery {
560                must: must
561                    .iter()
562                    .map(|q| -> Box<dyn super::Query> { Box::new(q.clone()) })
563                    .collect(),
564                should: should
565                    .iter()
566                    .map(|q| -> Box<dyn super::Query> { Box::new(q.clone()) })
567                    .collect(),
568                must_not: must_not
569                    .iter()
570                    .map(|q| -> Box<dyn super::Query> { Box::new(q.clone()) })
571                    .collect(),
572                filter: filter
573                    .iter()
574                    .map(|q| -> Box<dyn super::Query> { Box::new(q.clone()) })
575                    .collect(),
576                minimum_should_match: *minimum_should_match,
577            }
578            .bind(searcher, score_mode),
579
580            ScoringExpression::DisMax {
581                queries,
582                tie_breaker,
583            } => crate::query::dis_max::DisMaxQuery {
584                queries: queries
585                    .iter()
586                    .map(|q| -> Box<dyn super::Query> { Box::new(q.clone()) })
587                    .collect(),
588                tie_breaker: *tie_breaker,
589            }
590            .bind(searcher, score_mode),
591
592            ScoringExpression::ConstantScore { query, boost } => {
593                crate::query::constant_score::ConstantScoreQuery {
594                    inner: Box::new(query.as_ref().clone()),
595                    boost: *boost,
596                }
597                .bind(searcher, score_mode)
598            }
599
600            ScoringExpression::Boost { query, boost } => crate::query::boost::BoostQuery {
601                inner: Box::new(query.as_ref().clone()),
602                boost: *boost,
603            }
604            .bind(searcher, score_mode),
605
606            ScoringExpression::Boosting {
607                positive,
608                negative,
609                negative_boost,
610            } => crate::query::boosting::BoostingQuery {
611                positive: Box::new(positive.as_ref().clone()),
612                negative: Box::new(negative.as_ref().clone()),
613                negative_boost: *negative_boost,
614            }
615            .bind(searcher, score_mode),
616
617            ScoringExpression::ScriptScore {
618                query,
619                script,
620                params,
621            } => crate::query::script_score::ScriptScoreQuery {
622                query: Box::new(query.as_ref().clone()),
623                script: script.clone(),
624                params: params.clone(),
625            }
626            .bind(searcher, score_mode),
627
628            ScoringExpression::FunctionScore {
629                query,
630                functions,
631                score_mode: fn_score_mode,
632                boost_mode,
633            } => crate::query::function_score::FunctionScoreQuery {
634                query: Box::new(query.as_ref().clone()),
635                functions: functions.clone(),
636                score_mode: fn_score_mode.clone(),
637                boost_mode: boost_mode.clone(),
638            }
639            .bind(searcher, score_mode),
640
641            ScoringExpression::Nested {
642                path,
643                query,
644                inner_hits,
645            } => crate::query::nested::NestedQuery {
646                path: path.clone(),
647                inner: Box::new(query.as_ref().clone()),
648                inner_hits: inner_hits.clone(),
649            }
650            .bind(searcher, score_mode),
651
652            // --- Filter-context queries ---
653            ScoringExpression::Exists { field } => crate::query::exists::ExistsQuery {
654                field: field.clone(),
655            }
656            .bind(searcher, score_mode),
657
658            ScoringExpression::Prefix { field, value } => crate::query::prefix::PrefixQuery {
659                field: field.clone(),
660                value: value.clone(),
661            }
662            .bind(searcher, score_mode),
663
664            ScoringExpression::Range {
665                field,
666                gte,
667                gt,
668                lte,
669                lt,
670            } => crate::query::range::RangeQuery {
671                field: field.clone(),
672                gte: *gte,
673                gt: *gt,
674                lte: *lte,
675                lt: *lt,
676            }
677            .bind(searcher, score_mode),
678
679            // --- Rewrite queries (scan term dict, then bind) ---
680            ScoringExpression::Fuzzy {
681                field,
682                value,
683                fuzziness,
684            } => crate::query::fuzzy::FuzzyQuery {
685                field: field.clone(),
686                value: value.clone(),
687                fuzziness: *fuzziness,
688            }
689            .bind(searcher, score_mode),
690
691            ScoringExpression::Wildcard { field, value } => crate::query::wildcard::WildcardQuery {
692                field: field.clone(),
693                pattern: value.clone(),
694            }
695            .bind(searcher, score_mode),
696
697            ScoringExpression::Regexp { field, value } => crate::query::regexp::RegexpQuery {
698                field: field.clone(),
699                pattern: value.clone(),
700            }
701            .bind(searcher, score_mode),
702
703            // --- Geo queries ---
704            ScoringExpression::GeoDistance {
705                field,
706                lat,
707                lon,
708                distance,
709            } => {
710                let distance_km = crate::query::parser::parse_distance_km(distance);
711                crate::spatial::query::GeoDistanceQuery {
712                    field: field.clone(),
713                    center: crate::spatial::geo::GeoPoint::new(*lat, *lon),
714                    distance_km,
715                }
716                .bind(searcher, score_mode)
717            }
718
719            ScoringExpression::GeoBoundingBox {
720                field,
721                top_left_lat,
722                top_left_lon,
723                bottom_right_lat,
724                bottom_right_lon,
725            } => crate::spatial::query::GeoBoundingBoxQuery {
726                field: field.clone(),
727                top_left: crate::spatial::geo::GeoPoint::new(*top_left_lat, *top_left_lon),
728                bottom_right: crate::spatial::geo::GeoPoint::new(
729                    *bottom_right_lat,
730                    *bottom_right_lon,
731                ),
732            }
733            .bind(searcher, score_mode),
734
735            ScoringExpression::GeoShape {
736                field,
737                shape,
738                relation,
739            } => {
740                let query_geom = crate::spatial::shape::parse_geojson(&shape.json)
741                    .unwrap_or(::geo::Geometry::Point(::geo::Point::new(0.0, 0.0)));
742                let query_bbox = crate::spatial::shape::compute_bbox(&query_geom)
743                    .unwrap_or((0.0, 0.0, 0.0, 0.0));
744                crate::spatial::query::GeoShapeQuery {
745                    field: field.clone(),
746                    query_shape: query_geom,
747                    query_bbox,
748                    relation: relation.clone(),
749                }
750                .bind(searcher, score_mode)
751            }
752
753            // --- Span queries ---
754            // Single Span(...) wrapper delegates to the span runtime
755            // layer — mirrors QueryExpression::Scoring(ScoringExpression).
756            ScoringExpression::Span(span_ast) => {
757                Ok(crate::query::convert::ast_to_span_query(span_ast)
758                    .bind_span(searcher, score_mode)?)
759            }
760
761            // --- Vector queries ---
762            ScoringExpression::Knn {
763                field,
764                query_vector,
765                k,
766                num_candidates,
767                threshold,
768            } => crate::vector::query::KnnQuery {
769                field: field.clone(),
770                query_vector: query_vector.clone(),
771                k: *k,
772                num_candidates: *num_candidates,
773                threshold: *threshold,
774            }
775            .bind(searcher, score_mode),
776
777            // --- Terminal queries ---
778            ScoringExpression::MatchAll => {
779                crate::query::convert::MatchAllQuery.bind(searcher, score_mode)
780            }
781
782            ScoringExpression::MatchNone => {
783                crate::query::convert::MatchNoneQuery.bind(searcher, score_mode)
784            }
785        }
786    }
787}
788
789#[cfg(test)]
790mod tests {
791    use super::*;
792
793    #[test]
794    fn term_query() {
795        let q = ScoringExpression::Term {
796            field: "status".into(),
797            value: "active".into(),
798        };
799        assert_eq!(
800            q,
801            ScoringExpression::Term {
802                field: "status".into(),
803                value: "active".into()
804            }
805        );
806    }
807
808    #[test]
809    fn bool_query_construction() {
810        let q = ScoringExpression::bool_query(
811            vec![ScoringExpression::Term {
812                field: "f".into(),
813                value: "v".into(),
814            }],
815            vec![],
816            vec![],
817            vec![],
818        );
819        if let ScoringExpression::Bool { must, .. } = &q {
820            assert_eq!(must.len(), 1);
821        } else {
822            panic!("expected Bool");
823        }
824    }
825
826    #[test]
827    fn nested_bool() {
828        let inner = ScoringExpression::bool_query(
829            vec![ScoringExpression::MatchAll],
830            vec![],
831            vec![],
832            vec![],
833        );
834        let outer = ScoringExpression::bool_query(vec![inner], vec![], vec![], vec![]);
835        if let ScoringExpression::Bool { must, .. } = &outer {
836            assert!(matches!(&must[0], ScoringExpression::Bool { .. }));
837        }
838    }
839
840    #[test]
841    fn constant_score_wraps() {
842        let q = ScoringExpression::ConstantScore {
843            query: Box::new(ScoringExpression::Term {
844                field: "f".into(),
845                value: "v".into(),
846            }),
847            boost: 1.5,
848        };
849        if let ScoringExpression::ConstantScore { boost, .. } = q {
850            assert_eq!(boost, 1.5);
851        }
852    }
853
854    #[test]
855    fn match_query() {
856        let q = ScoringExpression::Match {
857            field: "body".into(),
858            query: "search engine".into(),
859            analyzer: None,
860        };
861        if let ScoringExpression::Match { field, query, .. } = &q {
862            assert_eq!(field, "body");
863            assert_eq!(query, "search engine");
864        }
865    }
866
867    #[test]
868    fn debug_format() {
869        let q = ScoringExpression::MatchAll;
870        let s = format!("{q:?}");
871        assert!(s.contains("MatchAll"));
872    }
873
874    #[test]
875    fn clone_works() {
876        let q = ScoringExpression::Term {
877            field: "f".into(),
878            value: "v".into(),
879        };
880        let q2 = q.clone();
881        assert_eq!(q, q2);
882    }
883
884    #[test]
885    fn terms_query() {
886        let q = ScoringExpression::Terms {
887            field: "tags".into(),
888            values: vec!["a".into(), "b".into(), "c".into()],
889        };
890        if let ScoringExpression::Terms { values, .. } = &q {
891            assert_eq!(values.len(), 3);
892        }
893    }
894}