use async_trait::async_trait;
use sqlx::{Row, SqlitePool};
use xz_memory_core::{Entry, IndexSearcher, ScoredEntry, SearchOptions, StoreError};
pub struct Fts5IndexSearcher {
pool: SqlitePool,
table_name: String,
}
impl Fts5IndexSearcher {
pub async fn new(pool: SqlitePool, table_name: &str) -> Result<Self, StoreError> {
let searcher = Self { pool, table_name: table_name.to_string() };
searcher.create_table().await?;
Ok(searcher)
}
async fn create_table(&self) -> Result<(), StoreError> {
let sql = format!(
"CREATE VIRTUAL TABLE IF NOT EXISTS {table} USING fts5(\
entry_id, \
partition, \
body, \
recorded_at, \
tokenize='unicode61'\
)",
table = self.table_name
);
sqlx::query(&sql)
.execute(&self.pool)
.await
.map_err(|e| StoreError::Backend(e.to_string()))?;
Ok(())
}
pub async fn index_entry(&self, entry: &Entry) -> Result<(), StoreError> {
self.remove_entry(&entry.id).await?;
let sql = format!(
"INSERT INTO {table} (entry_id, partition, body, recorded_at) VALUES (?, ?, ?, ?)",
table = self.table_name
);
sqlx::query(&sql)
.bind(&entry.id)
.bind(&entry.partition)
.bind(&entry.body)
.bind(entry.recorded_at as i64)
.execute(&self.pool)
.await
.map_err(|e| StoreError::Backend(e.to_string()))?;
Ok(())
}
pub async fn remove_entry(&self, id: &str) -> Result<(), StoreError> {
let sql = format!(
"DELETE FROM {table} WHERE entry_id = ?",
table = self.table_name
);
sqlx::query(&sql)
.bind(id)
.execute(&self.pool)
.await
.map_err(|e| StoreError::Backend(e.to_string()))?;
Ok(())
}
}
#[async_trait]
impl IndexSearcher for Fts5IndexSearcher {
async fn search(
&self,
partitions: &[String],
query: &str,
opts: &SearchOptions,
) -> Result<Vec<ScoredEntry>, StoreError> {
if query.trim().is_empty() {
return Ok(vec![]);
}
let partition_filter = if partitions.is_empty() {
String::new()
} else {
let placeholders: Vec<&str> = vec!["?"; partitions.len()];
format!(" AND partition IN ({})", placeholders.join(", "))
};
let sql = format!(
"SELECT entry_id, partition, body, recorded_at, \
bm25({table}) as rank \
FROM {table} \
WHERE {table} MATCH ?{partition_filter} \
ORDER BY rank \
LIMIT ?",
table = self.table_name,
partition_filter = partition_filter,
);
let mut q = sqlx::query(&sql).bind(query);
for p in partitions {
q = q.bind(p);
}
q = q.bind(opts.limit as i64);
let rows = q.fetch_all(&self.pool).await.map_err(|e| StoreError::Backend(e.to_string()))?;
if rows.is_empty() {
return Ok(vec![]);
}
let mut raw_scores: Vec<f64> = Vec::with_capacity(rows.len());
let mut raw_entries: Vec<Entry> = Vec::with_capacity(rows.len());
for row in &rows {
raw_entries.push(Entry {
id: row.get("entry_id"),
partition: row.get("partition"),
body: row.get("body"),
recorded_at: row.get::<i64, _>("recorded_at") as u64,
});
raw_scores.push(row.get("rank"));
}
let normalized = normalize_scores(&raw_scores);
let mut results: Vec<ScoredEntry> = raw_entries
.into_iter()
.zip(normalized.into_iter())
.map(|(entry, relevance)| ScoredEntry { entry, relevance })
.collect();
if let Some(min_rel) = opts.min_relevance {
results.retain(|se| se.relevance >= min_rel);
}
results.truncate(opts.limit);
Ok(results)
}
}
fn normalize_scores(scores: &[f64]) -> Vec<f32> {
if scores.is_empty() {
return vec![];
}
if scores.len() == 1 {
return vec![1.0_f32];
}
let min_score = scores.iter().cloned().fold(f64::INFINITY, f64::min);
let max_score = scores.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let range = max_score - min_score;
if range.abs() < f64::EPSILON {
return vec![1.0_f32; scores.len()];
}
scores.iter().map(|&s| ((s - min_score) / range) as f32).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use xz_memory_core::SearchOptions;
async fn new_searcher(table_name: &str) -> (SqlitePool, Fts5IndexSearcher) {
let pool = SqlitePool::connect("sqlite::memory:").await.expect("failed to create pool");
let searcher = Fts5IndexSearcher::new(pool.clone(), table_name)
.await
.expect("failed to create searcher");
(pool, searcher)
}
async fn insert_entry(
pool: &SqlitePool,
table_name: &str,
id: &str,
partition: &str,
body: &str,
recorded_at: u64,
) {
let sql = format!(
"INSERT INTO {table} (entry_id, partition, body, recorded_at) VALUES (?, ?, ?, ?)",
table = table_name
);
sqlx::query(&sql)
.bind(id)
.bind(partition)
.bind(body)
.bind(recorded_at as i64)
.execute(pool)
.await
.expect("failed to insert test entry");
}
#[tokio::test]
async fn search_with_entries_returns_results() {
let (pool, searcher) = new_searcher("test_fts_1").await;
insert_entry(&pool, "test_fts_1", "e1", "facts", "Tokyo is the capital of Japan", 1000)
.await;
insert_entry(&pool, "test_fts_1", "e2", "facts", "Paris is the capital of France", 2000)
.await;
insert_entry(&pool, "test_fts_1", "e3", "other", "random data not matching", 3000).await;
let opts = SearchOptions { limit: 10, min_relevance: None };
let results =
searcher.search(&["facts".to_string()], "Tokyo", &opts).await.expect("search failed");
assert!(!results.is_empty(), "expected non-empty results");
assert_eq!(results.len(), 1, "should match exactly one entry");
assert_eq!(results[0].entry.id, "e1");
assert!(
results[0].relevance >= 0.0 && results[0].relevance <= 1.0,
"relevance {} must be in [0.0, 1.0]",
results[0].relevance
);
}
#[tokio::test]
async fn search_multiple_matches_returns_ranked() {
let (pool, searcher) = new_searcher("test_fts_2").await;
insert_entry(&pool, "test_fts_2", "e1", "facts", "machine learning is powerful", 1000)
.await;
insert_entry(
&pool,
"test_fts_2",
"e2",
"facts",
"deep learning with machine techniques",
2000,
)
.await;
insert_entry(&pool, "test_fts_2", "e3", "facts", "learning to program in Rust", 3000).await;
let opts = SearchOptions { limit: 10, min_relevance: None };
let results = searcher
.search(&["facts".to_string()], "machine learning", &opts)
.await
.expect("search failed");
assert_eq!(results.len(), 2, "should match 2 entries");
for se in &results {
assert!(
se.relevance >= 0.0 && se.relevance <= 1.0,
"relevance {} out of range",
se.relevance
);
}
}
#[tokio::test]
async fn empty_database_returns_empty_vec() {
let (_pool, searcher) = new_searcher("test_fts_empty").await;
let opts = SearchOptions { limit: 10, min_relevance: None };
let results =
searcher.search(&["facts".to_string()], "Tokyo", &opts).await.expect("search failed");
assert!(results.is_empty(), "expected empty vec for empty database");
}
#[tokio::test]
async fn empty_query_returns_empty_vec() {
let (pool, searcher) = new_searcher("test_fts_empty_query").await;
insert_entry(&pool, "test_fts_empty_query", "e1", "facts", "some data", 1000).await;
let opts = SearchOptions { limit: 10, min_relevance: None };
let results = searcher
.search(&["facts".to_string()], "", &opts)
.await
.expect("search on empty query should not error");
assert!(results.is_empty(), "expected empty vec for empty query");
}
#[tokio::test]
async fn whitespace_only_query_returns_empty_vec() {
let (pool, searcher) = new_searcher("test_fts_ws_query").await;
insert_entry(&pool, "test_fts_ws_query", "e1", "facts", "some data", 1000).await;
let opts = SearchOptions { limit: 10, min_relevance: None };
let results = searcher
.search(&["facts".to_string()], " \t ", &opts)
.await
.expect("search on whitespace query should not error");
assert!(results.is_empty());
}
#[tokio::test]
async fn no_match_returns_empty_vec() {
let (pool, searcher) = new_searcher("test_fts_no_match").await;
insert_entry(&pool, "test_fts_no_match", "e1", "facts", "apples and oranges", 1000).await;
let opts = SearchOptions { limit: 10, min_relevance: None };
let results = searcher
.search(&["facts".to_string()], "zzzzzzzzzz", &opts)
.await
.expect("search failed");
assert!(results.is_empty(), "no-match query should return empty vec");
}
#[tokio::test]
async fn unknown_table_name_errors_gracefully() {
let pool = SqlitePool::connect("sqlite::memory:").await.expect("pool creation");
sqlx::query("CREATE TABLE bad_table (id INTEGER PRIMARY KEY, data TEXT)")
.execute(&pool)
.await
.expect("regular table creation");
let searcher = Fts5IndexSearcher::new(pool, "bad_table").await;
assert!(searcher.is_ok(), "IF NOT EXISTS should not error on existing non-FTS table");
let searcher = searcher.unwrap();
let opts = SearchOptions { limit: 10, min_relevance: None };
let result = searcher.search(&["facts".to_string()], "hello", &opts).await;
assert!(result.is_err(), "search on non-FTS table should error");
}
#[tokio::test]
async fn partition_filter_excludes_other_partition() {
let (pool, searcher) = new_searcher("test_fts_partition").await;
insert_entry(&pool, "test_fts_partition", "e1", "facts", "machine learning rocks", 1000)
.await;
insert_entry(
&pool,
"test_fts_partition",
"e2",
"projects",
"machine learning projects",
2000,
)
.await;
let opts = SearchOptions { limit: 10, min_relevance: None };
let results =
searcher.search(&["facts".to_string()], "machine", &opts).await.expect("search failed");
assert_eq!(results.len(), 1, "should only return facts partition entry");
assert_eq!(results[0].entry.id, "e1");
}
#[tokio::test]
async fn multiple_partitions_returns_all_matching() {
let (pool, searcher) = new_searcher("test_fts_multi_part").await;
insert_entry(&pool, "test_fts_multi_part", "e1", "facts", "machine learning", 1000).await;
insert_entry(&pool, "test_fts_multi_part", "e2", "projects", "deep learning", 2000).await;
insert_entry(&pool, "test_fts_multi_part", "e3", "other", "learning rust", 3000).await;
let opts = SearchOptions { limit: 10, min_relevance: None };
let results = searcher
.search(&["facts".to_string(), "projects".to_string()], "learning", &opts)
.await
.expect("search failed");
assert_eq!(results.len(), 2, "should match facts + projects");
}
#[tokio::test]
async fn respect_search_limit() {
let (pool, searcher) = new_searcher("test_fts_limit").await;
for i in 0..5 {
insert_entry(
&pool,
"test_fts_limit",
&format!("e{i}"),
"facts",
&format!("learning rust tip {i}"),
i * 100,
)
.await;
}
let opts = SearchOptions { limit: 2, min_relevance: None };
let results = searcher
.search(&["facts".to_string()], "learning", &opts)
.await
.expect("search failed");
assert_eq!(results.len(), 2, "should respect limit=2");
}
#[tokio::test]
async fn min_relevance_filters_low_scores() {
let (pool, searcher) = new_searcher("test_fts_min_rel").await;
insert_entry(&pool, "test_fts_min_rel", "e1", "facts", "rust programming language", 1000)
.await;
insert_entry(&pool, "test_fts_min_rel", "e2", "facts", "python scripting language", 2000)
.await;
insert_entry(&pool, "test_fts_min_rel", "e3", "facts", "go compiler toolchain", 3000).await;
let opts = SearchOptions { limit: 10, min_relevance: Some(0.3) };
let results =
searcher.search(&["facts".to_string()], "rust", &opts).await.expect("search failed");
for se in &results {
assert!(se.relevance >= 0.3, "relevance {} must be >= min_relevance 0.3", se.relevance);
}
}
#[tokio::test]
async fn single_result_has_relevance_one() {
let (pool, searcher) = new_searcher("test_fts_single").await;
insert_entry(&pool, "test_fts_single", "e1", "facts", "exact keyword match", 1000).await;
let opts = SearchOptions { limit: 10, min_relevance: None };
let results =
searcher.search(&["facts".to_string()], "keyword", &opts).await.expect("search failed");
assert_eq!(results.len(), 1);
assert!(
(results[0].relevance - 1.0).abs() < f32::EPSILON,
"single result should have relevance 1.0, got {}",
results[0].relevance
);
}
#[test]
fn normalize_scores_empty() {
let result = normalize_scores(&[]);
assert!(result.is_empty());
}
#[test]
fn normalize_scores_single() {
let result = normalize_scores(&[-5.0]);
assert_eq!(result, vec![1.0_f32]);
}
#[test]
fn normalize_scores_uniform() {
let result = normalize_scores(&[-3.0, -3.0, -3.0]);
assert_eq!(result, vec![1.0_f32, 1.0_f32, 1.0_f32]);
}
#[test]
fn normalize_scores_varied() {
let result = normalize_scores(&[-10.0, -5.0, 0.0]);
assert_eq!(result.len(), 3);
assert!((result[0] - 0.0).abs() < 0.001, "worst → 0.0");
assert!((result[1] - 0.5).abs() < 0.001, "middle → 0.5");
assert!((result[2] - 1.0).abs() < 0.001, "best → 1.0");
}
}