use core_traits::TypedValue;
use sql_traits::errors::{BuildQueryResult, SQLError};
use mentat_sql::QueryBuilder;
use crate::fts_unicode61::{ALNUM_ASCII, ALNUM_EXCEPT};
use crate::{push_comparand, ColumnOrExpression, Comparand};
#[derive(Clone, Debug, PartialEq)]
struct Word {
token: String,
prefix: bool,
first: bool,
}
#[derive(Debug, PartialEq)]
enum Lexeme {
Open,
Close,
And,
Or,
Not,
Near,
Phrase(Vec<Word>),
}
#[derive(Debug, PartialEq)]
enum Node {
Empty,
Phrase(Vec<Word>),
And(Vec<Node>),
Or(Vec<Node>),
Near(Vec<Node>),
Not(Box<Node>, Box<Node>),
}
const MAX_PARENS: usize = 100;
const MAX_DEPTH: u32 = 12;
impl Node {
fn balanced_depth(&self, m: u32) -> Option<u32> {
if m == 0 {
return None;
}
match self {
Node::Empty | Node::Phrase(_) => Some(0),
Node::Near(xs) => Some(xs.len() as u32 - 1),
Node::Not(l, r) => Some(1 + l.balanced_depth(m - 1)?.max(r.balanced_depth(m - 1)?)),
Node::And(xs) | Node::Or(xs) => {
let mut slots: Vec<Option<u32>> = vec![None; m as usize];
for x in xs {
let mut carry = Some(x.balanced_depth(m - 1)?);
for slot in slots.iter_mut() {
match (slot.take(), carry) {
(None, c) => {
*slot = c;
carry = None;
break;
}
(Some(d), Some(c)) => carry = Some(1 + d.max(c)),
(Some(_), None) => unreachable!(),
}
}
carry.is_none().then_some(())?;
}
slots.into_iter().flatten().reduce(|acc, d| 1 + acc.max(d))
}
}
}
}
fn chain(l: Node, r: Node, and: bool) -> Node {
let same = |n: Node| match n {
Node::And(xs) if and => Ok(xs),
Node::Or(xs) if !and => Ok(xs),
n => Err(n),
};
let mut xs = same(l).unwrap_or_else(|l| vec![l]);
match same(r) {
Ok(ys) => xs.extend(ys),
Err(r) => xs.push(r),
}
if and {
Node::And(xs)
} else {
Node::Or(xs)
}
}
fn malformed(q: &str) -> SQLError {
SQLError::MalformedMatch(q.to_string())
}
fn is_alnum(c: char) -> bool {
let c = c as u32;
if c < 128 {
return ALNUM_ASCII[(c >> 5) as usize] & (1 << (c & 31)) == 0;
}
if c >= 1 << 22 {
return true;
}
let key = (c << 10) | 0x3FF;
let e = ALNUM_EXCEPT[ALNUM_EXCEPT.partition_point(|&e| e <= key) - 1];
c >= (e >> 10) + (e & 0x3FF)
}
fn is_diacritic(c: char) -> bool {
let c = c as u32;
match c {
768..=799 => (0x08029FDF_u32 >> (c - 768)) & 1 == 1,
800..=817 => (0x000361F8_u32 >> (c - 800)) & 1 == 1,
_ => false,
}
}
fn tokens(s: &str) -> impl Iterator<Item = (usize, usize)> + '_ {
let mut at = 0;
std::iter::from_fn(move || {
let start = at + s[at..].find(is_alnum)?;
let len = s[start..]
.find(|c| !(is_alnum(c) || is_diacritic(c)))
.unwrap_or(s.len() - start);
at = start + len;
Some((start, at))
})
}
fn word(s: &str, (start, end): (usize, usize)) -> Word {
Word {
token: s[start..end].to_string(),
prefix: s[end..].starts_with('*'),
first: s[..start].ends_with('^'),
}
}
fn is_space(c: char) -> bool {
matches!(c, ' ' | '\t' | '\n' | '\r' | '\x0b' | '\x0c')
}
fn keyword(rest: &str) -> Option<(Lexeme, usize)> {
let b = rest.as_bytes();
let (lexeme, mut n) = [
("OR", Lexeme::Or),
("AND", Lexeme::And),
("NOT", Lexeme::Not),
("NEAR", Lexeme::Near),
]
.into_iter()
.find(|(k, _)| b.starts_with(k.as_bytes()))
.map(|(k, l)| (l, k.len()))?;
if lexeme == Lexeme::Near && b.get(4) == Some(&b'/') && b.get(5).is_some_and(u8::is_ascii_digit)
{
n += 1 + b[5..].iter().take_while(|c| c.is_ascii_digit()).count();
}
match b.get(n) {
None | Some(b'"' | b'(' | b')') => Some((lexeme, n)),
Some(&c) if is_space(c as char) => Some((lexeme, n)),
_ => None,
}
}
fn lex(q: &str) -> Result<Vec<Lexeme>, SQLError> {
let q = q.split('\0').next().unwrap_or_default();
let mut out = Vec::new();
let mut i = 0;
let mut depth = 0;
loop {
i = q[i..].find(|c| !is_space(c)).map_or(q.len(), |n| i + n);
let rest = &q[i..];
if rest.is_empty() {
return Ok(out);
}
if let Some((lexeme, n)) = keyword(rest) {
out.push(lexeme);
i += n;
continue;
}
match rest.as_bytes()[0] {
b'"' => {
let len = rest[1..].find('"').ok_or_else(|| malformed(q))?;
let inner = &rest[1..1 + len];
out.push(Lexeme::Phrase(
tokens(inner).map(|t| word(inner, t)).collect(),
));
i += len + 2;
continue;
}
b'(' => {
depth += 1;
if depth > MAX_PARENS {
return Err(malformed(q));
}
out.push(Lexeme::Open);
i += 1;
continue;
}
b')' => {
depth = depth.saturating_sub(1);
if out.last() == Some(&Lexeme::Open) {
out.pop();
} else {
out.push(Lexeme::Close);
}
i += 1;
continue;
}
_ => {}
}
let column = ["text", "searchid"].into_iter().find(|c| {
rest.len() > c.len()
&& rest.as_bytes()[c.len()] == b':'
&& rest.as_bytes()[..c.len()].eq_ignore_ascii_case(c.as_bytes())
});
let skip = column.map_or(0, |c| c.len() + 1);
let rest = &rest[skip..];
let stop = rest
.find(|c| is_alnum(c) || matches!(c, '"' | '(' | ')'))
.unwrap_or(rest.len());
let consumed = if rest[stop..].starts_with(is_alnum) {
let (_, end) = tokens(&rest[stop..]).next().expect("starts with a token");
let w = word(rest, (stop, stop + end));
let n = stop + end + usize::from(w.prefix);
out.push(Lexeme::Phrase(match column {
Some("searchid") => Vec::new(),
_ => vec![w],
}));
n
} else {
stop
};
i += skip + consumed;
}
}
struct Parser<'q> {
lexemes: Vec<Lexeme>,
pos: usize,
q: &'q str,
}
impl Parser<'_> {
fn peek(&self) -> Option<&Lexeme> {
self.lexemes.get(self.pos)
}
fn or(&mut self) -> Result<Node, SQLError> {
let mut left = self.and()?;
while self.peek() == Some(&Lexeme::Or) {
self.pos += 1;
let right = self.and()?;
left = chain(left, right, false);
}
Ok(left)
}
fn and(&mut self) -> Result<Node, SQLError> {
let mut left = self.not()?;
loop {
match self.peek() {
Some(Lexeme::And) => self.pos += 1,
Some(Lexeme::Open | Lexeme::Phrase(_)) => {}
_ => return Ok(left),
}
let right = self.not()?;
left = chain(left, right, true);
}
}
fn not(&mut self) -> Result<Node, SQLError> {
let mut left = self.near()?;
let mut nots = 0;
while self.peek() == Some(&Lexeme::Not) {
self.pos += 1;
let right = self.near()?;
left = Node::Not(Box::new(left), Box::new(right));
nots += 1;
if nots >= MAX_DEPTH {
return Err(malformed(self.q));
}
}
Ok(left)
}
fn near(&mut self) -> Result<Node, SQLError> {
let first = self.primary()?;
if self.peek() != Some(&Lexeme::Near) {
return Ok(first);
}
let mut xs = vec![first];
while self.peek() == Some(&Lexeme::Near) {
self.pos += 1;
xs.push(self.primary()?);
if xs.len() > MAX_DEPTH as usize + 1 {
return Err(malformed(self.q));
}
}
if !xs.iter().all(|x| matches!(x, Node::Phrase(_))) {
return Err(malformed(self.q));
}
Ok(Node::Near(xs))
}
fn primary(&mut self) -> Result<Node, SQLError> {
let lexeme = self.lexemes.get_mut(self.pos);
match lexeme {
Some(Lexeme::Phrase(words)) => {
let words = std::mem::take(words);
self.pos += 1;
Ok(Node::Phrase(words))
}
Some(Lexeme::Open) => {
self.pos += 1;
let inner = self.or()?;
if self.peek() != Some(&Lexeme::Close) {
return Err(malformed(self.q));
}
self.pos += 1;
Ok(inner)
}
_ => Err(malformed(self.q)),
}
}
}
fn parse(q: &str) -> Result<Node, SQLError> {
let mut p = Parser {
lexemes: lex(q)?,
pos: 0,
q,
};
if p.lexemes.is_empty() {
return Ok(Node::Empty);
}
let node = p.or()?;
if p.pos != p.lexemes.len() || node.balanced_depth(MAX_DEPTH).is_none_or(|d| d > MAX_DEPTH) {
return Err(malformed(q));
}
Ok(node)
}
const FOLD_FROM: &str =
"\u{b5}\u{17f}\u{3c2}\u{3d0}\u{3d1}\u{3d5}\u{3d6}\u{3f0}\u{3f1}\u{3f5}\u{1e9b}\u{1fbe}";
const FOLD_TO: &str =
"\u{3bc}s\u{3c3}\u{3b2}\u{3b8}\u{3c6}\u{3c0}\u{3ba}\u{3c1}\u{3b5}\u{1e61}\u{3b9}";
fn token_re() -> &'static str {
static RE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
RE.get_or_init(|| {
let mut ranges: Vec<(u32, u32)> = Vec::new();
for c in (0..=0x10FFFF_u32).filter(|&c| char::from_u32(c).is_some_and(is_alnum)) {
match ranges.last_mut() {
Some((_, end)) if *end + 1 == c => *end = c,
_ => ranges.push((c, c)),
}
}
let class: String = ranges
.iter()
.map(|&(a, b)| match a == b {
true => format!(r"\x{{{a:x}}}"),
false => format!(r"\x{{{a:x}}}-\x{{{b:x}}}"),
})
.collect();
let marks: String = (0x300..=0x331_u32)
.filter(|&c| char::from_u32(c).is_some_and(is_diacritic))
.map(|c| format!(r"\x{{{c:x}}}"))
.collect();
format!("[{class}][{class}{marks}]*")
})
}
fn push_folded(out: &mut dyn QueryBuilder, x: &ColumnOrExpression) -> BuildQueryResult {
out.push_sql("translate(lower(");
push_comparand(out, x, Comparand::Member("v_s"))?;
out.push_sql(&format!("), '{FOLD_FROM}', '{FOLD_TO}')"));
Ok(())
}
fn push_tokens(out: &mut dyn QueryBuilder, x: &ColumnOrExpression) -> BuildQueryResult {
out.push_sql("regexp_extract_all(");
push_folded(out, x)?;
out.push_sql(&format!(", '{}')", token_re()));
Ok(())
}
fn folded_lit(s: &str) -> String {
let s = s.replace('\'', "''");
if s.is_ascii() {
format!("'{}'", s.to_ascii_lowercase())
} else {
format!("translate(lower('{s}'), '{FOLD_FROM}', '{FOLD_TO}')")
}
}
fn join(parts: impl IntoIterator<Item = String>, op: &str) -> String {
let mut seen = std::collections::HashSet::new();
let parts: Vec<String> = parts
.into_iter()
.filter(|p| seen.insert(p.clone()))
.collect();
match parts.len() {
1 => parts.into_iter().next().unwrap_or_default(),
_ => format!("({})", parts.join(op)),
}
}
fn matchable(words: &[Word]) -> bool {
!words.is_empty() && !words.iter().skip(1).any(|w| w.first)
}
fn phrase_re(words: &[Word]) -> String {
let mut re = String::from(if words[0].first { "^ " } else { " " });
for w in words {
re.push_str(&w.token);
re.push_str(if w.prefix { "[^ ]* " } else { " " });
}
re
}
const REGEX_BYTES: usize = 32 * 1024;
fn alternation(alts: Vec<(String, String)>, col: &str) -> Vec<String> {
let mut runs: Vec<Vec<(String, String)>> = vec![];
let mut len = 0;
for alt in alts {
len += alt.0.len() + 1;
match runs.last_mut() {
Some(run) if len <= REGEX_BYTES => run.push(alt),
_ => {
len = alt.0.len() + 1;
runs.push(vec![alt]);
}
}
}
runs.into_iter()
.map(|mut run| match run.len() {
1 => run.remove(0).1,
_ => {
let re: Vec<String> = run.into_iter().map(|(re, _)| re).collect();
format!(
"regexp_matches({col}, {})",
folded_lit(&format!("(?:{})", re.join("|")))
)
}
})
.collect()
}
fn or_parts(
xs: &[Node],
sql: fn(&Node) -> String,
phrase: impl Fn(&[Word]) -> (String, String),
col: &str,
) -> String {
let mut alts = vec![];
let mut rest = vec![];
for x in xs {
match x {
Node::Phrase(words) if matchable(words) => alts.push(phrase(words)),
x => rest.push(sql(x)),
}
}
join(alternation(alts, col).into_iter().chain(rest), " OR ")
}
fn exact(node: &Node) -> String {
match node {
Node::Empty => "false".into(),
Node::Phrase(words) if !matchable(words) => "false".into(),
Node::Phrase(words) => {
if words[..words.len() - 1].iter().any(|w| w.prefix) {
return format!(
"regexp_matches(mentat_t, {})",
folded_lit(&phrase_re(words))
);
}
let mut text = String::new();
for w in words {
text.push(' ');
text.push_str(&w.token);
}
if !words[words.len() - 1].prefix {
text.push(' ');
}
let f = if words[0].first {
"starts_with"
} else {
"contains"
};
format!("{f}(mentat_t, {})", folded_lit(&text))
}
Node::And(xs) | Node::Near(xs) => join(xs.iter().map(exact), " AND "),
Node::Or(xs) => or_parts(
xs,
exact,
|words| (phrase_re(words), exact(&Node::Phrase(words.to_vec()))),
"mentat_t",
),
Node::Not(l, r) => format!("({} AND NOT {})", exact(l), exact(r)),
}
}
const PREFILTER_WORDS: usize = 8;
fn prefilter(node: &Node) -> String {
let has = |w: &Word| format!("contains(mentat_f, {})", folded_lit(&w.token));
match node {
Node::Empty => "false".into(),
Node::Phrase(words) if !matchable(words) => "false".into(),
Node::Phrase(words) => join(
words
.iter()
.map(has)
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.take(PREFILTER_WORDS),
" AND ",
),
Node::And(xs) | Node::Near(xs) => join(xs.iter().map(prefilter), " AND "),
Node::Or(xs) => or_parts(
xs,
prefilter,
|words| {
let w = words
.iter()
.max_by_key(|w| w.token.len())
.expect("matchable");
(w.token.clone(), has(w))
},
"mentat_f",
),
Node::Not(l, _) => prefilter(l),
}
}
pub(crate) fn push_duckdb_match(
out: &mut dyn QueryBuilder,
text: &ColumnOrExpression,
query: &ColumnOrExpression,
) -> BuildQueryResult {
if let ColumnOrExpression::Value(TypedValue::String(q)) = query {
let node = parse(q)?;
let pre = prefilter(&node);
if pre == "false" {
out.push_sql("false");
return Ok(());
}
out.push_sql("CASE WHEN list_transform([");
push_folded(out, text)?;
out.push_sql(&format!(
"], lambda mentat_f: CASE WHEN {pre} THEN true ELSE false END)[1] \
THEN list_transform([' ' || array_to_string("
));
push_tokens(out, text)?;
out.push_sql(&format!(
", ' ') || ' '], lambda mentat_t: CASE WHEN {} THEN true ELSE false END)[1] \
ELSE false END",
exact(&node)
));
return Ok(());
}
out.push_sql("(list_has_all(");
push_tokens(out, text)?;
out.push_sql(", ");
push_tokens(out, query)?;
out.push_sql(") AND len(");
push_tokens(out, query)?;
out.push_sql(") > 0)");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn p(q: &str) -> String {
match parse(q) {
Ok(n) => show(&n),
Err(_) => "ERR".into(),
}
}
fn show(n: &Node) -> String {
let list = |xs: &[Node]| xs.iter().map(show).collect::<Vec<_>>().join(" ");
match n {
Node::Empty => "()".into(),
Node::Phrase(ws) if ws.is_empty() => "\"\"".into(),
Node::Phrase(ws) => ws
.iter()
.map(|w| {
format!(
"{}{}{}",
if w.first { "^" } else { "" },
w.token.to_lowercase(),
if w.prefix { "*" } else { "" }
)
})
.collect::<Vec<_>>()
.join("_"),
Node::And(xs) => format!("(& {})", list(xs)),
Node::Or(xs) => format!("(| {})", list(xs)),
Node::Near(xs) => format!("(~ {})", list(xs)),
Node::Not(l, r) => format!("(- {} {})", show(l), show(r)),
}
}
#[test]
fn parses_like_fts4() {
for (q, want) in [
("a OR b c", "(| a (& b c))"),
("a b OR c", "(| (& a b) c)"),
("a NOT b c", "(& (- a b) c)"),
("a NOT b NOT c", "(- (- a b) c)"),
("a NOT (b c)", "(- a (& b c))"),
("(a OR b) NOT c", "(- (| a b) c)"),
("a AND b", "(& a b)"),
("a NEAR/3 b", "(~ a b)"),
("a NOT b NEAR c", "(- a (~ b c))"),
("\"hel* dark*\"", "hel*_dark*"),
("\"a b\"*", "a_b"),
("a\"b\"", "(& a b)"),
("a(b)", "(& a b)"),
("hello-world", "(& hello world)"),
("don't", "(& don t)"),
("a*b", "(& a* b)"),
("hello. OR world", "(& hello or world)"),
("C++ OR java", "(& c or java)"),
("a and b", "(& a and b)"),
("^a b", "(& ^a b)"),
("text:abc", "abc"),
("TEXT:abc", "abc"),
("searchid:1", "\"\""),
("hello ---", "hello"),
("---", "()"),
("", "()"),
("()", "()"),
("a ()", "a"),
("--- NOT a", "(& not a)"),
("--- OR a", "(& or a)"),
("a \"\"", "(& a \"\")"),
("\"\" OR a", "(| \"\" a)"),
("a NOT \"\"", "(- a \"\")"),
] {
assert_eq!(p(q), want, "{q}");
}
for bad in [
"NOT a",
"a NOT",
"OR",
"AND",
"NEAR",
"(a",
"a)",
"\"a",
"a OR ---",
"a NOT ---",
"a AND AND b",
"() OR a",
"(a OR b) NEAR c",
"a NEAR (b OR c)",
"a (NOT b)",
] {
assert_eq!(p(bad), "ERR", "{bad}");
}
}
#[test]
fn empty_groups_like_fts4() {
for (q, want) in [
("()", "()"),
("( )", "()"),
("a ()", "a"),
("a ( )", "a"),
("() a", "a"),
("( ) a", "a"),
("a AND ()", "ERR"),
("a AND ( )", "ERR"),
("() AND a", "ERR"),
("( ) AND a", "ERR"),
("a OR ()", "ERR"),
("() OR a", "ERR"),
("() NOT a", "ERR"),
("( ) NOT a", "ERR"),
("a NOT ()", "ERR"),
("a NOT ( )", "ERR"),
("\"b a\" AND ( )", "ERR"),
("()NOT\"a b\"c", "ERR"),
("a\"b\" NOT ( ) not", "(& a (- b not))"),
("(())", "()"),
("(( ))", "()"),
("a (())", "a"),
("a ( ( ) )", "a"),
("( a ( ) )", "a"),
("(a ())", "a"),
("(a ( ))", "a"),
("( ) ( )", "()"),
("a () b", "(& a b)"),
("a ( ) b", "(& a b)"),
] {
assert_eq!(p(q), want, "{q}");
}
let empty = |n: usize| format!("a {}{}", "(".repeat(n), ")".repeat(n));
assert_eq!(p(&empty(100)), "a");
assert_eq!(p(&empty(101)), "ERR");
}
#[test]
fn tokenizes_like_unicode61() {
let toks = |s: &str| {
tokens(s)
.map(|(a, b)| s[a..b].to_string())
.collect::<Vec<_>>()
};
assert_eq!(toks("\u{301}abc x\u{301}y"), ["abc", "x\u{301}y"]);
assert_eq!(toks("हिंदी"), ["ह", "द"]);
assert_eq!(toks("foo_bar 3.14 file Ⓐ"), ["foo", "bar", "3", "14", "file"]);
assert_eq!(toks("\u{e000}x \u{2160}"), ["\u{e000}x", "\u{2160}"]);
}
#[test]
fn depth_limits_like_fts4() {
let nots = |n: usize| format!("a{}", " NOT b".repeat(n));
let ands = |n: usize| vec!["a"; n].join(" ");
let alts = |n: usize| (0..n).fold("a".to_string(), |q, _| format!("({q} OR b) c"));
let nears = |n: usize| format!("a{}", " NEAR b".repeat(n));
let parens = |n: usize| format!("{}a{}", "(".repeat(n), ")".repeat(n));
for (ok, bad) in [
(nots(11), nots(12)),
(ands(4095), ands(4096)),
(alts(5), alts(6)),
(nears(12), nears(13)),
(parens(100), parens(101)),
] {
assert_ne!(p(&ok), "ERR", "{}", &ok[..ok.len().min(60)]);
assert_eq!(p(&bad), "ERR", "{}", &bad[..bad.len().min(60)]);
}
assert_eq!(p(&parens(100_000)), "ERR");
assert_eq!(p(¬s(100_000)), "ERR");
assert_eq!(p(&format!("{}a", "(a NOT ".repeat(100_000))), "ERR");
}
fn sql(q: &str) -> String {
let mut b = mentat_sql::SQLiteQueryBuilder::new();
b.set_dialect(mentat_sql::Dialect::DuckDb);
let text = ColumnOrExpression::ExistingColumn("t".into());
let query = ColumnOrExpression::Value(TypedValue::typed_string(q));
push_duckdb_match(&mut b, &text, &query).unwrap();
b.finish().sql
}
#[test]
fn duckdb_sql_tokenizes_once() {
let re = token_re();
for q in [
"a".to_string(),
vec!["a"; 4095].join(" "),
(0..4095)
.map(|i| format!("w{i}"))
.collect::<Vec<_>>()
.join(" "),
format!("\"{}\"", vec!["a"; 10_000].join(" ")),
"\"hel* dark*\" OR x NOT y".into(),
(0..4095)
.map(|i| format!("w{i}"))
.collect::<Vec<_>>()
.join(" OR "),
] {
let s = sql(&q);
assert_eq!(s.matches(re).count(), 1, "{}", &q[..q.len().min(40)]);
assert!(s.len() < re.len() + 40 * q.len() + 500, "{} bytes", s.len());
}
assert_eq!(
sql(&vec!["a"; 4095].join(" "))
.matches("contains(mentat_t, ' a ')")
.count(),
1
);
let long = sql(&format!("\"{}\"", vec!["a"; 10_000].join(" ")));
assert!(long.contains(&format!("contains(mentat_t, '{} ')", " a".repeat(10_000))));
assert_eq!(
long.matches("||").count(),
2,
"only the tokens' ' ' || ... || ' '"
);
assert_eq!(sql("\"\""), "false");
assert_eq!(sql("---"), "false");
}
#[test]
fn duckdb_phrases() {
let s = sql("\"Hello dark*\" NOT fox");
assert!(s.contains("contains(mentat_t, ' hello dark')"), "{s}");
assert!(s.contains("NOT contains(mentat_t, ' fox ')"), "{s}");
assert!(
s.contains("(contains(mentat_f, 'dark') AND contains(mentat_f, 'hello'))"),
"{s}"
);
assert!(
!s.contains("mentat_f, 'fox'"),
"a NOT's right side is not required: {s}"
);
assert!(sql("^a b").contains("starts_with(mentat_t, ' a ')"));
assert!(sql("\"a* b\"").contains("regexp_matches(mentat_t, ' a[^ ]* b ')"));
assert!(sql("\"^a* b\"").contains("regexp_matches(mentat_t, '^ a[^ ]* b ')"));
assert!(sql("Σ").contains("contains(mentat_t, translate(lower(' Σ '), "));
let s = sql("a OR \"bb c\" OR d*");
assert!(s.contains("regexp_matches(mentat_f, '(?:a|bb|d)')"), "{s}");
assert!(
s.contains("regexp_matches(mentat_t, '(?: a | bb c | d[^ ]* )')"),
"{s}"
);
let s = sql("a OR b OR (c d)");
assert!(s.contains("(regexp_matches(mentat_f, '(?:a|b)') OR (contains(mentat_f, 'c') AND contains(mentat_f, 'd')))"), "{s}");
assert!(sql("a OR (c d)").contains("(contains(mentat_f, 'a') OR "));
let s = sql(&(0..4095)
.map(|i| format!("w{i}"))
.collect::<Vec<_>>()
.join(" OR "));
assert!(
s.matches("regexp_matches").count() <= 4,
"{}",
s.matches("regexp_matches").count()
);
assert!(!s.contains("contains("), "no per-word conditions");
}
}