use std::collections::HashSet;
use nodedb_types::text_search::TextSearchParams;
use crate::row_decode::HitSource;
use crate::row_decode::search_hit::ID_COLUMN;
use crate::sql_escape::{quote_identifier, quote_string_literal};
use super::key_filter::key_in_list;
const SCORE_COLUMN: &str = "score";
pub(crate) fn text_hit_source(collection: &str) -> HitSource<'_> {
HitSource {
op: "text_search",
collection,
score_column: SCORE_COLUMN,
}
}
pub(crate) struct TextSearchRequest<'a> {
pub collection: &'a str,
pub field: &'a str,
pub query: &'a str,
pub top_k: usize,
pub params: &'a TextSearchParams,
pub allowed: Option<(&'a str, &'a HashSet<String>)>,
}
pub(crate) fn text_search_sql(request: &TextSearchRequest<'_>) -> String {
let target = text_search_target(request.field);
let q = quote_string_literal(request.query);
let opts = text_search_options(request.params);
let allowed = match request.allowed {
Some((key, ids)) => format!(" AND {}", key_in_list(key, ids)),
None => String::new(),
};
format!(
"SELECT {ID_COLUMN}, bm25_score({target}, {q}{opts}) AS {SCORE_COLUMN} \
FROM {} WHERE text_match({target}, {q}{opts}){allowed} \
ORDER BY {SCORE_COLUMN} DESC LIMIT {}",
quote_identifier(request.collection),
request.top_k
)
}
fn text_search_options(params: &TextSearchParams) -> String {
let defaults = TextSearchParams::default();
let mut options = String::new();
if params.mode != defaults.mode {
options.push_str(&format!(", mode => '{}'", params.mode.as_str()));
}
if params.fuzzy != defaults.fuzzy {
options.push_str(&format!(", fuzzy => {}", params.fuzzy));
}
options
}
fn text_search_target(field: &str) -> String {
if field.is_empty() {
"*".to_string()
} else {
quote_identifier(field)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::row_decode::decode_search_hits;
use nodedb_types::text_search::QueryMode;
use nodedb_types::value::Value;
fn request<'a>(
query: &'a str,
top_k: usize,
params: &'a TextSearchParams,
allowed: Option<(&'a str, &'a HashSet<String>)>,
) -> TextSearchRequest<'a> {
TextSearchRequest {
collection: "docs",
field: "body",
query,
top_k,
params,
allowed,
}
}
#[test]
fn an_empty_field_searches_the_whole_document() {
assert_eq!(text_search_target(""), "*");
assert_eq!(text_search_target("body"), "\"body\"");
assert_eq!(text_search_target("we\"ird"), "\"we\"\"ird\"");
}
#[test]
fn the_statement_ranks_best_first_before_the_limit() {
let params = TextSearchParams::default();
let sql = text_search_sql(&request("it's", 5, ¶ms, None));
assert_eq!(
sql,
"SELECT id, bm25_score(\"body\", 'it''s') AS score FROM \"docs\" \
WHERE text_match(\"body\", 'it''s') ORDER BY score DESC LIMIT 5"
);
}
#[test]
fn allowed_ids_restrict_the_candidates_on_the_key_column() {
let ids: HashSet<String> = ["b", "a'"].iter().map(|s| s.to_string()).collect();
let params = TextSearchParams::default();
let sql = text_search_sql(&request("q", 3, ¶ms, Some(("sku", &ids))));
assert!(
sql.contains("WHERE text_match(\"body\", 'q') AND \"sku\" IN ('a''', 'b') ORDER BY"),
"{sql}"
);
}
#[test]
fn default_params_render_no_option() {
assert_eq!(text_search_options(&TextSearchParams::default()), "");
}
#[test]
fn non_default_params_render_on_both_calls() {
let params = TextSearchParams {
mode: QueryMode::And,
fuzzy: true,
};
assert_eq!(
text_search_options(¶ms),
", mode => 'and', fuzzy => true"
);
let sql = text_search_sql(&request("q", 3, ¶ms, None));
assert!(
sql.contains("bm25_score(\"body\", 'q', mode => 'and', fuzzy => true) AS score"),
"{sql}"
);
assert!(
sql.contains("WHERE text_match(\"body\", 'q', mode => 'and', fuzzy => true) ORDER"),
"{sql}"
);
let fuzzy_only = TextSearchParams {
mode: QueryMode::Or,
fuzzy: true,
};
assert_eq!(text_search_options(&fuzzy_only), ", fuzzy => true");
}
#[test]
fn hits_decode_from_the_score_column() {
let rows = vec![vec![
Value::String("0.5".into()),
Value::String("d1".into()),
]];
let names = vec!["score".to_string(), "id".to_string()];
let hits = decode_search_hits(&text_hit_source("c"), &names, &rows).expect("decode");
assert_eq!(hits[0].id, "d1");
assert_eq!(hits[0].distance, 0.5);
let err = decode_search_hits(&text_hit_source("c"), &names[1..], &rows)
.expect_err("no score column");
assert!(err.to_string().contains("'score'"), "{err}");
}
}