use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct FieldPath(Vec<String>);
impl FieldPath {
pub fn new<I, S>(segments: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
FieldPath(segments.into_iter().map(Into::into).collect())
}
pub fn segments(&self) -> &[String] {
&self.0
}
pub fn resolve<'v>(&self, value: &'v Value) -> Option<&'v Value> {
self.0
.iter()
.try_fold(value, |v, seg| v.as_object()?.get(seg))
}
}
impl From<&str> for FieldPath {
fn from(s: &str) -> Self {
FieldPath(s.split('.').map(str::to_owned).collect())
}
}
impl From<String> for FieldPath {
fn from(s: String) -> Self {
FieldPath::from(s.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CompareOp {
Eq,
Ne,
Lt,
Le,
Gt,
Ge,
Contains,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Predicate {
Compare {
path: FieldPath,
op: CompareOp,
value: Value,
},
Exists {
path: FieldPath,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Filter {
Predicate(Predicate),
And(Vec<Filter>),
Or(Vec<Filter>),
Not(Box<Filter>),
}
impl Filter {
pub fn compare(path: impl Into<FieldPath>, op: CompareOp, value: impl Into<Value>) -> Filter {
Filter::Predicate(Predicate::Compare {
path: path.into(),
op,
value: value.into(),
})
}
pub fn eq(path: impl Into<FieldPath>, value: impl Into<Value>) -> Filter {
Filter::compare(path, CompareOp::Eq, value)
}
pub fn ne(path: impl Into<FieldPath>, value: impl Into<Value>) -> Filter {
Filter::compare(path, CompareOp::Ne, value)
}
pub fn lt(path: impl Into<FieldPath>, value: impl Into<Value>) -> Filter {
Filter::compare(path, CompareOp::Lt, value)
}
pub fn le(path: impl Into<FieldPath>, value: impl Into<Value>) -> Filter {
Filter::compare(path, CompareOp::Le, value)
}
pub fn gt(path: impl Into<FieldPath>, value: impl Into<Value>) -> Filter {
Filter::compare(path, CompareOp::Gt, value)
}
pub fn ge(path: impl Into<FieldPath>, value: impl Into<Value>) -> Filter {
Filter::compare(path, CompareOp::Ge, value)
}
pub fn contains(path: impl Into<FieldPath>, value: impl Into<Value>) -> Filter {
Filter::compare(path, CompareOp::Contains, value)
}
pub fn exists(path: impl Into<FieldPath>) -> Filter {
Filter::Predicate(Predicate::Exists { path: path.into() })
}
pub fn all<I: IntoIterator<Item = Filter>>(filters: I) -> Filter {
Filter::And(filters.into_iter().collect())
}
pub fn any<I: IntoIterator<Item = Filter>>(filters: I) -> Filter {
Filter::Or(filters.into_iter().collect())
}
pub fn negate(self) -> Filter {
Filter::Not(Box::new(self))
}
pub fn matches(&self, payload: &Value) -> bool {
match self {
Filter::Predicate(p) => p.matches(payload),
Filter::And(fs) => fs.iter().all(|f| f.matches(payload)),
Filter::Or(fs) => fs.iter().any(|f| f.matches(payload)),
Filter::Not(f) => !f.matches(payload),
}
}
}
impl Predicate {
fn matches(&self, payload: &Value) -> bool {
match self {
Predicate::Exists { path } => path.resolve(payload).is_some(),
Predicate::Compare { path, op, value } => match path.resolve(payload) {
None => false,
Some(found) => compare(found, *op, value),
},
}
}
}
fn compare(found: &Value, op: CompareOp, value: &Value) -> bool {
match op {
CompareOp::Contains => contains(found, value),
CompareOp::Eq => equal(found, value),
CompareOp::Ne => !equal(found, value),
CompareOp::Lt => ordering(found, value) == Some(std::cmp::Ordering::Less),
CompareOp::Le => matches!(
ordering(found, value),
Some(std::cmp::Ordering::Less | std::cmp::Ordering::Equal)
),
CompareOp::Gt => ordering(found, value) == Some(std::cmp::Ordering::Greater),
CompareOp::Ge => matches!(
ordering(found, value),
Some(std::cmp::Ordering::Greater | std::cmp::Ordering::Equal)
),
}
}
fn equal(a: &Value, b: &Value) -> bool {
match (a, b) {
(Value::Number(x), Value::Number(y)) => match (x.as_f64(), y.as_f64()) {
(Some(x), Some(y)) => x == y,
_ => false,
},
_ => a == b,
}
}
fn ordering(a: &Value, b: &Value) -> Option<std::cmp::Ordering> {
match (a, b) {
(Value::Number(x), Value::Number(y)) => x.as_f64()?.partial_cmp(&y.as_f64()?),
(Value::String(x), Value::String(y)) => Some(x.as_str().cmp(y.as_str())),
(Value::Bool(x), Value::Bool(y)) => Some(x.cmp(y)),
_ => None,
}
}
fn contains(found: &Value, value: &Value) -> bool {
match found {
Value::String(haystack) => value
.as_str()
.is_some_and(|needle| haystack.contains(needle)),
Value::Array(items) => items.iter().any(|item| equal(item, value)),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn missing_path_never_matches() {
let p = json!({ "a": 1 });
assert!(!Filter::eq("b", 1).matches(&p));
assert!(!Filter::eq("a.b", 1).matches(&p)); assert!(
!Filter::ne("b", 1).matches(&p),
"Ne on a missing path is false"
);
assert!(!Filter::gt("b", 0).matches(&p));
}
#[test]
fn type_mismatch_is_false_not_error() {
let p = json!({ "n": 5, "s": "x" });
assert!(!Filter::eq("n", "5").matches(&p), "number vs string");
assert!(!Filter::gt("s", 1).matches(&p), "string vs number");
assert!(!Filter::lt("n", "z").matches(&p));
assert!(Filter::ne("n", "5").matches(&p), "different type is Ne");
}
#[test]
fn numbers_compare_as_f64() {
let p = json!({ "n": 42 });
assert!(Filter::eq("n", 42).matches(&p));
assert!(Filter::eq("n", 42.0).matches(&p));
assert!(Filter::gt("n", 41.9).matches(&p));
assert!(Filter::le("n", 42).matches(&p));
assert!(!Filter::lt("n", 42).matches(&p));
}
#[test]
fn eq_ne_over_all_json_types() {
let p = json!({ "nil": null, "arr": ["a", "b"], "obj": { "k": 1 }, "flag": true });
assert!(Filter::eq("nil", Value::Null).matches(&p), "null == null");
assert!(
Filter::eq("arr", json!(["a", "b"])).matches(&p),
"array equality"
);
assert!(!Filter::eq("arr", json!(["a"])).matches(&p));
assert!(
Filter::eq("obj", json!({ "k": 1 })).matches(&p),
"object equality"
);
assert!(Filter::eq("flag", true).matches(&p));
assert!(
Filter::ne("arr", json!(["x"])).matches(&p),
"differing array is Ne"
);
assert!(!Filter::ne("arr", json!(["a", "b"])).matches(&p));
assert!(!Filter::ne("nil", Value::Null).matches(&p));
assert!(Filter::eq("obj.k", 1.0).matches(&p));
}
#[test]
fn contains_number_membership_normalizes() {
let p = json!({ "nums": [1, 2, 3] });
assert!(Filter::contains("nums", 3.0).matches(&p));
assert!(Filter::contains("nums", 2).matches(&p));
assert!(!Filter::contains("nums", 4).matches(&p));
}
#[test]
fn strings_compare_lexicographically() {
let p = json!({ "s": "banana" });
assert!(Filter::gt("s", "apple").matches(&p));
assert!(Filter::lt("s", "cherry").matches(&p));
assert!(Filter::ge("s", "banana").matches(&p));
}
#[test]
fn contains_substring_and_array_membership() {
let p = json!({ "s": "hello world", "arr": [1, "two", 3] });
assert!(Filter::contains("s", "o wo").matches(&p));
assert!(!Filter::contains("s", "xyz").matches(&p));
assert!(Filter::contains("arr", "two").matches(&p));
assert!(Filter::contains("arr", 3).matches(&p));
assert!(!Filter::contains("arr", 9).matches(&p));
assert!(!Filter::contains("s", 1).matches(&json!({ "s": 5 })));
}
#[test]
fn exists_treats_present_null_as_existing() {
let p = json!({ "present": null, "nested": { "x": 1 } });
assert!(
Filter::exists("present").matches(&p),
"present-but-null exists"
);
assert!(Filter::exists("nested.x").matches(&p));
assert!(!Filter::exists("absent").matches(&p));
assert!(!Filter::exists("nested.y").matches(&p));
}
#[test]
fn empty_and_or_identities() {
let p = json!({});
assert!(Filter::all([]).matches(&p), "empty And is vacuously true");
assert!(!Filter::any([]).matches(&p), "empty Or is false");
}
#[test]
fn boolean_composition() {
let p = json!({ "a": 1, "b": "x" });
let f = Filter::all([Filter::eq("a", 1), Filter::eq("b", "x")]);
assert!(f.matches(&p));
assert!(Filter::any([Filter::eq("a", 2), Filter::eq("b", "x")]).matches(&p));
assert!(Filter::eq("a", 2).negate().matches(&p));
assert!(!Filter::eq("a", 1).negate().matches(&p));
}
#[test]
fn serde_roundtrips() {
let f = Filter::all([
Filter::eq("owner", "alice"),
Filter::gt("size", 10),
Filter::any([Filter::contains("tags", "b"), Filter::exists("archived")]),
Filter::ne("state", "deleted").negate(),
]);
let s = serde_json::to_string(&f).unwrap();
let back: Filter = serde_json::from_str(&s).unwrap();
assert_eq!(f, back);
}
}