use std::cmp::Ordering;
use std::fmt::Display;
use serde::Deserialize;
use serde_json::Value;
use crate::{Error, Result};
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum FilterOp {
Eq,
Neq,
Gt,
Gte,
Lt,
Lte,
Contains,
Exists,
RegEq,
RegNeq,
}
impl Display for FilterOp {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FilterOp::Eq => write!(f, "eq"),
FilterOp::Neq => write!(f, "neq"),
FilterOp::Gt => write!(f, "gt"),
FilterOp::Gte => write!(f, "gte"),
FilterOp::Lt => write!(f, "lt"),
FilterOp::Lte => write!(f, "lte"),
FilterOp::Contains => write!(f, "contains"),
FilterOp::Exists => write!(f, "exists"),
FilterOp::RegEq => write!(f, "regeq"),
FilterOp::RegNeq => write!(f, "regneq"),
}
}
}
#[derive(Deserialize, Debug, Clone, PartialEq)]
pub struct FilterCondition {
pub field: String,
pub op: FilterOp,
pub value: Value,
}
impl FilterCondition {
pub fn apply(&self, item: &Value) -> Result<bool> {
let target = item.get(&self.field);
warn_if_type_mismatch(&self.op, &self.field, target, &self.value);
let result = match self.op {
FilterOp::Eq => target.is_some_and(|t| t.eq(&self.value)),
FilterOp::Neq => target.is_some_and(|t| t.ne(&self.value)),
FilterOp::Gt => compare_ord(target, &self.value, Ordering::is_gt),
FilterOp::Gte => compare_ord(target, &self.value, Ordering::is_ge),
FilterOp::Lt => compare_ord(target, &self.value, Ordering::is_lt),
FilterOp::Lte => compare_ord(target, &self.value, Ordering::is_le),
FilterOp::Contains => contains_value(target, &self.value),
FilterOp::Exists => {
let expected = self.value.as_bool().unwrap_or(false);
target.is_some() == expected
}
FilterOp::RegEq => regex_match(target, &self.value, true)?,
FilterOp::RegNeq => regex_match(target, &self.value, false)?,
};
Ok(result)
}
}
fn compare_ord<F>(target: Option<&Value>, rhs: &Value, accept: F) -> bool
where
F: Fn(Ordering) -> bool,
{
let Some(lhs) = target else {
return false;
};
scalar_ordering(lhs, rhs).is_some_and(accept)
}
fn scalar_ordering(lhs: &Value, rhs: &Value) -> Option<Ordering> {
match (lhs, rhs) {
(Value::Number(_), Value::Number(_)) => lhs.as_f64()?.partial_cmp(&rhs.as_f64()?),
(Value::String(lhs), Value::String(rhs)) => Some(lhs.as_str().cmp(rhs.as_str())),
_ => None,
}
}
fn contains_value(target: Option<&Value>, rhs: &Value) -> bool {
let Some(target) = target else {
return false;
};
match target {
Value::String(s) => rhs
.as_str()
.map(|needle| s.contains(needle))
.unwrap_or(false),
Value::Array(arr) => arr.iter().any(|v| v == rhs),
_ => false,
}
}
fn regex_match(target: Option<&Value>, rhs: &Value, positive: bool) -> Result<bool> {
let Some(value) = target.and_then(Value::as_str) else {
return Ok(!positive);
};
let pattern = rhs
.as_str()
.ok_or_else(|| Error::Config("regex filter value must be a string".to_string()))?;
let re = crate::compile_regex(pattern)
.map_err(|e| Error::Config(format!("invalid regex '{}': {}", pattern, e)))?;
let matched = re.is_match(value);
Ok(if positive { matched } else { !matched })
}
fn warn_if_type_mismatch(op: &FilterOp, field: &str, target: Option<&Value>, right: &Value) {
let Some(left) = target else {
return;
};
if !is_type_compatible(op, left, right) {
crate::emit_type_mismatch_warning(op, field, left, right);
}
}
fn is_type_compatible(op: &FilterOp, lhs: &Value, rhs: &Value) -> bool {
use FilterOp::*;
match op {
Eq | Neq => crate::value_kind(lhs) == crate::value_kind(rhs),
Gt | Gte | Lt | Lte => {
(lhs.is_number() && rhs.is_number()) || (lhs.is_string() && rhs.is_string())
}
Contains => match lhs {
Value::String(_) => rhs.is_string(),
Value::Array(_) => true,
_ => false,
},
RegEq | RegNeq => lhs.is_string() && rhs.is_string(),
_ => true,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn cond(field: &str, op: FilterOp, value: Value) -> FilterCondition {
FilterCondition {
field: field.to_string(),
op,
value,
}
}
#[test]
fn test_iso_dates_compare_as_strings() {
let early = json!({ "from": "2018-04-01" });
let late = json!({ "from": "2026-10-16" });
let pivot = json!("2020-01-01");
assert!(
cond("from", FilterOp::Gt, pivot.clone())
.apply(&late)
.unwrap()
);
assert!(
!cond("from", FilterOp::Gt, pivot.clone())
.apply(&early)
.unwrap()
);
assert!(
cond("from", FilterOp::Lt, pivot.clone())
.apply(&early)
.unwrap()
);
assert!(
!cond("from", FilterOp::Lt, pivot.clone())
.apply(&late)
.unwrap()
);
}
#[test]
fn test_inclusive_operators_are_not_always_true() {
let item = json!({ "from": "2020-01-01" });
let same = json!("2020-01-01");
let later = json!("2021-01-01");
assert!(
cond("from", FilterOp::Gte, same.clone())
.apply(&item)
.unwrap()
);
assert!(cond("from", FilterOp::Lte, same).apply(&item).unwrap());
assert!(
!cond("from", FilterOp::Gte, later.clone())
.apply(&item)
.unwrap()
);
assert!(cond("from", FilterOp::Lte, later).apply(&item).unwrap());
}
#[test]
fn test_numbers_still_compare_numerically() {
let item = json!({ "age": 20 });
assert!(cond("age", FilterOp::Gt, json!(18)).apply(&item).unwrap());
assert!(!cond("age", FilterOp::Gt, json!(20)).apply(&item).unwrap());
assert!(cond("age", FilterOp::Gte, json!(20)).apply(&item).unwrap());
assert!(cond("age", FilterOp::Gt, json!(9)).apply(&item).unwrap());
}
#[test]
fn test_unorderable_and_mismatched_kinds_do_not_match() {
for (item, value) in [
(json!({ "f": "2020" }), json!(2020)),
(json!({ "f": 2020 }), json!("2020")),
(json!({ "f": true }), json!(false)),
(json!({ "f": [1, 2] }), json!([1])),
(json!({ "f": null }), json!(null)),
] {
for op in [FilterOp::Gt, FilterOp::Gte, FilterOp::Lt, FilterOp::Lte] {
assert!(
!cond("f", op.clone(), value.clone()).apply(&item).unwrap(),
"{:?} {} {:?} should not match",
item,
op,
value
);
}
}
}
#[test]
fn test_absent_field_does_not_match() {
let item = json!({ "other": "x" });
assert!(!cond("f", FilterOp::Gte, json!("a")).apply(&item).unwrap());
}
#[test]
fn test_string_operands_are_not_a_type_mismatch() {
for op in [FilterOp::Gt, FilterOp::Gte, FilterOp::Lt, FilterOp::Lte] {
assert!(
is_type_compatible(&op, &json!("2020-01-01"), &json!("2021-01-01")),
"two strings should be compatible under {}",
op
);
assert!(
!is_type_compatible(&op, &json!("2020"), &json!(2020)),
"mixed kinds should still be reported under {}",
op
);
}
}
}