Skip to main content

fathomdb_query/
lib.rs

1//! **FathomDB query** — the JSON-filter AST and its validation surface.
2//!
3//! An internal leaf crate of the FathomDB workspace, kept separate so the
4//! filter grammar can be validated without pulling in the engine. **Application
5//! code should depend on the `fathomdb` facade crate instead**, which exposes
6//! the supported filter types (`Predicate`, `ScalarValue`, `ComparisonOp`,
7//! `SearchFilter`) as governed surface.
8//!
9//! The filter grammar is deliberately **closed** — a fixed set of comparison
10//! operators over an allowlisted set of JSON paths — rather than an open DSL.
11//! Values are always bound as parameterized SQL and never interpolated.
12
13use std::collections::HashSet;
14
15#[derive(Clone, Debug, Eq, PartialEq)]
16pub struct QueryAst {
17    pub raw: String,
18}
19
20#[derive(Clone, Debug, Eq, PartialEq)]
21pub struct CompiledQuery {
22    pub match_expression: String,
23}
24
25/// Function words stripped from the content-OR query (they add false matches
26/// under OR semantics without carrying topical signal). Mirrors the IR-C
27/// `content-OR` experiment list (`dev/plans/runs/performance-output-and-compare.md`,
28/// 2026-06-10b): the smallest list that lifted exploratory recall with no
29/// exact_fact cost.
30const STOPWORDS: &[&str] = &[
31    "the", "and", "for", "are", "was", "were", "what", "when", "where", "who", "whom", "which",
32    "how", "why", "did", "does", "do", "is", "of", "to", "in", "on", "at", "by", "an", "a", "it",
33    "its", "this", "that", "these", "those", "with", "from", "as", "be", "or", "if", "about",
34    "into", "over", "than", "then", "they", "them", "their", "you", "your", "we", "our", "i",
35];
36
37/// Content tokens of a query: lowercased, split on non-alphanumeric, ≥3 chars,
38/// stopwords removed, de-duplicated in first-seen order. These are the OR terms
39/// of the compiled MATCH expression. Splitting on non-alphanumeric drops every
40/// FTS5 control character (`*`, `"`, `:`, `^`, `(`, `)`, `,`), so the emitted
41/// tokens are pure literals — the injection-safety property (AC-038).
42#[must_use]
43fn content_tokens(raw: &str) -> Vec<String> {
44    let stop: HashSet<&str> = STOPWORDS.iter().copied().collect();
45    let mut seen: HashSet<String> = HashSet::new();
46    let mut out: Vec<String> = Vec::new();
47    for token in raw.to_lowercase().split(|c: char| !c.is_alphanumeric()) {
48        if token.len() < 3 || stop.contains(token) {
49            continue;
50        }
51        if seen.insert(token.to_string()) {
52            out.push(token.to_string());
53        }
54    }
55    out
56}
57
58/// Compile a raw query into an FTS5 MATCH expression.
59///
60/// IR-C (2026-06-10b/c, `performance-output-and-compare.md`): the production
61/// arm was an **AND** of every whitespace token, which near-zeroes recall on
62/// natural-language questions (every token must be present). The validated
63/// recipe is **content-OR** — OR over the content tokens (stopwords stripped) —
64/// which any-token-matches and lets `bm25()` rank by overlap, the way the
65/// same-dataset BM25 baselines (EnronQA/QAConv) are run.
66///
67/// All-stopword / symbol-only / sub-3-char queries (no content tokens) fall back
68/// to an OR over the raw whitespace tokens as injection-safe quoted phrases, so
69/// such a query still searches instead of returning nothing.
70#[must_use]
71pub fn compile_text_query(raw: impl Into<String>) -> CompiledQuery {
72    let raw = raw.into();
73    let content = content_tokens(&raw);
74    let match_expression = if content.is_empty() {
75        raw.split_whitespace()
76            .filter(|token| !token.is_empty())
77            .map(|token| format!("\"{}\"", token.replace('"', "\"\"")))
78            .collect::<Vec<_>>()
79            .join(" OR ")
80    } else {
81        content.into_iter().map(|token| format!("\"{token}\"")).collect::<Vec<_>>().join(" OR ")
82    };
83
84    CompiledQuery { match_expression }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::compile_text_query;
90
91    #[test]
92    fn content_tokens_are_or_joined() {
93        // IR-C content-OR: content tokens OR-joined (was AND).
94        let compiled = compile_text_query("alpha   beta");
95        assert_eq!(compiled.match_expression, "\"alpha\" OR \"beta\"");
96    }
97
98    #[test]
99    fn stopwords_and_short_tokens_are_dropped() {
100        // "of"/"the" are stopwords; "a" is sub-3-char — only content survives.
101        let compiled = compile_text_query("status of the alpha");
102        assert_eq!(compiled.match_expression, "\"status\" OR \"alpha\"");
103    }
104
105    #[test]
106    fn duplicate_content_tokens_collapse_in_order() {
107        let compiled = compile_text_query("alpha beta alpha");
108        assert_eq!(compiled.match_expression, "\"alpha\" OR \"beta\"");
109    }
110
111    #[test]
112    fn control_characters_are_stripped_to_literals() {
113        // FTS5 control syntax splits into literal content tokens — no operators
114        // reach SQLite (AC-038).
115        let compiled = compile_text_query("alpha* AND \"beta\" NEAR(gamma)");
116        assert_eq!(compiled.match_expression, "\"alpha\" OR \"beta\" OR \"near\" OR \"gamma\"");
117    }
118
119    #[test]
120    fn all_stopword_query_falls_back_to_raw_or() {
121        // No content tokens: OR over the raw whitespace tokens (still searches).
122        let compiled = compile_text_query("who are we");
123        assert_eq!(compiled.match_expression, "\"who\" OR \"are\" OR \"we\"");
124    }
125
126    #[test]
127    fn escapes_double_quotes_in_fallback_tokens() {
128        // The fallback path keeps injection-safe quote-escaping.
129        let compiled = compile_text_query("an \"of");
130        assert_eq!(compiled.match_expression, "\"an\" OR \"\"\"of\"");
131    }
132}