use std::cmp::Ordering;
use anyhow::{bail, Result};
use regex::{Regex, RegexBuilder};
use crate::data::{CompactValue, FlatRecord};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Op {
Eq,
Ne,
Gt,
Lt,
Ge,
Le,
Regex,
}
#[derive(Debug, Clone)]
pub struct Clause {
pub field: String,
pub op: Op,
pub value: String,
regex: Option<Regex>,
}
pub fn parse_filter(input: &str) -> Result<Vec<Clause>> {
input.split_whitespace().map(parse_clause).collect()
}
fn parse_clause(token: &str) -> Result<Clause> {
const OPS: [(&str, Op); 7] = [
("!=", Op::Ne),
(">=", Op::Ge),
("<=", Op::Le),
("=", Op::Eq),
(">", Op::Gt),
("<", Op::Lt),
("~", Op::Regex),
];
let mut best: Option<(usize, &str, Op)> = None;
for (sym, op) in OPS {
if let Some(idx) = token.find(sym) {
let better = match best {
None => true,
Some((bidx, bsym, _)) => idx < bidx || (idx == bidx && sym.len() > bsym.len()),
};
if better {
best = Some((idx, sym, op));
}
}
}
let Some((idx, sym, op)) = best else {
bail!("clause '{token}' has no operator (expected =, !=, >, <, >=, <=, ~)");
};
let field = token[..idx].trim();
let value = token[idx + sym.len()..].trim();
if field.is_empty() {
bail!("clause '{token}' is missing a field name");
}
if value.is_empty() {
bail!("clause '{token}' is missing a value");
}
let regex = if op == Op::Regex {
Some(Regex::new(value).map_err(|e| anyhow::anyhow!("bad regex '{value}': {e}"))?)
} else {
None
};
Ok(Clause {
field: field.to_string(),
op,
value: value.to_string(),
regex,
})
}
fn is_grouping_char(c: char) -> bool {
matches!(c, ' ' | '\u{a0}' | '\u{202f}' | '\'' | '_')
}
pub fn fuzzy_number(s: &str) -> Option<f64> {
let t = s.trim();
if t.is_empty() {
return None;
}
if let Ok(n) = t.parse::<f64>() {
return Some(n);
}
let chars: Vec<char> = t.chars().collect();
let first = chars.iter().position(|c| c.is_ascii_digit())?;
let negative = chars[..first].contains(&'-');
let mut end = first;
while end < chars.len() {
let c = chars[end];
if c.is_ascii_digit() || c == '.' || c == ',' || is_grouping_char(c) {
end += 1;
} else {
break;
}
}
let token: String = chars[first..end].iter().collect();
let token = token.trim_end_matches(|c: char| !c.is_ascii_digit());
let cleaned: String = token.chars().filter(|c| !is_grouping_char(*c)).collect();
let n_dots = cleaned.matches('.').count();
let n_commas = cleaned.matches(',').count();
let normalized: String = match (n_dots, n_commas) {
(0, 0) => cleaned,
(1, 0) => cleaned,
(_, 0) => cleaned.chars().filter(|c| *c != '.').collect(),
(0, 1) => {
let after = cleaned.split(',').nth(1).unwrap_or("");
if after.len() == 3 {
cleaned.replace(',', "")
} else {
cleaned.replace(',', ".")
}
}
(0, _) => cleaned.replace(',', ""),
(_, _) => {
let dec = if cleaned.rfind(',') > cleaned.rfind('.') {
','
} else {
'.'
};
let grp = if dec == ',' { '.' } else { ',' };
let no_grp: String = cleaned.chars().filter(|c| *c != grp).collect();
match no_grp.rfind(dec) {
Some(pos) => {
let (int, frac) = no_grp.split_at(pos);
format!("{}.{}", int.replace(dec, ""), &frac[dec.len_utf8()..])
}
None => no_grp,
}
}
};
let n: f64 = normalized.parse().ok()?;
Some(if negative { -n } else { n })
}
#[derive(Debug, Clone, PartialEq)]
pub enum SortKey {
Num(f64),
Str(String),
}
impl SortKey {
pub fn compare(&self, other: &SortKey, desc: bool) -> Ordering {
let dir = |ord: Ordering| if desc { ord.reverse() } else { ord };
match (self, other) {
(SortKey::Num(a), SortKey::Num(b)) => {
dir(a.partial_cmp(b).unwrap_or(Ordering::Equal))
}
(SortKey::Str(a), SortKey::Str(b)) => dir(a.cmp(b)),
(SortKey::Num(_), SortKey::Str(_)) => Ordering::Less,
(SortKey::Str(_), SortKey::Num(_)) => Ordering::Greater,
}
}
}
pub fn sort_key(v: &CompactValue) -> Option<SortKey> {
match v {
CompactValue::Null => None,
CompactValue::Int(_) | CompactValue::UInt(_) | CompactValue::Float(_) => {
Some(SortKey::Num(v.as_number().unwrap_or(0.0)))
}
CompactValue::Str(s) => Some(match fuzzy_number(s) {
Some(n) => SortKey::Num(n),
None => SortKey::Str(s.to_string()),
}),
other => Some(SortKey::Str(other.display())),
}
}
pub fn coerce_compare(a: &str, b: &str) -> Ordering {
match (fuzzy_number(a), fuzzy_number(b)) {
(Some(x), Some(y)) => x.partial_cmp(&y).unwrap_or(Ordering::Equal),
_ => a.cmp(b),
}
}
impl Clause {
pub fn matches(&self, v: Option<&CompactValue>) -> bool {
let Some(v) = v else {
return self.op == Op::Ne;
};
if v.is_null() {
return match self.op {
Op::Eq => self.value == "null",
Op::Ne => self.value != "null",
Op::Regex => self
.regex
.as_ref()
.map(|re| re.is_match("null"))
.unwrap_or(false),
_ => false, };
}
if self.op == Op::Regex {
return self
.regex
.as_ref()
.map(|re| re.is_match(&v.display()))
.unwrap_or(false);
}
let ord = match (v.as_number(), fuzzy_number(&self.value)) {
(Some(a), Some(b)) => a.partial_cmp(&b).unwrap_or(Ordering::Equal),
_ => coerce_compare(&v.display(), &self.value),
};
match self.op {
Op::Regex => unreachable!("handled above"),
Op::Eq => ord == Ordering::Equal,
Op::Ne => ord != Ordering::Equal,
Op::Gt => ord == Ordering::Greater,
Op::Lt => ord == Ordering::Less,
Op::Ge => ord != Ordering::Less,
Op::Le => ord != Ordering::Greater,
}
}
}
pub fn matches_all(clauses: &[Clause], ids: &[Option<u32>], flat: &FlatRecord) -> bool {
clauses
.iter()
.zip(ids)
.all(|(c, id)| c.matches(id.and_then(|i| flat.get(i))))
}
pub enum SearchMode {
Substring(String),
Regex(Regex),
}
pub fn build_search(query: &str) -> Result<Option<SearchMode>> {
let q = query.trim();
if q.is_empty() {
return Ok(None);
}
if let Some(pat) = q.strip_prefix("re:") {
if pat.is_empty() {
return Ok(None);
}
let re = RegexBuilder::new(pat)
.case_insensitive(true)
.build()
.map_err(|e| anyhow::anyhow!("bad search regex: {e}"))?;
Ok(Some(SearchMode::Regex(re)))
} else {
Ok(Some(SearchMode::Substring(q.to_lowercase())))
}
}
pub fn record_matches_search(flat: &FlatRecord, columns: &[u32], mode: &SearchMode) -> bool {
columns.iter().filter_map(|&id| flat.get(id)).any(|v| {
let s = v.display();
match mode {
SearchMode::Substring(q) => s.to_lowercase().contains(q),
SearchMode::Regex(re) => re.is_match(&s),
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::{flatten_compact, PathInterner};
use serde_json::json;
struct Ctx {
interner: PathInterner,
flat: FlatRecord,
}
fn flat(v: serde_json::Value) -> Ctx {
let mut interner = PathInterner::default();
let flat = flatten_compact(&v, &mut interner);
Ctx { interner, flat }
}
fn check_all(clauses: &[Clause], ctx: &Ctx) -> bool {
let ids: Vec<Option<u32>> = clauses.iter().map(|c| ctx.interner.id(&c.field)).collect();
matches_all(clauses, &ids, &ctx.flat)
}
fn check_search(ctx: &Ctx, columns: &[&str], mode: &SearchMode) -> bool {
let ids: Vec<u32> = columns.iter().filter_map(|c| ctx.interner.id(c)).collect();
record_matches_search(&ctx.flat, &ids, mode)
}
#[test]
fn parse_all_operators() {
let cases = [
("a=1", Op::Eq),
("a!=1", Op::Ne),
("a>1", Op::Gt),
("a<1", Op::Lt),
("a>=1", Op::Ge),
("a<=1", Op::Le),
("a~^x", Op::Regex),
];
for (input, op) in cases {
let c = &parse_filter(input).unwrap()[0];
assert_eq!(c.op, op, "input {input}");
assert_eq!(c.field, "a");
}
}
#[test]
fn parse_multi_clause_and_values() {
let cs = parse_filter("status=error user.score>=3").unwrap();
assert_eq!(cs.len(), 2);
assert_eq!(cs[0].field, "status");
assert_eq!(cs[0].value, "error");
assert_eq!(cs[1].field, "user.score");
assert_eq!(cs[1].op, Op::Ge);
assert_eq!(cs[1].value, "3");
}
#[test]
fn parse_errors() {
assert!(parse_filter("noop").is_err());
assert!(parse_filter("=value").is_err());
assert!(parse_filter("field=").is_err());
assert!(parse_filter("a~[bad").is_err());
}
#[test]
fn numeric_coercion_beats_string_compare() {
let r = flat(json!({"score": 10}));
assert!(check_all(&parse_filter("score>3").unwrap(), &r));
assert!(!check_all(&parse_filter("score<3").unwrap(), &r));
assert!(check_all(&parse_filter("score=10.0").unwrap(), &r));
}
#[test]
fn string_fallback_compare() {
let r = flat(json!({"name": "banana"}));
assert!(check_all(&parse_filter("name>apple").unwrap(), &r));
assert!(check_all(&parse_filter("name!=apple").unwrap(), &r));
}
#[test]
fn regex_clause() {
let r = flat(json!({"msg": "connection timeout after 30s"}));
assert!(check_all(&parse_filter("msg~time.ut").unwrap(), &r));
assert!(!check_all(&parse_filter("msg~^timeout").unwrap(), &r));
}
#[test]
fn nested_paths_and_missing_fields() {
let r = flat(json!({"user": {"id": 7}}));
assert!(check_all(&parse_filter("user.id=7").unwrap(), &r));
assert!(!check_all(&parse_filter("ghost=1").unwrap(), &r));
assert!(check_all(&parse_filter("ghost!=1").unwrap(), &r));
}
#[test]
fn and_combination() {
let r = flat(json!({"a": 1, "b": "x"}));
assert!(check_all(&parse_filter("a=1 b=x").unwrap(), &r));
assert!(!check_all(&parse_filter("a=1 b=y").unwrap(), &r));
}
#[test]
fn search_substring_case_insensitive() {
let r = flat(json!({"msg": "Hello World", "other": 5}));
let m = build_search("wORLd").unwrap().unwrap();
assert!(check_search(&r, &["msg"], &m));
let m = build_search("mars").unwrap().unwrap();
assert!(!check_search(&r, &["msg"], &m));
let m = build_search("5").unwrap().unwrap();
assert!(!check_search(&r, &["msg"], &m));
}
#[test]
fn search_regex_mode() {
let r = flat(json!({"msg": "Error 404"}));
let m = build_search("re:error \\d+").unwrap().unwrap();
assert!(check_search(&r, &["msg"], &m));
assert!(build_search("re:[bad").is_err());
assert!(build_search(" ").unwrap().is_none());
}
#[test]
fn fuzzy_number_parsing() {
assert_eq!(fuzzy_number("1 000 $"), Some(1000.0));
assert_eq!(fuzzy_number("239129 EURO"), Some(239129.0));
assert_eq!(fuzzy_number("$1,234.56"), Some(1234.56));
assert_eq!(fuzzy_number("1.234,56 \u{20ac}"), Some(1234.56));
assert_eq!(fuzzy_number("1'000'000"), Some(1_000_000.0));
assert_eq!(fuzzy_number("-$5,000"), Some(-5000.0));
assert_eq!(fuzzy_number("1\u{a0}000"), Some(1000.0)); assert_eq!(fuzzy_number("2.5"), Some(2.5));
assert_eq!(fuzzy_number("-2e3"), Some(-2000.0));
assert_eq!(fuzzy_number("1,5"), Some(1.5)); assert_eq!(fuzzy_number("1,234"), Some(1234.0)); assert_eq!(fuzzy_number("1.234.567"), Some(1_234_567.0)); assert_eq!(fuzzy_number("12%"), Some(12.0));
assert_eq!(fuzzy_number("abc"), None);
assert_eq!(fuzzy_number(""), None);
assert_eq!(fuzzy_number("N/A"), None);
}
#[test]
fn filter_compares_noisy_numeric_strings() {
let r = flat(json!({"price": "1 500 $"}));
assert!(check_all(&parse_filter("price>1000").unwrap(), &r));
assert!(!check_all(&parse_filter("price>2000").unwrap(), &r));
assert!(check_all(&parse_filter("price=1500").unwrap(), &r));
let r = flat(json!({"price": "239129 EURO"}));
assert!(check_all(&parse_filter("price>=239129").unwrap(), &r));
assert!(check_all(&parse_filter("price<240000").unwrap(), &r));
}
#[test]
fn null_values_in_filters() {
let r = flat(json!({"price": null}));
assert!(check_all(&parse_filter("price=null").unwrap(), &r));
assert!(!check_all(&parse_filter("price!=null").unwrap(), &r));
assert!(!check_all(&parse_filter("price>0").unwrap(), &r));
}
fn cv(v: serde_json::Value) -> CompactValue {
CompactValue::from_value(&v)
}
#[test]
fn sort_key_classification() {
assert_eq!(sort_key(&cv(json!(null))), None, "null sorts like missing");
assert_eq!(sort_key(&cv(json!(5))), Some(SortKey::Num(5.0)));
assert_eq!(sort_key(&cv(json!("1 000 $"))), Some(SortKey::Num(1000.0)));
assert_eq!(
sort_key(&cv(json!("hello"))),
Some(SortKey::Str("hello".into()))
);
assert_eq!(
SortKey::Num(2.0).compare(&SortKey::Str("a".into()), false),
Ordering::Less
);
assert_eq!(
SortKey::Num(2.0).compare(&SortKey::Str("a".into()), true),
Ordering::Less
);
assert_eq!(
SortKey::Num(500.0).compare(&SortKey::Num(1000.0), false),
Ordering::Less
);
assert_eq!(
SortKey::Num(500.0).compare(&SortKey::Num(1000.0), true),
Ordering::Greater
);
}
}