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);
check_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 check_type_mismatch(
op: &FilterOp,
field: &str,
target: Option<&Value>,
right: &Value,
) -> Result<()> {
let Some(left) = target else {
return Ok(());
};
if left.is_null() || right.is_null() || is_type_compatible(op, left, right) {
return Ok(());
}
let (left_kind, right_kind) = (crate::value_kind(left), crate::value_kind(right));
let detail = if left_kind == right_kind {
format!("op '{op}' cannot compare two values of kind {left_kind}")
} else {
format!("the record holds {left_kind}, the condition compares against {right_kind}")
};
Err(Error::FilterType(format!("field '{field}': {detail}")))
}
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_mismatched_kinds_are_rejected() {
for (item, value) in [
(json!({ "f": "2020" }), json!(2020)),
(json!({ "f": 2020 }), json!("2020")),
] {
for op in [FilterOp::Eq, FilterOp::Gt, FilterOp::Gte, FilterOp::Lte] {
let err = cond("f", op.clone(), value.clone())
.apply(&item)
.expect_err("mixed kinds must be rejected");
let message = err.to_string();
assert!(
message.contains("'f'")
&& message.contains("number")
&& message.contains("string"),
"{:?} {} {:?} reported unhelpfully: {}",
item,
op,
value,
message
);
}
}
}
#[test]
fn test_unorderable_kinds_are_rejected() {
for (item, value) in [
(json!({ "f": true }), json!(false)),
(json!({ "f": [1, 2] }), json!([1])),
] {
for op in [FilterOp::Gt, FilterOp::Gte, FilterOp::Lt, FilterOp::Lte] {
let err = cond("f", op.clone(), value.clone())
.apply(&item)
.expect_err("unorderable kinds must be rejected");
assert!(
err.to_string().contains(&op.to_string()),
"{:?} {} {:?} did not name the operator: {}",
item,
op,
value,
err
);
}
}
}
#[test]
fn test_null_operands_are_exempt() {
for (item, value) in [
(json!({ "f": null }), json!(null)),
(json!({ "f": null }), json!(2020)),
(json!({ "f": null }), json!("2020")),
(json!({ "f": 2020 }), json!(null)),
(json!({ "f": "2020" }), json!(null)),
] {
for op in [FilterOp::Eq, FilterOp::Neq, FilterOp::Gt, FilterOp::Lte] {
assert!(
cond("f", op.clone(), value.clone()).apply(&item).is_ok(),
"{:?} {} {:?} must not fail the run",
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
);
}
}
}