use crate::executor::expression::functions::signature::{FunctionSignature, ValueType};
use crate::executor::expression::{ExpressionError, ExpressionErrorType};
use linkrs_core::Value;
use linkrs_fulltext::FulltextSearchEntry;
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FulltextFunction {
Score,
Highlight,
MatchedFields,
Snippet,
Rank,
SearchMatch,
FieldScore,
}
impl FulltextFunction {
pub fn name(&self) -> &'static str {
match self {
FulltextFunction::Score => "score",
FulltextFunction::Highlight => "highlight",
FulltextFunction::MatchedFields => "matched_fields",
FulltextFunction::Snippet => "snippet",
FulltextFunction::Rank => "rank",
FulltextFunction::SearchMatch => "search_match",
FulltextFunction::FieldScore => "field_score",
}
}
pub fn signature(&self) -> FunctionSignature {
match self {
FulltextFunction::Score => {
FunctionSignature::new("score", vec![], Some(ValueType::Float), false)
}
FulltextFunction::Highlight => FunctionSignature::new(
"highlight",
vec![
ValueType::String,
ValueType::String,
ValueType::String,
ValueType::Int,
],
Some(ValueType::String),
true,
),
FulltextFunction::MatchedFields => {
FunctionSignature::new("matched_fields", vec![], Some(ValueType::List), false)
}
FulltextFunction::Snippet => FunctionSignature::new(
"snippet",
vec![ValueType::String, ValueType::Int],
Some(ValueType::String),
true,
),
FulltextFunction::Rank => {
FunctionSignature::new("rank", vec![], Some(ValueType::Int), false)
}
FulltextFunction::SearchMatch => FunctionSignature::new(
"search_match",
vec![ValueType::String, ValueType::String],
Some(ValueType::Bool),
false,
),
FulltextFunction::FieldScore => FunctionSignature::new(
"field_score",
vec![ValueType::String],
Some(ValueType::Float),
false,
),
}
}
pub fn arity(&self) -> usize {
match self {
FulltextFunction::Score => 0,
FulltextFunction::Highlight => 4,
FulltextFunction::MatchedFields => 0,
FulltextFunction::Snippet => 2,
FulltextFunction::Rank => 0,
FulltextFunction::SearchMatch => 2,
FulltextFunction::FieldScore => 1,
}
}
pub fn is_variadic(&self) -> bool {
match self {
FulltextFunction::Score => false,
FulltextFunction::Highlight => true,
FulltextFunction::MatchedFields => false,
FulltextFunction::Snippet => true,
FulltextFunction::Rank => false,
FulltextFunction::SearchMatch => false,
FulltextFunction::FieldScore => false,
}
}
pub fn description(&self) -> &'static str {
match self {
FulltextFunction::Score => "Get the relevance score of a document in full-text search",
FulltextFunction::Highlight => "Get highlighted text fragments for a field",
FulltextFunction::MatchedFields => "Get the list of matched fields",
FulltextFunction::Snippet => "Get a text snippet from a field",
FulltextFunction::Rank => "Get the rank position of a document in search results",
FulltextFunction::SearchMatch => "Check if a field matches a search query pattern",
FulltextFunction::FieldScore => "Get the field-specific relevance score",
}
}
pub fn execute(
&self,
args: &[Value],
context: &FulltextExecutionContext,
) -> Result<Value, ExpressionError> {
match self {
FulltextFunction::Score => self.execute_score(args, context),
FulltextFunction::Highlight => self.execute_highlight(args, context),
FulltextFunction::MatchedFields => self.execute_matched_fields(args, context),
FulltextFunction::Snippet => self.execute_snippet(args, context),
FulltextFunction::Rank => self.execute_rank(args, context),
FulltextFunction::SearchMatch => self.execute_search_match(args, context),
FulltextFunction::FieldScore => self.execute_field_score(args, context),
}
}
fn execute_score(
&self,
args: &[Value],
context: &FulltextExecutionContext,
) -> Result<Value, ExpressionError> {
if !args.is_empty() {
return Err(ExpressionError::new(
ExpressionErrorType::InvalidArgumentCount,
format!("score() expects 0 arguments, got {}", args.len()),
));
}
Ok(Value::Double(context.score))
}
fn execute_highlight(
&self,
args: &[Value],
context: &FulltextExecutionContext,
) -> Result<Value, ExpressionError> {
if args.is_empty() {
return Err(ExpressionError::new(
ExpressionErrorType::InvalidArgumentCount,
"highlight() expects at least 1 argument (field name)",
));
}
let field_name = match &args[0] {
Value::String(s) => s.to_string(),
_ => {
return Err(ExpressionError::new(
ExpressionErrorType::TypeError,
"highlight() first argument must be a string (field name)",
));
}
};
let pre_tag = args
.get(1)
.and_then(|v| {
if let Value::String(s) = v {
Some(s.to_string())
} else {
None
}
})
.unwrap_or_else(|| "<em>".to_string());
let post_tag = args
.get(2)
.and_then(|v| {
if let Value::String(s) = v {
Some(s.to_string())
} else {
None
}
})
.unwrap_or_else(|| "</em>".to_string());
let fragment_size = args
.get(3)
.and_then(|v| {
if let Value::Int(n) = v {
Some(*n as usize)
} else {
None
}
})
.unwrap_or(100);
if let Some(highlights) = &context.highlights {
if let Some(field_highlights) = highlights.get(field_name.as_str()) {
if !field_highlights.is_empty() {
let highlighted_text = field_highlights.join(" ... ");
return Ok(Value::string(highlighted_text));
}
}
}
if let Some(source) = &context.source {
if let Some(Value::String(text)) = source.get(field_name.as_str()) {
let char_count = text.chars().count();
if char_count > fragment_size {
let truncated: String = text.chars().take(fragment_size).collect();
return Ok(Value::string(format!(
"{}{}{}",
pre_tag, truncated, post_tag
)));
}
return Ok(Value::string(text.clone()));
}
}
Ok(Value::Null(linkrs_core::null::NullType::Null))
}
fn execute_matched_fields(
&self,
args: &[Value],
context: &FulltextExecutionContext,
) -> Result<Value, ExpressionError> {
if !args.is_empty() {
return Err(ExpressionError::new(
ExpressionErrorType::InvalidArgumentCount,
"matched_fields() expects 0 arguments",
));
}
let fields: Vec<Value> = context
.matched_fields
.iter()
.map(|f| Value::string(f.clone()))
.collect();
Ok(Value::list(linkrs_core::value::list::List {
values: fields,
}))
}
fn execute_snippet(
&self,
args: &[Value],
context: &FulltextExecutionContext,
) -> Result<Value, ExpressionError> {
if args.is_empty() {
return Err(ExpressionError::new(
ExpressionErrorType::InvalidArgumentCount,
"snippet() expects at least 1 argument (field name)",
));
}
let field_name = match &args[0] {
Value::String(s) => s.to_string(),
_ => {
return Err(ExpressionError::new(
ExpressionErrorType::TypeError,
"snippet() first argument must be a string (field name)",
));
}
};
let max_len = args
.get(1)
.and_then(|v| {
if let Value::Int(n) = v {
Some(*n as usize)
} else {
None
}
})
.unwrap_or(200);
if let Some(source) = &context.source {
if let Some(Value::String(text)) = source.get(field_name.as_str()) {
if text.chars().count() <= max_len {
return Ok(Value::string(text.clone()));
}
let prefix: String = text.chars().take(max_len).collect();
let break_point = prefix.rfind(' ').unwrap_or(prefix.len());
return Ok(Value::string(format!("{}...", &prefix[..break_point])));
}
}
Ok(Value::Null(linkrs_core::null::NullType::Null))
}
fn execute_rank(
&self,
args: &[Value],
context: &FulltextExecutionContext,
) -> Result<Value, ExpressionError> {
if !args.is_empty() {
return Err(ExpressionError::new(
ExpressionErrorType::InvalidArgumentCount,
format!("rank() expects 0 arguments, got {}", args.len()),
));
}
let rank = if context.score > 0.0 {
((1.0 / context.score) * 100.0) as i64
} else {
0
};
Ok(Value::BigInt(rank))
}
fn execute_search_match(
&self,
args: &[Value],
context: &FulltextExecutionContext,
) -> Result<Value, ExpressionError> {
if args.len() < 2 {
return Err(ExpressionError::new(
ExpressionErrorType::InvalidArgumentCount,
"search_match() expects 2 arguments (field, query)",
));
}
let field_name = match &args[0] {
Value::String(s) => s.to_string(),
_ => {
return Err(ExpressionError::new(
ExpressionErrorType::TypeError,
"search_match() first argument must be a string (field name)",
));
}
};
let query = match &args[1] {
Value::String(s) => s.to_lowercase(),
_ => {
return Err(ExpressionError::new(
ExpressionErrorType::TypeError,
"search_match() second argument must be a string (query)",
));
}
};
if let Some(source) = &context.source {
if let Some(Value::String(text)) = source.get(field_name.as_str()) {
return Ok(Value::Bool(text.to_lowercase().contains(query.as_str())));
}
}
Ok(Value::Bool(false))
}
fn execute_field_score(
&self,
args: &[Value],
context: &FulltextExecutionContext,
) -> Result<Value, ExpressionError> {
if args.is_empty() {
return Err(ExpressionError::new(
ExpressionErrorType::InvalidArgumentCount,
"field_score() expects 1 argument (field name)",
));
}
let field_name = match &args[0] {
Value::String(s) => s.to_string(),
_ => {
return Err(ExpressionError::new(
ExpressionErrorType::TypeError,
"field_score() first argument must be a string (field name)",
));
}
};
if context.matched_fields.contains(&field_name) {
let field_bonus = if context
.highlights
.as_ref()
.is_some_and(|h| h.contains_key(field_name.as_str()))
{
1.5
} else {
1.0
};
return Ok(Value::Double(context.score * field_bonus));
}
Ok(Value::Double(0.0))
}
}
#[derive(Debug, Clone)]
pub struct FulltextExecutionContext {
pub score: f64,
pub highlights: Option<HashMap<String, Vec<String>>>,
pub matched_fields: Vec<String>,
pub source: Option<HashMap<String, Value>>,
}
impl Default for FulltextExecutionContext {
fn default() -> Self {
Self {
score: 0.0,
highlights: None,
matched_fields: Vec::new(),
source: None,
}
}
}
impl FulltextExecutionContext {
pub fn new() -> Self {
Self::default()
}
pub fn from_search_entry(entry: &FulltextSearchEntry) -> Self {
Self {
score: entry.score,
highlights: entry.highlights.clone(),
matched_fields: entry.matched_fields.clone(),
source: entry.source.clone(),
}
}
pub fn with_score(mut self, score: f64) -> Self {
self.score = score;
self
}
pub fn with_highlights(mut self, highlights: HashMap<String, Vec<String>>) -> Self {
self.highlights = Some(highlights);
self
}
pub fn with_matched_fields(mut self, fields: Vec<String>) -> Self {
self.matched_fields = fields;
self
}
pub fn with_source(mut self, source: HashMap<String, Value>) -> Self {
self.source = Some(source);
self
}
}
pub fn register_fulltext_functions(
registry: &mut crate::executor::expression::functions::FunctionRegistry,
) {
registry.register_builtin(
crate::executor::expression::functions::BuiltinFunction::Fulltext(FulltextFunction::Score),
);
registry.register_builtin(
crate::executor::expression::functions::BuiltinFunction::Fulltext(
FulltextFunction::Highlight,
),
);
registry.register_builtin(
crate::executor::expression::functions::BuiltinFunction::Fulltext(
FulltextFunction::MatchedFields,
),
);
registry.register_builtin(
crate::executor::expression::functions::BuiltinFunction::Fulltext(
FulltextFunction::Snippet,
),
);
registry.register_builtin(
crate::executor::expression::functions::BuiltinFunction::Fulltext(FulltextFunction::Rank),
);
registry.register_builtin(
crate::executor::expression::functions::BuiltinFunction::Fulltext(
FulltextFunction::SearchMatch,
),
);
registry.register_builtin(
crate::executor::expression::functions::BuiltinFunction::Fulltext(
FulltextFunction::FieldScore,
),
);
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_context() -> FulltextExecutionContext {
let mut source = HashMap::new();
source.insert("title".into(), Value::string("Database Optimization"));
source.insert(
"content".to_string(),
Value::string("This is a test article about database optimization techniques."),
);
let mut highlights = HashMap::new();
highlights.insert(
"content".to_string(),
vec!["<em>database</em> optimization".to_string()],
);
FulltextExecutionContext {
score: 0.85,
highlights: Some(highlights),
matched_fields: vec!["title".to_string(), "content".to_string()],
source: Some(source),
}
}
#[test]
fn test_score_function() {
let func = FulltextFunction::Score;
let context = create_test_context();
let result = func.execute(&[], &context).unwrap();
assert!(matches!(result, Value::Double(_)));
if let Value::Double(score) = result {
assert!((score - 0.85).abs() < 0.001);
}
}
#[test]
fn test_highlight_function() {
let func = FulltextFunction::Highlight;
let context = create_test_context();
let result = func.execute(&[Value::string("content")], &context).unwrap();
assert!(matches!(result, Value::String(_)));
if let Value::String(text) = result {
assert!(text.contains("<em>"));
assert!(text.contains("</em>"));
}
}
#[test]
fn test_matched_fields_function() {
let func = FulltextFunction::MatchedFields;
let context = create_test_context();
let result = func.execute(&[], &context).unwrap();
assert!(matches!(result, Value::List(_)));
if let Value::List(fields) = result {
assert_eq!(fields.len(), 2);
assert!(fields.contains(&Value::string("title")));
assert!(fields.contains(&Value::string("content")));
}
}
#[test]
fn test_snippet_function() {
let func = FulltextFunction::Snippet;
let context = create_test_context();
let result = func
.execute(&[Value::string("content"), Value::Int(50)], &context)
.unwrap();
assert!(matches!(result, Value::String(_)));
if let Value::String(text) = result {
assert!(text.len() <= 53); }
}
#[test]
fn test_snippet_non_ascii_truncation() {
let func = FulltextFunction::Snippet;
let mut source = HashMap::new();
source.insert(
"content".to_string(),
Value::string("你好世界这是一个测试内容"),
);
let context = FulltextExecutionContext {
source: Some(source),
..FulltextExecutionContext::default()
};
let result = func
.execute(&[Value::string("content"), Value::Int(5)], &context)
.unwrap();
assert_eq!(result, Value::string("你好世界这..."));
}
#[test]
fn test_highlight_non_ascii_truncation() {
let func = FulltextFunction::Highlight;
let mut source = HashMap::new();
source.insert("title".into(), Value::string("数据库优化指南"));
let context = FulltextExecutionContext {
source: Some(source),
..FulltextExecutionContext::default()
};
let result = func
.execute(
&[
Value::string("title"),
Value::string("<b>"),
Value::string("</b>"),
Value::Int(2),
],
&context,
)
.unwrap();
assert_eq!(result, Value::string("<b>数据</b>"));
}
}