use std::borrow::Cow;
use serde::{Deserialize, Serialize};
use crate::error::{QueryError, QueryResult};
use crate::sql::DatabaseType;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
pub enum SearchMode {
#[default]
Any,
All,
Phrase,
Boolean,
Natural,
}
impl SearchMode {
pub fn to_postgres_operator(&self) -> &'static str {
match self {
Self::Any | Self::Natural => " | ",
Self::All | Self::Boolean => " & ",
Self::Phrase => " <-> ",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SearchLanguage {
Simple,
English,
Spanish,
French,
German,
Custom(String),
}
impl SearchLanguage {
pub fn to_postgres_config(&self) -> Cow<'static, str> {
match self {
Self::Simple => Cow::Borrowed("simple"),
Self::English => Cow::Borrowed("english"),
Self::Spanish => Cow::Borrowed("spanish"),
Self::French => Cow::Borrowed("french"),
Self::German => Cow::Borrowed("german"),
Self::Custom(name) => Cow::Owned(name.clone()),
}
}
pub fn to_sqlite_tokenizer(&self) -> &'static str {
match self {
Self::Simple => "unicode61",
Self::English => "porter unicode61",
_ => "unicode61", }
}
}
impl Default for SearchLanguage {
fn default() -> Self {
Self::English
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RankingOptions {
pub enabled: bool,
pub score_alias: String,
pub normalization: u32,
pub weights: Vec<(String, f32)>,
}
impl Default for RankingOptions {
fn default() -> Self {
Self {
enabled: false,
score_alias: "search_score".to_string(),
normalization: 0,
weights: Vec::new(),
}
}
}
impl RankingOptions {
pub fn enabled(mut self) -> Self {
self.enabled = true;
self
}
pub fn alias(mut self, alias: impl Into<String>) -> Self {
self.score_alias = alias.into();
self
}
pub fn normalization(mut self, norm: u32) -> Self {
self.normalization = norm;
self
}
pub fn weight(mut self, field: impl Into<String>, weight: f32) -> Self {
self.weights.push((field.into(), weight));
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HighlightOptions {
pub enabled: bool,
pub start_tag: String,
pub end_tag: String,
pub max_length: Option<u32>,
pub max_fragments: Option<u32>,
pub delimiter: String,
}
impl Default for HighlightOptions {
fn default() -> Self {
Self {
enabled: false,
start_tag: "<b>".to_string(),
end_tag: "</b>".to_string(),
max_length: Some(150),
max_fragments: Some(3),
delimiter: " ... ".to_string(),
}
}
}
impl HighlightOptions {
pub fn enabled(mut self) -> Self {
self.enabled = true;
self
}
pub fn tags(mut self, start: impl Into<String>, end: impl Into<String>) -> Self {
self.start_tag = start.into();
self.end_tag = end.into();
self
}
pub fn max_length(mut self, length: u32) -> Self {
self.max_length = Some(length);
self
}
pub fn max_fragments(mut self, count: u32) -> Self {
self.max_fragments = Some(count);
self
}
pub fn delimiter(mut self, delimiter: impl Into<String>) -> Self {
self.delimiter = delimiter.into();
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FuzzyOptions {
pub enabled: bool,
pub max_edits: u32,
pub prefix_length: u32,
pub threshold: f32,
}
impl Default for FuzzyOptions {
fn default() -> Self {
Self {
enabled: false,
max_edits: 2,
prefix_length: 0,
threshold: 0.3,
}
}
}
impl FuzzyOptions {
pub fn enabled(mut self) -> Self {
self.enabled = true;
self
}
pub fn max_edits(mut self, edits: u32) -> Self {
self.max_edits = edits;
self
}
pub fn prefix_length(mut self, length: u32) -> Self {
self.prefix_length = length;
self
}
pub fn threshold(mut self, threshold: f32) -> Self {
self.threshold = threshold;
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SearchQuery {
pub query: String,
pub columns: Vec<String>,
pub mode: SearchMode,
pub language: SearchLanguage,
pub ranking: RankingOptions,
pub highlight: HighlightOptions,
pub fuzzy: FuzzyOptions,
pub min_word_length: Option<u32>,
pub filters: Vec<(String, String)>,
}
impl SearchQuery {
pub fn new(query: impl Into<String>) -> SearchQueryBuilder {
SearchQueryBuilder::new(query)
}
pub fn to_postgres_sql(&self, table: &str) -> QueryResult<SearchSql> {
let config = self.language.to_postgres_config();
let tsvector = if self.columns.len() == 1 {
format!("to_tsvector('{}', {})", config, self.columns[0])
} else {
let concat_cols = self.columns.join(" || ' ' || ");
format!("to_tsvector('{}', {})", config, concat_cols)
};
let words: Vec<&str> = self.query.split_whitespace().collect();
let tsquery_parts: Vec<String> = words
.iter()
.map(|w| format!("'{}'", w.replace('\'', "''")))
.collect();
let tsquery = format!(
"to_tsquery('{}', '{}')",
config,
tsquery_parts.join(self.mode.to_postgres_operator())
);
let where_clause = format!("{} @@ {}", tsvector, tsquery);
let mut select_cols = vec!["*".to_string()];
if self.ranking.enabled {
let weights = if self.ranking.weights.is_empty() {
String::new()
} else {
String::new()
};
select_cols.push(format!(
"ts_rank({}{}, {}) AS {}",
tsvector, weights, tsquery, self.ranking.score_alias
));
}
if self.highlight.enabled && !self.columns.is_empty() {
let col = &self.columns[0];
select_cols.push(format!(
"ts_headline('{}', {}, {}, 'StartSel={}, StopSel={}, MaxWords={}, MaxFragments={}') AS highlighted",
config,
col,
tsquery,
self.highlight.start_tag,
self.highlight.end_tag,
self.highlight.max_length.unwrap_or(35),
self.highlight.max_fragments.unwrap_or(3)
));
}
let sql = format!(
"SELECT {} FROM {} WHERE {}",
select_cols.join(", "),
table,
where_clause
);
let order_by = if self.ranking.enabled {
Some(format!("{} DESC", self.ranking.score_alias))
} else {
None
};
Ok(SearchSql {
sql,
order_by,
params: vec![],
})
}
pub fn to_mysql_sql(&self, table: &str) -> QueryResult<SearchSql> {
let columns = self.columns.join(", ");
let match_mode = match self.mode {
SearchMode::Natural | SearchMode::Any => "",
SearchMode::Boolean | SearchMode::All => " IN BOOLEAN MODE",
SearchMode::Phrase => " IN BOOLEAN MODE", };
let search_query = if self.mode == SearchMode::Phrase {
format!("\"{}\"", self.query)
} else if self.mode == SearchMode::All {
self.query
.split_whitespace()
.map(|w| format!("+{}", w))
.collect::<Vec<_>>()
.join(" ")
} else {
self.query.clone()
};
let match_expr = format!(
"MATCH({}) AGAINST('{}'{}))",
columns, search_query, match_mode
);
let mut select_cols = vec!["*".to_string()];
if self.ranking.enabled {
select_cols.push(format!("{} AS {}", match_expr, self.ranking.score_alias));
}
let sql = format!(
"SELECT {} FROM {} WHERE {}",
select_cols.join(", "),
table,
match_expr
);
let order_by = if self.ranking.enabled {
Some(format!("{} DESC", self.ranking.score_alias))
} else {
None
};
Ok(SearchSql {
sql,
order_by,
params: vec![],
})
}
pub fn to_sqlite_sql(&self, table: &str, fts_table: &str) -> QueryResult<SearchSql> {
let search_query = match self.mode {
SearchMode::Phrase => format!("\"{}\"", self.query),
SearchMode::All => self
.query
.split_whitespace()
.collect::<Vec<_>>()
.join(" AND "),
SearchMode::Any => self
.query
.split_whitespace()
.collect::<Vec<_>>()
.join(" OR "),
_ => self.query.clone(),
};
let mut select_cols = vec![format!("{}.*", table)];
if self.ranking.enabled {
select_cols.push(format!(
"bm25({}) AS {}",
fts_table, self.ranking.score_alias
));
}
if self.highlight.enabled && !self.columns.is_empty() {
select_cols.push(format!(
"highlight({}, 0, '{}', '{}') AS highlighted",
fts_table, self.highlight.start_tag, self.highlight.end_tag
));
}
let sql = format!(
"SELECT {} FROM {} JOIN {} ON {}.rowid = {}.rowid WHERE {} MATCH '{}'",
select_cols.join(", "),
table,
fts_table,
table,
fts_table,
fts_table,
search_query
);
let order_by = if self.ranking.enabled {
Some(format!("{}", self.ranking.score_alias))
} else {
None
};
Ok(SearchSql {
sql,
order_by,
params: vec![],
})
}
pub fn to_mssql_sql(&self, table: &str) -> QueryResult<SearchSql> {
let columns = self.columns.join(", ");
let contains_expr = match self.mode {
SearchMode::Phrase => format!("\"{}\"", self.query),
SearchMode::All => {
let terms: Vec<String> = self
.query
.split_whitespace()
.map(|w| format!("\"{}\"", w))
.collect();
terms.join(" AND ")
}
SearchMode::Any | SearchMode::Natural => {
let terms: Vec<String> = self
.query
.split_whitespace()
.map(|w| format!("\"{}\"", w))
.collect();
terms.join(" OR ")
}
SearchMode::Boolean => self.query.clone(),
};
let select_cols = vec!["*".to_string()];
if self.ranking.enabled {
let sql = format!(
"SELECT {}.*, ft.RANK AS {} FROM {} \
INNER JOIN CONTAINSTABLE({}, ({}), '{}') AS ft \
ON {}.id = ft.[KEY]",
table, self.ranking.score_alias, table, table, columns, contains_expr, table
);
return Ok(SearchSql {
sql,
order_by: Some(format!("{} DESC", self.ranking.score_alias)),
params: vec![],
});
}
let sql = format!(
"SELECT {} FROM {} WHERE CONTAINS(({}), '{}')",
select_cols.join(", "),
table,
columns,
contains_expr
);
Ok(SearchSql {
sql,
order_by: None,
params: vec![],
})
}
pub fn to_sql(&self, table: &str, db_type: DatabaseType) -> QueryResult<SearchSql> {
match db_type {
DatabaseType::PostgreSQL => self.to_postgres_sql(table),
DatabaseType::MySQL => self.to_mysql_sql(table),
DatabaseType::SQLite => self.to_sqlite_sql(table, &format!("{}_fts", table)),
DatabaseType::MSSQL => self.to_mssql_sql(table),
}
}
}
#[derive(Debug, Clone)]
pub struct SearchQueryBuilder {
query: String,
columns: Vec<String>,
mode: SearchMode,
language: SearchLanguage,
ranking: RankingOptions,
highlight: HighlightOptions,
fuzzy: FuzzyOptions,
min_word_length: Option<u32>,
filters: Vec<(String, String)>,
}
impl SearchQueryBuilder {
pub fn new(query: impl Into<String>) -> Self {
Self {
query: query.into(),
columns: Vec::new(),
mode: SearchMode::default(),
language: SearchLanguage::default(),
ranking: RankingOptions::default(),
highlight: HighlightOptions::default(),
fuzzy: FuzzyOptions::default(),
min_word_length: None,
filters: Vec::new(),
}
}
pub fn column(mut self, column: impl Into<String>) -> Self {
self.columns.push(column.into());
self
}
pub fn columns(mut self, columns: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.columns.extend(columns.into_iter().map(Into::into));
self
}
pub fn mode(mut self, mode: SearchMode) -> Self {
self.mode = mode;
self
}
pub fn match_all(self) -> Self {
self.mode(SearchMode::All)
}
pub fn match_any(self) -> Self {
self.mode(SearchMode::Any)
}
pub fn phrase(self) -> Self {
self.mode(SearchMode::Phrase)
}
pub fn boolean(self) -> Self {
self.mode(SearchMode::Boolean)
}
pub fn language(mut self, language: SearchLanguage) -> Self {
self.language = language;
self
}
pub fn with_ranking(mut self) -> Self {
self.ranking.enabled = true;
self
}
pub fn ranking(mut self, options: RankingOptions) -> Self {
self.ranking = options;
self
}
pub fn with_highlight(mut self) -> Self {
self.highlight.enabled = true;
self
}
pub fn highlight(mut self, options: HighlightOptions) -> Self {
self.highlight = options;
self
}
pub fn with_fuzzy(mut self) -> Self {
self.fuzzy.enabled = true;
self
}
pub fn fuzzy(mut self, options: FuzzyOptions) -> Self {
self.fuzzy = options;
self
}
pub fn min_word_length(mut self, length: u32) -> Self {
self.min_word_length = Some(length);
self
}
pub fn filter(mut self, field: impl Into<String>, value: impl Into<String>) -> Self {
self.filters.push((field.into(), value.into()));
self
}
pub fn build(self) -> SearchQuery {
SearchQuery {
query: self.query,
columns: self.columns,
mode: self.mode,
language: self.language,
ranking: self.ranking,
highlight: self.highlight,
fuzzy: self.fuzzy,
min_word_length: self.min_word_length,
filters: self.filters,
}
}
}
#[derive(Debug, Clone)]
pub struct SearchSql {
pub sql: String,
pub order_by: Option<String>,
pub params: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FullTextIndex {
pub name: String,
pub table: String,
pub columns: Vec<String>,
pub language: SearchLanguage,
pub index_type: Option<String>,
}
impl FullTextIndex {
pub fn builder(name: impl Into<String>) -> FullTextIndexBuilder {
FullTextIndexBuilder::new(name)
}
pub fn to_postgres_sql(&self) -> String {
let config = self.language.to_postgres_config();
let columns_expr = if self.columns.len() == 1 {
format!("to_tsvector('{}', {})", config, self.columns[0])
} else {
let concat = self.columns.join(" || ' ' || ");
format!("to_tsvector('{}', {})", config, concat)
};
format!(
"CREATE INDEX {} ON {} USING GIN ({});",
self.name, self.table, columns_expr
)
}
pub fn to_mysql_sql(&self) -> String {
format!(
"CREATE FULLTEXT INDEX {} ON {} ({});",
self.name,
self.table,
self.columns.join(", ")
)
}
pub fn to_sqlite_sql(&self) -> String {
let tokenizer = self.language.to_sqlite_tokenizer();
format!(
"CREATE VIRTUAL TABLE {}_fts USING fts5({}, content='{}', tokenize='{}');",
self.table,
self.columns.join(", "),
self.table,
tokenizer
)
}
pub fn to_mssql_sql(&self, catalog_name: &str) -> Vec<String> {
vec![
format!("CREATE FULLTEXT CATALOG {} AS DEFAULT;", catalog_name),
format!(
"CREATE FULLTEXT INDEX ON {} ({}) KEY INDEX PK_{} ON {};",
self.table,
self.columns.join(", "),
self.table,
catalog_name
),
]
}
pub fn to_sql(&self, db_type: DatabaseType) -> QueryResult<Vec<String>> {
match db_type {
DatabaseType::PostgreSQL => Ok(vec![self.to_postgres_sql()]),
DatabaseType::MySQL => Ok(vec![self.to_mysql_sql()]),
DatabaseType::SQLite => Ok(vec![self.to_sqlite_sql()]),
DatabaseType::MSSQL => Ok(self.to_mssql_sql(&format!("{}_catalog", self.table))),
}
}
pub fn to_drop_sql(&self, db_type: DatabaseType) -> QueryResult<String> {
match db_type {
DatabaseType::PostgreSQL => Ok(format!("DROP INDEX IF EXISTS {};", self.name)),
DatabaseType::MySQL => Ok(format!("DROP INDEX {} ON {};", self.name, self.table)),
DatabaseType::SQLite => Ok(format!("DROP TABLE IF EXISTS {}_fts;", self.table)),
DatabaseType::MSSQL => Ok(format!(
"DROP FULLTEXT INDEX ON {}; DROP FULLTEXT CATALOG {}_catalog;",
self.table, self.table
)),
}
}
}
#[derive(Debug, Clone)]
pub struct FullTextIndexBuilder {
name: String,
table: Option<String>,
columns: Vec<String>,
language: SearchLanguage,
index_type: Option<String>,
}
impl FullTextIndexBuilder {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
table: None,
columns: Vec::new(),
language: SearchLanguage::default(),
index_type: None,
}
}
pub fn on_table(mut self, table: impl Into<String>) -> Self {
self.table = Some(table.into());
self
}
pub fn column(mut self, column: impl Into<String>) -> Self {
self.columns.push(column.into());
self
}
pub fn columns(mut self, columns: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.columns.extend(columns.into_iter().map(Into::into));
self
}
pub fn language(mut self, language: SearchLanguage) -> Self {
self.language = language;
self
}
pub fn build(self) -> QueryResult<FullTextIndex> {
let table = self.table.ok_or_else(|| {
QueryError::invalid_input("table", "Must specify table with on_table()")
})?;
if self.columns.is_empty() {
return Err(QueryError::invalid_input(
"columns",
"Must specify at least one column",
));
}
Ok(FullTextIndex {
name: self.name,
table,
columns: self.columns,
language: self.language,
index_type: self.index_type,
})
}
}
pub mod mongodb {
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AtlasSearchIndex {
pub name: String,
pub collection: String,
pub analyzer: String,
pub mappings: SearchMappings,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct SearchMappings {
pub dynamic: bool,
pub fields: Vec<SearchField>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SearchField {
pub path: String,
pub field_type: SearchFieldType,
pub analyzer: Option<String>,
pub facet: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SearchFieldType {
String,
Number,
Date,
Boolean,
ObjectId,
Geo,
Autocomplete,
}
impl SearchFieldType {
pub fn as_str(&self) -> &'static str {
match self {
Self::String => "string",
Self::Number => "number",
Self::Date => "date",
Self::Boolean => "boolean",
Self::ObjectId => "objectId",
Self::Geo => "geo",
Self::Autocomplete => "autocomplete",
}
}
}
#[derive(Debug, Clone, Default)]
pub struct AtlasSearchQuery {
pub query: String,
pub path: Vec<String>,
pub fuzzy: Option<FuzzyConfig>,
pub score: Option<ScoreConfig>,
pub highlight: Option<HighlightConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FuzzyConfig {
pub max_edits: u32,
pub prefix_length: u32,
pub max_expansions: u32,
}
impl Default for FuzzyConfig {
fn default() -> Self {
Self {
max_edits: 2,
prefix_length: 0,
max_expansions: 50,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ScoreConfig {
pub boost: Option<f64>,
pub function: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HighlightConfig {
pub path: String,
pub max_chars_to_examine: u32,
pub max_num_passages: u32,
}
impl Default for HighlightConfig {
fn default() -> Self {
Self {
path: String::new(),
max_chars_to_examine: 500_000,
max_num_passages: 5,
}
}
}
impl AtlasSearchQuery {
pub fn new(query: impl Into<String>) -> Self {
Self {
query: query.into(),
..Default::default()
}
}
pub fn path(mut self, path: impl Into<String>) -> Self {
self.path.push(path.into());
self
}
pub fn paths(mut self, paths: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.path.extend(paths.into_iter().map(Into::into));
self
}
pub fn fuzzy(mut self, config: FuzzyConfig) -> Self {
self.fuzzy = Some(config);
self
}
pub fn boost(mut self, factor: f64) -> Self {
self.score = Some(ScoreConfig {
boost: Some(factor),
function: None,
});
self
}
pub fn highlight(mut self, path: impl Into<String>) -> Self {
self.highlight = Some(HighlightConfig {
path: path.into(),
..Default::default()
});
self
}
pub fn to_search_stage(&self) -> serde_json::Value {
let mut text = serde_json::json!({
"query": self.query,
"path": if self.path.len() == 1 {
serde_json::Value::String(self.path[0].clone())
} else {
serde_json::Value::Array(self.path.iter().map(|p| serde_json::Value::String(p.clone())).collect())
}
});
if let Some(ref fuzzy) = self.fuzzy {
text["fuzzy"] = serde_json::json!({
"maxEdits": fuzzy.max_edits,
"prefixLength": fuzzy.prefix_length,
"maxExpansions": fuzzy.max_expansions
});
}
let mut search = serde_json::json!({
"$search": {
"text": text
}
});
if let Some(ref hl) = self.highlight {
search["$search"]["highlight"] = serde_json::json!({
"path": hl.path,
"maxCharsToExamine": hl.max_chars_to_examine,
"maxNumPassages": hl.max_num_passages
});
}
search
}
pub fn to_pipeline(&self) -> Vec<serde_json::Value> {
let mut pipeline = vec![self.to_search_stage()];
pipeline.push(serde_json::json!({
"$addFields": {
"score": { "$meta": "searchScore" }
}
}));
if self.highlight.is_some() {
pipeline.push(serde_json::json!({
"$addFields": {
"highlights": { "$meta": "searchHighlights" }
}
}));
}
pipeline
}
}
#[derive(Debug, Clone, Default)]
pub struct AtlasSearchIndexBuilder {
name: String,
collection: Option<String>,
analyzer: String,
dynamic: bool,
fields: Vec<SearchField>,
}
impl AtlasSearchIndexBuilder {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
analyzer: "lucene.standard".to_string(),
..Default::default()
}
}
pub fn collection(mut self, collection: impl Into<String>) -> Self {
self.collection = Some(collection.into());
self
}
pub fn analyzer(mut self, analyzer: impl Into<String>) -> Self {
self.analyzer = analyzer.into();
self
}
pub fn dynamic(mut self) -> Self {
self.dynamic = true;
self
}
pub fn text_field(mut self, path: impl Into<String>) -> Self {
self.fields.push(SearchField {
path: path.into(),
field_type: SearchFieldType::String,
analyzer: None,
facet: false,
});
self
}
pub fn facet_field(mut self, path: impl Into<String>, field_type: SearchFieldType) -> Self {
self.fields.push(SearchField {
path: path.into(),
field_type,
analyzer: None,
facet: true,
});
self
}
pub fn autocomplete_field(mut self, path: impl Into<String>) -> Self {
self.fields.push(SearchField {
path: path.into(),
field_type: SearchFieldType::Autocomplete,
analyzer: None,
facet: false,
});
self
}
pub fn build(self) -> serde_json::Value {
let mut fields = serde_json::Map::new();
for field in &self.fields {
let mut field_def = serde_json::json!({
"type": field.field_type.as_str()
});
if let Some(ref analyzer) = field.analyzer {
field_def["analyzer"] = serde_json::Value::String(analyzer.clone());
}
fields.insert(field.path.clone(), field_def);
}
serde_json::json!({
"name": self.name,
"analyzer": self.analyzer,
"mappings": {
"dynamic": self.dynamic,
"fields": fields
}
})
}
}
pub fn search(query: impl Into<String>) -> AtlasSearchQuery {
AtlasSearchQuery::new(query)
}
pub fn search_index(name: impl Into<String>) -> AtlasSearchIndexBuilder {
AtlasSearchIndexBuilder::new(name)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_search_query_builder() {
let search = SearchQuery::new("rust async")
.columns(["title", "body"])
.match_all()
.with_ranking()
.build();
assert_eq!(search.query, "rust async");
assert_eq!(search.columns, vec!["title", "body"]);
assert_eq!(search.mode, SearchMode::All);
assert!(search.ranking.enabled);
}
#[test]
fn test_postgres_search_sql() {
let search = SearchQuery::new("rust programming")
.column("content")
.with_ranking()
.build();
let sql = search.to_postgres_sql("posts").unwrap();
assert!(sql.sql.contains("to_tsvector"));
assert!(sql.sql.contains("to_tsquery"));
assert!(sql.sql.contains("ts_rank"));
assert!(sql.sql.contains("@@"));
}
#[test]
fn test_mysql_search_sql() {
let search = SearchQuery::new("database performance")
.columns(["title", "body"])
.match_any()
.build();
let sql = search.to_mysql_sql("articles").unwrap();
assert!(sql.sql.contains("MATCH"));
assert!(sql.sql.contains("AGAINST"));
}
#[test]
fn test_sqlite_search_sql() {
let search = SearchQuery::new("web development")
.column("content")
.with_ranking()
.build();
let sql = search.to_sqlite_sql("posts", "posts_fts").unwrap();
assert!(sql.sql.contains("MATCH"));
assert!(sql.sql.contains("bm25"));
}
#[test]
fn test_mssql_search_sql() {
let search = SearchQuery::new("machine learning")
.columns(["title", "abstract"])
.phrase()
.build();
let sql = search.to_mssql_sql("papers").unwrap();
assert!(sql.sql.contains("CONTAINS"));
}
#[test]
fn test_mssql_ranked_search() {
let search = SearchQuery::new("neural network")
.column("content")
.with_ranking()
.build();
let sql = search.to_mssql_sql("papers").unwrap();
assert!(sql.sql.contains("CONTAINSTABLE"));
assert!(sql.sql.contains("RANK"));
}
#[test]
fn test_fulltext_index_postgres() {
let index = FullTextIndex::builder("posts_search_idx")
.on_table("posts")
.columns(["title", "body"])
.language(SearchLanguage::English)
.build()
.unwrap();
let sql = index.to_postgres_sql();
assert!(sql.contains("CREATE INDEX posts_search_idx"));
assert!(sql.contains("USING GIN"));
assert!(sql.contains("to_tsvector"));
}
#[test]
fn test_fulltext_index_mysql() {
let index = FullTextIndex::builder("posts_fulltext")
.on_table("posts")
.columns(["title", "body"])
.build()
.unwrap();
let sql = index.to_mysql_sql();
assert_eq!(
sql,
"CREATE FULLTEXT INDEX posts_fulltext ON posts (title, body);"
);
}
#[test]
fn test_fulltext_index_sqlite() {
let index = FullTextIndex::builder("posts_fts")
.on_table("posts")
.columns(["title", "content"])
.build()
.unwrap();
let sql = index.to_sqlite_sql();
assert!(sql.contains("CREATE VIRTUAL TABLE"));
assert!(sql.contains("USING fts5"));
}
#[test]
fn test_highlight_options() {
let opts = HighlightOptions::default()
.enabled()
.tags("<mark>", "</mark>")
.max_length(200)
.max_fragments(5);
assert!(opts.enabled);
assert_eq!(opts.start_tag, "<mark>");
assert_eq!(opts.end_tag, "</mark>");
assert_eq!(opts.max_length, Some(200));
}
#[test]
fn test_fuzzy_options() {
let opts = FuzzyOptions::default()
.enabled()
.max_edits(1)
.threshold(0.5);
assert!(opts.enabled);
assert_eq!(opts.max_edits, 1);
assert_eq!(opts.threshold, 0.5);
}
#[test]
fn test_ranking_with_weights() {
let opts = RankingOptions::default()
.enabled()
.alias("relevance")
.weight("title", 2.0)
.weight("body", 1.0);
assert_eq!(opts.score_alias, "relevance");
assert_eq!(opts.weights.len(), 2);
}
mod mongodb_tests {
use super::super::mongodb::*;
#[test]
fn test_atlas_search_query() {
let query = search("rust async")
.paths(["title", "body"])
.fuzzy(FuzzyConfig::default())
.boost(2.0);
let stage = query.to_search_stage();
assert!(stage["$search"]["text"]["query"].is_string());
}
#[test]
fn test_atlas_search_pipeline() {
let query = search("database").path("content").highlight("content");
let pipeline = query.to_pipeline();
assert!(pipeline.len() >= 2);
assert!(pipeline[0]["$search"].is_object());
}
#[test]
fn test_atlas_search_index_builder() {
let index = search_index("default")
.collection("posts")
.analyzer("lucene.english")
.dynamic()
.text_field("title")
.text_field("body")
.facet_field("category", SearchFieldType::String)
.build();
assert!(index["name"].is_string());
assert!(index["mappings"]["dynamic"].as_bool().unwrap());
}
}
}