use crate::relational::error::{RelError, Result};
pub const MAX_POS: u16 = 16383;
pub const MAX_NUM_POS: usize = 256;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct TsLexeme {
pub word: String,
pub positions: Vec<u16>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TsQueryNode {
Lexeme(String),
Not(Box<TsQueryNode>),
And(Box<TsQueryNode>, Box<TsQueryNode>),
Or(Box<TsQueryNode>, Box<TsQueryNode>),
}
impl TsQueryNode {
pub fn count_nodes(&self) -> i32 {
match self {
TsQueryNode::Lexeme(_) => 1,
TsQueryNode::Not(c) => 1 + c.count_nodes(),
TsQueryNode::And(a, b) | TsQueryNode::Or(a, b) => 1 + a.count_nodes() + b.count_nodes(),
}
}
pub fn lexemes<'a>(&'a self, out: &mut Vec<&'a str>) {
match self {
TsQueryNode::Lexeme(w) => out.push(w),
TsQueryNode::Not(c) => c.lexemes(out),
TsQueryNode::And(a, b) | TsQueryNode::Or(a, b) => {
a.lexemes(out);
b.lexemes(out);
}
}
}
}
pub fn normalize_lexemes(raw: Vec<(String, Vec<u16>)>) -> Vec<TsLexeme> {
let mut sorted = raw;
sorted.sort_by(|a, b| a.0.cmp(&b.0));
let mut out: Vec<TsLexeme> = Vec::with_capacity(sorted.len());
for (word, positions) in sorted {
match out.last_mut() {
Some(last) if last.word == word => last.positions.extend(positions),
_ => out.push(TsLexeme { word, positions }),
}
}
for lex in &mut out {
lex.positions.iter_mut().for_each(|p| {
*p = (*p).clamp(1, MAX_POS);
});
lex.positions.sort_unstable();
lex.positions.dedup();
lex.positions.truncate(MAX_NUM_POS);
}
out
}
pub fn eval_match(lexemes: &[TsLexeme], node: &TsQueryNode) -> bool {
match node {
TsQueryNode::Lexeme(w) => lexemes.binary_search_by(|l| l.word.as_str().cmp(w)).is_ok(),
TsQueryNode::Not(c) => !eval_match(lexemes, c),
TsQueryNode::And(a, b) => eval_match(lexemes, a) && eval_match(lexemes, b),
TsQueryNode::Or(a, b) => eval_match(lexemes, a) || eval_match(lexemes, b),
}
}
fn quote_lexeme(word: &str) -> String {
format!("'{}'", word.replace('\\', "\\\\").replace('\'', "''"))
}
pub fn format_tsvector(lexemes: &[TsLexeme]) -> String {
lexemes
.iter()
.map(|l| {
let mut s = quote_lexeme(&l.word);
if !l.positions.is_empty() {
s.push(':');
s.push_str(
&l.positions
.iter()
.map(u16::to_string)
.collect::<Vec<_>>()
.join(","),
);
}
s
})
.collect::<Vec<_>>()
.join(" ")
}
pub fn format_tsquery(root: Option<&TsQueryNode>) -> String {
let mut out = String::new();
if let Some(node) = root {
infix(node, 0, &mut out);
}
out
}
fn priority(node: &TsQueryNode) -> u8 {
match node {
TsQueryNode::Or(..) => 0,
TsQueryNode::And(..) => 1,
TsQueryNode::Not(_) => 3,
TsQueryNode::Lexeme(_) => u8::MAX,
}
}
fn infix(node: &TsQueryNode, parent_priority: u8, out: &mut String) {
let wrap = priority(node) < parent_priority;
if wrap {
out.push_str("( ");
}
match node {
TsQueryNode::Lexeme(w) => out.push_str("e_lexeme(w)),
TsQueryNode::Not(c) => {
out.push('!');
infix(c, priority(node), out);
}
TsQueryNode::And(a, b) | TsQueryNode::Or(a, b) => {
let p = priority(node);
infix(a, p, out);
out.push_str(if matches!(node, TsQueryNode::And(..)) {
" & "
} else {
" | "
});
infix(b, p, out);
}
}
if wrap {
out.push_str(" )");
}
}
fn tsvector_syntax(input: &str) -> RelError {
RelError::Syntax(format!("syntax error in tsvector: \"{input}\""))
}
fn tsquery_syntax(input: &str) -> RelError {
RelError::Syntax(format!("syntax error in tsquery: \"{input}\""))
}
fn lexeme_token(chars: &mut std::iter::Peekable<std::str::Chars>, stop: &[char]) -> Option<String> {
let mut out = String::new();
if chars.peek() == Some(&'\'') {
chars.next();
loop {
match chars.next()? {
'\'' => {
if chars.peek() == Some(&'\'') {
chars.next();
out.push('\'');
} else {
return if out.is_empty() { None } else { Some(out) };
}
}
'\\' => out.push(chars.next()?),
c => out.push(c),
}
}
}
loop {
match chars.peek() {
Some('\\') => {
chars.next();
out.push(chars.next()?);
}
Some(c) if !c.is_whitespace() && !stop.contains(c) => {
out.push(*c);
chars.next();
}
_ => break,
}
}
if out.is_empty() { None } else { Some(out) }
}
pub fn parse_tsvector(input: &str) -> Result<Vec<TsLexeme>> {
let mut chars = input.chars().peekable();
let mut raw: Vec<(String, Vec<u16>)> = Vec::new();
loop {
while chars.peek().is_some_and(|c| c.is_whitespace()) {
chars.next();
}
if chars.peek().is_none() {
break;
}
let word = lexeme_token(&mut chars, &[':']).ok_or_else(|| tsvector_syntax(input))?;
let mut positions = Vec::new();
if chars.peek() == Some(&':') {
chars.next();
loop {
let mut digits = String::new();
while chars.peek().is_some_and(char::is_ascii_digit) {
digits.push(chars.next().unwrap());
}
let pos: u32 = digits.parse().map_err(|_| tsvector_syntax(input))?;
if pos == 0 {
return Err(tsvector_syntax(input));
}
positions.push(pos.min(MAX_POS as u32) as u16);
match chars.peek() {
Some('A' | 'a' | 'B' | 'b' | 'C' | 'c') => {
return Err(RelError::FeatureNotSupported(format!(
"tsvector position weight labels (\"{}{}\") are not supported \
(setweight is out of the full-text-search subset)",
digits,
chars.peek().unwrap()
)));
}
Some('D' | 'd') => {
chars.next();
}
_ => {}
}
match chars.peek() {
Some(',') => {
chars.next();
}
Some(c) if c.is_whitespace() => break,
None => break,
Some(_) => return Err(tsvector_syntax(input)),
}
}
}
raw.push((word, positions));
}
Ok(normalize_lexemes(raw))
}
enum QTok {
Open,
Close,
And,
Or,
Not,
Lexeme(String),
}
fn tsquery_tokens(input: &str) -> Result<Vec<QTok>> {
let mut chars = input.chars().peekable();
let mut out = Vec::new();
loop {
while chars.peek().is_some_and(|c| c.is_whitespace()) {
chars.next();
}
let Some(&c) = chars.peek() else { break };
match c {
'(' => {
chars.next();
out.push(QTok::Open);
}
')' => {
chars.next();
out.push(QTok::Close);
}
'&' => {
chars.next();
out.push(QTok::And);
}
'|' => {
chars.next();
out.push(QTok::Or);
}
'!' => {
chars.next();
out.push(QTok::Not);
}
'<' => {
return Err(RelError::FeatureNotSupported(
"the tsquery phrase operator <-> is not supported (position-aware \
phrase search is out of the full-text-search subset)"
.into(),
));
}
_ => {
let word = lexeme_token(&mut chars, &['(', ')', '&', '|', '!', ':', '<'])
.ok_or_else(|| tsquery_syntax(input))?;
if chars.peek() == Some(&':') {
chars.next();
let mut label = String::new();
while chars
.peek()
.is_some_and(|c| matches!(c, 'A'..='D' | 'a'..='d' | '*'))
{
label.push(chars.next().unwrap());
}
if label.is_empty() {
return Err(tsquery_syntax(input));
}
if label.contains('*') {
return Err(RelError::FeatureNotSupported(
"tsquery prefix matching (:*) is not supported".into(),
));
}
return Err(RelError::FeatureNotSupported(format!(
"tsquery weight restrictions (\":{label}\") are not supported \
(setweight is out of the full-text-search subset)"
)));
}
out.push(QTok::Lexeme(word));
}
}
}
Ok(out)
}
pub fn parse_tsquery(input: &str) -> Result<Option<TsQueryNode>> {
let tokens = tsquery_tokens(input)?;
if tokens.is_empty() {
return Ok(None);
}
let mut pos = 0;
let node = parse_or(&tokens, &mut pos, input)?;
if pos != tokens.len() {
return Err(tsquery_syntax(input));
}
Ok(Some(node))
}
fn parse_or(tokens: &[QTok], pos: &mut usize, input: &str) -> Result<TsQueryNode> {
let mut left = parse_and(tokens, pos, input)?;
while matches!(tokens.get(*pos), Some(QTok::Or)) {
*pos += 1;
let right = parse_and(tokens, pos, input)?;
left = TsQueryNode::Or(Box::new(left), Box::new(right));
}
Ok(left)
}
fn parse_and(tokens: &[QTok], pos: &mut usize, input: &str) -> Result<TsQueryNode> {
let mut left = parse_not(tokens, pos, input)?;
while matches!(tokens.get(*pos), Some(QTok::And)) {
*pos += 1;
let right = parse_not(tokens, pos, input)?;
left = TsQueryNode::And(Box::new(left), Box::new(right));
}
Ok(left)
}
fn parse_not(tokens: &[QTok], pos: &mut usize, input: &str) -> Result<TsQueryNode> {
if matches!(tokens.get(*pos), Some(QTok::Not)) {
*pos += 1;
return Ok(TsQueryNode::Not(Box::new(parse_not(tokens, pos, input)?)));
}
match tokens.get(*pos) {
Some(QTok::Open) => {
*pos += 1;
let inner = parse_or(tokens, pos, input)?;
if !matches!(tokens.get(*pos), Some(QTok::Close)) {
return Err(tsquery_syntax(input));
}
*pos += 1;
Ok(inner)
}
Some(QTok::Lexeme(w)) => {
*pos += 1;
Ok(TsQueryNode::Lexeme(w.clone()))
}
_ => Err(tsquery_syntax(input)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tsvector_raw_parse_matches_pg() {
let v = parse_tsvector("fat:2 cat:3").unwrap();
assert_eq!(format_tsvector(&v), "'cat':3 'fat':2");
let v = parse_tsvector("The Fat Rats").unwrap();
assert_eq!(format_tsvector(&v), "'Fat' 'Rats' 'The'");
let v = parse_tsvector("'fat rat':1 'don''t':2").unwrap();
assert_eq!(format_tsvector(&v), "'don''t':2 'fat rat':1");
let v = parse_tsvector("a:3,1 a:2,3").unwrap();
assert_eq!(format_tsvector(&v), "'a':1,2,3");
let v = parse_tsvector("a:99999 b:5D").unwrap();
assert_eq!(format_tsvector(&v), "'a':16383 'b':5");
}
#[test]
fn tsvector_raw_parse_errors() {
for bad in ["a:", "a:0", "a:x", ":1", "'a", "a:1,"] {
assert!(
matches!(parse_tsvector(bad), Err(RelError::Syntax(_))),
"{bad:?} should be a tsvector syntax error"
);
}
assert!(matches!(
parse_tsvector("a:1A"),
Err(RelError::FeatureNotSupported(_))
));
}
#[test]
fn tsquery_parse_precedence_and_display() {
let q = parse_tsquery("fat & rat").unwrap();
assert_eq!(format_tsquery(q.as_ref()), "'fat' & 'rat'");
let q = parse_tsquery("!a & b | c").unwrap();
assert_eq!(
q,
Some(TsQueryNode::Or(
Box::new(TsQueryNode::And(
Box::new(TsQueryNode::Not(Box::new(TsQueryNode::Lexeme("a".into())))),
Box::new(TsQueryNode::Lexeme("b".into())),
)),
Box::new(TsQueryNode::Lexeme("c".into())),
))
);
assert_eq!(format_tsquery(q.as_ref()), "!'a' & 'b' | 'c'");
let q = parse_tsquery("fat & (rat | cat)").unwrap();
assert_eq!(format_tsquery(q.as_ref()), "'fat' & ( 'rat' | 'cat' )");
let q = parse_tsquery("!(a | b)").unwrap();
assert_eq!(format_tsquery(q.as_ref()), "!( 'a' | 'b' )");
assert_eq!(parse_tsquery("").unwrap(), None);
assert_eq!(format_tsquery(None), "");
}
#[test]
fn tsquery_parse_errors_and_exclusions() {
for bad in ["a b", "a &", "& a", "(a", "a)", "()", "!"] {
assert!(
matches!(parse_tsquery(bad), Err(RelError::Syntax(_))),
"{bad:?} should be a tsquery syntax error"
);
}
for unsupported in ["a <-> b", "a <2> b", "a:*", "a:A"] {
assert!(
matches!(
parse_tsquery(unsupported),
Err(RelError::FeatureNotSupported(_))
),
"{unsupported:?} should be typed-unsupported"
);
}
}
#[test]
fn match_evaluation() {
let doc = normalize_lexemes(vec![
("cat".into(), vec![3]),
("fat".into(), vec![2]),
("rat".into(), vec![]),
]);
let m = |q: &str| eval_match(&doc, &parse_tsquery(q).unwrap().unwrap());
assert!(m("cat"));
assert!(!m("dog"));
assert!(m("cat & rat"));
assert!(!m("cat & dog"));
assert!(m("cat | dog"));
assert!(m("!dog"));
assert!(!m("!cat"));
assert!(m("cat & !dog"));
assert!(m("!(dog & cat)"));
assert!(!m("!(fat | cat)"));
}
#[test]
fn numnode_counts() {
let n = |q: &str| {
parse_tsquery(q)
.unwrap()
.map(|n| n.count_nodes())
.unwrap_or(0)
};
assert_eq!(n("(fat & rat) | cat"), 5);
assert_eq!(n("foo & bar"), 3);
assert_eq!(n("foo"), 1);
assert_eq!(n("!foo"), 2);
assert_eq!(n(""), 0);
}
}