1use std::borrow::Cow;
33
34use serde::{Deserialize, Serialize};
35
36use crate::error::{QueryError, QueryResult};
37use crate::sql::DatabaseType;
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
41pub enum SearchMode {
42 #[default]
44 Any,
45 All,
47 Phrase,
49 Boolean,
51 Natural,
53}
54
55impl SearchMode {
56 pub fn to_postgres_operator(&self) -> &'static str {
58 match self {
59 Self::Any | Self::Natural => " | ",
60 Self::All | Self::Boolean => " & ",
61 Self::Phrase => " <-> ",
62 }
63 }
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
68pub enum SearchLanguage {
69 Simple,
71 #[default]
73 English,
74 Spanish,
76 French,
78 German,
80 Custom(String),
82}
83
84impl SearchLanguage {
85 pub fn to_postgres_config(&self) -> Cow<'static, str> {
87 match self {
88 Self::Simple => Cow::Borrowed("simple"),
89 Self::English => Cow::Borrowed("english"),
90 Self::Spanish => Cow::Borrowed("spanish"),
91 Self::French => Cow::Borrowed("french"),
92 Self::German => Cow::Borrowed("german"),
93 Self::Custom(name) => Cow::Owned(name.clone()),
94 }
95 }
96
97 pub fn to_sqlite_tokenizer(&self) -> &'static str {
99 match self {
100 Self::Simple => "unicode61",
101 Self::English => "porter unicode61",
102 _ => "unicode61", }
104 }
105}
106
107#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
109pub struct RankingOptions {
110 pub enabled: bool,
112 pub score_alias: String,
114 pub normalization: u32,
116 pub weights: Vec<(String, f32)>,
118}
119
120impl Default for RankingOptions {
121 fn default() -> Self {
122 Self {
123 enabled: false,
124 score_alias: "search_score".to_string(),
125 normalization: 0,
126 weights: Vec::new(),
127 }
128 }
129}
130
131impl RankingOptions {
132 pub fn enabled(mut self) -> Self {
134 self.enabled = true;
135 self
136 }
137
138 pub fn alias(mut self, alias: impl Into<String>) -> Self {
140 self.score_alias = alias.into();
141 self
142 }
143
144 pub fn normalization(mut self, norm: u32) -> Self {
146 self.normalization = norm;
147 self
148 }
149
150 pub fn weight(mut self, field: impl Into<String>, weight: f32) -> Self {
152 self.weights.push((field.into(), weight));
153 self
154 }
155}
156
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
159pub struct HighlightOptions {
160 pub enabled: bool,
162 pub start_tag: String,
164 pub end_tag: String,
166 pub max_length: Option<u32>,
168 pub max_fragments: Option<u32>,
170 pub delimiter: String,
172}
173
174impl Default for HighlightOptions {
175 fn default() -> Self {
176 Self {
177 enabled: false,
178 start_tag: "<b>".to_string(),
179 end_tag: "</b>".to_string(),
180 max_length: Some(150),
181 max_fragments: Some(3),
182 delimiter: " ... ".to_string(),
183 }
184 }
185}
186
187impl HighlightOptions {
188 pub fn enabled(mut self) -> Self {
190 self.enabled = true;
191 self
192 }
193
194 pub fn tags(mut self, start: impl Into<String>, end: impl Into<String>) -> Self {
196 self.start_tag = start.into();
197 self.end_tag = end.into();
198 self
199 }
200
201 pub fn max_length(mut self, length: u32) -> Self {
203 self.max_length = Some(length);
204 self
205 }
206
207 pub fn max_fragments(mut self, count: u32) -> Self {
209 self.max_fragments = Some(count);
210 self
211 }
212
213 pub fn delimiter(mut self, delimiter: impl Into<String>) -> Self {
215 self.delimiter = delimiter.into();
216 self
217 }
218}
219
220#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
222pub struct FuzzyOptions {
223 pub enabled: bool,
225 pub max_edits: u32,
227 pub prefix_length: u32,
229 pub threshold: f32,
231}
232
233impl Default for FuzzyOptions {
234 fn default() -> Self {
235 Self {
236 enabled: false,
237 max_edits: 2,
238 prefix_length: 0,
239 threshold: 0.3,
240 }
241 }
242}
243
244impl FuzzyOptions {
245 pub fn enabled(mut self) -> Self {
247 self.enabled = true;
248 self
249 }
250
251 pub fn max_edits(mut self, edits: u32) -> Self {
253 self.max_edits = edits;
254 self
255 }
256
257 pub fn prefix_length(mut self, length: u32) -> Self {
259 self.prefix_length = length;
260 self
261 }
262
263 pub fn threshold(mut self, threshold: f32) -> Self {
265 self.threshold = threshold;
266 self
267 }
268}
269
270#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
272pub struct SearchQuery {
273 pub query: String,
275 pub columns: Vec<String>,
277 pub mode: SearchMode,
279 pub language: SearchLanguage,
281 pub ranking: RankingOptions,
283 pub highlight: HighlightOptions,
285 pub fuzzy: FuzzyOptions,
287 pub min_word_length: Option<u32>,
289 pub filters: Vec<(String, String)>,
291}
292
293impl SearchQuery {
294 pub fn new(query: impl Into<String>) -> SearchQueryBuilder {
296 SearchQueryBuilder::new(query)
297 }
298
299 pub fn to_postgres_sql(&self, table: &str) -> QueryResult<SearchSql> {
301 let config = self.language.to_postgres_config();
302
303 let tsvector = if self.columns.len() == 1 {
305 format!("to_tsvector('{}', {})", config, self.columns[0])
306 } else {
307 let concat_cols = self.columns.join(" || ' ' || ");
308 format!("to_tsvector('{}', {})", config, concat_cols)
309 };
310
311 let words: Vec<&str> = self.query.split_whitespace().collect();
313 let tsquery_parts: Vec<String> = words
314 .iter()
315 .map(|w| format!("'{}'", w.replace('\'', "''")))
316 .collect();
317 let tsquery = format!(
318 "to_tsquery('{}', '{}')",
319 config,
320 tsquery_parts.join(self.mode.to_postgres_operator())
321 );
322
323 let where_clause = format!("{} @@ {}", tsvector, tsquery);
325
326 let mut select_cols = vec!["*".to_string()];
328
329 if self.ranking.enabled {
331 let weights = if self.ranking.weights.is_empty() {
332 String::new()
333 } else {
334 String::new()
336 };
337 select_cols.push(format!(
338 "ts_rank({}{}, {}) AS {}",
339 tsvector, weights, tsquery, self.ranking.score_alias
340 ));
341 }
342
343 if self.highlight.enabled && !self.columns.is_empty() {
345 let col = &self.columns[0];
346 select_cols.push(format!(
347 "ts_headline('{}', {}, {}, 'StartSel={}, StopSel={}, MaxWords={}, MaxFragments={}') AS highlighted",
348 config,
349 col,
350 tsquery,
351 self.highlight.start_tag,
352 self.highlight.end_tag,
353 self.highlight.max_length.unwrap_or(35),
354 self.highlight.max_fragments.unwrap_or(3)
355 ));
356 }
357
358 let sql = format!(
359 "SELECT {} FROM {} WHERE {}",
360 select_cols.join(", "),
361 table,
362 where_clause
363 );
364
365 let order_by = if self.ranking.enabled {
366 Some(format!("{} DESC", self.ranking.score_alias))
367 } else {
368 None
369 };
370
371 Ok(SearchSql {
372 sql,
373 order_by,
374 params: vec![],
375 })
376 }
377
378 pub fn to_mysql_sql(&self, table: &str) -> QueryResult<SearchSql> {
380 let columns = self.columns.join(", ");
381
382 let match_mode = match self.mode {
384 SearchMode::Natural | SearchMode::Any => "",
385 SearchMode::Boolean | SearchMode::All => " IN BOOLEAN MODE",
386 SearchMode::Phrase => " IN BOOLEAN MODE", };
388
389 let search_query = if self.mode == SearchMode::Phrase {
390 format!("\"{}\"", self.query)
391 } else if self.mode == SearchMode::All {
392 self.query
394 .split_whitespace()
395 .map(|w| format!("+{}", w))
396 .collect::<Vec<_>>()
397 .join(" ")
398 } else {
399 self.query.clone()
400 };
401
402 let match_expr = format!(
403 "MATCH({}) AGAINST('{}'{}))",
404 columns, search_query, match_mode
405 );
406
407 let mut select_cols = vec!["*".to_string()];
408
409 if self.ranking.enabled {
411 select_cols.push(format!("{} AS {}", match_expr, self.ranking.score_alias));
412 }
413
414 let sql = format!(
415 "SELECT {} FROM {} WHERE {}",
416 select_cols.join(", "),
417 table,
418 match_expr
419 );
420
421 let order_by = if self.ranking.enabled {
422 Some(format!("{} DESC", self.ranking.score_alias))
423 } else {
424 None
425 };
426
427 Ok(SearchSql {
428 sql,
429 order_by,
430 params: vec![],
431 })
432 }
433
434 pub fn to_sqlite_sql(&self, table: &str, fts_table: &str) -> QueryResult<SearchSql> {
436 let search_query = match self.mode {
437 SearchMode::Phrase => format!("\"{}\"", self.query),
438 SearchMode::All => self
439 .query
440 .split_whitespace()
441 .collect::<Vec<_>>()
442 .join(" AND "),
443 SearchMode::Any => self
444 .query
445 .split_whitespace()
446 .collect::<Vec<_>>()
447 .join(" OR "),
448 _ => self.query.clone(),
449 };
450
451 let mut select_cols = vec![format!("{}.*", table)];
452
453 if self.ranking.enabled {
455 select_cols.push(format!(
456 "bm25({}) AS {}",
457 fts_table, self.ranking.score_alias
458 ));
459 }
460
461 if self.highlight.enabled && !self.columns.is_empty() {
463 select_cols.push(format!(
464 "highlight({}, 0, '{}', '{}') AS highlighted",
465 fts_table, self.highlight.start_tag, self.highlight.end_tag
466 ));
467 }
468
469 let sql = format!(
470 "SELECT {} FROM {} JOIN {} ON {}.rowid = {}.rowid WHERE {} MATCH '{}'",
471 select_cols.join(", "),
472 table,
473 fts_table,
474 table,
475 fts_table,
476 fts_table,
477 search_query
478 );
479
480 let order_by = if self.ranking.enabled {
481 Some(self.ranking.score_alias.to_string())
482 } else {
483 None
484 };
485
486 Ok(SearchSql {
487 sql,
488 order_by,
489 params: vec![],
490 })
491 }
492
493 pub fn to_mssql_sql(&self, table: &str) -> QueryResult<SearchSql> {
495 let columns = self.columns.join(", ");
496
497 let contains_expr = match self.mode {
498 SearchMode::Phrase => format!("\"{}\"", self.query),
499 SearchMode::All => {
500 let terms: Vec<String> = self
501 .query
502 .split_whitespace()
503 .map(|w| format!("\"{}\"", w))
504 .collect();
505 terms.join(" AND ")
506 }
507 SearchMode::Any | SearchMode::Natural => {
508 let terms: Vec<String> = self
509 .query
510 .split_whitespace()
511 .map(|w| format!("\"{}\"", w))
512 .collect();
513 terms.join(" OR ")
514 }
515 SearchMode::Boolean => self.query.clone(),
516 };
517
518 let select_cols = ["*".to_string()];
519
520 if self.ranking.enabled {
522 let sql = format!(
523 "SELECT {}.*, ft.RANK AS {} FROM {} \
524 INNER JOIN CONTAINSTABLE({}, ({}), '{}') AS ft \
525 ON {}.id = ft.[KEY]",
526 table, self.ranking.score_alias, table, table, columns, contains_expr, table
527 );
528
529 return Ok(SearchSql {
530 sql,
531 order_by: Some(format!("{} DESC", self.ranking.score_alias)),
532 params: vec![],
533 });
534 }
535
536 let sql = format!(
537 "SELECT {} FROM {} WHERE CONTAINS(({}), '{}')",
538 select_cols.join(", "),
539 table,
540 columns,
541 contains_expr
542 );
543
544 Ok(SearchSql {
545 sql,
546 order_by: None,
547 params: vec![],
548 })
549 }
550
551 pub fn to_sql(&self, table: &str, db_type: DatabaseType) -> QueryResult<SearchSql> {
553 match db_type {
554 DatabaseType::PostgreSQL => self.to_postgres_sql(table),
555 DatabaseType::MySQL => self.to_mysql_sql(table),
556 DatabaseType::SQLite => self.to_sqlite_sql(table, &format!("{}_fts", table)),
557 DatabaseType::MSSQL => self.to_mssql_sql(table),
558 }
559 }
560}
561
562#[derive(Debug, Clone)]
564pub struct SearchQueryBuilder {
565 query: String,
566 columns: Vec<String>,
567 mode: SearchMode,
568 language: SearchLanguage,
569 ranking: RankingOptions,
570 highlight: HighlightOptions,
571 fuzzy: FuzzyOptions,
572 min_word_length: Option<u32>,
573 filters: Vec<(String, String)>,
574}
575
576impl SearchQueryBuilder {
577 pub fn new(query: impl Into<String>) -> Self {
579 Self {
580 query: query.into(),
581 columns: Vec::new(),
582 mode: SearchMode::default(),
583 language: SearchLanguage::default(),
584 ranking: RankingOptions::default(),
585 highlight: HighlightOptions::default(),
586 fuzzy: FuzzyOptions::default(),
587 min_word_length: None,
588 filters: Vec::new(),
589 }
590 }
591
592 pub fn column(mut self, column: impl Into<String>) -> Self {
594 self.columns.push(column.into());
595 self
596 }
597
598 pub fn columns(mut self, columns: impl IntoIterator<Item = impl Into<String>>) -> Self {
600 self.columns.extend(columns.into_iter().map(Into::into));
601 self
602 }
603
604 pub fn mode(mut self, mode: SearchMode) -> Self {
606 self.mode = mode;
607 self
608 }
609
610 pub fn match_all(self) -> Self {
612 self.mode(SearchMode::All)
613 }
614
615 pub fn match_any(self) -> Self {
617 self.mode(SearchMode::Any)
618 }
619
620 pub fn phrase(self) -> Self {
622 self.mode(SearchMode::Phrase)
623 }
624
625 pub fn boolean(self) -> Self {
627 self.mode(SearchMode::Boolean)
628 }
629
630 pub fn language(mut self, language: SearchLanguage) -> Self {
632 self.language = language;
633 self
634 }
635
636 pub fn with_ranking(mut self) -> Self {
638 self.ranking.enabled = true;
639 self
640 }
641
642 pub fn ranking(mut self, options: RankingOptions) -> Self {
644 self.ranking = options;
645 self
646 }
647
648 pub fn with_highlight(mut self) -> Self {
650 self.highlight.enabled = true;
651 self
652 }
653
654 pub fn highlight(mut self, options: HighlightOptions) -> Self {
656 self.highlight = options;
657 self
658 }
659
660 pub fn with_fuzzy(mut self) -> Self {
662 self.fuzzy.enabled = true;
663 self
664 }
665
666 pub fn fuzzy(mut self, options: FuzzyOptions) -> Self {
668 self.fuzzy = options;
669 self
670 }
671
672 pub fn min_word_length(mut self, length: u32) -> Self {
674 self.min_word_length = Some(length);
675 self
676 }
677
678 pub fn filter(mut self, field: impl Into<String>, value: impl Into<String>) -> Self {
680 self.filters.push((field.into(), value.into()));
681 self
682 }
683
684 pub fn build(self) -> SearchQuery {
686 SearchQuery {
687 query: self.query,
688 columns: self.columns,
689 mode: self.mode,
690 language: self.language,
691 ranking: self.ranking,
692 highlight: self.highlight,
693 fuzzy: self.fuzzy,
694 min_word_length: self.min_word_length,
695 filters: self.filters,
696 }
697 }
698}
699
700#[derive(Debug, Clone)]
702pub struct SearchSql {
703 pub sql: String,
705 pub order_by: Option<String>,
707 pub params: Vec<String>,
709}
710
711#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
713pub struct FullTextIndex {
714 pub name: String,
716 pub table: String,
718 pub columns: Vec<String>,
720 pub language: SearchLanguage,
722 pub index_type: Option<String>,
724}
725
726impl FullTextIndex {
727 pub fn builder(name: impl Into<String>) -> FullTextIndexBuilder {
729 FullTextIndexBuilder::new(name)
730 }
731
732 pub fn to_postgres_sql(&self) -> String {
734 let config = self.language.to_postgres_config();
735 let columns_expr = if self.columns.len() == 1 {
736 format!("to_tsvector('{}', {})", config, self.columns[0])
737 } else {
738 let concat = self.columns.join(" || ' ' || ");
739 format!("to_tsvector('{}', {})", config, concat)
740 };
741
742 format!(
743 "CREATE INDEX {} ON {} USING GIN ({});",
744 self.name, self.table, columns_expr
745 )
746 }
747
748 pub fn to_mysql_sql(&self) -> String {
750 format!(
751 "CREATE FULLTEXT INDEX {} ON {} ({});",
752 self.name,
753 self.table,
754 self.columns.join(", ")
755 )
756 }
757
758 pub fn to_sqlite_sql(&self) -> String {
760 let tokenizer = self.language.to_sqlite_tokenizer();
761 format!(
762 "CREATE VIRTUAL TABLE {}_fts USING fts5({}, content='{}', tokenize='{}');",
763 self.table,
764 self.columns.join(", "),
765 self.table,
766 tokenizer
767 )
768 }
769
770 pub fn to_mssql_sql(&self, catalog_name: &str) -> Vec<String> {
772 vec![
773 format!("CREATE FULLTEXT CATALOG {} AS DEFAULT;", catalog_name),
774 format!(
775 "CREATE FULLTEXT INDEX ON {} ({}) KEY INDEX PK_{} ON {};",
776 self.table,
777 self.columns.join(", "),
778 self.table,
779 catalog_name
780 ),
781 ]
782 }
783
784 pub fn to_sql(&self, db_type: DatabaseType) -> QueryResult<Vec<String>> {
786 match db_type {
787 DatabaseType::PostgreSQL => Ok(vec![self.to_postgres_sql()]),
788 DatabaseType::MySQL => Ok(vec![self.to_mysql_sql()]),
789 DatabaseType::SQLite => Ok(vec![self.to_sqlite_sql()]),
790 DatabaseType::MSSQL => Ok(self.to_mssql_sql(&format!("{}_catalog", self.table))),
791 }
792 }
793
794 pub fn to_drop_sql(&self, db_type: DatabaseType) -> QueryResult<String> {
796 match db_type {
797 DatabaseType::PostgreSQL => Ok(format!("DROP INDEX IF EXISTS {};", self.name)),
798 DatabaseType::MySQL => Ok(format!("DROP INDEX {} ON {};", self.name, self.table)),
799 DatabaseType::SQLite => Ok(format!("DROP TABLE IF EXISTS {}_fts;", self.table)),
800 DatabaseType::MSSQL => Ok(format!(
801 "DROP FULLTEXT INDEX ON {}; DROP FULLTEXT CATALOG {}_catalog;",
802 self.table, self.table
803 )),
804 }
805 }
806}
807
808#[derive(Debug, Clone)]
810pub struct FullTextIndexBuilder {
811 name: String,
812 table: Option<String>,
813 columns: Vec<String>,
814 language: SearchLanguage,
815 index_type: Option<String>,
816}
817
818impl FullTextIndexBuilder {
819 pub fn new(name: impl Into<String>) -> Self {
821 Self {
822 name: name.into(),
823 table: None,
824 columns: Vec::new(),
825 language: SearchLanguage::default(),
826 index_type: None,
827 }
828 }
829
830 pub fn on_table(mut self, table: impl Into<String>) -> Self {
832 self.table = Some(table.into());
833 self
834 }
835
836 pub fn column(mut self, column: impl Into<String>) -> Self {
838 self.columns.push(column.into());
839 self
840 }
841
842 pub fn columns(mut self, columns: impl IntoIterator<Item = impl Into<String>>) -> Self {
844 self.columns.extend(columns.into_iter().map(Into::into));
845 self
846 }
847
848 pub fn language(mut self, language: SearchLanguage) -> Self {
850 self.language = language;
851 self
852 }
853
854 pub fn build(self) -> QueryResult<FullTextIndex> {
856 let table = self.table.ok_or_else(|| {
857 QueryError::invalid_input("table", "Must specify table with on_table()")
858 })?;
859
860 if self.columns.is_empty() {
861 return Err(QueryError::invalid_input(
862 "columns",
863 "Must specify at least one column",
864 ));
865 }
866
867 Ok(FullTextIndex {
868 name: self.name,
869 table,
870 columns: self.columns,
871 language: self.language,
872 index_type: self.index_type,
873 })
874 }
875}
876
877pub mod mongodb {
879 use serde::{Deserialize, Serialize};
880
881 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
883 pub struct AtlasSearchIndex {
884 pub name: String,
886 pub collection: String,
888 pub analyzer: String,
890 pub mappings: SearchMappings,
892 }
893
894 #[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
896 pub struct SearchMappings {
897 pub dynamic: bool,
899 pub fields: Vec<SearchField>,
901 }
902
903 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
905 pub struct SearchField {
906 pub path: String,
908 pub field_type: SearchFieldType,
910 pub analyzer: Option<String>,
912 pub facet: bool,
914 }
915
916 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
918 pub enum SearchFieldType {
919 String,
921 Number,
923 Date,
925 Boolean,
927 ObjectId,
929 Geo,
931 Autocomplete,
933 }
934
935 impl SearchFieldType {
936 pub fn as_str(&self) -> &'static str {
938 match self {
939 Self::String => "string",
940 Self::Number => "number",
941 Self::Date => "date",
942 Self::Boolean => "boolean",
943 Self::ObjectId => "objectId",
944 Self::Geo => "geo",
945 Self::Autocomplete => "autocomplete",
946 }
947 }
948 }
949
950 #[derive(Debug, Clone, Default)]
952 pub struct AtlasSearchQuery {
953 pub query: String,
955 pub path: Vec<String>,
957 pub fuzzy: Option<FuzzyConfig>,
959 pub score: Option<ScoreConfig>,
961 pub highlight: Option<HighlightConfig>,
963 }
964
965 #[derive(Debug, Clone, Serialize, Deserialize)]
967 pub struct FuzzyConfig {
968 pub max_edits: u32,
970 pub prefix_length: u32,
972 pub max_expansions: u32,
974 }
975
976 impl Default for FuzzyConfig {
977 fn default() -> Self {
978 Self {
979 max_edits: 2,
980 prefix_length: 0,
981 max_expansions: 50,
982 }
983 }
984 }
985
986 #[derive(Debug, Clone, Default, Serialize, Deserialize)]
988 pub struct ScoreConfig {
989 pub boost: Option<f64>,
991 pub function: Option<String>,
993 }
994
995 #[derive(Debug, Clone, Serialize, Deserialize)]
997 pub struct HighlightConfig {
998 pub path: String,
1000 pub max_chars_to_examine: u32,
1002 pub max_num_passages: u32,
1004 }
1005
1006 impl Default for HighlightConfig {
1007 fn default() -> Self {
1008 Self {
1009 path: String::new(),
1010 max_chars_to_examine: 500_000,
1011 max_num_passages: 5,
1012 }
1013 }
1014 }
1015
1016 impl AtlasSearchQuery {
1017 pub fn new(query: impl Into<String>) -> Self {
1019 Self {
1020 query: query.into(),
1021 ..Default::default()
1022 }
1023 }
1024
1025 pub fn path(mut self, path: impl Into<String>) -> Self {
1027 self.path.push(path.into());
1028 self
1029 }
1030
1031 pub fn paths(mut self, paths: impl IntoIterator<Item = impl Into<String>>) -> Self {
1033 self.path.extend(paths.into_iter().map(Into::into));
1034 self
1035 }
1036
1037 pub fn fuzzy(mut self, config: FuzzyConfig) -> Self {
1039 self.fuzzy = Some(config);
1040 self
1041 }
1042
1043 pub fn boost(mut self, factor: f64) -> Self {
1045 self.score = Some(ScoreConfig {
1046 boost: Some(factor),
1047 function: None,
1048 });
1049 self
1050 }
1051
1052 pub fn highlight(mut self, path: impl Into<String>) -> Self {
1054 self.highlight = Some(HighlightConfig {
1055 path: path.into(),
1056 ..Default::default()
1057 });
1058 self
1059 }
1060
1061 pub fn to_search_stage(&self) -> serde_json::Value {
1063 let mut text = serde_json::json!({
1064 "query": self.query,
1065 "path": if self.path.len() == 1 {
1066 serde_json::Value::String(self.path[0].clone())
1067 } else {
1068 serde_json::Value::Array(self.path.iter().map(|p| serde_json::Value::String(p.clone())).collect())
1069 }
1070 });
1071
1072 if let Some(ref fuzzy) = self.fuzzy {
1073 text["fuzzy"] = serde_json::json!({
1074 "maxEdits": fuzzy.max_edits,
1075 "prefixLength": fuzzy.prefix_length,
1076 "maxExpansions": fuzzy.max_expansions
1077 });
1078 }
1079
1080 let mut search = serde_json::json!({
1081 "$search": {
1082 "text": text
1083 }
1084 });
1085
1086 if let Some(ref hl) = self.highlight {
1087 search["$search"]["highlight"] = serde_json::json!({
1088 "path": hl.path,
1089 "maxCharsToExamine": hl.max_chars_to_examine,
1090 "maxNumPassages": hl.max_num_passages
1091 });
1092 }
1093
1094 search
1095 }
1096
1097 pub fn to_pipeline(&self) -> Vec<serde_json::Value> {
1099 let mut pipeline = vec![self.to_search_stage()];
1100
1101 pipeline.push(serde_json::json!({
1103 "$addFields": {
1104 "score": { "$meta": "searchScore" }
1105 }
1106 }));
1107
1108 if self.highlight.is_some() {
1110 pipeline.push(serde_json::json!({
1111 "$addFields": {
1112 "highlights": { "$meta": "searchHighlights" }
1113 }
1114 }));
1115 }
1116
1117 pipeline
1118 }
1119 }
1120
1121 #[derive(Debug, Clone, Default)]
1123 pub struct AtlasSearchIndexBuilder {
1124 name: String,
1125 collection: Option<String>,
1126 analyzer: String,
1127 dynamic: bool,
1128 fields: Vec<SearchField>,
1129 }
1130
1131 impl AtlasSearchIndexBuilder {
1132 pub fn new(name: impl Into<String>) -> Self {
1134 Self {
1135 name: name.into(),
1136 analyzer: "lucene.standard".to_string(),
1137 ..Default::default()
1138 }
1139 }
1140
1141 pub fn collection(mut self, collection: impl Into<String>) -> Self {
1143 self.collection = Some(collection.into());
1144 self
1145 }
1146
1147 pub fn analyzer(mut self, analyzer: impl Into<String>) -> Self {
1149 self.analyzer = analyzer.into();
1150 self
1151 }
1152
1153 pub fn dynamic(mut self) -> Self {
1155 self.dynamic = true;
1156 self
1157 }
1158
1159 pub fn text_field(mut self, path: impl Into<String>) -> Self {
1161 self.fields.push(SearchField {
1162 path: path.into(),
1163 field_type: SearchFieldType::String,
1164 analyzer: None,
1165 facet: false,
1166 });
1167 self
1168 }
1169
1170 pub fn facet_field(mut self, path: impl Into<String>, field_type: SearchFieldType) -> Self {
1172 self.fields.push(SearchField {
1173 path: path.into(),
1174 field_type,
1175 analyzer: None,
1176 facet: true,
1177 });
1178 self
1179 }
1180
1181 pub fn autocomplete_field(mut self, path: impl Into<String>) -> Self {
1183 self.fields.push(SearchField {
1184 path: path.into(),
1185 field_type: SearchFieldType::Autocomplete,
1186 analyzer: None,
1187 facet: false,
1188 });
1189 self
1190 }
1191
1192 pub fn build(self) -> serde_json::Value {
1194 let mut fields = serde_json::Map::new();
1195
1196 for field in &self.fields {
1197 let mut field_def = serde_json::json!({
1198 "type": field.field_type.as_str()
1199 });
1200
1201 if let Some(ref analyzer) = field.analyzer {
1202 field_def["analyzer"] = serde_json::Value::String(analyzer.clone());
1203 }
1204
1205 fields.insert(field.path.clone(), field_def);
1206 }
1207
1208 serde_json::json!({
1209 "name": self.name,
1210 "analyzer": self.analyzer,
1211 "mappings": {
1212 "dynamic": self.dynamic,
1213 "fields": fields
1214 }
1215 })
1216 }
1217 }
1218
1219 pub fn search(query: impl Into<String>) -> AtlasSearchQuery {
1221 AtlasSearchQuery::new(query)
1222 }
1223
1224 pub fn search_index(name: impl Into<String>) -> AtlasSearchIndexBuilder {
1226 AtlasSearchIndexBuilder::new(name)
1227 }
1228}
1229
1230#[cfg(test)]
1231mod tests {
1232 use super::*;
1233
1234 #[test]
1235 fn test_search_query_builder() {
1236 let search = SearchQuery::new("rust async")
1237 .columns(["title", "body"])
1238 .match_all()
1239 .with_ranking()
1240 .build();
1241
1242 assert_eq!(search.query, "rust async");
1243 assert_eq!(search.columns, vec!["title", "body"]);
1244 assert_eq!(search.mode, SearchMode::All);
1245 assert!(search.ranking.enabled);
1246 }
1247
1248 #[test]
1249 fn test_postgres_search_sql() {
1250 let search = SearchQuery::new("rust programming")
1251 .column("content")
1252 .with_ranking()
1253 .build();
1254
1255 let sql = search.to_postgres_sql("posts").unwrap();
1256 assert!(sql.sql.contains("to_tsvector"));
1257 assert!(sql.sql.contains("to_tsquery"));
1258 assert!(sql.sql.contains("ts_rank"));
1259 assert!(sql.sql.contains("@@"));
1260 }
1261
1262 #[test]
1263 fn test_mysql_search_sql() {
1264 let search = SearchQuery::new("database performance")
1265 .columns(["title", "body"])
1266 .match_any()
1267 .build();
1268
1269 let sql = search.to_mysql_sql("articles").unwrap();
1270 assert!(sql.sql.contains("MATCH"));
1271 assert!(sql.sql.contains("AGAINST"));
1272 }
1273
1274 #[test]
1275 fn test_sqlite_search_sql() {
1276 let search = SearchQuery::new("web development")
1277 .column("content")
1278 .with_ranking()
1279 .build();
1280
1281 let sql = search.to_sqlite_sql("posts", "posts_fts").unwrap();
1282 assert!(sql.sql.contains("MATCH"));
1283 assert!(sql.sql.contains("bm25"));
1284 }
1285
1286 #[test]
1287 fn test_mssql_search_sql() {
1288 let search = SearchQuery::new("machine learning")
1289 .columns(["title", "abstract"])
1290 .phrase()
1291 .build();
1292
1293 let sql = search.to_mssql_sql("papers").unwrap();
1294 assert!(sql.sql.contains("CONTAINS"));
1295 }
1296
1297 #[test]
1298 fn test_mssql_ranked_search() {
1299 let search = SearchQuery::new("neural network")
1300 .column("content")
1301 .with_ranking()
1302 .build();
1303
1304 let sql = search.to_mssql_sql("papers").unwrap();
1305 assert!(sql.sql.contains("CONTAINSTABLE"));
1306 assert!(sql.sql.contains("RANK"));
1307 }
1308
1309 #[test]
1310 fn test_fulltext_index_postgres() {
1311 let index = FullTextIndex::builder("posts_search_idx")
1312 .on_table("posts")
1313 .columns(["title", "body"])
1314 .language(SearchLanguage::English)
1315 .build()
1316 .unwrap();
1317
1318 let sql = index.to_postgres_sql();
1319 assert!(sql.contains("CREATE INDEX posts_search_idx"));
1320 assert!(sql.contains("USING GIN"));
1321 assert!(sql.contains("to_tsvector"));
1322 }
1323
1324 #[test]
1325 fn test_fulltext_index_mysql() {
1326 let index = FullTextIndex::builder("posts_fulltext")
1327 .on_table("posts")
1328 .columns(["title", "body"])
1329 .build()
1330 .unwrap();
1331
1332 let sql = index.to_mysql_sql();
1333 assert_eq!(
1334 sql,
1335 "CREATE FULLTEXT INDEX posts_fulltext ON posts (title, body);"
1336 );
1337 }
1338
1339 #[test]
1340 fn test_fulltext_index_sqlite() {
1341 let index = FullTextIndex::builder("posts_fts")
1342 .on_table("posts")
1343 .columns(["title", "content"])
1344 .build()
1345 .unwrap();
1346
1347 let sql = index.to_sqlite_sql();
1348 assert!(sql.contains("CREATE VIRTUAL TABLE"));
1349 assert!(sql.contains("USING fts5"));
1350 }
1351
1352 #[test]
1353 fn test_highlight_options() {
1354 let opts = HighlightOptions::default()
1355 .enabled()
1356 .tags("<mark>", "</mark>")
1357 .max_length(200)
1358 .max_fragments(5);
1359
1360 assert!(opts.enabled);
1361 assert_eq!(opts.start_tag, "<mark>");
1362 assert_eq!(opts.end_tag, "</mark>");
1363 assert_eq!(opts.max_length, Some(200));
1364 }
1365
1366 #[test]
1367 fn test_fuzzy_options() {
1368 let opts = FuzzyOptions::default()
1369 .enabled()
1370 .max_edits(1)
1371 .threshold(0.5);
1372
1373 assert!(opts.enabled);
1374 assert_eq!(opts.max_edits, 1);
1375 assert_eq!(opts.threshold, 0.5);
1376 }
1377
1378 #[test]
1379 fn test_ranking_with_weights() {
1380 let opts = RankingOptions::default()
1381 .enabled()
1382 .alias("relevance")
1383 .weight("title", 2.0)
1384 .weight("body", 1.0);
1385
1386 assert_eq!(opts.score_alias, "relevance");
1387 assert_eq!(opts.weights.len(), 2);
1388 }
1389
1390 mod mongodb_tests {
1391 use super::super::mongodb::*;
1392
1393 #[test]
1394 fn test_atlas_search_query() {
1395 let query = search("rust async")
1396 .paths(["title", "body"])
1397 .fuzzy(FuzzyConfig::default())
1398 .boost(2.0);
1399
1400 let stage = query.to_search_stage();
1401 assert!(stage["$search"]["text"]["query"].is_string());
1402 }
1403
1404 #[test]
1405 fn test_atlas_search_pipeline() {
1406 let query = search("database").path("content").highlight("content");
1407
1408 let pipeline = query.to_pipeline();
1409 assert!(pipeline.len() >= 2);
1410 assert!(pipeline[0]["$search"].is_object());
1411 }
1412
1413 #[test]
1414 fn test_atlas_search_index_builder() {
1415 let index = search_index("default")
1416 .collection("posts")
1417 .analyzer("lucene.english")
1418 .dynamic()
1419 .text_field("title")
1420 .text_field("body")
1421 .facet_field("category", SearchFieldType::String)
1422 .build();
1423
1424 assert!(index["name"].is_string());
1425 assert!(index["mappings"]["dynamic"].as_bool().unwrap());
1426 }
1427 }
1428}