use nodedb_types::Value;
pub fn try_eval_fts(name: &str, args: &[Value]) -> Option<Value> {
match name {
"pg_fts_match" => Some(eval_pg_fts_match(args)),
"pg_to_tsquery" | "pg_plainto_tsquery" | "pg_websearch_to_tsquery" => {
Some(eval_tsquery_passthrough(args))
}
"pg_phraseto_tsquery" => {
Some(Value::Null)
}
"pg_to_tsvector" => {
Some(
args.last()
.and_then(|v| {
if let Value::String(s) = v {
Some(Value::String(s.clone()))
} else {
None
}
})
.unwrap_or(Value::Null),
)
}
"pg_ts_rank" => {
Some(Value::Float(0.0))
}
"pg_ts_headline" => Some(eval_pg_ts_headline(args)),
_ => None,
}
}
fn eval_pg_fts_match(args: &[Value]) -> Value {
let doc = match args.first() {
Some(Value::String(s)) => s.to_ascii_lowercase(),
_ => return Value::Null,
};
let query = match args.get(1) {
Some(Value::String(s)) => s.clone(),
_ => return Value::Null,
};
let terms = extract_terms(&query);
if terms.is_empty() {
return Value::Bool(false);
}
Value::Bool(terms.iter().all(|t| doc.contains(t.as_str())))
}
fn extract_terms(query: &str) -> Vec<String> {
query
.split(['&', '|', '!', '(', ')'])
.flat_map(|s| s.split_whitespace())
.map(|s| s.trim_end_matches(":*").to_ascii_lowercase())
.filter(|s| !s.is_empty())
.collect()
}
fn eval_tsquery_passthrough(args: &[Value]) -> Value {
let query_val = if args.len() >= 2 {
args.get(1)
} else {
args.first()
};
match query_val {
Some(Value::String(s)) => Value::String(s.clone()),
_ => Value::Null,
}
}
fn eval_pg_ts_headline(args: &[Value]) -> Value {
let doc = match args.first() {
Some(Value::String(s)) => s.clone(),
_ => return Value::Null,
};
let query = match args.get(1) {
Some(Value::String(s)) => s.clone(),
_ => return Value::Null,
};
let terms = extract_terms(&query);
if terms.is_empty() {
return Value::String(doc);
}
let lower_doc = doc.to_ascii_lowercase();
let mut result = doc.clone();
let mut replacements: Vec<(usize, usize)> = Vec::new();
for term in &terms {
let mut search_from = 0;
while let Some(pos) = lower_doc[search_from..].find(term.as_str()) {
let abs_pos = search_from + pos;
replacements.push((abs_pos, abs_pos + term.len()));
search_from = abs_pos + term.len();
}
}
replacements.sort_by_key(|r| std::cmp::Reverse(r.0));
replacements.dedup_by(|a, b| a.0 >= b.0 && a.0 < b.1);
for (start, end) in &replacements {
if *end <= result.len() {
let matched = result[*start..*end].to_string();
result.replace_range(*start..*end, &format!("<b>{matched}</b>"));
}
}
Value::String(result)
}
#[cfg(test)]
mod tests {
use super::*;
fn s(v: &str) -> Value {
Value::String(v.to_string())
}
#[test]
fn fts_match_all_present() {
let result = eval_pg_fts_match(&[
s("The quick brown fox jumps over the lazy dog"),
s("quick & fox"),
]);
assert_eq!(result, Value::Bool(true));
}
#[test]
fn fts_match_missing_term() {
let result = eval_pg_fts_match(&[s("The quick brown fox"), s("quick & cat")]);
assert_eq!(result, Value::Bool(false));
}
#[test]
fn fts_match_prefix_stripped() {
let result = eval_pg_fts_match(&[s("rustlang is great"), s("rust:*")]);
assert_eq!(result, Value::Bool(true));
}
#[test]
fn fts_match_null_doc() {
let result = eval_pg_fts_match(&[Value::Null, s("query")]);
assert_eq!(result, Value::Null);
}
#[test]
fn tsquery_passthrough_one_arg() {
let result = eval_tsquery_passthrough(&[s("rust & lang")]);
assert_eq!(result, s("rust & lang"));
}
#[test]
fn tsquery_passthrough_two_args_uses_second() {
let result = eval_tsquery_passthrough(&[s("english"), s("rust")]);
assert_eq!(result, s("rust"));
}
#[test]
fn ts_headline_wraps_terms() {
let result = eval_pg_ts_headline(&[s("The quick brown fox jumps"), s("quick & fox")]);
match result {
Value::String(s) => {
assert!(s.contains("<b>quick</b>") || s.contains("<b>fox</b>"));
}
other => panic!("expected String, got {other:?}"),
}
}
}