Skip to main content

summa_core/query/
prefix.rs

1//! Prefix query — matches all documents containing any term that starts with a
2//! given prefix. Ranked callers merge posting cursors lazily; complete/mapped
3//! callers materialize bounded document sets. Score is always 1.0
4//! (filter-style, like `RangeQuery`).
5
6use crate::dsl::Field;
7use crate::segment::SegmentReader;
8
9#[cfg(test)]
10use super::term_union::materialize_union;
11use super::term_union::{TermUnionScorer, reject_chunked};
12use super::traits::{CountFuture, Query, Scorer, ScorerFuture};
13
14/// Prefix query — matches documents containing any term starting with `prefix`.
15#[derive(Debug, Clone)]
16pub struct PrefixQuery {
17    pub field: Field,
18    pub prefix: Vec<u8>,
19}
20
21impl std::fmt::Display for PrefixQuery {
22    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23        write!(
24            f,
25            "Prefix({}:\"{}*\")",
26            self.field.0,
27            String::from_utf8_lossy(&self.prefix)
28        )
29    }
30}
31
32impl PrefixQuery {
33    /// Create from raw bytes.
34    pub fn new(field: Field, prefix: impl Into<Vec<u8>>) -> Self {
35        Self {
36            field,
37            prefix: prefix.into(),
38        }
39    }
40
41    /// Create from text — lowercased to match default tokenization.
42    pub fn text(field: Field, text: &str) -> Self {
43        Self {
44            field,
45            prefix: text.to_lowercase().into_bytes(),
46        }
47    }
48}
49
50impl Query for PrefixQuery {
51    fn scorer<'a>(&self, reader: &'a SegmentReader, limit: usize) -> ScorerFuture<'a> {
52        let field = self.field;
53        let prefix = self.prefix.clone();
54        Box::pin(async move {
55            reject_chunked(reader, field, "PrefixQuery")?;
56            let postings = reader.get_prefix_expansion(field, &prefix).await?;
57            Ok(Box::new(TermUnionScorer::from_expanded(
58                postings,
59                reader.num_docs(),
60                reader.chunk_map(field),
61                limit,
62            )) as Box<dyn Scorer>)
63        })
64    }
65
66    #[cfg(feature = "sync")]
67    fn scorer_sync<'a>(
68        &self,
69        reader: &'a SegmentReader,
70        limit: usize,
71    ) -> crate::Result<Box<dyn Scorer + 'a>> {
72        reject_chunked(reader, self.field, "PrefixQuery")?;
73        let postings = reader.get_prefix_expansion_sync(self.field, &self.prefix)?;
74        Ok(Box::new(TermUnionScorer::from_expanded(
75            postings,
76            reader.num_docs(),
77            reader.chunk_map(self.field),
78            limit,
79        )))
80    }
81
82    fn count_estimate<'a>(&self, reader: &'a SegmentReader) -> CountFuture<'a> {
83        let field = self.field;
84        let prefix = self.prefix.clone();
85        Box::pin(async move {
86            let postings = reader.get_prefix_expansion(field, &prefix).await?;
87            Ok(postings
88                .iter()
89                .fold(0u32, |sum, posting| sum.saturating_add(posting.doc_count()))
90                .min(reader.num_docs()))
91        })
92    }
93
94    fn is_filter(&self) -> bool {
95        true
96    }
97
98    #[cfg(feature = "sync")]
99    fn as_doc_predicate<'a>(&self, reader: &'a SegmentReader) -> Option<super::DocPredicate<'a>> {
100        let bitset = self.as_doc_bitset(reader)?;
101        Some(Box::new(move |doc_id: crate::DocId| {
102            bitset.contains(doc_id)
103        }))
104    }
105
106    #[cfg(feature = "sync")]
107    fn as_doc_bitset(&self, reader: &SegmentReader) -> Option<super::DocBitset> {
108        if reader.is_chunked_field(self.field) {
109            return None;
110        }
111        let postings = reader
112            .get_prefix_postings_sync(self.field, &self.prefix)
113            .ok()?;
114        let mut bitset = super::DocBitset::new(reader.num_docs());
115        for posting in &postings {
116            let mut iter = posting.iterator();
117            loop {
118                let d = iter.doc();
119                if d == crate::structures::TERMINATED {
120                    break;
121                }
122                bitset.set(reader.chunk_map(self.field).map_or(d, |map| map.doc_id(d)));
123                iter.advance();
124            }
125        }
126        Some(bitset)
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::query::DocSet;
134    use crate::structures::{BlockPostingList, TERMINATED};
135
136    #[test]
137    fn test_materialize_union_empty() {
138        let docs = materialize_union(&[], 0, None);
139        assert!(docs.is_empty());
140    }
141
142    #[test]
143    fn test_materialize_union_deduplicates() {
144        let mut left = crate::structures::PostingList::new();
145        left.push(1, 1);
146        left.push(5, 1);
147        left.push(9, 1);
148        let mut right = crate::structures::PostingList::new();
149        right.push(2, 1);
150        right.push(5, 1);
151        right.push(10, 1);
152        let postings = vec![
153            BlockPostingList::from_posting_list(&left).unwrap(),
154            BlockPostingList::from_posting_list(&right).unwrap(),
155        ];
156
157        assert_eq!(materialize_union(&postings, 11, None), vec![1, 2, 5, 9, 10]);
158        // A huge segment with a narrow prefix takes the posting-vector path;
159        // it must not allocate a num_docs-sized bitset.
160        assert_eq!(
161            materialize_union(&postings[..1], 1_000_000_000, None),
162            vec![1, 5, 9]
163        );
164    }
165
166    #[test]
167    fn test_prefix_scorer_basic() {
168        let mut scorer = TermUnionScorer::new(vec![1, 5, 10, 20]);
169        assert_eq!(scorer.doc(), 1);
170        assert_eq!(scorer.score(), 1.0);
171        assert_eq!(scorer.advance(), 5);
172        assert_eq!(scorer.seek(10), 10);
173        assert_eq!(scorer.advance(), 20);
174        assert_eq!(scorer.advance(), TERMINATED);
175    }
176
177    #[test]
178    fn test_prefix_scorer_seek_past() {
179        let mut scorer = TermUnionScorer::new(vec![1, 5, 10, 20]);
180        assert_eq!(scorer.seek(7), 10);
181        assert_eq!(scorer.seek(100), TERMINATED);
182    }
183
184    #[test]
185    fn test_prefix_query_display() {
186        let q = PrefixQuery::text(Field(0), "abc");
187        assert_eq!(format!("{}", q), "Prefix(0:\"abc*\")");
188    }
189
190    #[test]
191    fn test_prefix_query_is_filter() {
192        let q = PrefixQuery::text(Field(0), "test");
193        assert!(q.is_filter());
194    }
195}