Skip to main content

luci/query/
convert.rs

1//! Convert [`ScoringExpression`] trees to executable [`Query`] objects.
2//!
3//! Bridges the JSON parser output to the execution pipeline.
4
5use crate::query::ast::{ScoringExpression, SpanExpression};
6use crate::query::boolean::BoolQuery;
7use crate::query::constant_score::ConstantScoreQuery;
8use crate::query::exists::ExistsQuery;
9use crate::query::match_query::MatchQuery;
10use crate::query::phrase::MatchPhraseQuery;
11use crate::query::prefix::PrefixQuery;
12use crate::query::range::RangeQuery;
13use crate::query::span::{SpanFirstQuery, SpanNearQuery, SpanNotQuery, SpanTermQuery};
14use crate::query::term::TermQuery;
15use crate::query::{Query, SpanQuery};
16
17/// Convert a parsed [`ScoringExpression`] tree into an executable [`Query`] trait object.
18#[allow(dead_code)] // used in unit tests and available for future callers
19pub(crate) fn ast_to_query(ast: &ScoringExpression) -> Box<dyn Query> {
20    match ast {
21        ScoringExpression::Term { field, value } => Box::new(TermQuery {
22            field: field.clone(),
23            value: value.clone(),
24        }),
25
26        ScoringExpression::Terms { field, values } => {
27            // Rewrite to Bool { should: [TermQuery * N] }
28            let should: Vec<Box<dyn Query>> = values
29                .iter()
30                .map(|v| -> Box<dyn Query> {
31                    Box::new(TermQuery {
32                        field: field.clone(),
33                        value: v.clone(),
34                    })
35                })
36                .collect();
37            Box::new(BoolQuery {
38                must: vec![],
39                should,
40                must_not: vec![],
41                filter: vec![],
42                minimum_should_match: None,
43            })
44        }
45
46        ScoringExpression::Match {
47            field,
48            query,
49            analyzer,
50        } => Box::new(MatchQuery {
51            field: field.clone(),
52            query_text: query.clone(),
53            analyzer: analyzer.clone(),
54        }),
55
56        ScoringExpression::MatchPhrase {
57            field,
58            query,
59            analyzer,
60        } => Box::new(MatchPhraseQuery {
61            field: field.clone(),
62            query_text: query.clone(),
63            analyzer: analyzer.clone(),
64        }),
65
66        ScoringExpression::Bool {
67            must,
68            should,
69            must_not,
70            filter,
71            minimum_should_match,
72        } => Box::new(BoolQuery {
73            must: must.iter().map(ast_to_query).collect(),
74            should: should.iter().map(ast_to_query).collect(),
75            must_not: must_not.iter().map(ast_to_query).collect(),
76            filter: filter.iter().map(ast_to_query).collect(),
77            minimum_should_match: *minimum_should_match,
78        }),
79
80        ScoringExpression::MatchBoolPrefix {
81            field,
82            query,
83            analyzer,
84        } => {
85            // Wrap in MatchBoolPrefixQuery so analysis is deferred to bind()
86            // where the searcher's analyzer registry is available. The eager
87            // analysis here would otherwise miss custom and field-configured
88            // analyzers. See [[investigation-20260405-06-match-bool-prefix-analyzer]].
89            Box::new(crate::query::match_query::MatchBoolPrefixQuery {
90                field: field.clone(),
91                query_text: query.clone(),
92                analyzer: analyzer.clone(),
93            })
94        }
95
96        ScoringExpression::DisMax {
97            queries,
98            tie_breaker,
99        } => Box::new(crate::query::dis_max::DisMaxQuery {
100            queries: queries.iter().map(ast_to_query).collect(),
101            tie_breaker: *tie_breaker,
102        }),
103
104        ScoringExpression::Exists { field } => Box::new(ExistsQuery {
105            field: field.clone(),
106        }),
107
108        ScoringExpression::Prefix { field, value } => Box::new(PrefixQuery {
109            field: field.clone(),
110            value: value.clone(),
111        }),
112
113        ScoringExpression::ConstantScore { query, boost } => Box::new(ConstantScoreQuery {
114            inner: ast_to_query(query),
115            boost: *boost,
116        }),
117
118        ScoringExpression::Nested {
119            path,
120            query,
121            inner_hits,
122        } => Box::new(crate::query::nested::NestedQuery {
123            path: path.clone(),
124            inner: ast_to_query(query),
125            inner_hits: inner_hits.clone(),
126        }),
127
128        ScoringExpression::GeoDistance {
129            field,
130            lat,
131            lon,
132            distance,
133        } => {
134            let distance_km = crate::query::parser::parse_distance_km(distance);
135            Box::new(crate::spatial::query::GeoDistanceQuery {
136                field: field.clone(),
137                center: crate::spatial::geo::GeoPoint::new(*lat, *lon),
138                distance_km,
139            })
140        }
141
142        ScoringExpression::GeoBoundingBox {
143            field,
144            top_left_lat,
145            top_left_lon,
146            bottom_right_lat,
147            bottom_right_lon,
148        } => Box::new(crate::spatial::query::GeoBoundingBoxQuery {
149            field: field.clone(),
150            top_left: crate::spatial::geo::GeoPoint::new(*top_left_lat, *top_left_lon),
151            bottom_right: crate::spatial::geo::GeoPoint::new(*bottom_right_lat, *bottom_right_lon),
152        }),
153
154        ScoringExpression::GeoShape {
155            field,
156            shape,
157            relation,
158        } => {
159            let query_geom = crate::spatial::shape::parse_geojson(&shape.json)
160                .unwrap_or(::geo::Geometry::Point(::geo::Point::new(0.0, 0.0)));
161            let query_bbox =
162                crate::spatial::shape::compute_bbox(&query_geom).unwrap_or((0.0, 0.0, 0.0, 0.0));
163            Box::new(crate::spatial::query::GeoShapeQuery {
164                field: field.clone(),
165                query_shape: query_geom,
166                query_bbox,
167                relation: relation.clone(),
168            })
169        }
170
171        ScoringExpression::Range {
172            field,
173            gte,
174            gt,
175            lte,
176            lt,
177        } => Box::new(RangeQuery {
178            field: field.clone(),
179            gte: *gte,
180            gt: *gt,
181            lte: *lte,
182            lt: *lt,
183        }),
184
185        ScoringExpression::Boost { query, boost } => Box::new(crate::query::boost::BoostQuery {
186            inner: ast_to_query(query),
187            boost: *boost,
188        }),
189
190        ScoringExpression::ScriptScore {
191            query,
192            script,
193            params,
194        } => Box::new(crate::query::script_score::ScriptScoreQuery {
195            query: ast_to_query(query),
196            script: script.clone(),
197            params: params.clone(),
198        }),
199
200        ScoringExpression::FunctionScore {
201            query,
202            functions,
203            score_mode,
204            boost_mode,
205        } => Box::new(crate::query::function_score::FunctionScoreQuery {
206            query: ast_to_query(query),
207            functions: functions.clone(),
208            score_mode: score_mode.clone(),
209            boost_mode: boost_mode.clone(),
210        }),
211
212        ScoringExpression::Boosting {
213            positive,
214            negative,
215            negative_boost,
216        } => Box::new(crate::query::boosting::BoostingQuery {
217            positive: ast_to_query(positive),
218            negative: ast_to_query(negative),
219            negative_boost: *negative_boost,
220        }),
221
222        ScoringExpression::Fuzzy {
223            field,
224            value,
225            fuzziness,
226        } => Box::new(crate::query::fuzzy::FuzzyQuery {
227            field: field.clone(),
228            value: value.clone(),
229            fuzziness: *fuzziness,
230        }),
231
232        ScoringExpression::Regexp { field, value } => Box::new(crate::query::regexp::RegexpQuery {
233            field: field.clone(),
234            pattern: value.clone(),
235        }),
236
237        ScoringExpression::Wildcard { field, value } => {
238            Box::new(crate::query::wildcard::WildcardQuery {
239                field: field.clone(),
240                pattern: value.clone(),
241            })
242        }
243
244        ScoringExpression::MultiMatch {
245            fields,
246            query,
247            analyzer,
248            tie_breaker,
249        } => {
250            let queries: Vec<Box<dyn Query>> = fields
251                .iter()
252                .map(|f| -> Box<dyn Query> {
253                    Box::new(MatchQuery {
254                        field: f.clone(),
255                        query_text: query.clone(),
256                        analyzer: analyzer.clone(),
257                    })
258                })
259                .collect();
260            Box::new(crate::query::dis_max::DisMaxQuery {
261                queries,
262                tie_breaker: *tie_breaker,
263            })
264        }
265
266        // Single Span(...) arm mirrors QueryExpression::Scoring(...)
267        // one-level delegation. All span variants live inside
268        // SpanExpression; ast_to_span_query dispatches to their runtime
269        // form, which coerces to Box<dyn Query> via trait upcasting
270        // (SpanQuery: Query).
271        ScoringExpression::Span(span_ast) => ast_to_span_query(span_ast),
272
273        ScoringExpression::Knn {
274            field,
275            query_vector,
276            k,
277            num_candidates,
278            threshold,
279        } => Box::new(crate::vector::query::KnnQuery {
280            field: field.clone(),
281            query_vector: query_vector.clone(),
282            k: *k,
283            num_candidates: *num_candidates,
284            threshold: *threshold,
285        }),
286
287        ScoringExpression::MatchAll => Box::new(MatchAllQuery),
288
289        ScoringExpression::MatchNone => Box::new(MatchNoneQuery),
290    }
291}
292
293/// Convert a parsed [`SpanExpression`] tree into a runtime
294/// [`SpanQuery`]. The AST's span-only typing guarantees every
295/// downstream query is also span-typed — no runtime rejection
296/// needed. This is the bridge used by ``SpanFirst.query`` and
297/// ``SpanNot.{include,exclude}`` when crossing from the AST
298/// layer to the runtime layer.
299pub(crate) fn ast_to_span_query(ast: &SpanExpression) -> Box<dyn SpanQuery> {
300    match ast {
301        SpanExpression::SpanTerm { field, value } => Box::new(SpanTermQuery {
302            field: field.clone(),
303            value: value.clone(),
304        }),
305        SpanExpression::SpanNear {
306            field,
307            terms,
308            slop,
309            in_order,
310        } => Box::new(SpanNearQuery {
311            field: field.clone(),
312            terms: terms.clone(),
313            slop: *slop,
314            in_order: *in_order,
315        }),
316        SpanExpression::SpanNot { include, exclude } => Box::new(SpanNotQuery {
317            include: ast_to_span_query(include),
318            exclude: ast_to_span_query(exclude),
319        }),
320        SpanExpression::SpanFirst { query, end } => Box::new(SpanFirstQuery {
321            inner: ast_to_span_query(query),
322            end: *end,
323        }),
324    }
325}
326
327/// Matches all documents with score 1.0.
328pub struct MatchAllQuery;
329
330impl Query for MatchAllQuery {
331    fn bind(
332        &self,
333        _searcher: &crate::search::searcher::Searcher,
334        _score_mode: crate::core::ScoreMode,
335    ) -> crate::core::Result<Box<dyn crate::query::BoundQuery>> {
336        Ok(Box::new(BoundMatchAllQuery))
337    }
338}
339
340struct BoundMatchAllQuery;
341
342impl crate::query::BoundQuery for BoundMatchAllQuery {
343    fn is_match_all(&self) -> bool {
344        true
345    }
346
347    fn scorer_supplier(
348        &self,
349        reader: &crate::segment::reader::SegmentReader,
350    ) -> crate::core::Result<Option<Box<dyn crate::query::ScorerSupplier>>> {
351        let doc_count = reader.doc_count();
352        if doc_count == 0 {
353            return Ok(None);
354        }
355        Ok(Some(Box::new(MatchAllScorerSupplier { doc_count })))
356    }
357}
358
359struct MatchAllScorerSupplier {
360    doc_count: u32,
361}
362
363impl crate::query::ScorerSupplier for MatchAllScorerSupplier {
364    fn cost(&self) -> u64 {
365        self.doc_count as u64
366    }
367
368    fn scorer(self: Box<Self>) -> crate::core::Result<Box<dyn crate::core::Scorer>> {
369        Ok(Box::new(MatchAllScorer {
370            current: 0,
371            doc_count: self.doc_count,
372        }))
373    }
374}
375
376struct MatchAllScorer {
377    current: u32,
378    doc_count: u32,
379}
380
381impl crate::core::Scorer for MatchAllScorer {
382    fn doc_id(&self) -> crate::core::DocId {
383        if self.current < self.doc_count {
384            crate::core::DocId::new(self.current)
385        } else {
386            crate::core::NO_MORE_DOCS
387        }
388    }
389    fn next(&mut self) -> crate::core::DocId {
390        self.current += 1;
391        self.doc_id()
392    }
393    fn advance(&mut self, target: crate::core::DocId) -> crate::core::DocId {
394        self.current = target.as_u32();
395        self.doc_id()
396    }
397    fn score(&mut self) -> f32 {
398        1.0
399    }
400    fn two_phase(&mut self) -> Option<&mut dyn crate::core::TwoPhaseIterator> {
401        None
402    }
403}
404
405pub struct MatchNoneQuery;
406
407impl Query for MatchNoneQuery {
408    fn bind(
409        &self,
410        _searcher: &crate::search::searcher::Searcher,
411        _score_mode: crate::core::ScoreMode,
412    ) -> crate::core::Result<Box<dyn crate::query::BoundQuery>> {
413        Ok(Box::new(BoundMatchNoneQuery))
414    }
415}
416
417struct BoundMatchNoneQuery;
418
419impl crate::query::BoundQuery for BoundMatchNoneQuery {
420    fn scorer_supplier(
421        &self,
422        _reader: &crate::segment::reader::SegmentReader,
423    ) -> crate::core::Result<Option<Box<dyn crate::query::ScorerSupplier>>> {
424        Ok(None)
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431    use crate::query::ast::ScoringExpression;
432
433    #[test]
434    fn convert_term() {
435        let ast = ScoringExpression::Term {
436            field: "f".into(),
437            value: "v".into(),
438        };
439        let _query = ast_to_query(&ast); // should not panic
440    }
441
442    #[test]
443    fn convert_bool_nested() {
444        let ast = ScoringExpression::Bool {
445            must: vec![ScoringExpression::Term {
446                field: "a".into(),
447                value: "1".into(),
448            }],
449            should: vec![ScoringExpression::Match {
450                field: "b".into(),
451                query: "hello".into(),
452                analyzer: None,
453            }],
454            must_not: vec![],
455            filter: vec![ScoringExpression::Exists { field: "c".into() }],
456            minimum_should_match: None,
457        };
458        let _query = ast_to_query(&ast);
459    }
460
461    #[test]
462    fn convert_all_types() {
463        let types = vec![
464            ScoringExpression::Term {
465                field: "f".into(),
466                value: "v".into(),
467            },
468            ScoringExpression::Terms {
469                field: "f".into(),
470                values: vec!["a".into(), "b".into()],
471            },
472            ScoringExpression::Match {
473                field: "f".into(),
474                query: "q".into(),
475                analyzer: None,
476            },
477            ScoringExpression::MatchPhrase {
478                field: "f".into(),
479                query: "q".into(),
480                analyzer: None,
481            },
482            ScoringExpression::Exists { field: "f".into() },
483            ScoringExpression::Prefix {
484                field: "f".into(),
485                value: "p".into(),
486            },
487            ScoringExpression::ConstantScore {
488                query: Box::new(ScoringExpression::MatchAll),
489                boost: 1.0,
490            },
491            ScoringExpression::MatchAll,
492            ScoringExpression::MatchNone,
493        ];
494        for ast in &types {
495            let _q = ast_to_query(ast);
496        }
497    }
498}