1use std::sync::Arc;
4
5use async_trait::async_trait;
6use chrono::{DateTime, TimeZone, Utc};
7use uuid::Uuid;
8
9use khive_score::DeterministicScore;
10use khive_storage::error::StorageError;
11use khive_storage::types::{
12 BatchWriteSummary, IndexRebuildScope, SqlStatement, SqlValue, TextDocument, TextFilter,
13 TextGatherMode, TextIndexStats, TextQueryMode, TextSearchHit, TextSearchOptions,
14 TextSearchRequest, TextTermStats, TextTermStatsRequest,
15};
16use khive_storage::StorageCapability;
17use khive_storage::TextSearch;
18use khive_types::SubstrateKind;
19
20use crate::error::SqliteError;
21use crate::pool::ConnectionPool;
22use crate::sql_bridge::bind_params;
23use crate::writer_task::WriterTaskHandle;
24
25pub fn delete_document_statement(table: &str, namespace: &str, subject_id: Uuid) -> SqlStatement {
32 SqlStatement {
33 sql: format!("DELETE FROM {table} WHERE namespace = ?1 AND subject_id = ?2"),
34 params: vec![
35 SqlValue::Text(namespace.to_string()),
36 SqlValue::Text(subject_id.to_string()),
37 ],
38 label: Some(format!("fts-delete-{table}")),
39 }
40}
41
42#[cfg(test)]
46pub(crate) fn ensure_fts5_schema(
47 conn: &rusqlite::Connection,
48 table_key: &str,
49) -> Result<(), rusqlite::Error> {
50 let table_name = format!("fts_{}", table_key);
51 let ddl = format!(
52 "CREATE VIRTUAL TABLE IF NOT EXISTS {} USING fts5(\
53 subject_id UNINDEXED, \
54 kind UNINDEXED, \
55 title, \
56 body, \
57 tags UNINDEXED, \
58 namespace UNINDEXED, \
59 metadata UNINDEXED, \
60 updated_at UNINDEXED\
61 )",
62 table_name
63 );
64 conn.execute_batch(&ddl)
65}
66
67fn map_err(e: rusqlite::Error, op: &'static str) -> StorageError {
68 StorageError::driver(StorageCapability::Text, op, e)
69}
70
71fn map_sqlite_err(e: SqliteError, op: &'static str) -> StorageError {
72 StorageError::driver(StorageCapability::Text, op, e)
73}
74
75pub struct Fts5TextSearch {
81 pool: Arc<ConnectionPool>,
82 is_file_backed: bool,
83 table_name: String,
84 writer_task: Option<WriterTaskHandle>,
85}
86
87impl Fts5TextSearch {
88 pub(crate) fn new(pool: Arc<ConnectionPool>, is_file_backed: bool, table_key: String) -> Self {
92 let table_name = format!("fts_{}", table_key);
93 let writer_task = pool.writer_task_handle().ok().flatten();
97 Self {
98 pool,
99 is_file_backed,
100 table_name,
101 writer_task,
102 }
103 }
104
105 fn open_standalone_writer(&self) -> Result<rusqlite::Connection, StorageError> {
106 self.pool
107 .open_standalone_writer()
108 .map_err(|e| map_sqlite_err(e, "open_fts_writer"))
109 }
110
111 fn open_standalone_reader(&self) -> Result<rusqlite::Connection, StorageError> {
112 self.pool
113 .open_standalone_reader()
114 .map_err(|e| map_sqlite_err(e, "open_fts_reader"))
115 }
116
117 async fn with_writer<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
140 where
141 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
142 R: Send + 'static,
143 {
144 if let Some(writer_task) = &self.writer_task {
145 return writer_task
146 .send(move |conn| f(conn).map_err(|e| map_err(e, op)))
147 .await;
148 }
149
150 self.with_writer_unmanaged(op, f).await
151 }
152
153 async fn with_writer_unmanaged<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
162 where
163 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
164 R: Send + 'static,
165 {
166 if self.is_file_backed {
167 let conn = self.open_standalone_writer()?;
168 tokio::task::spawn_blocking(move || f(&conn).map_err(|e| map_err(e, op)))
169 .await
170 .map_err(|e| StorageError::driver(StorageCapability::Text, op, e))?
171 } else {
172 let pool = Arc::clone(&self.pool);
173 tokio::task::spawn_blocking(move || {
174 let guard = pool.try_writer().map_err(|e| map_sqlite_err(e, op))?;
175 f(guard.conn()).map_err(|e| map_err(e, op))
176 })
177 .await
178 .map_err(|e| StorageError::driver(StorageCapability::Text, op, e))?
179 }
180 }
181
182 async fn with_reader<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
183 where
184 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
185 R: Send + 'static,
186 {
187 if self.is_file_backed {
188 let conn = self.open_standalone_reader()?;
189 tokio::task::spawn_blocking(move || f(&conn).map_err(|e| map_err(e, op)))
190 .await
191 .map_err(|e| StorageError::driver(StorageCapability::Text, op, e))?
192 } else {
193 let pool = Arc::clone(&self.pool);
194 tokio::task::spawn_blocking(move || {
195 let guard = pool.reader().map_err(|e| map_sqlite_err(e, op))?;
196 f(guard.conn()).map_err(|e| map_err(e, op))
197 })
198 .await
199 .map_err(|e| StorageError::driver(StorageCapability::Text, op, e))?
200 }
201 }
202}
203
204fn tags_to_json(tags: &[String]) -> String {
207 serde_json::to_string(tags).unwrap_or_else(|_| "[]".to_string())
208}
209
210fn tags_from_json(s: &str) -> Vec<String> {
211 serde_json::from_str(s).unwrap_or_default()
212}
213
214fn dt_to_micros(dt: &DateTime<Utc>) -> i64 {
215 dt.timestamp_micros()
216}
217
218fn micros_to_dt(micros: i64) -> DateTime<Utc> {
219 Utc.timestamp_micros(micros)
220 .single()
221 .unwrap_or_else(Utc::now)
222}
223
224fn sanitize_fts5_query(query: &str) -> String {
244 let spaced: String = query
248 .chars()
249 .map(|c| {
250 if matches!(c, '(' | ')' | ',' | ':' | '-' | '.' | '/') {
251 ' '
252 } else {
253 c
254 }
255 })
256 .collect();
257
258 let sanitized: String = spaced
265 .chars()
266 .filter(|c| {
267 !matches!(c, '*' | '"' | '\'' | '+' | '^' | '~' | '!' | '$' | '\0') && !c.is_control()
268 })
269 .collect();
270
271 sanitized
273 .split_whitespace()
274 .filter(|t| {
275 !matches!(
276 t.to_ascii_uppercase().as_str(),
277 "AND" | "OR" | "NOT" | "NEAR"
278 )
279 })
280 .collect::<Vec<_>>()
281 .join(" ")
282}
283
284fn sanitize_fts5_query_legacy_merged(query: &str) -> String {
297 let spaced: String = query
298 .chars()
299 .map(|c| {
300 if matches!(c, '(' | ')' | ',' | ':' | '/') {
301 ' '
302 } else {
303 c
304 }
305 })
306 .collect();
307
308 let sanitized: String = spaced
309 .chars()
310 .filter(|c| {
311 !matches!(
312 c,
313 '*' | '"' | '\'' | '+' | '-' | '^' | '.' | '~' | '!' | '$' | '\0'
314 ) && !c.is_control()
315 })
316 .collect();
317
318 sanitized
319 .split_whitespace()
320 .filter(|t| {
321 !matches!(
322 t.to_ascii_uppercase().as_str(),
323 "AND" | "OR" | "NOT" | "NEAR"
324 )
325 })
326 .collect::<Vec<_>>()
327 .join(" ")
328}
329
330fn sanitize_fts5_phrase_literal(token: &str) -> Option<String> {
339 let literal: String = token
340 .chars()
341 .filter(|c| !matches!(c, '"' | '*' | '\0') && !c.is_control())
342 .collect();
343 if literal.is_empty() {
344 None
345 } else {
346 Some(literal)
347 }
348}
349
350const FTS5_TRIGRAM_MIN_SAFE_LEN: usize = 3;
358
359fn sanitize_fts5_token_group(token: &str) -> Option<String> {
404 let split = sanitize_fts5_query(token);
405 let split_terms: Vec<&str> = split.split_whitespace().collect();
406 if split_terms.is_empty() {
407 return None;
408 }
409 if split_terms.len() == 1 {
410 return Some(split_terms[0].to_string());
411 }
412
413 let has_trigram_unsafe_segment = split_terms
414 .iter()
415 .any(|t| t.chars().count() < FTS5_TRIGRAM_MIN_SAFE_LEN);
416
417 let mut alternatives = Vec::new();
418 if !has_trigram_unsafe_segment {
419 alternatives.push(format!("({})", split_terms.join(" ")));
420 }
421
422 let merged = sanitize_fts5_query_legacy_merged(token);
433 let merged_terms: Vec<&str> = merged.split_whitespace().collect();
434 let merged_has_unsafe_segment = merged_terms.len() > 1
435 && merged_terms
436 .iter()
437 .any(|t| t.chars().count() < FTS5_TRIGRAM_MIN_SAFE_LEN);
438 let phrase = sanitize_fts5_phrase_literal(token);
447 let duplicates_split = merged == split_terms.join(" ");
448 let duplicates_phrase = phrase.as_deref() == Some(merged.as_str());
449 if !merged.is_empty() && !duplicates_split && !duplicates_phrase && !merged_has_unsafe_segment {
450 alternatives.push(merged);
451 }
452
453 if let Some(phrase) = phrase {
454 alternatives.push(format!("\"{}\"", phrase));
455 }
456
457 match alternatives.len() {
458 0 => Some(format!("({})", split_terms.join(" "))),
459 1 => alternatives.into_iter().next(),
460 _ => Some(format!("({})", alternatives.join(" OR "))),
461 }
462}
463
464fn join_plain_groups(groups: &[String]) -> String {
479 let mut expr = String::new();
480 for (i, group) in groups.iter().enumerate() {
481 if i > 0 {
482 let prev_compound = groups[i - 1].starts_with('(');
483 let this_compound = group.starts_with('(');
484 expr.push_str(if prev_compound || this_compound {
485 " AND "
486 } else {
487 " "
488 });
489 }
490 expr.push_str(group);
491 }
492 expr
493}
494
495fn build_match_expr(query: &str, mode: TextQueryMode) -> Option<String> {
512 match mode {
513 TextQueryMode::AnyTerm => {
514 let groups: Vec<String> = query
515 .split_whitespace()
516 .filter_map(sanitize_fts5_token_group)
517 .collect();
518 if groups.is_empty() {
519 None
520 } else {
521 Some(groups.join(" OR "))
522 }
523 }
524 TextQueryMode::Plain => {
525 let groups: Vec<String> = query
526 .split_whitespace()
527 .filter_map(sanitize_fts5_token_group)
528 .collect();
529 if groups.is_empty() {
530 None
531 } else {
532 Some(join_plain_groups(&groups))
533 }
534 }
535 TextQueryMode::Phrase => {
536 sanitize_fts5_phrase_literal(query).map(|literal| format!("\"{}\"", literal))
537 }
538 }
539}
540
541fn build_filter_clause(
546 filter: &TextFilter,
547 table: &str,
548 start_idx: usize,
549) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
550 let mut conditions: Vec<String> = Vec::new();
551 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
552 let mut idx = start_idx;
553
554 if !filter.ids.is_empty() {
555 let placeholders: Vec<String> = filter
556 .ids
557 .iter()
558 .map(|_| {
559 let p = format!("?{}", idx);
560 idx += 1;
561 p
562 })
563 .collect();
564 conditions.push(format!(
565 "{}.subject_id IN ({})",
566 table,
567 placeholders.join(", ")
568 ));
569 for id in &filter.ids {
570 params.push(Box::new(id.to_string()));
571 }
572 }
573
574 if !filter.kinds.is_empty() {
575 let placeholders: Vec<String> = filter
576 .kinds
577 .iter()
578 .map(|_| {
579 let p = format!("?{}", idx);
580 idx += 1;
581 p
582 })
583 .collect();
584 conditions.push(format!("{}.kind IN ({})", table, placeholders.join(", ")));
585 for kind in &filter.kinds {
586 params.push(Box::new(kind.to_string()));
587 }
588 }
589
590 if !filter.namespaces.is_empty() {
591 let placeholders: Vec<String> = filter
592 .namespaces
593 .iter()
594 .map(|_| {
595 let p = format!("?{}", idx);
596 idx += 1;
597 p
598 })
599 .collect();
600 conditions.push(format!(
601 "{}.namespace IN ({})",
602 table,
603 placeholders.join(", ")
604 ));
605 for ns in &filter.namespaces {
606 params.push(Box::new(ns.clone()));
607 }
608 }
609
610 if conditions.is_empty() {
611 (String::new(), params)
612 } else {
613 (format!(" AND {}", conditions.join(" AND ")), params)
614 }
615}
616
617fn upsert_document_dml(
623 conn: &rusqlite::Connection,
624 table: &str,
625 document: &TextDocument,
626) -> Result<(), rusqlite::Error> {
627 let namespace = &document.namespace;
628
629 let del_sql = format!(
630 "DELETE FROM {} WHERE namespace = ?1 AND subject_id = ?2",
631 table
632 );
633 conn.execute(
634 &del_sql,
635 rusqlite::params![namespace, document.subject_id.to_string()],
636 )?;
637
638 let ins_sql = format!(
639 "INSERT INTO {} \
640 (subject_id, kind, title, body, tags, namespace, metadata, updated_at) \
641 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
642 table
643 );
644 let tags_json = tags_to_json(&document.tags);
645 let metadata_json: Option<String> = document.metadata.as_ref().map(|v| v.to_string());
646
647 conn.execute(
648 &ins_sql,
649 rusqlite::params![
650 document.subject_id.to_string(),
651 document.kind.to_string(),
652 document.title.as_deref().unwrap_or(""),
653 document.body,
654 tags_json,
655 namespace,
656 metadata_json,
657 dt_to_micros(&document.updated_at),
658 ],
659 )?;
660 Ok(())
661}
662
663fn batch_upsert_documents_dml(
672 conn: &rusqlite::Connection,
673 table: &str,
674 documents: &[TextDocument],
675 attempted: u64,
676) -> Result<BatchWriteSummary, rusqlite::Error> {
677 let del_sql = format!(
678 "DELETE FROM {} WHERE namespace = ?1 AND subject_id = ?2",
679 table
680 );
681 let ins_sql = format!(
682 "INSERT INTO {} \
683 (subject_id, kind, title, body, tags, namespace, metadata, updated_at) \
684 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
685 table
686 );
687
688 let mut affected = 0u64;
689 let mut failed = 0u64;
690 let mut first_error = String::new();
691
692 for doc in documents {
693 conn.execute_batch("SAVEPOINT fts_upsert_doc")?;
694 let id_str = doc.subject_id.to_string();
695 let namespace = &doc.namespace;
696 let result = (|| {
697 conn.execute(&del_sql, rusqlite::params![namespace, &id_str])?;
698
699 let tags_json = tags_to_json(&doc.tags);
700 let metadata_json: Option<String> = doc.metadata.as_ref().map(|v| v.to_string());
701
702 conn.execute(
703 &ins_sql,
704 rusqlite::params![
705 &id_str,
706 &doc.kind.to_string(),
707 doc.title.as_deref().unwrap_or(""),
708 &doc.body,
709 &tags_json,
710 namespace,
711 &metadata_json,
712 dt_to_micros(&doc.updated_at),
713 ],
714 )?;
715 Ok::<(), rusqlite::Error>(())
716 })();
717
718 match result {
719 Ok(()) => {
720 conn.execute_batch("RELEASE SAVEPOINT fts_upsert_doc")?;
721 affected += 1;
722 }
723 Err(e) => {
724 let _ = conn.execute_batch("ROLLBACK TO SAVEPOINT fts_upsert_doc");
725 let _ = conn.execute_batch("RELEASE SAVEPOINT fts_upsert_doc");
726 if first_error.is_empty() {
727 first_error = e.to_string();
728 }
729 failed += 1;
730 }
731 }
732 }
733
734 Ok(BatchWriteSummary {
735 attempted,
736 affected,
737 failed,
738 first_error,
739 })
740}
741
742#[async_trait]
743impl TextSearch for Fts5TextSearch {
744 async fn upsert_document(&self, document: TextDocument) -> Result<(), StorageError> {
745 let table = self.table_name.clone();
746
747 if let Some(writer_task) = &self.writer_task {
752 let table2 = table.clone();
753 return writer_task
754 .send(move |conn| {
755 upsert_document_dml(conn, &table2, &document)
756 .map_err(|e| map_err(e, "fts_upsert"))
757 })
758 .await;
759 }
760
761 self.with_writer("fts_upsert", move |conn| {
764 conn.execute_batch("BEGIN IMMEDIATE")?;
765 let _tx_handle =
766 khive_storage::tx_registry::register(Some("text_upsert_document".to_string()));
767
768 if let Err(e) = upsert_document_dml(conn, &table, &document) {
769 let _ = conn.execute_batch("ROLLBACK");
770 return Err(e);
771 }
772
773 conn.execute_batch("COMMIT")?;
774 Ok(())
775 })
776 .await
777 }
778
779 async fn upsert_documents(
780 &self,
781 documents: Vec<TextDocument>,
782 ) -> Result<BatchWriteSummary, StorageError> {
783 let table = self.table_name.clone();
784 let attempted = documents.len() as u64;
785
786 if let Some(writer_task) = &self.writer_task {
792 let table2 = table.clone();
793 return writer_task
794 .send(move |conn| {
795 batch_upsert_documents_dml(conn, &table2, &documents, attempted)
796 .map_err(|e| map_err(e, "fts_upsert_batch"))
797 })
798 .await;
799 }
800
801 self.with_writer("fts_upsert_batch", move |conn| {
804 conn.execute_batch("BEGIN IMMEDIATE")?;
805 let _tx_handle =
806 khive_storage::tx_registry::register(Some("text_upsert_batch".to_string()));
807
808 let summary = batch_upsert_documents_dml(conn, &table, &documents, attempted)?;
809
810 conn.execute_batch("COMMIT")?;
811
812 Ok(summary)
813 })
814 .await
815 }
816
817 async fn delete_document(
818 &self,
819 namespace: &str,
820 subject_id: Uuid,
821 ) -> Result<bool, StorageError> {
822 let statement = delete_document_statement(&self.table_name, namespace, subject_id);
823
824 self.with_writer("fts_delete", move |conn| {
825 let mut stmt = conn.prepare(&statement.sql)?;
826 bind_params(&mut stmt, &statement.params)?;
827 Ok(stmt.raw_execute()? > 0)
828 })
829 .await
830 }
831
832 async fn get_document(
833 &self,
834 namespace: &str,
835 subject_id: Uuid,
836 ) -> Result<Option<TextDocument>, StorageError> {
837 let namespace = namespace.to_string();
838 let table = self.table_name.clone();
839
840 self.with_reader("fts_get", move |conn| {
841 let sql = format!(
842 "SELECT subject_id, kind, title, body, tags, namespace, metadata, updated_at \
843 FROM {} WHERE namespace = ?1 AND subject_id = ?2",
844 table
845 );
846 let mut stmt = conn.prepare(&sql)?;
847 let mut rows = stmt.query(rusqlite::params![namespace, subject_id.to_string()])?;
848
849 match rows.next()? {
850 Some(row) => {
851 let id_str: String = row.get(0)?;
852 let kind_str: String = row.get(1)?;
853 let title: String = row.get(2)?;
854 let body: String = row.get(3)?;
855 let tags_json: String = row.get(4)?;
856 let ns: String = row.get(5)?;
857 let metadata_json: Option<String> = row.get(6)?;
858 let updated_at_micros: i64 = row.get(7)?;
859
860 let sid = Uuid::parse_str(&id_str).map_err(|e| {
861 rusqlite::Error::FromSqlConversionFailure(
862 0,
863 rusqlite::types::Type::Text,
864 Box::new(e),
865 )
866 })?;
867
868 let kind = kind_str.parse::<SubstrateKind>().map_err(|e| {
869 rusqlite::Error::FromSqlConversionFailure(
870 1,
871 rusqlite::types::Type::Text,
872 Box::new(e),
873 )
874 })?;
875
876 Ok(Some(TextDocument {
877 subject_id: sid,
878 kind,
879 title: if title.is_empty() { None } else { Some(title) },
880 body,
881 tags: tags_from_json(&tags_json),
882 namespace: ns,
883 metadata: metadata_json.and_then(|s| serde_json::from_str(&s).ok()),
884 updated_at: micros_to_dt(updated_at_micros),
885 }))
886 }
887 None => Ok(None),
888 }
889 })
890 .await
891 }
892
893 async fn search(&self, request: TextSearchRequest) -> Result<Vec<TextSearchHit>, StorageError> {
894 let table = self.table_name.clone();
895
896 self.with_reader("fts_search", move |conn| {
897 let match_expr = match build_match_expr(&request.query, request.mode) {
898 Some(expr) => expr,
899 None => return Ok(Vec::new()),
900 };
901
902 let snippet_expr = if request.snippet_chars == 0 {
909 "NULL AS snippet".to_string()
910 } else {
911 let chars = i32::try_from(request.snippet_chars).unwrap_or(i32::MAX);
912 format!("snippet({table}, 3, '', '', '...', {chars})")
913 };
914
915 let (filter_clause, filter_params) = if let Some(ref filter) = request.filter {
916 build_filter_clause(filter, &table, 3)
917 } else {
918 (String::new(), Vec::new())
919 };
920
921 let sql = format!(
922 "SELECT subject_id, rank, title, {snippet_expr} \
923 FROM {table} WHERE {table} MATCH ?1{filter_clause} \
924 ORDER BY rank LIMIT ?2",
925 );
926
927 let mut stmt = conn.prepare(&sql)?;
928 stmt.raw_bind_parameter(1, &match_expr)?;
929 stmt.raw_bind_parameter(2, request.top_k as i64)?;
930
931 for (i, param) in filter_params.iter().enumerate() {
932 param
933 .to_sql()
934 .map(|val| stmt.raw_bind_parameter(3 + i, val))
935 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))??;
936 }
937
938 let mut hits = Vec::new();
939 let mut rows = stmt.raw_query();
940 let mut rank_idx = 0u32;
941
942 while let Some(row) = rows.next()? {
943 let id_str: String = row.get(0)?;
944 let fts_rank: f64 = row.get(1)?;
945 let title: String = row.get(2)?;
946 let snippet: Option<String> = row.get(3)?;
947
948 let subject_id = Uuid::parse_str(&id_str).map_err(|e| {
949 rusqlite::Error::FromSqlConversionFailure(
950 0,
951 rusqlite::types::Type::Text,
952 Box::new(e),
953 )
954 })?;
955
956 rank_idx += 1;
957 hits.push((subject_id, fts_rank, rank_idx, title, snippet));
958 }
959
960 let min_rank = hits.iter().map(|h| h.1).fold(f64::INFINITY, f64::min);
963 let max_rank = hits.iter().map(|h| h.1).fold(f64::NEG_INFINITY, f64::max);
964 let range = max_rank - min_rank;
965
966 let results = hits
967 .into_iter()
968 .map(|(subject_id, raw_rank, rank, title, snippet)| {
969 let score = if range.abs() < 1e-12 {
970 1.0
971 } else {
972 let t = (max_rank - raw_rank) / range;
973 0.05 + 0.95 * t
974 };
975 TextSearchHit {
976 subject_id,
977 score: DeterministicScore::from_f64(score),
978 rank,
979 title: if title.is_empty() { None } else { Some(title) },
980 snippet: snippet.filter(|s| !s.is_empty()),
981 }
982 })
983 .collect();
984
985 Ok(results)
986 })
987 .await
988 }
989
990 async fn count(&self, filter: TextFilter) -> Result<u64, StorageError> {
991 let table = self.table_name.clone();
992
993 self.with_reader("fts_count", move |conn| {
994 let (filter_clause, filter_params) = build_filter_clause(&filter, &table, 1);
995
996 let sql = if filter_clause.is_empty() {
997 format!("SELECT COUNT(*) FROM {}", table)
998 } else {
999 let where_part = filter_clause.trim_start_matches(" AND ");
1000 format!("SELECT COUNT(*) FROM {} WHERE {}", table, where_part)
1001 };
1002
1003 let mut stmt = conn.prepare(&sql)?;
1004
1005 for (i, param) in filter_params.iter().enumerate() {
1006 param
1007 .to_sql()
1008 .map(|val| stmt.raw_bind_parameter(1 + i, val))
1009 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))??;
1010 }
1011
1012 let mut rows = stmt.raw_query();
1013 match rows.next()? {
1014 Some(row) => {
1015 let count: i64 = row.get(0)?;
1016 Ok(count as u64)
1017 }
1018 None => Ok(0),
1019 }
1020 })
1021 .await
1022 }
1023
1024 async fn stats(&self) -> Result<TextIndexStats, StorageError> {
1025 let table = self.table_name.clone();
1026
1027 self.with_reader("fts_stats", move |conn| {
1028 let sql = format!("SELECT COUNT(*) FROM {}", table);
1029 let count: i64 = conn.query_row(&sql, [], |row| row.get(0))?;
1030
1031 Ok(TextIndexStats {
1032 document_count: count as u64,
1033 needs_rebuild: false,
1034 last_rebuild_at: None,
1035 })
1036 })
1037 .await
1038 }
1039
1040 async fn search_with_options(
1041 &self,
1042 request: TextSearchRequest,
1043 options: TextSearchOptions,
1044 ) -> Result<Vec<TextSearchHit>, StorageError> {
1045 match options.gather_mode {
1046 TextGatherMode::Ranked => self.search(request).await,
1047 TextGatherMode::Unranked => self.search_unranked(request).await,
1048 TextGatherMode::RankWithinCap => {
1049 let gather_limit = options
1050 .gather_limit
1051 .unwrap_or(request.top_k)
1052 .max(request.top_k);
1053 self.search_rank_within_cap(request, gather_limit).await
1054 }
1055 }
1056 }
1057
1058 async fn term_stats(
1059 &self,
1060 request: TextTermStatsRequest,
1061 ) -> Result<Vec<TextTermStats>, StorageError> {
1062 let table = self.table_name.clone();
1063
1064 self.with_reader("fts_term_stats", move |conn| {
1065 let filter = request.filter.as_ref();
1066
1067 let (count_filter_clause, count_filter_params) = if let Some(f) = filter {
1069 build_filter_clause(f, &table, 1)
1070 } else {
1071 (String::new(), Vec::new())
1072 };
1073
1074 let document_count: u64 = {
1075 let count_sql = if count_filter_clause.is_empty() {
1076 format!("SELECT COUNT(*) FROM {table}")
1077 } else {
1078 let where_part = count_filter_clause.trim_start_matches(" AND ");
1079 format!("SELECT COUNT(*) FROM {table} WHERE {where_part}")
1080 };
1081 let mut stmt = conn.prepare(&count_sql)?;
1082 for (i, param) in count_filter_params.iter().enumerate() {
1083 param
1084 .to_sql()
1085 .map(|val| stmt.raw_bind_parameter(1 + i, val))
1086 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))??;
1087 }
1088 let mut rows = stmt.raw_query();
1089 match rows.next()? {
1090 Some(row) => {
1091 let c: i64 = row.get(0)?;
1092 c as u64
1093 }
1094 None => 0,
1095 }
1096 };
1097
1098 let mut results = Vec::with_capacity(request.terms.len());
1099 for term in &request.terms {
1100 let sanitized = sanitize_fts5_token_group(term).unwrap_or_default();
1102 if sanitized.is_empty() {
1103 results.push(TextTermStats {
1104 term: term.clone(),
1105 sanitized_term: sanitized,
1106 document_frequency: 0,
1107 document_count,
1108 inverse_document_frequency: 0.0,
1109 });
1110 continue;
1111 }
1112
1113 let (term_filter_clause, term_filter_params) = if let Some(f) = filter {
1115 build_filter_clause(f, &table, 2)
1116 } else {
1117 (String::new(), Vec::new())
1118 };
1119
1120 let count_sql = format!(
1121 "SELECT COUNT(*) FROM {table} WHERE {table} MATCH ?1{term_filter_clause}"
1122 );
1123 let mut stmt = conn.prepare(&count_sql)?;
1124 stmt.raw_bind_parameter(1, &sanitized)?;
1125 for (i, param) in term_filter_params.iter().enumerate() {
1126 param
1127 .to_sql()
1128 .map(|val| stmt.raw_bind_parameter(2 + i, val))
1129 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))??;
1130 }
1131
1132 let df: u64 = {
1133 let mut rows = stmt.raw_query();
1134 match rows.next()? {
1135 Some(row) => {
1136 let c: i64 = row.get(0)?;
1137 c as u64
1138 }
1139 None => 0,
1140 }
1141 };
1142
1143 let idf = Fts5TextSearch::bm25_idf(df, document_count);
1144 results.push(TextTermStats {
1145 term: term.clone(),
1146 sanitized_term: sanitized,
1147 document_frequency: df,
1148 document_count,
1149 inverse_document_frequency: idf,
1150 });
1151 }
1152
1153 Ok(results)
1154 })
1155 .await
1156 }
1157
1158 async fn rebuild(&self, _scope: IndexRebuildScope) -> Result<TextIndexStats, StorageError> {
1159 let table = self.table_name.clone();
1160
1161 self.with_writer("fts_rebuild", move |conn| {
1162 let sql = format!("INSERT INTO {}({}) VALUES('rebuild')", table, table);
1164 conn.execute(&sql, [])?;
1165
1166 let count_sql = format!("SELECT COUNT(*) FROM {}", table);
1167 let count: i64 = conn.query_row(&count_sql, [], |row| row.get(0))?;
1168
1169 Ok(TextIndexStats {
1170 document_count: count as u64,
1171 needs_rebuild: false,
1172 last_rebuild_at: Some(Utc::now()),
1173 })
1174 })
1175 .await
1176 }
1177}
1178
1179impl Fts5TextSearch {
1180 fn bm25_idf(df: u64, document_count: u64) -> f64 {
1182 let n = document_count as f64;
1183 let f = df as f64;
1184 ((n - f + 0.5) / (f + 0.5) + 1.0).ln()
1185 }
1186
1187 async fn search_unranked(
1189 &self,
1190 request: TextSearchRequest,
1191 ) -> Result<Vec<TextSearchHit>, StorageError> {
1192 let table = self.table_name.clone();
1193
1194 self.with_reader("fts_search_unranked", move |conn| {
1195 let match_expr = match build_match_expr(&request.query, request.mode) {
1196 Some(expr) => expr,
1197 None => return Ok(Vec::new()),
1198 };
1199
1200 let (filter_clause, filter_params) = if let Some(ref filter) = request.filter {
1201 build_filter_clause(filter, &table, 3)
1202 } else {
1203 (String::new(), Vec::new())
1204 };
1205
1206 let sql = format!(
1208 "SELECT subject_id, title \
1209 FROM {table} WHERE {table} MATCH ?1{filter_clause} \
1210 LIMIT ?2",
1211 );
1212
1213 let mut stmt = conn.prepare(&sql)?;
1214 stmt.raw_bind_parameter(1, &match_expr)?;
1215 stmt.raw_bind_parameter(2, request.top_k as i64)?;
1216
1217 for (i, param) in filter_params.iter().enumerate() {
1218 param
1219 .to_sql()
1220 .map(|val| stmt.raw_bind_parameter(3 + i, val))
1221 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))??;
1222 }
1223
1224 let mut results = Vec::new();
1225 let mut rows = stmt.raw_query();
1226 let mut rank_idx = 0u32;
1227
1228 while let Some(row) = rows.next()? {
1229 let id_str: String = row.get(0)?;
1230 let title: String = row.get(1)?;
1231
1232 let subject_id = Uuid::parse_str(&id_str).map_err(|e| {
1233 rusqlite::Error::FromSqlConversionFailure(
1234 0,
1235 rusqlite::types::Type::Text,
1236 Box::new(e),
1237 )
1238 })?;
1239
1240 rank_idx += 1;
1241 results.push(TextSearchHit {
1242 subject_id,
1243 score: DeterministicScore::from_f64(1.0),
1244 rank: rank_idx,
1245 title: if title.is_empty() { None } else { Some(title) },
1246 snippet: None,
1247 });
1248 }
1249
1250 Ok(results)
1251 })
1252 .await
1253 }
1254
1255 async fn search_rank_within_cap(
1257 &self,
1258 request: TextSearchRequest,
1259 gather_limit: u32,
1260 ) -> Result<Vec<TextSearchHit>, StorageError> {
1261 let table = self.table_name.clone();
1262
1263 self.with_reader("fts_search_rank_within_cap", move |conn| {
1264 let match_expr = match build_match_expr(&request.query, request.mode) {
1265 Some(expr) => expr,
1266 None => return Ok(Vec::new()),
1267 };
1268
1269 let (filter_clause, filter_params) = if let Some(ref filter) = request.filter {
1270 build_filter_clause(filter, &table, 3)
1271 } else {
1272 (String::new(), Vec::new())
1273 };
1274
1275 let gather_sql = format!(
1277 "SELECT subject_id FROM {table} WHERE {table} MATCH ?1{filter_clause} LIMIT ?2"
1278 );
1279
1280 let mut stmt = conn.prepare(&gather_sql)?;
1281 stmt.raw_bind_parameter(1, &match_expr)?;
1282 stmt.raw_bind_parameter(2, gather_limit as i64)?;
1283 for (i, param) in filter_params.iter().enumerate() {
1284 param
1285 .to_sql()
1286 .map(|val| stmt.raw_bind_parameter(3 + i, val))
1287 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))??;
1288 }
1289
1290 let mut gathered_ids: Vec<String> = Vec::new();
1291 let mut rows = stmt.raw_query();
1292 while let Some(row) = rows.next()? {
1293 gathered_ids.push(row.get::<_, String>(0)?);
1294 }
1295
1296 if gathered_ids.is_empty() {
1297 return Ok(Vec::new());
1298 }
1299
1300 let snippet_expr = if request.snippet_chars == 0 {
1302 "NULL AS snippet".to_string()
1303 } else {
1304 let chars = i32::try_from(request.snippet_chars).unwrap_or(i32::MAX);
1305 format!("snippet({table}, 3, '', '', '...', {chars})")
1306 };
1307
1308 let id_placeholders: Vec<String> = gathered_ids
1310 .iter()
1311 .enumerate()
1312 .map(|(i, _)| format!("?{}", 3 + i))
1313 .collect();
1314 let in_clause = id_placeholders.join(", ");
1315
1316 let rank_sql = format!(
1317 "SELECT subject_id, rank, title, {snippet_expr} \
1318 FROM {table} WHERE {table} MATCH ?1 AND subject_id IN ({in_clause}) \
1319 ORDER BY rank LIMIT ?2"
1320 );
1321
1322 let mut stmt2 = conn.prepare(&rank_sql)?;
1323 stmt2.raw_bind_parameter(1, &match_expr)?;
1324 stmt2.raw_bind_parameter(2, request.top_k as i64)?;
1325 for (i, id_str) in gathered_ids.iter().enumerate() {
1326 stmt2.raw_bind_parameter(3 + i, id_str.as_str())?;
1327 }
1328
1329 let mut hits = Vec::new();
1330 let mut rows2 = stmt2.raw_query();
1331 let mut rank_idx = 0u32;
1332
1333 while let Some(row) = rows2.next()? {
1334 let id_str: String = row.get(0)?;
1335 let fts_rank: f64 = row.get(1)?;
1336 let title: String = row.get(2)?;
1337 let snippet: Option<String> = row.get(3)?;
1338
1339 let subject_id = Uuid::parse_str(&id_str).map_err(|e| {
1340 rusqlite::Error::FromSqlConversionFailure(
1341 0,
1342 rusqlite::types::Type::Text,
1343 Box::new(e),
1344 )
1345 })?;
1346
1347 rank_idx += 1;
1348 hits.push((subject_id, fts_rank, rank_idx, title, snippet));
1349 }
1350
1351 let min_rank = hits.iter().map(|h| h.1).fold(f64::INFINITY, f64::min);
1353 let max_rank = hits.iter().map(|h| h.1).fold(f64::NEG_INFINITY, f64::max);
1354 let range = max_rank - min_rank;
1355
1356 let results = hits
1357 .into_iter()
1358 .map(|(subject_id, raw_rank, rank, title, snippet)| {
1359 let score = if range.abs() < 1e-12 {
1360 1.0
1361 } else {
1362 let t = (max_rank - raw_rank) / range;
1363 0.05 + 0.95 * t
1364 };
1365 TextSearchHit {
1366 subject_id,
1367 score: DeterministicScore::from_f64(score),
1368 rank,
1369 title: if title.is_empty() { None } else { Some(title) },
1370 snippet: snippet.filter(|s| !s.is_empty()),
1371 }
1372 })
1373 .collect();
1374
1375 Ok(results)
1376 })
1377 .await
1378 }
1379
1380 #[allow(dead_code)]
1391 pub(crate) async fn rename_namespace(
1392 &self,
1393 old_namespace: &str,
1394 new_namespace: &str,
1395 ) -> Result<u64, StorageError> {
1396 if old_namespace == new_namespace {
1397 return Ok(0);
1398 }
1399 let table = self.table_name.clone();
1400 let old_ns = old_namespace.to_string();
1401 let new_ns = new_namespace.to_string();
1402
1403 self.with_writer_unmanaged("fts_rename_namespace", move |conn| {
1404 let sel_sql = format!(
1405 "SELECT subject_id, kind, title, body, tags, metadata, updated_at \
1406 FROM {} WHERE namespace = ?1",
1407 table
1408 );
1409 struct Row {
1410 subject_id: String,
1411 kind: String,
1412 title: String,
1413 body: String,
1414 tags: String,
1415 metadata: Option<String>,
1416 updated_at: i64,
1417 }
1418 let rows: Vec<Row> = {
1419 let mut stmt = conn.prepare(&sel_sql)?;
1420 let iter = stmt.query_map(rusqlite::params![&old_ns], |row| {
1421 Ok(Row {
1422 subject_id: row.get(0)?,
1423 kind: row.get(1)?,
1424 title: row.get(2)?,
1425 body: row.get(3)?,
1426 tags: row.get(4)?,
1427 metadata: row.get(5)?,
1428 updated_at: row.get(6)?,
1429 })
1430 })?;
1431 iter.collect::<Result<Vec<_>, _>>()?
1432 };
1433 let moved = rows.len() as u64;
1434 if moved == 0 {
1435 return Ok(0u64);
1436 }
1437
1438 conn.execute_batch("BEGIN IMMEDIATE")?;
1439 let _tx_handle =
1440 khive_storage::tx_registry::register(Some("text_rename_namespace".to_string()));
1441
1442 let del_sql = format!("DELETE FROM {} WHERE namespace = ?1", table);
1443 if let Err(e) = conn.execute(&del_sql, rusqlite::params![&old_ns]) {
1444 let _ = conn.execute_batch("ROLLBACK");
1445 return Err(e);
1446 }
1447
1448 let ins_sql = format!(
1449 "INSERT INTO {} \
1450 (subject_id, kind, title, body, tags, namespace, metadata, updated_at) \
1451 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1452 table
1453 );
1454 for row in &rows {
1455 if let Err(e) = conn.execute(
1456 &ins_sql,
1457 rusqlite::params![
1458 row.subject_id,
1459 row.kind,
1460 row.title,
1461 row.body,
1462 row.tags,
1463 &new_ns,
1464 row.metadata,
1465 row.updated_at,
1466 ],
1467 ) {
1468 let _ = conn.execute_batch("ROLLBACK");
1469 return Err(e);
1470 }
1471 }
1472
1473 conn.execute_batch("COMMIT")?;
1474 Ok(moved)
1475 })
1476 .await
1477 }
1478}
1479
1480#[cfg(test)]
1481#[path = "text_tests.rs"]
1482mod tests;