hayson-engine 0.8.0

Storage engine for hayson document databases — the embeddable core behind hayson-server, hayson-cli and hayson-browser
Documentation
use std::cmp::Ordering;

use serde_json::Value;

use crate::schema::HaysonRecord;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ComparisonOp {
    Eq,
    Ne,
    Gt,
    Gte,
    Lt,
    Lte,
    In,
    Contains,
    StartsWith,
}

#[derive(Debug, Clone, PartialEq)]
pub struct Predicate {
    pub field: String,
    pub op: ComparisonOp,
    pub value: Value,
}

impl Predicate {
    pub fn new(field: impl Into<String>, op: ComparisonOp, value: Value) -> Self {
        Predicate {
            field: field.into(),
            op,
            value,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Combine {
    And,
    Or,
}

#[derive(Debug, Clone, PartialEq)]
pub enum PredicateNode {
    Leaf(Predicate),
    Group {
        combine: Combine,
        nodes: Vec<PredicateNode>,
    },
}

impl PredicateNode {
    pub fn leaf(field: impl Into<String>, op: ComparisonOp, value: Value) -> Self {
        PredicateNode::Leaf(Predicate::new(field, op, value))
    }

    pub fn and(nodes: Vec<PredicateNode>) -> Self {
        PredicateNode::Group {
            combine: Combine::And,
            nodes,
        }
    }

    pub fn or(nodes: Vec<PredicateNode>) -> Self {
        PredicateNode::Group {
            combine: Combine::Or,
            nodes,
        }
    }
}

pub fn same_value(a: &Value, b: &Value) -> bool {
    match (a, b) {
        (Value::Array(left), Value::Array(right)) => {
            left.len() == right.len() && left.iter().zip(right.iter()).all(|(x, y)| x == y)
        }
        _ => a == b,
    }
}

fn is_nullish(value: &Value) -> bool {
    matches!(value, Value::Null)
}

// NOTE: mirrors the TS numeric/string fallback of compareValues, not a general JSON ordering.
pub fn compare_values(a: &Value, b: &Value) -> Ordering {
    if a == b {
        return Ordering::Equal;
    }
    if is_nullish(a) {
        return Ordering::Less;
    }
    if is_nullish(b) {
        return Ordering::Greater;
    }
    if let (Value::Number(x), Value::Number(y)) = (a, b) {
        if let (Some(x), Some(y)) = (x.as_f64(), y.as_f64()) {
            return x.partial_cmp(&y).unwrap_or(Ordering::Equal);
        }
    }
    if let (Value::Bool(x), Value::Bool(y)) = (a, b) {
        return x.cmp(y);
    }
    let left = value_as_string(a);
    let right = value_as_string(b);
    left.cmp(&right)
}

fn value_as_string(value: &Value) -> String {
    match value {
        Value::String(s) => s.clone(),
        other => other.to_string(),
    }
}

pub fn test_predicate(record: &HaysonRecord, predicate: &Predicate) -> bool {
    let value = record.get(&predicate.field);
    match predicate.op {
        ComparisonOp::Eq => value
            .map(|v| same_value(v, &predicate.value))
            .unwrap_or_else(|| same_value(&Value::Null, &predicate.value)),
        ComparisonOp::Ne => !test_predicate(
            record,
            &Predicate {
                field: predicate.field.clone(),
                op: ComparisonOp::Eq,
                value: predicate.value.clone(),
            },
        ),
        ComparisonOp::Gt => {
            value.is_some_and(|v| !is_nullish(v) && compare_values(v, &predicate.value) == Ordering::Greater)
        }
        ComparisonOp::Gte => {
            value.is_some_and(|v| !is_nullish(v) && compare_values(v, &predicate.value) != Ordering::Less)
        }
        ComparisonOp::Lt => {
            value.is_some_and(|v| !is_nullish(v) && compare_values(v, &predicate.value) == Ordering::Less)
        }
        ComparisonOp::Lte => {
            value.is_some_and(|v| !is_nullish(v) && compare_values(v, &predicate.value) != Ordering::Greater)
        }
        ComparisonOp::In => match &predicate.value {
            Value::Array(items) => value.is_some_and(|v| items.iter().any(|item| same_value(v, item))),
            _ => false,
        },
        ComparisonOp::Contains => match value {
            Some(Value::Array(items)) => items.iter().any(|item| item == &predicate.value),
            Some(Value::String(text)) => match &predicate.value {
                Value::String(needle) => text.contains(needle.as_str()),
                _ => false,
            },
            _ => false,
        },
        ComparisonOp::StartsWith => match (value, &predicate.value) {
            (Some(Value::String(text)), Value::String(prefix)) => text.starts_with(prefix.as_str()),
            _ => false,
        },
    }
}

pub fn test_node(record: &HaysonRecord, node: &PredicateNode) -> bool {
    match node {
        PredicateNode::Leaf(predicate) => test_predicate(record, predicate),
        PredicateNode::Group {
            combine: Combine::And,
            nodes,
        } => nodes.iter().all(|child| test_node(record, child)),
        PredicateNode::Group {
            combine: Combine::Or,
            nodes,
        } => {
            if nodes.is_empty() {
                return true;
            }
            nodes.iter().any(|child| test_node(record, child))
        }
    }
}

/// Direct-level leaves of `nodes` — nested groups are skipped, not descended into.
pub fn leaves_of(nodes: &[PredicateNode]) -> Vec<&Predicate> {
    nodes
        .iter()
        .filter_map(|node| match node {
            PredicateNode::Leaf(predicate) => Some(predicate),
            PredicateNode::Group { .. } => None,
        })
        .collect()
}

pub fn all_leaves(nodes: &[PredicateNode]) -> Vec<&Predicate> {
    let mut leaves = Vec::new();
    collect_leaves(nodes, &mut leaves);
    leaves
}

fn collect_leaves<'a>(nodes: &'a [PredicateNode], out: &mut Vec<&'a Predicate>) {
    for node in nodes {
        match node {
            PredicateNode::Leaf(predicate) => out.push(predicate),
            PredicateNode::Group { nodes, .. } => collect_leaves(nodes, out),
        }
    }
}

#[cfg(test)]
mod tests {
    use serde_json::json;

    use super::*;

    fn record(pairs: &[(&str, Value)]) -> HaysonRecord {
        pairs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
    }

    #[test]
    fn tests_every_comparison_operator() {
        let row = record(&[
            ("age", json!(31)),
            ("tags", json!(["backend", "node"])),
            ("name", json!("Ania")),
        ]);

        assert!(test_predicate(
            &row,
            &Predicate::new("age", ComparisonOp::Eq, json!(31))
        ));
        assert!(test_predicate(&row, &Predicate::new("age", ComparisonOp::Ne, json!(1))));
        assert!(test_predicate(
            &row,
            &Predicate::new("age", ComparisonOp::Gt, json!(30))
        ));
        assert!(test_predicate(
            &row,
            &Predicate::new("age", ComparisonOp::Gte, json!(31))
        ));
        assert!(test_predicate(
            &row,
            &Predicate::new("age", ComparisonOp::Lt, json!(32))
        ));
        assert!(test_predicate(
            &row,
            &Predicate::new("age", ComparisonOp::Lte, json!(31))
        ));
        assert!(test_predicate(
            &row,
            &Predicate::new("age", ComparisonOp::In, json!([1, 31]))
        ));
        assert!(test_predicate(
            &row,
            &Predicate::new("tags", ComparisonOp::Contains, json!("node"))
        ));
        assert!(test_predicate(
            &row,
            &Predicate::new("name", ComparisonOp::StartsWith, json!("An"))
        ));
        assert!(!test_predicate(
            &row,
            &Predicate::new("name", ComparisonOp::StartsWith, json!("Zz"))
        ));
    }

    #[test]
    fn combines_and_and_or_with_arbitrary_nesting() {
        let row = record(&[("role", json!("admin")), ("age", json!(40))]);

        let nested = PredicateNode::and(vec![
            PredicateNode::leaf("role", ComparisonOp::Eq, json!("admin")),
            PredicateNode::or(vec![
                PredicateNode::leaf("age", ComparisonOp::Lt, json!(20)),
                PredicateNode::leaf("age", ComparisonOp::Gt, json!(30)),
            ]),
        ]);
        assert!(test_node(&row, &nested));

        let unmatched = PredicateNode::and(vec![
            PredicateNode::leaf("role", ComparisonOp::Eq, json!("user")),
            PredicateNode::or(vec![PredicateNode::leaf("age", ComparisonOp::Gt, json!(30))]),
        ]);
        assert!(!test_node(&row, &unmatched));
    }

    #[test]
    fn treats_an_empty_or_group_as_no_predicate_at_all() {
        let row = record(&[("role", json!("admin"))]);
        assert!(test_node(&row, &PredicateNode::or(vec![])));
    }
}