sheeter-engine 1.5.0

Convert, inspect, and transform data files across eleven formats with streaming where it matters and auto-detection where it can.
Documentation
use crate::CanonicalValue;
use std::error::Error;

/// Comparison operators supported by the filter mini-DSL.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Op {
    Eq,
    Ne,
    Lt,
    Le,
    Gt,
    Ge,
}

/// A parsed filter expression.
///
/// The mini-DSL supports a conjunction of comparisons joined by `and`:
///
/// ```text
/// filter := clause ( " and " clause )*
/// clause := field op literal
/// op     := "==" | "!=" | "<=" | ">=" | "<" | ">"
/// literal := number | 'string' | "string" | true | false | null | bareword
/// ```
///
/// # Examples
///
/// ```
/// use sheeter_engine::Filter;
/// use sheeter_engine::CanonicalValue;
///
/// let f = Filter::parse("age > 30 and name == 'Bob'").unwrap();
/// let row = CanonicalValue::Object(vec![
///     ("age".to_string(), CanonicalValue::Number(serde_json::Number::from(40))),
///     ("name".to_string(), CanonicalValue::String("Bob".to_string())),
/// ]);
/// assert!(f.matches(&row));
/// ```
#[derive(Debug, Clone)]
pub struct Filter {
    clauses: Vec<Clause>,
}

#[derive(Debug, Clone)]
struct Clause {
    field: String,
    op: Op,
    literal: Literal,
}

#[derive(Debug, Clone)]
enum Literal {
    Null,
    Bool(bool),
    Number(f64),
    String(String),
}

impl Filter {
    /// Parses a filter expression.
    ///
    /// # Errors
    /// Returns an error if the expression has no valid clause.
    pub fn parse(input: &str) -> Result<Self, Box<dyn Error>> {
        let mut clauses = Vec::new();
        for raw in input.split(" and ") {
            let part = raw.trim();
            if part.is_empty() {
                continue;
            }
            let mut found = false;
            for (op_str, op) in [
                ("==", Op::Eq),
                ("!=", Op::Ne),
                ("<=", Op::Le),
                (">=", Op::Ge),
                ("<", Op::Lt),
                (">", Op::Gt),
            ] {
                if let Some(idx) = part.find(op_str) {
                    let field = part[..idx].trim().to_string();
                    let lit_str = part[idx + op_str.len()..].trim();
                    if field.is_empty() || lit_str.is_empty() {
                        return Err(format!("filter: empty field or value in {part:?}").into());
                    }
                    clauses.push(Clause {
                        field,
                        op,
                        literal: parse_literal(lit_str),
                    });
                    found = true;
                    break;
                }
            }
            if !found {
                return Err(format!("filter: no operator in {part:?}").into());
            }
        }
        if clauses.is_empty() {
            return Err("filter: no valid clauses".into());
        }
        Ok(Self { clauses })
    }

    /// Returns true if the row (a `CanonicalValue::Object`) satisfies the filter.
    pub fn matches(&self, row: &CanonicalValue) -> bool {
        let obj = match row {
            CanonicalValue::Object(o) => o,
            _ => return false,
        };
        self.clauses.iter().all(|c| c.matches(obj))
    }
}

fn parse_literal(s: &str) -> Literal {
    if s.eq_ignore_ascii_case("null") {
        return Literal::Null;
    }
    if s.eq_ignore_ascii_case("true") {
        return Literal::Bool(true);
    }
    if s.eq_ignore_ascii_case("false") {
        return Literal::Bool(false);
    }
    if s.len() >= 2 {
        let b = s.as_bytes();
        if (b[0] == b'\'' && b[b.len() - 1] == b'\'') || (b[0] == b'"' && b[b.len() - 1] == b'"') {
            return Literal::String(s[1..s.len() - 1].to_string());
        }
    }
    if let Ok(n) = s.parse::<f64>() {
        return Literal::Number(n);
    }
    Literal::String(s.to_string())
}

impl Clause {
    fn matches(&self, obj: &[(String, CanonicalValue)]) -> bool {
        let cell = match obj.iter().find(|(k, _)| k == &self.field) {
            Some((_, v)) => v,
            None => return false,
        };
        match self.op {
            Op::Eq => eq(cell, &self.literal),
            Op::Ne => !eq(cell, &self.literal),
            Op::Lt => ord(cell, &self.literal) == Some(std::cmp::Ordering::Less),
            Op::Le => matches!(
                ord(cell, &self.literal),
                Some(std::cmp::Ordering::Less) | Some(std::cmp::Ordering::Equal)
            ),
            Op::Gt => ord(cell, &self.literal) == Some(std::cmp::Ordering::Greater),
            Op::Ge => matches!(
                ord(cell, &self.literal),
                Some(std::cmp::Ordering::Greater) | Some(std::cmp::Ordering::Equal)
            ),
        }
    }
}

fn as_f64(v: &CanonicalValue) -> Option<f64> {
    match v {
        CanonicalValue::Number(n) => n.as_f64(),
        CanonicalValue::String(s) => s.parse().ok(),
        CanonicalValue::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
        _ => None,
    }
}

fn eq(cell: &CanonicalValue, lit: &Literal) -> bool {
    match (cell, lit) {
        (CanonicalValue::Null, Literal::Null) => true,
        (CanonicalValue::Bool(b), Literal::Bool(l)) => *b == *l,
        (CanonicalValue::String(s), Literal::String(l)) => s == l,
        _ => match (as_f64(cell), as_f64_lit(lit)) {
            (Some(a), Some(b)) => (a - b).abs() < 1e-10,
            _ => false,
        },
    }
}

fn as_f64_lit(l: &Literal) -> Option<f64> {
    match l {
        Literal::Number(n) => Some(*n),
        Literal::String(s) => s.parse().ok(),
        Literal::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
        Literal::Null => None,
    }
}

fn ord(cell: &CanonicalValue, lit: &Literal) -> Option<std::cmp::Ordering> {
    if let (CanonicalValue::String(a), Literal::String(b)) = (cell, lit) {
        return Some(a.cmp(b));
    }
    let a = as_f64(cell)?;
    let b = as_f64_lit(lit)?;
    a.partial_cmp(&b)
}