Resolves the 142 findings from tidal/docs/reviews/CODE_REVIEW_m0-m10.md across the engine, server, net, and CLI surfaces: - WAL/session-journal durability, checkpoint format, and crash-recovery hardening - Replication shipper/receiver, tenant isolation, and migration paths - Cluster scatter-gather, router, standalone server + health/offload endpoints - tidalctl refactored into command modules with JSON output and WAL-state tooling - Cohort, governance, signal-ledger, and vector-registry correctness fixes - Expanded UAT/integration/durability test coverage across all milestones
341 lines
12 KiB
Rust
341 lines
12 KiB
Rust
use tantivy::{query::Query, schema::Field};
|
|
|
|
use crate::{TidalError, schema::TextFieldType, text::index::TantivyFields};
|
|
|
|
/// Parser for text search queries. Wraps Tantivy's `QueryParser` with
|
|
/// tidalDB-specific syntax extensions.
|
|
///
|
|
/// **Default search fields:** only [`TextFieldType::Text`] fields (tokenized).
|
|
/// `Keyword` fields require explicit field scoping (e.g., `category:programming`).
|
|
///
|
|
/// **Conjunction mode:** AND by default. Multi-word queries like `rust tutorial`
|
|
/// match documents containing both terms, not either.
|
|
///
|
|
/// # Supported syntax
|
|
///
|
|
/// - Bare terms: `rust tutorial` (conjunction of rust AND tutorial)
|
|
/// - Exact phrase: `"exact phrase"` (`PhraseQuery`)
|
|
/// - Boolean AND: `jazz AND piano`
|
|
/// - Boolean OR: `jazz OR rock`
|
|
/// - Boolean NOT / exclusion: `jazz -beginner` or `jazz NOT beginner`
|
|
/// - Field-scoped: `title:jazz`
|
|
/// - Wildcard prefix: `pian*`
|
|
/// - Hashtag: `#jazz` (pre-processed to `jazz`)
|
|
pub struct TextQueryParser {
|
|
inner: tantivy::query::QueryParser,
|
|
}
|
|
|
|
impl TextQueryParser {
|
|
/// Create a parser configured for the given index and fields.
|
|
///
|
|
/// Default fields are the subset of `fields.text_fields` with
|
|
/// `TextFieldType::Text`. Keyword fields are not searched by default --
|
|
/// the caller must use field-scoped syntax (e.g., `category:tech`).
|
|
///
|
|
/// Sets AND as the default conjunction mode so that multi-word queries
|
|
/// require all terms to be present.
|
|
#[must_use]
|
|
pub fn new(index: &tantivy::Index, fields: &TantivyFields) -> Self {
|
|
let default_fields: Vec<Field> = fields
|
|
.text_fields
|
|
.iter()
|
|
.filter(|(_, _, ft)| *ft == TextFieldType::Text)
|
|
.map(|(_, f, _)| *f)
|
|
.collect();
|
|
|
|
let mut inner = tantivy::query::QueryParser::for_index(index, default_fields);
|
|
inner.set_conjunction_by_default(); // "rust tutorial" = rust AND tutorial
|
|
Self { inner }
|
|
}
|
|
|
|
/// Parse a query string into a Tantivy [`Query`].
|
|
///
|
|
/// Applies hashtag pre-processing (`#jazz` -> `jazz`) before delegating
|
|
/// to Tantivy's parser.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns `TidalError::Internal` with a descriptive message if the query
|
|
/// string cannot be parsed (e.g., unbalanced quotes, unknown field names).
|
|
pub fn parse(&self, query_str: &str) -> crate::Result<Box<dyn Query>> {
|
|
let preprocessed = preprocess_query(query_str);
|
|
self.inner.parse_query(&preprocessed).map_err(|e| {
|
|
TidalError::internal("text_query_parse", format!("text query parse error: {e}"))
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Pre-process tidalDB query strings before passing to Tantivy's `QueryParser`.
|
|
///
|
|
/// Current transformations:
|
|
/// - `#jazz` -> `jazz` (strips the `#` prefix from valid hashtags, where the
|
|
/// character immediately after `#` is ASCII alphanumeric)
|
|
///
|
|
/// A lone `#` or `# ` (hash followed by space/EOF) is left unchanged.
|
|
fn preprocess_query(query: &str) -> String {
|
|
let mut result = String::with_capacity(query.len());
|
|
let mut chars = query.chars().peekable();
|
|
while let Some(ch) = chars.next() {
|
|
if ch == '#' && chars.peek().is_some_and(char::is_ascii_alphanumeric) {
|
|
// Valid hashtag prefix -- skip the '#', let the word through.
|
|
} else {
|
|
result.push(ch);
|
|
}
|
|
}
|
|
result
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[allow(clippy::unwrap_used)]
|
|
mod tests {
|
|
use std::collections::HashMap;
|
|
|
|
use tantivy::{TantivyDocument, collector::TopDocs, schema::Value};
|
|
|
|
use super::*;
|
|
use crate::{
|
|
schema::{EntityId, TextFieldDef, TextFieldType},
|
|
text::index::TextIndex,
|
|
};
|
|
|
|
/// Helper: create an ephemeral index with "title" (Text) and "category"
|
|
/// (Keyword), populate it with 4 documents, commit, and reload the reader.
|
|
fn setup_index() -> TextIndex {
|
|
let fields = vec![
|
|
TextFieldDef {
|
|
key: "title".to_owned(),
|
|
field_type: TextFieldType::Text,
|
|
},
|
|
TextFieldDef {
|
|
key: "category".to_owned(),
|
|
field_type: TextFieldType::Keyword,
|
|
},
|
|
];
|
|
let idx = TextIndex::ephemeral(&fields).unwrap();
|
|
|
|
let docs: Vec<(u64, &str, &str)> = vec![
|
|
(1, "jazz piano beginner", "music"),
|
|
(2, "rock guitar advanced", "music"),
|
|
(3, "jazz violin intermediate", "music"),
|
|
(4, "rust programming language", "tech"),
|
|
];
|
|
|
|
let mut w = idx.writer_guard().unwrap();
|
|
for (id, title, cat) in docs {
|
|
let mut m = HashMap::new();
|
|
m.insert("title".to_owned(), title.to_owned());
|
|
m.insert("category".to_owned(), cat.to_owned());
|
|
w.index_item(EntityId::new(id), &m).unwrap();
|
|
}
|
|
w.commit().unwrap();
|
|
drop(w);
|
|
idx.reader.reload().unwrap();
|
|
idx
|
|
}
|
|
|
|
/// Helper: execute a query and return the matched entity IDs.
|
|
fn search_ids(idx: &TextIndex, query: &dyn Query) -> Vec<u64> {
|
|
let searcher = idx.reader.searcher();
|
|
let top_docs = searcher.search(query, &TopDocs::with_limit(100)).unwrap();
|
|
top_docs
|
|
.iter()
|
|
.map(|(_score, doc_addr)| {
|
|
let doc: TantivyDocument = searcher.doc(*doc_addr).unwrap();
|
|
doc.get_first(idx.fields().entity_id)
|
|
.and_then(|v| v.as_u64())
|
|
.unwrap()
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn preprocess_removes_hashtag() {
|
|
assert_eq!(preprocess_query("#jazz"), "jazz");
|
|
assert_eq!(preprocess_query("#jazz #piano"), "jazz piano");
|
|
assert_eq!(preprocess_query("jazz #piano"), "jazz piano");
|
|
assert_eq!(preprocess_query("no-hashtag"), "no-hashtag");
|
|
// Space after # = not a hashtag.
|
|
assert_eq!(preprocess_query("# notag"), "# notag");
|
|
}
|
|
|
|
#[test]
|
|
fn preprocess_preserves_empty_and_plain() {
|
|
assert_eq!(preprocess_query(""), "");
|
|
assert_eq!(preprocess_query("hello world"), "hello world");
|
|
assert_eq!(preprocess_query("#"), "#");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_bare_terms_conjunction() {
|
|
// "jazz piano" with set_conjunction_by_default() -> AND.
|
|
// Should find entity 1 (has both jazz AND piano) but not entity 3
|
|
// (jazz but not piano).
|
|
let idx = setup_index();
|
|
let parser = idx.query_parser();
|
|
let q = parser.parse("jazz piano").unwrap();
|
|
let ids = search_ids(&idx, q.as_ref());
|
|
assert!(ids.contains(&1), "entity 1 should match jazz AND piano");
|
|
assert!(!ids.contains(&2), "entity 2 should not match");
|
|
assert!(!ids.contains(&3), "entity 3 has jazz but not piano");
|
|
idx.close().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn parse_exact_phrase() {
|
|
// "\"jazz piano\"" -> PhraseQuery, only entity 1 has the contiguous
|
|
// sequence "jazz piano".
|
|
let idx = setup_index();
|
|
let parser = idx.query_parser();
|
|
let q = parser.parse("\"jazz piano\"").unwrap();
|
|
let ids = search_ids(&idx, q.as_ref());
|
|
assert!(ids.contains(&1), "entity 1 has 'jazz piano' phrase");
|
|
assert!(
|
|
!ids.contains(&3),
|
|
"entity 3 has 'jazz violin', not 'jazz piano'"
|
|
);
|
|
idx.close().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn parse_boolean_or() {
|
|
// "jazz OR rock" -> entities 1, 2, 3 (all contain jazz or rock).
|
|
let idx = setup_index();
|
|
let parser = idx.query_parser();
|
|
let q = parser.parse("jazz OR rock").unwrap();
|
|
let ids = search_ids(&idx, q.as_ref());
|
|
assert!(ids.contains(&1), "entity 1 has jazz");
|
|
assert!(ids.contains(&2), "entity 2 has rock");
|
|
assert!(ids.contains(&3), "entity 3 has jazz");
|
|
assert!(!ids.contains(&4), "entity 4 has neither jazz nor rock");
|
|
idx.close().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn parse_exclusion_minus() {
|
|
// "jazz -beginner" -> jazz items excluding beginners.
|
|
// Entity 3 (jazz, no beginner) should match. Entity 1 (jazz beginner) excluded.
|
|
let idx = setup_index();
|
|
let parser = idx.query_parser();
|
|
let q = parser.parse("jazz -beginner").unwrap();
|
|
let ids = search_ids(&idx, q.as_ref());
|
|
assert!(
|
|
ids.contains(&3),
|
|
"entity 3 (jazz, no beginner) should match"
|
|
);
|
|
assert!(
|
|
!ids.contains(&1),
|
|
"entity 1 (jazz beginner) should be excluded"
|
|
);
|
|
idx.close().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn parse_field_scoped_keyword() {
|
|
// "category:tech" -> only entity 4 (rust programming).
|
|
let idx = setup_index();
|
|
let parser = idx.query_parser();
|
|
let q = parser.parse("category:tech").unwrap();
|
|
let ids = search_ids(&idx, q.as_ref());
|
|
assert!(ids.contains(&4), "entity 4 should match category:tech");
|
|
assert!(!ids.contains(&1), "entity 1 has category:music, not tech");
|
|
idx.close().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn parse_field_scoped_text() {
|
|
// "title:guitar" -> only entity 2.
|
|
let idx = setup_index();
|
|
let parser = idx.query_parser();
|
|
let q = parser.parse("title:guitar").unwrap();
|
|
let ids = search_ids(&idx, q.as_ref());
|
|
assert_eq!(ids, vec![2], "only entity 2 has 'guitar' in title");
|
|
idx.close().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn parse_wildcard_prefix() {
|
|
// "jaz*" -> verify parsing does not panic or produce a hard error.
|
|
// Tantivy 0.22's QueryParser may or may not support wildcard expansion
|
|
// depending on the index settings. We verify the parse itself succeeds
|
|
// and, if results are returned, they are plausible.
|
|
let idx = setup_index();
|
|
let parser = idx.query_parser();
|
|
let result = parser.parse("jaz*");
|
|
// Accept either success or a specific parse error -- do not panic.
|
|
if let Ok(q) = result {
|
|
// Wildcard support is best-effort. If Tantivy resolves it, great;
|
|
// if not, an empty result set is acceptable.
|
|
let _ids = search_ids(&idx, q.as_ref());
|
|
}
|
|
idx.close().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn parse_hashtag() {
|
|
// "#jazz" should produce same results as "jazz".
|
|
let idx = setup_index();
|
|
let parser = idx.query_parser();
|
|
let q_hash = parser.parse("#jazz").unwrap();
|
|
let q_bare = parser.parse("jazz").unwrap();
|
|
let ids_hash: std::collections::HashSet<u64> =
|
|
search_ids(&idx, q_hash.as_ref()).into_iter().collect();
|
|
let ids_bare: std::collections::HashSet<u64> =
|
|
search_ids(&idx, q_bare.as_ref()).into_iter().collect();
|
|
assert_eq!(
|
|
ids_hash, ids_bare,
|
|
"#jazz and jazz should return same entity IDs"
|
|
);
|
|
idx.close().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn parse_invalid_query_returns_error() {
|
|
let idx = TextIndex::ephemeral(&[TextFieldDef {
|
|
key: "title".to_owned(),
|
|
field_type: TextFieldType::Text,
|
|
}])
|
|
.unwrap();
|
|
let parser = idx.query_parser();
|
|
// An unbalanced quote is a parse error in Tantivy.
|
|
let result = parser.parse("\"unclosed phrase");
|
|
// Tantivy may either error or be lenient. Verify no panic either way.
|
|
// If it does error, verify it is TidalError::Internal.
|
|
if let Err(e) = result {
|
|
assert!(
|
|
e.to_string().contains("text query parse error")
|
|
|| e.to_string().contains("internal error"),
|
|
"error should describe parse failure, got: {e}"
|
|
);
|
|
}
|
|
idx.close().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn default_fields_exclude_keyword() {
|
|
// A bare search for "music" should not match because "category" is a
|
|
// Keyword field and not in the default search fields.
|
|
let idx = setup_index();
|
|
let parser = idx.query_parser();
|
|
let q = parser.parse("music").unwrap();
|
|
let ids = search_ids(&idx, q.as_ref());
|
|
// "music" does not appear in any title, and category (Keyword) is not
|
|
// a default field, so no results.
|
|
assert!(
|
|
ids.is_empty(),
|
|
"bare 'music' should not search keyword-only field; got: {ids:?}"
|
|
);
|
|
idx.close().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn query_parser_method_on_text_index() {
|
|
// Verify the convenience method exists and produces a working parser.
|
|
let idx = setup_index();
|
|
let parser = idx.query_parser();
|
|
let q = parser.parse("rust").unwrap();
|
|
let ids = search_ids(&idx, q.as_ref());
|
|
assert!(ids.contains(&4), "entity 4 has 'rust' in title");
|
|
idx.close().unwrap();
|
|
}
|
|
}
|