1use crate::idmap::IdMap;
30use crate::interner::Interner;
31use crate::types::Value;
32use std::collections::{BTreeMap, BTreeSet};
33
34pub fn stem(tok: &str) -> String {
40 use rust_stemmers::{Algorithm, Stemmer};
41 thread_local! {
42 static EN: Stemmer = Stemmer::create(Algorithm::English);
43 }
44 EN.with(|s| s.stem(tok).into_owned())
45}
46
47pub fn tokenize(s: &str) -> Vec<String> {
57 let mut tokens = Vec::new();
58 let mut current = String::new();
59 for ch in s.chars() {
60 if ch.is_alphanumeric() {
61 for lc in ch.to_lowercase() {
62 current.push(lc);
63 }
64 } else if !current.is_empty() {
65 tokens.push(std::mem::take(&mut current));
66 }
67 }
68 if !current.is_empty() {
69 tokens.push(current);
70 }
71 tokens
72}
73
74pub fn tokenize_stemmed_with_positions(s: &str) -> Vec<(String, u32)> {
77 let mut result = Vec::new();
78 let mut pos: u32 = 0;
79 let mut current = String::new();
80 for ch in s.chars() {
81 if ch.is_alphanumeric() {
82 for lc in ch.to_lowercase() {
83 current.push(lc);
84 }
85 } else if !current.is_empty() {
86 result.push((stem(¤t), pos));
87 pos += 1;
88 current.clear();
89 }
90 }
91 if !current.is_empty() {
92 result.push((stem(¤t), pos));
93 }
94 result
95}
96
97pub fn value_tokens_stemmed_with_positions(v: &Value) -> Vec<(String, u32)> {
104 match v {
105 Value::Str(s) => tokenize_stemmed_with_positions(s),
106 Value::List(items) => {
107 const POSITION_GAP: u32 = 2;
109 let mut result: Vec<(String, u32)> = Vec::new();
110 let mut pos_offset: u32 = 0;
111 for item in items {
112 if let Value::Str(s) = item {
113 let toks = tokenize_stemmed_with_positions(s);
114 for (tok, local_pos) in &toks {
115 result.push((tok.clone(), pos_offset + local_pos));
116 }
117 if !toks.is_empty() {
118 pos_offset += toks.len() as u32 + POSITION_GAP;
120 }
121 }
122 }
123 result
124 }
125 _ => vec![],
126 }
127}
128
129#[derive(Debug, Clone)]
142pub struct Term {
143 pub token: String,
145 pub prefix: bool,
147 pub negated: bool,
149}
150
151#[derive(Debug, Clone)]
153enum QueryAtom {
154 Term(Term),
155 Phrase(Vec<String>),
157}
158
159type Groups = Vec<Vec<QueryAtom>>;
161
162fn parse_query_v2(query: &str) -> Groups {
166 let mut groups: Groups = vec![vec![]];
167 let chars: Vec<char> = query.chars().collect();
168 let mut i = 0;
169
170 while i < chars.len() {
171 if chars[i].is_whitespace() {
173 i += 1;
174 continue;
175 }
176
177 if chars[i] == '"' {
178 i += 1; let mut phrase_tokens: Vec<String> = Vec::new();
181 let mut current = String::new();
182 while i < chars.len() && chars[i] != '"' {
183 let ch = chars[i];
184 if ch.is_alphanumeric() {
185 for lc in ch.to_lowercase() {
186 current.push(lc);
187 }
188 } else if !current.is_empty() {
189 phrase_tokens.push(stem(¤t));
190 current.clear();
191 }
192 i += 1;
193 }
194 if !current.is_empty() {
195 phrase_tokens.push(stem(¤t));
196 }
197 if chars.get(i) == Some(&'"') {
198 i += 1; }
200 if !phrase_tokens.is_empty() {
201 groups
203 .last_mut()
204 .unwrap()
205 .push(QueryAtom::Phrase(phrase_tokens));
206 }
207 } else {
208 let start = i;
210 while i < chars.len() && !chars[i].is_whitespace() {
211 i += 1;
212 }
213 let word: String = chars[start..i].iter().collect();
214
215 match word.to_ascii_uppercase().as_str() {
217 "OR" => {
218 groups.push(vec![]);
219 continue;
220 }
221 "AND" => continue,
222 _ => {}
223 }
224
225 let (negated, rest) = if let Some(stripped) = word.strip_prefix('-') {
227 (true, stripped)
228 } else {
229 (false, word.as_str())
230 };
231
232 let (raw, prefix) = if let Some(stripped) = rest.strip_suffix('*') {
234 (stripped, true)
235 } else {
236 (rest, false)
237 };
238
239 let token: String = raw
241 .chars()
242 .filter(|c| c.is_alphanumeric())
243 .flat_map(|c| c.to_lowercase())
244 .collect();
245
246 if token.is_empty() {
247 continue;
248 }
249
250 let final_token = if prefix { token } else { stem(&token) };
253
254 groups.last_mut().unwrap().push(QueryAtom::Term(Term {
256 token: final_token,
257 prefix,
258 negated,
259 }));
260 }
261 }
262
263 groups.retain(|g| !g.is_empty());
264 groups
265}
266
267pub fn parse_query(query: &str) -> Vec<Vec<Term>> {
280 parse_query_v2(query)
282 .into_iter()
283 .map(|group| {
284 group
285 .into_iter()
286 .flat_map(|atom| match atom {
287 QueryAtom::Term(t) => vec![t],
288 QueryAtom::Phrase(tokens) => tokens
290 .into_iter()
291 .map(|tok| Term {
292 token: tok,
293 prefix: false,
294 negated: false,
295 })
296 .collect(),
297 })
298 .collect()
299 })
300 .collect()
301}
302
303pub fn eval_query_str(field_value: &str, query: &str) -> bool {
317 let groups = parse_query_v2(query);
318 eval_groups_str(field_value, &groups)
319}
320
321pub fn eval_query_str_list(items: &[Value], query: &str) -> bool {
323 let combined: String = items
324 .iter()
325 .filter_map(|v| {
326 if let Value::Str(s) = v {
327 Some(s.as_str())
328 } else {
329 None
330 }
331 })
332 .collect::<Vec<_>>()
333 .join(" ");
334 eval_query_str(&combined, query)
335}
336
337fn eval_groups_str(field_value: &str, groups: &Groups) -> bool {
338 if groups.is_empty() {
339 return false;
340 }
341 let stemmed_with_pos = tokenize_stemmed_with_positions(field_value);
343 let token_set: BTreeSet<String> = stemmed_with_pos.iter().map(|(t, _)| t.clone()).collect();
344 let mut pos_map: BTreeMap<String, Vec<u32>> = BTreeMap::new();
346 for (tok, pos) in &stemmed_with_pos {
347 pos_map.entry(tok.clone()).or_default().push(*pos);
348 }
349
350 'outer: for group in groups {
351 for atom in group {
352 match atom {
353 QueryAtom::Term(t) => {
354 let found = if t.prefix {
355 token_set.iter().any(|tk| tk.starts_with(t.token.as_str()))
356 } else {
357 token_set.contains(&t.token)
358 };
359 if t.negated {
360 if found {
361 continue 'outer; }
363 } else if !found {
364 continue 'outer; }
366 }
367 QueryAtom::Phrase(tokens) => {
368 if !phrase_matches_pos_map(&pos_map, tokens) {
369 continue 'outer;
370 }
371 }
372 }
373 }
374 return true; }
376 false
377}
378
379fn phrase_matches_pos_map(pos_map: &BTreeMap<String, Vec<u32>>, tokens: &[String]) -> bool {
382 if tokens.is_empty() {
383 return false;
384 }
385 let Some(first_positions) = pos_map.get(&tokens[0]) else {
386 return false;
387 };
388 'start: for &start in first_positions {
389 let mut cur = start;
390 for tok in &tokens[1..] {
391 cur += 1;
392 let Some(positions) = pos_map.get(tok) else {
393 continue 'start;
394 };
395 if positions.binary_search(&cur).is_err() {
396 continue 'start;
397 }
398 }
399 return true;
400 }
401 false
402}
403
404#[derive(Debug, Default, Clone)]
423pub struct FulltextIndex {
424 enabled: BTreeSet<(String, String)>,
426 postings: BTreeMap<String, BTreeMap<String, BTreeMap<u32, Vec<u32>>>>,
431 doc_len: BTreeMap<String, BTreeMap<u32, u32>>,
436}
437
438impl FulltextIndex {
439 pub fn new() -> Self {
440 Self::default()
441 }
442
443 pub fn is_enabled(&self, label: &str, field: &str) -> bool {
445 self.enabled
446 .contains(&(label.to_string(), field.to_string()))
447 }
448
449 pub fn has_label(&self, label: &str) -> bool {
451 self.enabled.iter().any(|(l, _)| l == label)
452 }
453
454 pub fn field_indexed(&self, field: &str) -> bool {
456 self.enabled.iter().any(|(_, f)| f == field)
457 }
458
459 pub fn field_indexed_by_other(&self, label: &str, field: &str) -> bool {
461 self.enabled.iter().any(|(l, f)| f == field && l != label)
462 }
463
464 pub fn enabled_pairs(&self) -> impl Iterator<Item = &(String, String)> {
466 self.enabled.iter()
467 }
468
469 pub fn enable(&mut self, label: &str, field: &str) -> bool {
472 self.enabled.insert((label.to_string(), field.to_string()))
473 }
474
475 pub fn disable(&mut self, label: &str, field: &str) -> bool {
479 let removed = self.enabled.remove(&(label.to_string(), field.to_string()));
480 if removed && !self.field_indexed(field) {
481 self.postings.remove(field);
482 self.doc_len.remove(field);
483 }
484 removed
485 }
486
487 pub fn add_tokens(&mut self, node_id: u32, field: &str, value: &Value) {
495 let stemmed = value_tokens_stemmed_with_positions(value);
496 let dl = stemmed.len() as u32;
497
498 let dl_col = self.doc_len.entry(field.to_string()).or_default();
500 dl_col.insert(node_id, dl);
501
502 let col = self.postings.entry(field.to_string()).or_default();
504 for (tok, pos) in stemmed {
505 col.entry(tok)
506 .or_default()
507 .entry(node_id)
508 .or_default()
509 .push(pos);
510 }
511 }
512
513 pub fn remove_node_field(&mut self, node_id: u32, field: &str) {
515 if let Some(col) = self.postings.get_mut(field) {
516 col.retain(|_, node_map| {
517 node_map.remove(&node_id);
518 !node_map.is_empty()
519 });
520 if col.is_empty() {
521 self.postings.remove(field);
522 }
523 }
524 if let Some(dl) = self.doc_len.get_mut(field) {
525 dl.remove(&node_id);
526 if dl.is_empty() {
527 self.doc_len.remove(field);
528 }
529 }
530 }
531
532 pub fn remove_node(&mut self, node_id: u32) {
534 for col in self.postings.values_mut() {
535 col.retain(|_, node_map| {
536 node_map.remove(&node_id);
537 !node_map.is_empty()
538 });
539 }
540 self.postings.retain(|_, col| !col.is_empty());
541 for dl in self.doc_len.values_mut() {
542 dl.remove(&node_id);
543 }
544 self.doc_len.retain(|_, dl| !dl.is_empty());
545 }
546
547 pub fn search(&self, field: &str, query: &str, k: usize) -> Vec<(u32, f64)> {
559 let Some(col) = self.postings.get(field) else {
560 return vec![];
561 };
562 let groups = parse_query_v2(query);
563 if groups.is_empty() {
564 return vec![];
565 }
566
567 let dl_map = match self.doc_len.get(field) {
568 Some(m) => m,
569 None => return vec![],
570 };
571 let n = dl_map.len() as f64;
572 if n == 0.0 {
573 return vec![];
574 }
575 let avg_dl: f64 = dl_map.values().map(|&v| v as f64).sum::<f64>() / n;
576
577 const K1: f64 = 1.2;
578 const B: f64 = 0.75;
579
580 let mut scores: BTreeMap<u32, f64> = BTreeMap::new();
581
582 for group in &groups {
583 let candidates = group_candidates(col, group);
585
586 for node_id in candidates {
587 let dl = dl_map.get(&node_id).copied().unwrap_or(1) as f64;
588 let mut group_score = 0.0;
589
590 for atom in group {
591 match atom {
592 QueryAtom::Term(t) if !t.negated && !t.prefix => {
593 let (df, tf) = match col.get(&t.token) {
595 Some(node_map) => {
596 let df = node_map.len() as f64;
597 let tf = node_map
598 .get(&node_id)
599 .map(|v| v.len() as f64)
600 .unwrap_or(0.0);
601 (df, tf)
602 }
603 None => (0.0, 0.0),
604 };
605 if tf > 0.0 {
606 let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
607 let tf_norm =
608 tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
609 group_score += idf * tf_norm;
610 }
611 }
612 QueryAtom::Term(t) if !t.negated && t.prefix => {
613 for (tok, node_map) in col
615 .range(t.token.clone()..)
616 .take_while(|(k, _)| k.starts_with(t.token.as_str()))
617 {
618 let _ = tok;
619 let df = node_map.len() as f64;
620 let tf = node_map
621 .get(&node_id)
622 .map(|v| v.len() as f64)
623 .unwrap_or(0.0);
624 if tf > 0.0 {
625 let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
626 let tf_norm =
627 tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
628 group_score += idf * tf_norm;
629 }
630 }
631 }
632 QueryAtom::Term(_) => {
633 }
635 QueryAtom::Phrase(tokens) => {
636 if !phrase_matches_col(col, node_id, tokens) {
640 group_score = f64::NEG_INFINITY;
642 break;
643 }
644 for tok in tokens {
646 let (df, tf) = match col.get(tok) {
647 Some(node_map) => (
648 node_map.len() as f64,
649 node_map
650 .get(&node_id)
651 .map(|v| v.len() as f64)
652 .unwrap_or(0.0),
653 ),
654 None => (0.0, 0.0),
655 };
656 if tf > 0.0 && df > 0.0 {
657 let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
658 let tf_norm =
659 tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
660 group_score += idf * tf_norm;
661 }
662 }
663 }
664 }
665 }
666
667 if group_score > 0.0 {
673 *scores.entry(node_id).or_insert(0.0) += group_score;
674 }
675 }
676 }
677
678 let mut results: Vec<(u32, f64)> = scores.into_iter().collect();
679 results.sort_by(|a, b| {
680 b.1.partial_cmp(&a.1)
681 .unwrap_or(std::cmp::Ordering::Equal)
682 .then(a.0.cmp(&b.0))
683 });
684 if k > 0 {
685 results.truncate(k);
686 }
687 results
688 }
689
690 pub fn rebuild_all(
699 &mut self,
700 ids: &IdMap,
701 labels: &[u32],
702 syms: &Interner,
703 props: crate::v8::seam::ColumnsView<'_>,
704 ) {
705 if self.enabled.is_empty() {
706 return;
707 }
708 let enabled_vec: Vec<(String, String)> = self.enabled.iter().cloned().collect();
709 for (_, field) in &enabled_vec {
711 self.postings.remove(field);
712 self.doc_len.remove(field);
713 }
714 let n = ids.len() as u32;
715 for id in 0..n {
716 let Some(&sym) = labels.get(id as usize) else {
717 continue;
718 };
719 if sym == u32::MAX {
720 continue;
721 }
722 let Some(label) = syms.resolve(sym) else {
723 continue;
724 };
725 for (lbl, field) in &enabled_vec {
726 if lbl == label {
727 if let Some(vr) = props.get(id, field) {
728 let value = vr.into_value();
729 self.add_tokens(id, field, &value);
730 }
731 }
732 }
733 }
734 }
735}
736
737fn group_candidates(
746 col: &BTreeMap<String, BTreeMap<u32, Vec<u32>>>,
747 group: &[QueryAtom],
748) -> BTreeSet<u32> {
749 let has_positive = group.iter().any(|a| match a {
750 QueryAtom::Term(t) => !t.negated,
751 QueryAtom::Phrase(_) => true,
752 });
753
754 let mut result: Option<BTreeSet<u32>> = if has_positive {
756 None
757 } else {
758 Some(
759 col.values()
760 .flat_map(|node_map| node_map.keys().copied())
761 .collect(),
762 )
763 };
764
765 let mut negated: BTreeSet<u32> = BTreeSet::new();
766
767 for atom in group {
768 match atom {
769 QueryAtom::Term(t) if !t.negated && !t.prefix => {
770 let matching: BTreeSet<u32> = col
771 .get(&t.token)
772 .map(|m| m.keys().copied().collect())
773 .unwrap_or_default();
774 result = Some(match result {
775 None => matching,
776 Some(prev) => prev.intersection(&matching).copied().collect(),
777 });
778 }
779 QueryAtom::Term(t) if !t.negated && t.prefix => {
780 let matching: BTreeSet<u32> = col
781 .range(t.token.clone()..)
782 .take_while(|(k, _)| k.starts_with(t.token.as_str()))
783 .flat_map(|(_, node_map)| node_map.keys().copied())
784 .collect();
785 result = Some(match result {
786 None => matching,
787 Some(prev) => prev.intersection(&matching).copied().collect(),
788 });
789 }
790 QueryAtom::Term(t) if t.negated && !t.prefix => {
791 let exclude: BTreeSet<u32> = col
792 .get(&t.token)
793 .map(|m| m.keys().copied().collect())
794 .unwrap_or_default();
795 negated.extend(exclude);
796 }
797 QueryAtom::Term(t) if t.negated && t.prefix => {
798 let exclude: BTreeSet<u32> = col
799 .range(t.token.clone()..)
800 .take_while(|(k, _)| k.starts_with(t.token.as_str()))
801 .flat_map(|(_, node_map)| node_map.keys().copied())
802 .collect();
803 negated.extend(exclude);
804 }
805 QueryAtom::Term(_) => {}
806 QueryAtom::Phrase(tokens) => {
807 let mut phrase_candidates: Option<BTreeSet<u32>> = None;
810 for tok in tokens {
811 let matching: BTreeSet<u32> = col
812 .get(tok)
813 .map(|m| m.keys().copied().collect())
814 .unwrap_or_default();
815 phrase_candidates = Some(match phrase_candidates {
816 None => matching,
817 Some(prev) => prev.intersection(&matching).copied().collect(),
818 });
819 }
820 let phrase_set = phrase_candidates.unwrap_or_default();
821 result = Some(match result {
822 None => phrase_set,
823 Some(prev) => prev.intersection(&phrase_set).copied().collect(),
824 });
825 }
826 }
827 }
828
829 let mut candidates = result.unwrap_or_default();
830 for id in &negated {
831 candidates.remove(id);
832 }
833 candidates
834}
835
836fn phrase_matches_col(
838 col: &BTreeMap<String, BTreeMap<u32, Vec<u32>>>,
839 node_id: u32,
840 tokens: &[String],
841) -> bool {
842 if tokens.is_empty() {
843 return false;
844 }
845 let Some(first_positions) = col.get(&tokens[0]).and_then(|m| m.get(&node_id)) else {
846 return false;
847 };
848 'start: for &start in first_positions {
849 let mut cur = start;
850 for tok in &tokens[1..] {
851 cur += 1;
852 let Some(positions) = col.get(tok).and_then(|m| m.get(&node_id)) else {
853 continue 'start;
854 };
855 if positions.binary_search(&cur).is_err() {
856 continue 'start;
857 }
858 }
859 return true;
860 }
861 false
862}
863
864#[cfg(test)]
869mod tests {
870 use super::*;
871 use crate::columns::ColumnStore;
872
873 fn toks(s: &str) -> Vec<String> {
874 tokenize(s)
875 }
876
877 #[test]
878 fn tokenizer_basic() {
879 assert_eq!(toks("Hello, World!"), vec!["hello", "world"]);
880 assert_eq!(toks("rust-lang"), vec!["rust", "lang"]);
881 assert_eq!(toks("abc123"), vec!["abc123"]);
882 assert_eq!(toks(""), Vec::<String>::new());
883 }
884
885 #[test]
886 fn tokenizer_unicode() {
887 assert_eq!(toks("café"), vec!["café"]);
888 assert_eq!(toks("über alles"), vec!["über", "alles"]);
889 }
890
891 #[test]
892 fn stem_basic() {
893 assert_eq!(stem("running"), "run");
895 assert_eq!(stem("databases"), "databas");
896 assert_eq!(stem("embedded"), "embed");
897 assert_eq!(stem("a"), "a");
899 assert_eq!(stem("rust"), "rust");
900 }
901
902 #[test]
903 fn tokenize_stemmed_positions() {
904 let result = tokenize_stemmed_with_positions("running around the world");
905 assert_eq!(result[0].0, stem("running")); assert_eq!(result[0].1, 0);
908 assert_eq!(result[1].0, stem("around")); assert_eq!(result[1].1, 1);
910 assert_eq!(result[2].0, stem("the")); assert_eq!(result[2].1, 2);
912 assert_eq!(result[3].0, stem("world")); assert_eq!(result[3].1, 3);
914 }
915
916 #[test]
917 fn parse_query_and() {
918 let g = parse_query("foo bar");
919 assert_eq!(g.len(), 1);
920 assert_eq!(g[0].len(), 2);
921 assert_eq!(g[0][0].token, stem("foo"));
922 assert_eq!(g[0][1].token, stem("bar"));
923 assert!(!g[0][0].prefix);
924 assert!(!g[0][0].negated);
925 }
926
927 #[test]
928 fn parse_query_or() {
929 let g = parse_query("foo OR bar");
930 assert_eq!(g.len(), 2);
931 assert_eq!(g[0][0].token, stem("foo"));
932 assert_eq!(g[1][0].token, stem("bar"));
933 }
934
935 #[test]
936 fn parse_query_prefix() {
937 let g = parse_query("foo*");
938 assert_eq!(g.len(), 1);
939 assert!(g[0][0].prefix);
940 assert_eq!(g[0][0].token, "foo"); }
942
943 #[test]
944 fn parse_query_negation() {
945 let g = parse_query("-embedded rust");
946 assert_eq!(g.len(), 1);
947 assert_eq!(g[0].len(), 2);
948 assert!(g[0][0].negated);
949 assert_eq!(g[0][0].token, stem("embedded"));
950 assert!(!g[0][1].negated);
951 assert_eq!(g[0][1].token, stem("rust"));
952 }
953
954 #[test]
955 fn parse_query_explicit_and_keyword() {
956 let g = parse_query("foo AND bar");
957 assert_eq!(g.len(), 1);
958 assert_eq!(g[0].len(), 2);
959 }
960
961 #[test]
962 fn parse_query_or_case_insensitive() {
963 let g = parse_query("a or b");
964 assert_eq!(g.len(), 2);
965 }
966
967 #[test]
968 fn parse_query_v2_phrase() {
969 let g = parse_query_v2("\"graph database\"");
970 assert_eq!(g.len(), 1);
971 assert_eq!(g[0].len(), 1);
972 match &g[0][0] {
973 QueryAtom::Phrase(tokens) => {
974 assert_eq!(tokens[0], stem("graph"));
975 assert_eq!(tokens[1], stem("database"));
976 }
977 _ => panic!("expected Phrase"),
978 }
979 }
980
981 #[test]
982 fn eval_query_str_basic() {
983 assert!(eval_query_str("hello world rust", "hello world"));
984 assert!(!eval_query_str("hello world", "hello rust"));
985 assert!(eval_query_str("hello world", "hello OR rust"));
986 }
987
988 #[test]
989 fn eval_query_str_stemming() {
990 assert!(eval_query_str("I am running fast", "running"));
992 assert!(eval_query_str("I am running fast", "run"));
993 assert!(eval_query_str("graph databases embedded", "databases"));
995 }
996
997 #[test]
998 fn eval_query_str_phrase() {
999 assert!(eval_query_str(
1001 "graph database embedded",
1002 "\"graph database\""
1003 ));
1004 assert!(!eval_query_str(
1006 "graph embedded database",
1007 "\"graph database\""
1008 ));
1009 assert!(eval_query_str(
1011 "I am running fast today",
1012 "\"running fast\""
1013 ));
1014 }
1015
1016 #[test]
1017 fn eval_query_str_negation() {
1018 assert!(!eval_query_str(
1020 "graph embedded database",
1021 "-embedded graph"
1022 ));
1023 assert!(eval_query_str("graph database", "-embedded graph"));
1025 }
1026
1027 #[test]
1028 fn eval_query_str_prefix() {
1029 assert!(eval_query_str("embedding graph", "emb*"));
1030 assert!(!eval_query_str("graph only", "emb*"));
1031 }
1032
1033 #[test]
1034 fn index_and_search_bm25_basic() {
1035 let mut idx = FulltextIndex::new();
1036 idx.enable("Person", "bio");
1037 idx.add_tokens(0, "bio", &Value::Str("I love Rust and databases".into()));
1038 idx.add_tokens(1, "bio", &Value::Str("Python developer here".into()));
1039
1040 let r = idx.search("bio", "rust", 0);
1042 assert_eq!(r.len(), 1);
1043 assert_eq!(r[0].0, 0);
1044 assert!(r[0].1 > 0.0);
1045
1046 let r2 = idx.search("bio", "rust OR python", 0);
1048 assert_eq!(r2.len(), 2);
1049
1050 let r3 = idx.search("bio", "rust databases", 0);
1052 assert_eq!(r3.len(), 1);
1053 assert_eq!(r3[0].0, 0);
1054
1055 let r4 = idx.search("bio", "rust AND python", 0);
1057 assert!(r4.is_empty());
1058 }
1059
1060 #[test]
1078 fn bm25_rarer_term_ranks_higher() {
1079 let mut idx = FulltextIndex::new();
1080 idx.enable("Doc", "body");
1081 idx.add_tokens(0, "body", &Value::Str("alpha".into()));
1082 idx.add_tokens(1, "body", &Value::Str("beta".into()));
1083 idx.add_tokens(2, "body", &Value::Str("beta".into()));
1084
1085 let r = idx.search("body", "alpha OR beta", 0);
1086 assert_eq!(r.len(), 3);
1087 assert_eq!(r[0].0, 0, "rarer-term doc must rank first");
1089 assert_eq!(r[1].0, 1);
1091 assert_eq!(r[2].0, 2);
1092 assert!(r[0].1 > r[1].1, "alpha (df=1) must score above beta (df=2)");
1094 }
1095
1096 #[test]
1097 fn bm25_stemming_matches_root_form() {
1098 let mut idx = FulltextIndex::new();
1099 idx.enable("Doc", "body");
1100 idx.add_tokens(0, "body", &Value::Str("run".into()));
1102
1103 let r = idx.search("body", "running", 0);
1105 assert_eq!(r.len(), 1);
1106 assert_eq!(r[0].0, 0);
1107 }
1108
1109 #[test]
1110 fn bm25_phrase_adjacent_only() {
1111 let mut idx = FulltextIndex::new();
1112 idx.enable("Doc", "body");
1113 idx.add_tokens(0, "body", &Value::Str("graph database embedded".into()));
1115 idx.add_tokens(1, "body", &Value::Str("graph embedded database".into()));
1117
1118 let r = idx.search("body", "\"graph database\"", 0);
1119 assert_eq!(r.len(), 1, "only adjacent doc must match phrase");
1120 assert_eq!(r[0].0, 0);
1121 }
1122
1123 #[test]
1124 fn bm25_negation_excludes() {
1125 let mut idx = FulltextIndex::new();
1126 idx.enable("Doc", "body");
1127 idx.add_tokens(0, "body", &Value::Str("graph database embedded".into()));
1128 idx.add_tokens(1, "body", &Value::Str("graph database".into()));
1129
1130 let r = idx.search("body", "-embedded graph", 0);
1132 assert_eq!(r.len(), 1);
1133 assert_eq!(r[0].0, 1);
1134 }
1135
1136 #[test]
1137 fn prefix_search() {
1138 let mut idx = FulltextIndex::new();
1139 idx.enable("Doc", "body");
1140 idx.add_tokens(0, "body", &Value::Str("embedding graph".into()));
1141 idx.add_tokens(1, "body", &Value::Str("python java".into()));
1142
1143 let r = idx.search("body", "emb*", 0);
1144 assert_eq!(r.len(), 1);
1145 assert_eq!(r[0].0, 0);
1146 }
1147
1148 #[test]
1149 fn search_case_insensitive() {
1150 let mut idx = FulltextIndex::new();
1151 idx.enable("Doc", "body");
1152 idx.add_tokens(0, "body", &Value::Str("Rust is great".into()));
1153
1154 assert_eq!(idx.search("body", "RUST", 0).len(), 1);
1156 assert_eq!(idx.search("body", "Rust", 0).len(), 1);
1157 assert_eq!(idx.search("body", "rust", 0).len(), 1);
1158 }
1159
1160 #[test]
1161 fn search_empty_query_returns_empty() {
1162 let mut idx = FulltextIndex::new();
1163 idx.enable("Doc", "body");
1164 idx.add_tokens(0, "body", &Value::Str("hello world".into()));
1165 assert!(idx.search("body", "", 0).is_empty());
1166 assert!(idx.search("body", " ", 0).is_empty());
1167 }
1168
1169 #[test]
1170 fn search_k_truncates() {
1171 let mut idx = FulltextIndex::new();
1172 idx.enable("Doc", "f");
1173 for i in 0..5u32 {
1174 idx.add_tokens(i, "f", &Value::Str(format!("word{i}")));
1175 }
1176 let r = idx.search("f", "word0 OR word1 OR word2 OR word3 OR word4", 3);
1177 assert_eq!(r.len(), 3);
1178 }
1179
1180 #[test]
1181 fn remove_node_field_clears_tokens() {
1182 let mut idx = FulltextIndex::new();
1183 idx.enable("A", "f");
1184 idx.add_tokens(0, "f", &Value::Str("hello world".into()));
1185 idx.remove_node_field(0, "f");
1186 assert!(idx.search("f", "hello", 0).is_empty());
1187 }
1188
1189 #[test]
1190 fn remove_node_clears_all_fields() {
1191 let mut idx = FulltextIndex::new();
1192 idx.enable("A", "f");
1193 idx.enable("A", "g");
1194 idx.add_tokens(0, "f", &Value::Str("foo".into()));
1195 idx.add_tokens(0, "g", &Value::Str("bar".into()));
1196 idx.remove_node(0);
1197 assert!(idx.search("f", "foo", 0).is_empty());
1198 assert!(idx.search("g", "bar", 0).is_empty());
1199 }
1200
1201 #[test]
1202 fn unindexed_field_returns_empty() {
1203 let idx = FulltextIndex::new();
1204 assert!(idx.search("notindexed", "anything", 0).is_empty());
1205 }
1206
1207 #[test]
1208 fn rebuild_all_restores_index() {
1209 let mut ids = IdMap::new();
1210 let mut syms = Interner::new();
1211 let mut labels: Vec<u32> = Vec::new();
1212 let mut props = ColumnStore::new();
1213
1214 let id0 = ids.get_or_insert("k0");
1215 let sym = syms.intern("Person");
1216 labels.resize(id0 as usize + 1, u32::MAX);
1217 labels[id0 as usize] = sym;
1218 props.set(id0, "bio", Value::Str("I love Rust".into()));
1219
1220 let mut idx = FulltextIndex::new();
1221 idx.enable("Person", "bio");
1222 assert!(idx.search("bio", "rust", 0).is_empty());
1223
1224 idx.rebuild_all(
1225 &ids,
1226 &labels,
1227 &syms,
1228 crate::v8::seam::ColumnsView::owned(&props),
1229 );
1230 let r = idx.search("bio", "rust", 0);
1232 assert_eq!(r.len(), 1);
1233 }
1234
1235 #[test]
1237 fn mid_token_star_is_stripped_to_exact() {
1238 let toks = tokenize("ru*st");
1240 assert_eq!(toks, vec!["ru".to_string(), "st".to_string()]);
1241
1242 let groups = parse_query("ru*st");
1245 assert_eq!(groups.len(), 1);
1246 assert_eq!(groups[0].len(), 1);
1247 assert!(!groups[0][0].prefix, "mid-token * must NOT set prefix flag");
1248 assert_eq!(groups[0][0].token, stem("rust")); let mut idx = FulltextIndex::new();
1252 idx.enable("T", "f");
1253 idx.add_tokens(0, "f", &Value::Str("rust embedded".into()));
1254 assert_eq!(idx.search("f", "ru*", 0).len(), 1);
1255 assert_eq!(idx.search("f", "rust", 0).len(), 1);
1256 }
1257
1258 #[test]
1266 fn all_negation_query_returns_empty() {
1267 let mut idx = FulltextIndex::new();
1268 idx.enable("Doc", "body");
1269 idx.add_tokens(0, "body", &Value::Str("graph database embedded".into()));
1270 idx.add_tokens(1, "body", &Value::Str("graph database".into()));
1271
1272 let r = idx.search("body", "-embedded", 0);
1273 assert!(r.is_empty(), "pure negation query must return empty");
1274 }
1275
1276 #[test]
1283 fn negation_only_or_group_contributes_nothing() {
1284 let mut idx = FulltextIndex::new();
1285 idx.enable("Doc", "body");
1286 idx.add_tokens(0, "body", &Value::Str("graph database".into()));
1287 idx.add_tokens(1, "body", &Value::Str("rust embedded".into()));
1288
1289 let keys_plain: Vec<u32> = idx
1290 .search("body", "graph", 0)
1291 .into_iter()
1292 .map(|(id, _)| id)
1293 .collect();
1294 let keys_or_neg: Vec<u32> = idx
1295 .search("body", "graph OR -embedded", 0)
1296 .into_iter()
1297 .map(|(id, _)| id)
1298 .collect();
1299 assert_eq!(
1300 keys_plain, keys_or_neg,
1301 "negation-only OR group must not change result ordering"
1302 );
1303 }
1304
1305 #[test]
1326 fn phrase_adjacency_engine_matches_naive_checker() {
1327 for w in &["graph", "node", "disk", "wal", "commit"] {
1329 assert_eq!(stem(w), *w, "word '{w}' must be its own Snowball stem");
1330 }
1331
1332 let mut idx = FulltextIndex::new();
1333 idx.enable("Doc", "body");
1334 idx.add_tokens(0, "body", &Value::Str("graph node disk".into()));
1335 idx.add_tokens(1, "body", &Value::Str("graph disk node".into()));
1336 idx.add_tokens(2, "body", &Value::Str("commit graph node wal".into()));
1337
1338 let phrase_words: &[&str] = &["graph", "node"];
1341 let naive_check = |doc_text: &str| -> bool {
1342 let mut toks: Vec<String> = Vec::new();
1344 let mut cur = String::new();
1345 for ch in doc_text.chars() {
1346 if ch.is_alphanumeric() {
1347 for lc in ch.to_lowercase() {
1348 cur.push(lc);
1349 }
1350 } else if !cur.is_empty() {
1351 toks.push(std::mem::take(&mut cur));
1352 }
1353 }
1354 if !cur.is_empty() {
1355 toks.push(cur);
1356 }
1357 for i in 0..toks.len() {
1359 if toks[i] == phrase_words[0]
1360 && i + phrase_words.len() <= toks.len()
1361 && phrase_words
1362 .iter()
1363 .enumerate()
1364 .all(|(j, w)| toks[i + j] == *w)
1365 {
1366 return true;
1367 }
1368 }
1369 false
1370 };
1371
1372 let docs = [
1373 (0u32, "graph node disk"),
1374 (1u32, "graph disk node"),
1375 (2u32, "commit graph node wal"),
1376 ];
1377
1378 let engine_ids: BTreeSet<u32> = idx
1379 .search("body", "\"graph node\"", 0)
1380 .into_iter()
1381 .map(|(id, _)| id)
1382 .collect();
1383 let naive_ids: BTreeSet<u32> = docs
1384 .iter()
1385 .filter(|(_, text)| naive_check(text))
1386 .map(|(id, _)| *id)
1387 .collect();
1388
1389 assert_eq!(
1390 engine_ids, naive_ids,
1391 "engine phrase results must agree with independent naive adjacency checker"
1392 );
1393 assert!(engine_ids.contains(&0), "doc 0 (adjacent) must match");
1394 assert!(!engine_ids.contains(&1), "doc 1 (scattered) must not match");
1395 assert!(engine_ids.contains(&2), "doc 2 (preceded) must match");
1396 }
1397
1398 #[test]
1411 fn phrase_does_not_match_across_list_boundary() {
1412 let mut idx = FulltextIndex::new();
1413 idx.enable("Doc", "body");
1414 idx.add_tokens(
1416 0,
1417 "body",
1418 &Value::List(vec![
1419 Value::Str("graph".into()),
1420 Value::Str("database".into()),
1421 ]),
1422 );
1423 idx.add_tokens(1, "body", &Value::Str("graph database".into()));
1425
1426 let r = idx.search("body", "\"graph database\"", 0);
1427 assert_eq!(
1428 r.len(),
1429 1,
1430 "phrase must not match across list element boundary"
1431 );
1432 assert_eq!(r[0].0, 1, "only single-string doc must match");
1433 }
1434}