1use nodedb_types::SurrogateBitmap;
27
28use crate::backend::FtsBackend;
29use crate::index::FtsIndex;
30use crate::index::error::FtsIndexError;
31use crate::posting::{QueryMode, TextSearchResult};
32use crate::scope::IndexScope;
33use crate::search::bmw::scorer::{BmwInput, bmw_score};
34use crate::search::match_mode::staged_candidates;
35use crate::search::query_parser::parse_query;
36use crate::search::query_terms::TextQuery;
37use crate::search::staged::StagedView;
38
39pub struct FtsSearchParams<'a> {
45 pub query: &'a str,
47 pub top_k: usize,
49 pub fuzzy_enabled: bool,
51 pub mode: QueryMode,
53 pub prefilter: Option<&'a SurrogateBitmap>,
55}
56
57impl<B: FtsBackend> FtsIndex<B> {
58 pub fn search<'a>(
67 &self,
68 database_id: u64,
69 tid: u64,
70 index: impl Into<IndexScope<'a>>,
71 params: FtsSearchParams<'_>,
72 ) -> Result<Vec<TextSearchResult>, FtsIndexError<B::Error>> {
73 self.search_staged(database_id, tid, index, params, None)
74 }
75
76 pub fn search_staged<'a>(
83 &self,
84 database_id: u64,
85 tid: u64,
86 index: impl Into<IndexScope<'a>>,
87 params: FtsSearchParams<'_>,
88 staged: Option<&StagedView>,
89 ) -> Result<Vec<TextSearchResult>, FtsIndexError<B::Error>> {
90 let index = index.into();
91 let FtsSearchParams {
92 query,
93 top_k,
94 fuzzy_enabled,
95 mode,
96 prefilter,
97 } = params;
98 if top_k == 0 {
99 parse_query(query)?;
102 return Ok(Vec::new());
103 }
104 let text_query = TextQuery {
105 query,
106 fuzzy_enabled,
107 mode,
108 };
109 let Some(resolved) = self.resolve_query(database_id, tid, index, text_query, staged)?
110 else {
111 return Ok(Vec::new());
112 };
113
114 let mut deny = resolved.negated.clone();
115 if let Some(view) = staged {
116 deny.union_in_place(view.hidden());
117 }
118 let index_visible = staged.is_none_or(|view| !view.hides_all());
119 let staged_docs = staged_candidates(&resolved, staged, prefilter, &self.bm25_params);
120 let match_mode = resolved.match_mode(index_visible, prefilter, &deny, &staged_docs);
121 let allow = match &match_mode {
122 super::match_mode::MatchMode::All(docs) => Some(docs),
123 super::match_mode::MatchMode::Coverage | super::match_mode::MatchMode::Any => prefilter,
124 };
125
126 let mut hits: Vec<TextSearchResult> = Vec::new();
127 if index_visible {
128 let heap = bmw_score(&BmwInput {
129 terms: &resolved.blocks,
130 term_groups: &resolved.term_groups,
131 groups: resolved.groups,
132 total_docs: resolved.total_docs,
133 avg_doc_len: resolved.avg_doc_len,
134 params: &self.bm25_params,
135 top_k,
136 allow,
137 deny: Some(&deny),
138 combine: match_mode.combine(),
139 });
140 hits.extend(heap.into_sorted().into_iter().map(|doc| TextSearchResult {
141 doc_id: doc.doc_id,
142 score: doc.score,
143 fuzzy: resolved.fuzzy,
144 }));
145 }
146 for (doc_id, contributions) in &staged_docs {
147 if let Some(score) = match_mode.score(&resolved, contributions) {
148 hits.push(TextSearchResult {
149 doc_id: *doc_id,
150 score,
151 fuzzy: resolved.fuzzy,
152 });
153 }
154 }
155 hits.sort_by(|a, b| {
156 b.score
157 .partial_cmp(&a.score)
158 .unwrap_or(std::cmp::Ordering::Equal)
159 .then(a.doc_id.cmp(&b.doc_id))
160 });
161 hits.truncate(top_k);
162 Ok(hits)
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use nodedb_types::{Surrogate, SurrogateBitmap};
169
170 use super::FtsSearchParams;
171 use crate::backend::memory::MemoryBackend;
172 use crate::index::FtsIndex;
173 use crate::index::error::FtsIndexError;
174 use crate::posting::QueryMode;
175 use crate::search::query_parser::InvalidQuery;
176 use crate::search::staged::StagedDoc;
177 use crate::test_support::test_governor;
178
179 const DB: u64 = 0;
180 const T: u64 = 1;
181 const D1: Surrogate = Surrogate(1);
182 const D2: Surrogate = Surrogate(2);
183 const D3: Surrogate = Surrogate(3);
184
185 fn make_index() -> FtsIndex<MemoryBackend> {
186 let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
187 idx.index_document(
188 DB,
189 T,
190 "docs",
191 D1,
192 "The quick brown fox jumps over the lazy dog",
193 )
194 .unwrap();
195 idx.index_document(DB, T, "docs", D2, "A fast brown dog runs across the field")
196 .unwrap();
197 idx.index_document(DB, T, "docs", D3, "Rust programming language for systems")
198 .unwrap();
199 idx
200 }
201
202 #[test]
203 fn basic_search() {
204 let idx = make_index();
205 let results = idx
206 .search(
207 DB,
208 T,
209 "docs",
210 FtsSearchParams {
211 query: "brown fox",
212 top_k: 10,
213 fuzzy_enabled: false,
214 mode: QueryMode::And,
215 prefilter: None,
216 },
217 )
218 .unwrap();
219 assert!(!results.is_empty());
220 assert_eq!(results[0].doc_id, D1);
221 }
222
223 #[test]
224 fn search_with_stemming() {
225 let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
226 idx.index_document(DB, T, "docs", D1, "running distributed databases")
227 .unwrap();
228 idx.index_document(DB, T, "docs", D2, "the cat sat on a mat")
229 .unwrap();
230
231 let results = idx
232 .search(
233 DB,
234 T,
235 "docs",
236 FtsSearchParams {
237 query: "database distribution",
238 top_k: 10,
239 fuzzy_enabled: false,
240 mode: QueryMode::And,
241 prefilter: None,
242 },
243 )
244 .unwrap();
245 assert!(!results.is_empty());
246 assert_eq!(results[0].doc_id, D1);
247 }
248
249 #[test]
250 fn or_mode() {
251 let idx = make_index();
252 let results = idx
253 .search(
254 DB,
255 T,
256 "docs",
257 FtsSearchParams {
258 query: "brown fox",
259 top_k: 10,
260 fuzzy_enabled: false,
261 mode: QueryMode::Or,
262 prefilter: None,
263 },
264 )
265 .unwrap();
266 assert!(results.len() >= 2);
267 }
268
269 #[test]
270 fn and_mode_filters() {
271 let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
272 idx.index_document(DB, T, "docs", D1, "Rust programming language")
273 .unwrap();
274 idx.index_document(DB, T, "docs", D2, "Python programming language")
275 .unwrap();
276
277 let results = idx
278 .search(
279 DB,
280 T,
281 "docs",
282 FtsSearchParams {
283 query: "rust programming",
284 top_k: 10,
285 fuzzy_enabled: false,
286 mode: QueryMode::And,
287 prefilter: None,
288 },
289 )
290 .unwrap();
291 assert_eq!(results.len(), 1);
292 assert_eq!(results[0].doc_id, D1);
293 }
294
295 #[test]
296 fn and_fallback_to_or() {
297 let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
298 idx.index_document(DB, T, "docs", D1, "rust programming language")
299 .unwrap();
300 idx.index_document(DB, T, "docs", D2, "python programming language")
301 .unwrap();
302
303 let results = idx
304 .search(
305 DB,
306 T,
307 "docs",
308 FtsSearchParams {
309 query: "rust python",
310 top_k: 10,
311 fuzzy_enabled: false,
312 mode: QueryMode::And,
313 prefilter: None,
314 },
315 )
316 .unwrap();
317 assert_eq!(results.len(), 2);
318 for r in &results {
319 assert!(r.score > 0.0);
320 }
321 }
322
323 #[test]
324 fn and_no_fallback_when_results_exist() {
325 let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
326 idx.index_document(DB, T, "docs", D1, "rust programming language")
327 .unwrap();
328 idx.index_document(DB, T, "docs", D2, "python programming language")
329 .unwrap();
330
331 let results = idx
332 .search(
333 DB,
334 T,
335 "docs",
336 FtsSearchParams {
337 query: "rust programming",
338 top_k: 10,
339 fuzzy_enabled: false,
340 mode: QueryMode::And,
341 prefilter: None,
342 },
343 )
344 .unwrap();
345 assert_eq!(results.len(), 1);
346 assert_eq!(results[0].doc_id, D1);
347 }
348
349 #[test]
350 fn empty_query() {
351 let idx = make_index();
352 let results = idx
353 .search(
354 DB,
355 T,
356 "docs",
357 FtsSearchParams {
358 query: "the a is",
359 top_k: 10,
360 fuzzy_enabled: false,
361 mode: QueryMode::And,
362 prefilter: None,
363 },
364 )
365 .unwrap();
366 assert!(results.is_empty());
367 }
368
369 #[test]
370 fn collections_isolated() {
371 let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
372 idx.index_document(DB, T, "col_a", D1, "alpha bravo charlie")
373 .unwrap();
374 idx.index_document(DB, T, "col_b", D1, "delta echo foxtrot")
375 .unwrap();
376
377 assert_eq!(
378 idx.search(
379 DB,
380 T,
381 "col_a",
382 FtsSearchParams {
383 query: "alpha",
384 top_k: 10,
385 fuzzy_enabled: false,
386 mode: QueryMode::And,
387 prefilter: None
388 }
389 )
390 .unwrap()
391 .len(),
392 1
393 );
394 assert!(
395 idx.search(
396 DB,
397 T,
398 "col_b",
399 FtsSearchParams {
400 query: "alpha",
401 top_k: 10,
402 fuzzy_enabled: false,
403 mode: QueryMode::And,
404 prefilter: None
405 }
406 )
407 .unwrap()
408 .is_empty()
409 );
410 }
411
412 #[test]
413 fn fuzzy_search() {
414 let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
415 idx.index_document(DB, T, "docs", D1, "distributed database systems")
416 .unwrap();
417
418 let results = idx
419 .search(
420 DB,
421 T,
422 "docs",
423 FtsSearchParams {
424 query: "databse",
425 top_k: 10,
426 fuzzy_enabled: true,
427 mode: QueryMode::And,
428 prefilter: None,
429 },
430 )
431 .unwrap();
432 assert!(!results.is_empty());
433 assert!(results[0].fuzzy);
434 }
435
436 #[test]
437 fn phrase_boost_consecutive() {
438 let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
439 idx.index_document(DB, T, "docs", D1, "the quick brown fox jumped")
440 .unwrap();
441 idx.index_document(DB, T, "docs", D2, "a brown dog chased a fox")
442 .unwrap();
443
444 let results = idx
445 .search(
446 DB,
447 T,
448 "docs",
449 FtsSearchParams {
450 query: "brown fox",
451 top_k: 10,
452 fuzzy_enabled: false,
453 mode: QueryMode::Or,
454 prefilter: None,
455 },
456 )
457 .unwrap();
458 assert!(results.len() >= 2);
459 assert_eq!(results[0].doc_id, D1);
460 }
461
462 #[test]
463 fn phrase_boost_no_effect_single_term() {
464 let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
465 idx.index_document(DB, T, "docs", D1, "hello world")
466 .unwrap();
467
468 let results = idx
469 .search(
470 DB,
471 T,
472 "docs",
473 FtsSearchParams {
474 query: "hello",
475 top_k: 10,
476 fuzzy_enabled: false,
477 mode: QueryMode::And,
478 prefilter: None,
479 },
480 )
481 .unwrap();
482 assert_eq!(results.len(), 1);
483 }
484
485 #[test]
486 fn tenants_isolated() {
487 let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
488 idx.index_document(DB, 1, "docs", D1, "alpha bravo")
489 .unwrap();
490 idx.index_document(DB, 2, "docs", D1, "charlie delta")
491 .unwrap();
492
493 let r1 = idx
494 .search(
495 DB,
496 1,
497 "docs",
498 FtsSearchParams {
499 query: "alpha",
500 top_k: 10,
501 fuzzy_enabled: false,
502 mode: QueryMode::And,
503 prefilter: None,
504 },
505 )
506 .unwrap();
507 let r2 = idx
508 .search(
509 DB,
510 2,
511 "docs",
512 FtsSearchParams {
513 query: "alpha",
514 top_k: 10,
515 fuzzy_enabled: false,
516 mode: QueryMode::And,
517 prefilter: None,
518 },
519 )
520 .unwrap();
521 assert_eq!(r1.len(), 1);
522 assert!(r2.is_empty());
523 }
524
525 #[test]
526 fn prefilter_excludes_non_member_surrogates() {
527 let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
528
529 idx.index_document(DB, T, "docs", D1, "rust language system")
530 .unwrap();
531 idx.index_document(DB, T, "docs", D2, "rust rust rust rust rust")
532 .unwrap();
533 idx.index_document(DB, T, "docs", D3, "rust rust rust rust rust rust")
534 .unwrap();
535
536 let mut bm = SurrogateBitmap::new();
537 bm.insert(D1);
538
539 let results = idx
540 .search(
541 DB,
542 T,
543 "docs",
544 FtsSearchParams {
545 query: "rust",
546 top_k: 10,
547 fuzzy_enabled: false,
548 mode: QueryMode::And,
549 prefilter: Some(&bm),
550 },
551 )
552 .unwrap();
553
554 assert_eq!(results.len(), 1, "only D1 should be returned");
555 assert_eq!(results[0].doc_id, D1);
556
557 assert!(
558 !results.iter().any(|r| r.doc_id == D2),
559 "D2 must be excluded"
560 );
561 assert!(
562 !results.iter().any(|r| r.doc_id == D3),
563 "D3 must be excluded"
564 );
565
566 let all_results = idx
567 .search(
568 DB,
569 T,
570 "docs",
571 FtsSearchParams {
572 query: "rust",
573 top_k: 10,
574 fuzzy_enabled: false,
575 mode: QueryMode::And,
576 prefilter: None,
577 },
578 )
579 .unwrap();
580 assert_eq!(all_results.len(), 3, "all docs returned without prefilter");
581 assert!(
582 all_results[0].doc_id == D2 || all_results[0].doc_id == D3,
583 "D2 or D3 should lead without prefilter (higher tf)"
584 );
585
586 let empty_bm = SurrogateBitmap::new();
587 let empty_results = idx
588 .search(
589 DB,
590 T,
591 "docs",
592 FtsSearchParams {
593 query: "rust",
594 top_k: 10,
595 fuzzy_enabled: false,
596 mode: QueryMode::And,
597 prefilter: Some(&empty_bm),
598 },
599 )
600 .unwrap();
601 assert!(empty_results.is_empty(), "empty prefilter → no results");
602
603 let mut bm23 = SurrogateBitmap::new();
604 bm23.insert(D2);
605 bm23.insert(D3);
606 let results23 = idx
607 .search(
608 DB,
609 T,
610 "docs",
611 FtsSearchParams {
612 query: "rust",
613 top_k: 10,
614 fuzzy_enabled: false,
615 mode: QueryMode::And,
616 prefilter: Some(&bm23),
617 },
618 )
619 .unwrap();
620 assert_eq!(results23.len(), 2);
621 assert!(!results23.iter().any(|r| r.doc_id == D1));
622 }
623
624 #[test]
627 fn not_keyword_excludes_documents() {
628 let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
629 idx.index_document(DB, T, "docs", D1, "rust python programming")
631 .unwrap();
632 idx.index_document(DB, T, "docs", D2, "rust ruby programming")
633 .unwrap();
634 idx.index_document(DB, T, "docs", D3, "python ruby programming")
635 .unwrap();
636
637 let results = idx
638 .search(
639 DB,
640 T,
641 "docs",
642 FtsSearchParams {
643 query: "rust NOT python",
644 top_k: 10,
645 fuzzy_enabled: false,
646 mode: QueryMode::And,
647 prefilter: None,
648 },
649 )
650 .unwrap();
651 assert!(
653 results.iter().any(|r| r.doc_id == D2),
654 "D2 (rust+ruby) must be in results"
655 );
656 assert!(
657 !results.iter().any(|r| r.doc_id == D1),
658 "D1 (rust+python) must be excluded"
659 );
660 }
661
662 #[test]
663 fn dash_prefix_excludes_documents() {
664 let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
665 idx.index_document(DB, T, "docs", D1, "rust python programming")
666 .unwrap();
667 idx.index_document(DB, T, "docs", D2, "rust ruby programming")
668 .unwrap();
669 idx.index_document(DB, T, "docs", D3, "python ruby programming")
670 .unwrap();
671
672 let results = idx
673 .search(
674 DB,
675 T,
676 "docs",
677 FtsSearchParams {
678 query: "rust -python",
679 top_k: 10,
680 fuzzy_enabled: false,
681 mode: QueryMode::And,
682 prefilter: None,
683 },
684 )
685 .unwrap();
686 assert!(results.iter().any(|r| r.doc_id == D2));
687 assert!(!results.iter().any(|r| r.doc_id == D1));
688 }
689
690 #[test]
691 fn multiple_not_excludes_all_negated() {
692 let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
693 idx.index_document(DB, T, "docs", D1, "rust python programming")
694 .unwrap();
695 idx.index_document(DB, T, "docs", D2, "rust ruby programming")
696 .unwrap();
697 idx.index_document(DB, T, "docs", D3, "rust systems programming")
698 .unwrap();
699
700 let results = idx
701 .search(
702 DB,
703 T,
704 "docs",
705 FtsSearchParams {
706 query: "rust NOT python NOT ruby",
707 top_k: 10,
708 fuzzy_enabled: false,
709 mode: QueryMode::And,
710 prefilter: None,
711 },
712 )
713 .unwrap();
714 assert!(results.iter().any(|r| r.doc_id == D3));
716 assert!(!results.iter().any(|r| r.doc_id == D1));
717 assert!(!results.iter().any(|r| r.doc_id == D2));
718 }
719
720 #[test]
721 fn not_nonexistent_term_returns_all_positives() {
722 let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
723 idx.index_document(DB, T, "docs", D1, "rust programming")
724 .unwrap();
725 idx.index_document(DB, T, "docs", D2, "rust systems")
726 .unwrap();
727
728 let results_plain = idx
729 .search(
730 DB,
731 T,
732 "docs",
733 FtsSearchParams {
734 query: "rust",
735 top_k: 10,
736 fuzzy_enabled: false,
737 mode: QueryMode::And,
738 prefilter: None,
739 },
740 )
741 .unwrap();
742 let results_not = idx
743 .search(
744 DB,
745 T,
746 "docs",
747 FtsSearchParams {
748 query: "rust NOT nonexistentxyz",
749 top_k: 10,
750 fuzzy_enabled: false,
751 mode: QueryMode::And,
752 prefilter: None,
753 },
754 )
755 .unwrap();
756
757 let plain_ids: std::collections::HashSet<Surrogate> =
758 results_plain.iter().map(|r| r.doc_id).collect();
759 let not_ids: std::collections::HashSet<Surrogate> =
760 results_not.iter().map(|r| r.doc_id).collect();
761 assert_eq!(
762 plain_ids, not_ids,
763 "NOT with nonexistent term must not remove any docs"
764 );
765 }
766
767 #[test]
768 fn negative_only_returns_invalid_query_error() {
769 let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
770 idx.index_document(DB, T, "docs", D1, "python programming")
771 .unwrap();
772
773 let err = idx
774 .search(
775 DB,
776 T,
777 "docs",
778 FtsSearchParams {
779 query: "NOT python",
780 top_k: 10,
781 fuzzy_enabled: false,
782 mode: QueryMode::And,
783 prefilter: None,
784 },
785 )
786 .unwrap_err();
787 assert!(
788 matches!(err, FtsIndexError::InvalidQuery(InvalidQuery::NegativeOnly)),
789 "expected InvalidQuery(NegativeOnly), got {err:?}"
790 );
791 }
792
793 #[test]
794 fn parentheses_after_not_returns_invalid_query_error() {
795 let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
796 idx.index_document(DB, T, "docs", D1, "rust programming")
797 .unwrap();
798
799 let err = idx
800 .search(
801 DB,
802 T,
803 "docs",
804 FtsSearchParams {
805 query: "rust NOT (python OR ruby)",
806 top_k: 10,
807 fuzzy_enabled: false,
808 mode: QueryMode::And,
809 prefilter: None,
810 },
811 )
812 .unwrap_err();
813 assert!(
814 matches!(
815 err,
816 FtsIndexError::InvalidQuery(InvalidQuery::ParenthesesNotSupported)
817 ),
818 "expected InvalidQuery(ParenthesesNotSupported), got {err:?}"
819 );
820 }
821
822 fn ranked(
825 idx: &FtsIndex<MemoryBackend>,
826 query: &str,
827 top_k: usize,
828 mode: QueryMode,
829 prefilter: Option<&SurrogateBitmap>,
830 staged: Option<&super::StagedView>,
831 ) -> Vec<Surrogate> {
832 idx.search_staged(
833 DB,
834 T,
835 "docs",
836 FtsSearchParams {
837 query,
838 top_k,
839 fuzzy_enabled: false,
840 mode,
841 prefilter,
842 },
843 staged,
844 )
845 .unwrap()
846 .into_iter()
847 .map(|r| r.doc_id)
848 .collect()
849 }
850
851 #[test]
854 fn not_terms_are_excluded_before_the_limit() {
855 let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
856 idx.index_document(DB, T, "docs", D1, "rust rust rust python")
857 .unwrap();
858 idx.index_document(DB, T, "docs", D2, "rust rust rust python")
859 .unwrap();
860 idx.index_document(DB, T, "docs", D3, "rust golang compiler toolchain")
861 .unwrap();
862 assert_eq!(
863 ranked(&idx, "rust -python", 1, QueryMode::And, None, None),
864 vec![D3]
865 );
866 assert_eq!(
867 ranked(&idx, "rust -python", 2, QueryMode::Or, None, None),
868 vec![D3]
869 );
870 }
871
872 #[test]
875 fn and_match_is_found_past_many_single_word_out_scorers() {
876 let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
877 for i in 1..=40u32 {
878 idx.index_document(DB, T, "docs", Surrogate(i), "alpha alpha alpha alpha")
879 .unwrap();
880 }
881 for i in 41..=80u32 {
882 idx.index_document(DB, T, "docs", Surrogate(i), "bravo bravo bravo bravo")
883 .unwrap();
884 }
885 let both = Surrogate(81);
886 idx.index_document(DB, T, "docs", both, "alpha bravo filler words here")
887 .unwrap();
888 for limit in [1, 3, 10, usize::MAX] {
889 assert_eq!(
890 ranked(&idx, "alpha bravo", limit, QueryMode::And, None, None),
891 vec![both],
892 "limit {limit}"
893 );
894 }
895 }
896
897 #[test]
901 fn and_mode_inside_a_prefilter() {
902 let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
903 idx.index_document(DB, T, "docs", D1, "alpha bravo")
904 .unwrap();
905 idx.index_document(DB, T, "docs", D2, "alpha charlie")
906 .unwrap();
907 idx.index_document(DB, T, "docs", D3, "bravo delta")
908 .unwrap();
909
910 let mut without_match = SurrogateBitmap::new();
911 without_match.insert(D2);
912 without_match.insert(D3);
913 let mut fallback = ranked(
914 &idx,
915 "alpha bravo",
916 10,
917 QueryMode::And,
918 Some(&without_match),
919 None,
920 );
921 fallback.sort();
922 assert_eq!(fallback, vec![D2, D3], "no admitted AND match: OR fallback");
923
924 let mut with_match = without_match.clone();
925 with_match.insert(D1);
926 assert_eq!(
927 ranked(
928 &idx,
929 "alpha bravo",
930 10,
931 QueryMode::And,
932 Some(&with_match),
933 None
934 ),
935 vec![D1],
936 "an admitted AND match keeps AND semantics"
937 );
938 }
939
940 #[test]
943 fn staged_rows_are_ranked_before_the_cut() {
944 let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
945 idx.index_document(DB, T, "docs", D1, "rust rust rust rust")
946 .unwrap();
947 idx.index_document(DB, T, "docs", D2, "rust rust lang")
948 .unwrap();
949 idx.index_document(DB, T, "docs", D3, "rust tooling compiler words")
950 .unwrap();
951
952 let mut hidden = SurrogateBitmap::new();
953 hidden.insert(D1);
954 let removed_top = super::StagedView::new(
955 hidden,
956 false,
957 vec![StagedDoc {
958 doc_id: D1,
959 tokens: vec!["python".into()],
960 }],
961 );
962 assert_eq!(
963 ranked(&idx, "rust", 2, QueryMode::And, None, Some(&removed_top)),
964 vec![D2, D3]
965 );
966
967 let new_best = super::StagedView::new(
968 SurrogateBitmap::new(),
969 false,
970 vec![StagedDoc {
971 doc_id: Surrogate(9),
972 tokens: vec!["rust".into(); 6],
973 }],
974 );
975 assert_eq!(
976 ranked(&idx, "rust", 1, QueryMode::And, None, Some(&new_best))[0],
977 Surrogate(9)
978 );
979 }
980
981 #[test]
984 fn staged_rows_use_and_semantics() {
985 let idx = FtsIndex::new(MemoryBackend::new(), test_governor());
986 idx.index_document(DB, T, "docs", D1, "alpha bravo")
987 .unwrap();
988 let view = super::StagedView::new(
989 SurrogateBitmap::new(),
990 false,
991 vec![
992 StagedDoc {
993 doc_id: Surrogate(8),
994 tokens: vec!["alpha".into(), "alpha".into()],
995 },
996 StagedDoc {
997 doc_id: Surrogate(9),
998 tokens: vec!["alpha".into(), "bravo".into()],
999 },
1000 ],
1001 );
1002 let mut hits = ranked(&idx, "alpha bravo", 10, QueryMode::And, None, Some(&view));
1003 hits.sort();
1004 assert_eq!(hits, vec![D1, Surrogate(9)]);
1005
1006 let negated = ranked(&idx, "alpha -bravo", 10, QueryMode::And, None, Some(&view));
1007 assert_eq!(negated, vec![Surrogate(8)]);
1008 }
1009}