Skip to main content

alopex_sql/
fts.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3const MAX_DOCUMENT_BYTES: usize = 1024 * 1024;
4const MAX_QUERY_BYTES: usize = 4096;
5const MAX_TOKENS: usize = 65_536;
6const MAX_QUERY_TERMS: usize = 1024;
7const MAX_QUERY_DEPTH: usize = 64;
8
9pub const INDEX_FORMAT_VERSION: &str = "1";
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Token {
13    pub text: String,
14    pub position: usize,
15    pub start: usize,
16    pub end: usize,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum Query {
21    Term { text: String, prefix: bool },
22    Not(Box<Query>),
23    And(Box<Query>, Box<Query>),
24    Or(Box<Query>, Box<Query>),
25    Phrase(Box<Query>, Box<Query>),
26}
27
28pub fn tokenize(config: &str, input: &str) -> Result<Vec<Token>, String> {
29    validate_config(config)?;
30    if input.len() > MAX_DOCUMENT_BYTES {
31        return Err("document exceeds 1048576 bytes".into());
32    }
33    let mut output = Vec::new();
34    let mut start = None;
35    for (offset, ch) in input.char_indices() {
36        if ch.is_alphanumeric() || ch == '_' {
37            start.get_or_insert(offset);
38        } else if let Some(token_start) = start.take() {
39            push_token(config, input, token_start, offset, &mut output)?;
40        }
41    }
42    if let Some(token_start) = start {
43        push_token(config, input, token_start, input.len(), &mut output)?;
44    }
45    Ok(output)
46}
47
48fn push_token(
49    config: &str,
50    input: &str,
51    start: usize,
52    end: usize,
53    output: &mut Vec<Token>,
54) -> Result<(), String> {
55    if output.len() >= MAX_TOKENS {
56        return Err("document exceeds 65536 tokens".into());
57    }
58    output.push(Token {
59        text: normalize(config, &input[start..end]),
60        position: output.len() + 1,
61        start,
62        end,
63    });
64    Ok(())
65}
66
67fn validate_config(config: &str) -> Result<(), String> {
68    if matches!(config.to_ascii_lowercase().as_str(), "simple" | "english") {
69        Ok(())
70    } else {
71        Err(format!("unsupported text search configuration '{config}'"))
72    }
73}
74
75fn normalize(config: &str, token: &str) -> String {
76    let mut token = token.to_lowercase();
77    if config.eq_ignore_ascii_case("english") && token.is_ascii() {
78        for suffix in ["ing", "ed", "es", "s"] {
79            if token.len() > suffix.len() + 2 && token.ends_with(suffix) {
80                token.truncate(token.len() - suffix.len());
81                break;
82            }
83        }
84    }
85    token
86}
87
88pub fn to_tsvector(config: &str, document: &str) -> Result<String, String> {
89    let mut terms = BTreeMap::<String, Vec<usize>>::new();
90    for token in tokenize(config, document)? {
91        terms.entry(token.text).or_default().push(token.position);
92    }
93    Ok(terms
94        .into_iter()
95        .map(|(term, positions)| {
96            format!(
97                "'{}':{}",
98                term.replace('\'', "''"),
99                positions
100                    .into_iter()
101                    .map(|position| position.to_string())
102                    .collect::<Vec<_>>()
103                    .join(",")
104            )
105        })
106        .collect::<Vec<_>>()
107        .join(" "))
108}
109
110pub fn parse_tsquery(config: &str, input: &str) -> Result<Query, String> {
111    validate_config(config)?;
112    validate_query_input(input)?;
113    let tokens = lex_query(config, input)?;
114    if tokens.is_empty() {
115        return Err("TSQUERY is empty".into());
116    }
117    if tokens
118        .iter()
119        .filter(|token| matches!(token, QueryToken::Term(_, _)))
120        .count()
121        > MAX_QUERY_TERMS
122    {
123        return Err("TSQUERY exceeds 1024 terms".into());
124    }
125    let mut parser = QueryParser { tokens, index: 0 };
126    let query = parser.parse_or(0)?;
127    if parser.index != parser.tokens.len() {
128        return Err("TSQUERY contains an unexpected token".into());
129    }
130    Ok(query)
131}
132
133pub fn plainto_tsquery(config: &str, input: &str) -> Result<Query, String> {
134    validate_query_input(input)?;
135    let tokens = tokenize(config, input)?;
136    if tokens.len() > MAX_QUERY_TERMS {
137        return Err("TSQUERY exceeds 1024 terms".into());
138    }
139    terms_to_and(tokens.into_iter().map(|token| token.text))
140}
141
142pub fn websearch_to_tsquery(config: &str, input: &str) -> Result<Query, String> {
143    validate_config(config)?;
144    validate_query_input(input)?;
145    let mut groups = Vec::<Vec<Query>>::new();
146    let mut current = Vec::new();
147    let mut chars = input.char_indices().peekable();
148    while let Some((offset, ch)) = chars.next() {
149        if ch.is_whitespace() {
150            continue;
151        }
152        if matches!(ch, 'O' | 'o')
153            && input[offset..]
154                .get(..2)
155                .is_some_and(|value| value.eq_ignore_ascii_case("OR"))
156            && input[offset + 2..]
157                .chars()
158                .next()
159                .is_none_or(char::is_whitespace)
160        {
161            chars.next();
162            if current.is_empty() {
163                return Err("TSQUERY has an empty OR branch".into());
164            }
165            groups.push(std::mem::take(&mut current));
166            continue;
167        }
168        let negated = ch == '-';
169        let first = if negated {
170            chars
171                .next()
172                .ok_or_else(|| "TSQUERY has a dangling '-'".to_string())?
173        } else {
174            (offset, ch)
175        };
176        let query = if first.1 == '"' {
177            let start = first.0 + first.1.len_utf8();
178            let mut end = None;
179            for (position, next) in chars.by_ref() {
180                if next == '"' {
181                    end = Some(position);
182                    break;
183                }
184            }
185            let end = end.ok_or_else(|| "TSQUERY has an unterminated quote".to_string())?;
186            let terms = tokenize(config, &input[start..end])?;
187            terms_to_phrase(terms.into_iter().map(|token| token.text))?
188        } else {
189            let start = first.0;
190            let mut end = input.len();
191            while let Some(&(position, next)) = chars.peek() {
192                if next.is_whitespace() {
193                    end = position;
194                    break;
195                }
196                chars.next();
197            }
198            let terms = tokenize(config, &input[start..end])?;
199            terms_to_and(terms.into_iter().map(|token| token.text))?
200        };
201        current.push(if negated {
202            Query::Not(Box::new(query))
203        } else {
204            query
205        });
206    }
207    if !current.is_empty() {
208        groups.push(current);
209    }
210    if groups.is_empty() {
211        return Err("TSQUERY is empty".into());
212    }
213    let query = groups
214        .into_iter()
215        .map(|group| and_queries(group.into_iter()))
216        .collect::<Result<Vec<_>, _>>()?
217        .into_iter()
218        .reduce(|left, right| Query::Or(Box::new(left), Box::new(right)))
219        .ok_or_else(|| "TSQUERY is empty".to_string())?;
220    if query_term_count(&query) > MAX_QUERY_TERMS {
221        return Err("TSQUERY exceeds 1024 terms".into());
222    }
223    Ok(query)
224}
225
226fn validate_query_input(input: &str) -> Result<(), String> {
227    if input.len() > MAX_QUERY_BYTES {
228        Err("TSQUERY exceeds 4096 bytes".into())
229    } else {
230        Ok(())
231    }
232}
233
234fn query_term_count(query: &Query) -> usize {
235    match query {
236        Query::Term { .. } => 1,
237        Query::Not(query) => query_term_count(query),
238        Query::And(left, right) | Query::Or(left, right) | Query::Phrase(left, right) => {
239            query_term_count(left) + query_term_count(right)
240        }
241    }
242}
243
244fn terms_to_and(terms: impl IntoIterator<Item = String>) -> Result<Query, String> {
245    and_queries(terms.into_iter().map(|text| Query::Term {
246        text,
247        prefix: false,
248    }))
249}
250
251fn terms_to_phrase(terms: impl IntoIterator<Item = String>) -> Result<Query, String> {
252    terms
253        .into_iter()
254        .map(|text| Query::Term {
255            text,
256            prefix: false,
257        })
258        .reduce(|left, right| Query::Phrase(Box::new(left), Box::new(right)))
259        .ok_or_else(|| "TSQUERY phrase is empty".into())
260}
261
262fn and_queries(queries: impl Iterator<Item = Query>) -> Result<Query, String> {
263    queries
264        .reduce(|left, right| Query::And(Box::new(left), Box::new(right)))
265        .ok_or_else(|| "TSQUERY is empty".into())
266}
267
268#[derive(Debug, Clone, PartialEq, Eq)]
269enum QueryToken {
270    Term(String, bool),
271    And,
272    Or,
273    Not,
274    Phrase,
275    Left,
276    Right,
277}
278
279fn lex_query(config: &str, input: &str) -> Result<Vec<QueryToken>, String> {
280    let mut output = Vec::new();
281    let mut index = 0;
282    while index < input.len() {
283        let ch = input[index..].chars().next().unwrap();
284        if ch.is_whitespace() {
285            index += ch.len_utf8();
286            continue;
287        }
288        let token = match ch {
289            '&' => QueryToken::And,
290            '|' => QueryToken::Or,
291            '!' => QueryToken::Not,
292            '(' => QueryToken::Left,
293            ')' => QueryToken::Right,
294            '<' if input[index..].starts_with("<->") => {
295                index += 2;
296                QueryToken::Phrase
297            }
298            _ if ch.is_alphanumeric() || ch == '_' => {
299                let start = index;
300                index += ch.len_utf8();
301                while index < input.len() {
302                    let next = input[index..].chars().next().unwrap();
303                    if !(next.is_alphanumeric() || next == '_') {
304                        break;
305                    }
306                    index += next.len_utf8();
307                }
308                let end = index;
309                let prefix = input[index..].starts_with(":*");
310                if prefix {
311                    index += 2;
312                }
313                output.push(QueryToken::Term(
314                    normalize(config, &input[start..end]),
315                    prefix,
316                ));
317                continue;
318            }
319            _ => return Err(format!("TSQUERY contains unsupported character '{ch}'")),
320        };
321        output.push(token);
322        index += ch.len_utf8();
323    }
324    Ok(output)
325}
326
327struct QueryParser {
328    tokens: Vec<QueryToken>,
329    index: usize,
330}
331
332impl QueryParser {
333    fn parse_or(&mut self, depth: usize) -> Result<Query, String> {
334        let mut query = self.parse_and(depth + 1)?;
335        while self.take(&QueryToken::Or) {
336            query = Query::Or(Box::new(query), Box::new(self.parse_and(depth + 1)?));
337        }
338        Ok(query)
339    }
340
341    fn parse_and(&mut self, depth: usize) -> Result<Query, String> {
342        let mut query = self.parse_phrase(depth + 1)?;
343        while self.take(&QueryToken::And) {
344            query = Query::And(Box::new(query), Box::new(self.parse_phrase(depth + 1)?));
345        }
346        Ok(query)
347    }
348
349    fn parse_phrase(&mut self, depth: usize) -> Result<Query, String> {
350        let mut query = self.parse_unary(depth + 1)?;
351        while self.take(&QueryToken::Phrase) {
352            query = Query::Phrase(Box::new(query), Box::new(self.parse_unary(depth + 1)?));
353        }
354        Ok(query)
355    }
356
357    fn parse_unary(&mut self, depth: usize) -> Result<Query, String> {
358        if depth > MAX_QUERY_DEPTH {
359            return Err("TSQUERY exceeds nesting depth 64".into());
360        }
361        if self.take(&QueryToken::Not) {
362            return Ok(Query::Not(Box::new(self.parse_unary(depth + 1)?)));
363        }
364        match self.tokens.get(self.index).cloned() {
365            Some(QueryToken::Term(text, prefix)) => {
366                self.index += 1;
367                Ok(Query::Term { text, prefix })
368            }
369            Some(QueryToken::Left) => {
370                self.index += 1;
371                let query = self.parse_or(depth + 1)?;
372                if !self.take(&QueryToken::Right) {
373                    return Err("TSQUERY is missing ')'".into());
374                }
375                Ok(query)
376            }
377            _ => Err("TSQUERY expects a term".into()),
378        }
379    }
380
381    fn take(&mut self, expected: &QueryToken) -> bool {
382        if self.tokens.get(self.index) == Some(expected) {
383            self.index += 1;
384            true
385        } else {
386            false
387        }
388    }
389}
390
391pub fn format_query(query: &Query) -> String {
392    format_query_precedence(query, 0)
393}
394
395fn format_query_precedence(query: &Query, parent: u8) -> String {
396    let (precedence, value) = match query {
397        Query::Term { text, prefix } => (5, format!("{text}{}", if *prefix { ":*" } else { "" })),
398        Query::Not(query) => (4, format!("!{}", format_query_precedence(query, 4))),
399        Query::Phrase(left, right) => (
400            3,
401            format!(
402                "{} <-> {}",
403                format_query_precedence(left, 3),
404                format_query_precedence(right, 3)
405            ),
406        ),
407        Query::And(left, right) => (
408            2,
409            format!(
410                "{} & {}",
411                format_query_precedence(left, 2),
412                format_query_precedence(right, 2)
413            ),
414        ),
415        Query::Or(left, right) => (
416            1,
417            format!(
418                "{} | {}",
419                format_query_precedence(left, 1),
420                format_query_precedence(right, 1)
421            ),
422        ),
423    };
424    if precedence < parent {
425        format!("({value})")
426    } else {
427        value
428    }
429}
430
431pub fn matches_query(tokens: &[Token], query: &Query) -> bool {
432    !positions(tokens, query).is_empty()
433}
434
435fn positions(tokens: &[Token], query: &Query) -> BTreeSet<usize> {
436    match query {
437        Query::Term { text, prefix } => tokens
438            .iter()
439            .filter(|token| {
440                if *prefix {
441                    token.text.starts_with(text)
442                } else {
443                    token.text == *text
444                }
445            })
446            .map(|token| token.position)
447            .collect(),
448        Query::Not(query) => positions(tokens, query)
449            .is_empty()
450            .then_some(0)
451            .into_iter()
452            .collect(),
453        Query::And(left, right) => {
454            let left_positions = positions(tokens, left);
455            if left_positions.is_empty() || positions(tokens, right).is_empty() {
456                BTreeSet::new()
457            } else {
458                left_positions
459            }
460        }
461        Query::Or(left, right) => positions(tokens, left)
462            .union(&positions(tokens, right))
463            .copied()
464            .collect(),
465        Query::Phrase(left, right) => {
466            let left = positions(tokens, left);
467            positions(tokens, right)
468                .into_iter()
469                .filter(|position| {
470                    position
471                        .checked_sub(1)
472                        .is_some_and(|previous| left.contains(&previous))
473                })
474                .collect()
475        }
476    }
477}
478
479pub fn index_terms(query: &Query, output: &mut BTreeSet<String>) -> bool {
480    match query {
481        Query::Term {
482            text,
483            prefix: false,
484        } => {
485            output.insert(text.clone());
486            true
487        }
488        Query::Term { prefix: true, .. } | Query::Not(_) => false,
489        Query::And(left, right) | Query::Or(left, right) | Query::Phrase(left, right) => {
490            index_terms(left, output) && index_terms(right, output)
491        }
492    }
493}
494
495pub fn rank(tokens: &[Token], query: &Query) -> f64 {
496    if !matches_query(tokens, query) || tokens.is_empty() {
497        return 0.0;
498    }
499    let matched = tokens
500        .iter()
501        .filter(|token| positive_token_match(query, &token.text))
502        .count();
503    matched as f64 / tokens.len() as f64
504}
505
506fn positive_token_match(query: &Query, token: &str) -> bool {
507    match query {
508        Query::Term { text, prefix } => {
509            if *prefix {
510                token.starts_with(text)
511            } else {
512                token == text
513            }
514        }
515        Query::Not(_) => false,
516        Query::And(left, right) | Query::Or(left, right) | Query::Phrase(left, right) => {
517            positive_token_match(left, token) || positive_token_match(right, token)
518        }
519    }
520}
521
522pub fn headline(config: &str, document: &str, query: &Query) -> Result<String, String> {
523    let tokens = tokenize(config, document)?;
524    let mut output = String::with_capacity(document.len());
525    let mut cursor = 0;
526    for token in tokens {
527        output.push_str(&document[cursor..token.start]);
528        if positive_token_match(query, &token.text) {
529            output.push_str("<b>");
530            output.push_str(&document[token.start..token.end]);
531            output.push_str("</b>");
532        } else {
533            output.push_str(&document[token.start..token.end]);
534        }
535        cursor = token.end;
536    }
537    output.push_str(&document[cursor..]);
538    if output.len() > MAX_DOCUMENT_BYTES {
539        return Err("headline exceeds 1048576 bytes".into());
540    }
541    Ok(output)
542}
543
544pub fn parse_tsvector(input: &str) -> Result<Vec<Token>, String> {
545    if input.len() > MAX_DOCUMENT_BYTES {
546        return Err("TSVECTOR exceeds 1048576 bytes".into());
547    }
548    let mut tokens = Vec::new();
549    for entry in input.split_whitespace() {
550        let (term, positions) = entry
551            .rsplit_once(':')
552            .ok_or_else(|| "invalid TSVECTOR entry".to_string())?;
553        let term = term
554            .strip_prefix('\'')
555            .and_then(|value| value.strip_suffix('\''))
556            .ok_or_else(|| "invalid TSVECTOR term".to_string())?
557            .replace("''", "'");
558        for position in positions.split(',') {
559            if tokens.len() >= MAX_TOKENS {
560                return Err("TSVECTOR exceeds 65536 positions".into());
561            }
562            let position = position
563                .parse::<usize>()
564                .map_err(|_| "invalid TSVECTOR position".to_string())?;
565            tokens.push(Token {
566                text: term.clone(),
567                position,
568                start: 0,
569                end: 0,
570            });
571        }
572    }
573    tokens.sort_by_key(|token| token.position);
574    Ok(tokens)
575}
576
577#[cfg(test)]
578mod tests {
579    use super::*;
580
581    #[test]
582    fn phrase_tracks_the_rightmost_position() {
583        let tokens = tokenize("simple", "one two three").unwrap();
584        let query = parse_tsquery("simple", "one <-> two <-> three").unwrap();
585        assert!(matches_query(&tokens, &query));
586    }
587
588    #[test]
589    fn vector_round_trip_preserves_rank() {
590        let vector = to_tsvector("simple", "the quick brown fox").unwrap();
591        let query = plainto_tsquery("simple", "quick fox").unwrap();
592        assert_eq!(rank(&parse_tsvector(&vector).unwrap(), &query), 0.5);
593    }
594
595    #[test]
596    fn public_resource_limits_fail_closed() {
597        assert!(tokenize("simple", &"x".repeat(MAX_DOCUMENT_BYTES + 1)).is_err());
598        assert!(parse_tsquery("simple", &"x".repeat(MAX_QUERY_BYTES + 1)).is_err());
599    }
600}