use std::borrow::Cow;
use std::cmp::Ordering;
use std::collections::HashMap;
use std::convert::Infallible;
use nodedb_types::Value;
use nodedb_types::filter::MetadataFilter;
use crate::msgpack_scan::FieldIndex;
use crate::msgpack_scan::reader::read_value;
use crate::value_ops::{coerced_eq, involves_instant, numeric_order};
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum PropertyMapError {
#[error("property bytes are not a well-formed MessagePack map")]
NotAMap,
#[error("property field '{field}' does not decode")]
Field { field: String },
}
pub fn matches_metadata_filter_with<'a, F>(lookup: &F, filter: &MetadataFilter) -> bool
where
F: Fn(&str) -> Option<Cow<'a, Value>>,
{
let infallible = |name: &str| Ok::<_, Infallible>(lookup(name));
match try_matches_metadata_filter_with(&infallible, filter) {
Ok(admitted) => admitted,
Err(never) => match never {},
}
}
pub fn try_matches_metadata_filter_with<'a, F, E>(
lookup: &F,
filter: &MetadataFilter,
) -> Result<bool, E>
where
F: Fn(&str) -> Result<Option<Cow<'a, Value>>, E>,
{
Ok(match filter {
MetadataFilter::Eq { field, value } => equals(lookup(field)?.as_deref(), value),
MetadataFilter::Ne { field, value } => !equals(lookup(field)?.as_deref(), value),
MetadataFilter::Gt { field, value } => {
ordered(lookup(field)?.as_deref(), value, |o| o == Ordering::Greater)
}
MetadataFilter::Gte { field, value } => {
ordered(lookup(field)?.as_deref(), value, |o| o != Ordering::Less)
}
MetadataFilter::Lt { field, value } => {
ordered(lookup(field)?.as_deref(), value, |o| o == Ordering::Less)
}
MetadataFilter::Lte { field, value } => {
ordered(lookup(field)?.as_deref(), value, |o| o != Ordering::Greater)
}
MetadataFilter::In { field, values } => {
lookup(field)?.is_some_and(|found| values.iter().any(|v| filter_eq(&found, v)))
}
MetadataFilter::NotIn { field, values } => {
lookup(field)?.is_none_or(|found| !values.iter().any(|v| filter_eq(&found, v)))
}
MetadataFilter::And(children) => {
for child in children {
if !try_matches_metadata_filter_with(lookup, child)? {
return Ok(false);
}
}
true
}
MetadataFilter::Or(children) => {
for child in children {
if try_matches_metadata_filter_with(lookup, child)? {
return Ok(true);
}
}
false
}
MetadataFilter::Not(inner) => !try_matches_metadata_filter_with(lookup, inner)?,
_ => false,
})
}
fn equals(found: Option<&Value>, value: &Value) -> bool {
match found {
Some(found) => filter_eq(found, value),
None => value.is_null(),
}
}
fn filter_eq(a: &Value, b: &Value) -> bool {
coerced_eq(a, b) || matches!((text(a), text(b)), (Some(x), Some(y)) if x == y)
}
fn ordered(found: Option<&Value>, value: &Value, accept: impl Fn(Ordering) -> bool) -> bool {
match found {
Some(found) if !found.is_null() && !value.is_null() => {
filter_order(found, value).is_some_and(accept)
}
_ => false,
}
}
fn filter_order(a: &Value, b: &Value) -> Option<Ordering> {
if involves_instant(a, b) {
return a.partial_cmp_coerced(b);
}
if let Some(order) = numeric_order(a, b) {
return Some(order);
}
match (text(a), text(b)) {
(Some(x), Some(y)) => Some(x.cmp(y)),
_ => None,
}
}
fn text(v: &Value) -> Option<&str> {
match v {
Value::String(s) | Value::Uuid(s) | Value::Ulid(s) => Some(s),
_ => None,
}
}
pub fn matches_metadata_fields(fields: &HashMap<String, Value>, filter: &MetadataFilter) -> bool {
matches_metadata_filter_with(&|name: &str| fields.get(name).map(Cow::Borrowed), filter)
}
pub fn matches_all_fields(fields: &HashMap<String, Value>, filters: &[MetadataFilter]) -> bool {
filters.iter().all(|f| matches_metadata_fields(fields, f))
}
pub fn matches_all_msgpack(
doc: &[u8],
filters: &[MetadataFilter],
) -> Result<bool, PropertyMapError> {
if filters.is_empty() {
return Ok(true);
}
let index = if doc.is_empty() {
FieldIndex::empty()
} else {
FieldIndex::build(doc, 0).ok_or(PropertyMapError::NotAMap)?
};
let lookup = |name: &str| -> Result<Option<Cow<'static, Value>>, PropertyMapError> {
let Some((start, end)) = index.get(name) else {
return Ok(None);
};
let decoded = read_value(doc, start).or_else(|| {
doc.get(start..end)
.and_then(|bytes| nodedb_types::json_msgpack::value_from_msgpack(bytes).ok())
});
match decoded {
Some(value) => Ok(Some(Cow::Owned(value))),
None => Err(PropertyMapError::Field {
field: name.to_owned(),
}),
}
};
for filter in filters {
if !try_matches_metadata_filter_with(&lookup, filter)? {
return Ok(false);
}
}
Ok(true)
}
pub fn matches_metadata_filter(doc: &serde_json::Value, filter: &MetadataFilter) -> bool {
let lookup = |name: &str| doc.get(name).map(|v| Cow::Owned(Value::from(v.clone())));
matches_metadata_filter_with(&lookup, filter)
}
#[cfg(test)]
mod tests {
use super::*;
use nodedb_types::filter::MetadataFilter;
use nodedb_types::value::Value;
use serde_json::json;
fn gt(field: &str, value: Value) -> MetadataFilter {
MetadataFilter::Gt {
field: field.into(),
value,
}
}
fn lt(field: &str, value: Value) -> MetadataFilter {
MetadataFilter::Lt {
field: field.into(),
value,
}
}
fn ne(field: &str, value: Value) -> MetadataFilter {
MetadataFilter::Ne {
field: field.into(),
value,
}
}
#[test]
fn eq_match() {
let doc = json!({"status": "active", "age": 25});
let filter = MetadataFilter::eq("status", "active");
assert!(matches_metadata_filter(&doc, &filter));
}
#[test]
fn eq_no_match() {
let doc = json!({"status": "inactive"});
let filter = MetadataFilter::eq("status", "active");
assert!(!matches_metadata_filter(&doc, &filter));
}
#[test]
fn gt_numeric() {
let doc = json!({"age": 30});
assert!(matches_metadata_filter(
&doc,
>("age", Value::Integer(25))
));
}
#[test]
fn and_filter() {
let doc = json!({"status": "active", "age": 30});
let filter = MetadataFilter::and(vec![
MetadataFilter::eq("status", "active"),
gt("age", Value::Integer(25)),
]);
assert!(matches_metadata_filter(&doc, &filter));
}
#[test]
fn or_filter() {
let doc = json!({"status": "inactive", "age": 30});
let filter = MetadataFilter::or(vec![
MetadataFilter::eq("status", "active"),
gt("age", Value::Integer(25)),
]);
assert!(matches_metadata_filter(&doc, &filter));
}
#[test]
fn not_filter() {
let doc = json!({"status": "active"});
let filter = MetadataFilter::Not(Box::new(MetadataFilter::eq("status", "inactive")));
assert!(matches_metadata_filter(&doc, &filter));
}
#[test]
fn in_filter() {
let doc = json!({"role": "admin"});
let filter = MetadataFilter::In {
field: "role".into(),
values: vec![Value::from("admin"), Value::from("superadmin")],
};
assert!(matches_metadata_filter(&doc, &filter));
}
#[test]
fn missing_field() {
let doc = json!({"name": "Alice"});
let filter = MetadataFilter::eq("status", "active");
assert!(!matches_metadata_filter(&doc, &filter));
}
#[test]
fn eq_and_ne_on_missing_field_and_null() {
let doc = json!({"present": null});
assert!(matches_metadata_filter(
&doc,
&MetadataFilter::eq("absent", Value::Null)
));
assert!(matches_metadata_filter(
&doc,
&MetadataFilter::eq("present", Value::Null)
));
assert!(!matches_metadata_filter(&doc, &ne("absent", Value::Null)));
assert!(matches_metadata_filter(
&doc,
&ne("absent", Value::from("x"))
));
assert!(!matches_metadata_filter(
&doc,
&MetadataFilter::eq("absent", "x")
));
}
#[test]
fn eq_coerces_numeric_strings_and_bools() {
let doc = json!({"n": "5", "flag": true});
assert!(matches_metadata_filter(
&doc,
&MetadataFilter::eq("n", Value::Integer(5))
));
assert!(matches_metadata_filter(
&doc,
&MetadataFilter::eq("flag", Value::Integer(1))
));
}
#[test]
fn ordered_comparisons_reject_missing_and_null() {
let doc = json!({"score": null});
for filter in [
gt("missing", Value::Integer(0)),
lt("missing", Value::Integer(100)),
MetadataFilter::Lte {
field: "missing".into(),
value: Value::Integer(100),
},
MetadataFilter::Gte {
field: "missing".into(),
value: Value::Integer(-100),
},
lt("score", Value::Integer(100)),
gt("score", Value::Integer(-100)),
] {
assert!(!matches_metadata_filter(&doc, &filter), "{filter:?}");
}
let doc = json!({"score": 3});
assert!(!matches_metadata_filter(&doc, <("score", Value::Null)));
}
#[test]
fn ordered_comparisons_place_nan_above_every_number() {
let fields = HashMap::from([("score".to_owned(), Value::Float(f64::NAN))]);
let nan_gte_nan = MetadataFilter::Gte {
field: "score".into(),
value: Value::Float(f64::NAN),
};
for filter in [gt("score", Value::Integer(0)), nan_gte_nan] {
assert!(matches_metadata_fields(&fields, &filter), "{filter:?}");
}
let nan_lte_zero = MetadataFilter::Lte {
field: "score".into(),
value: Value::Integer(0),
};
for filter in [lt("score", Value::Integer(0)), nan_lte_zero] {
assert!(!matches_metadata_fields(&fields, &filter), "{filter:?}");
}
}
#[test]
fn in_and_not_in_on_missing_field() {
let doc = json!({});
let values = vec![Value::from("a")];
assert!(!matches_metadata_filter(
&doc,
&MetadataFilter::In {
field: "k".into(),
values: values.clone(),
}
));
assert!(matches_metadata_filter(
&doc,
&MetadataFilter::NotIn {
field: "k".into(),
values,
}
));
}
#[test]
fn empty_and_admits_empty_or_rejects() {
let doc = json!({});
assert!(matches_metadata_filter(
&doc,
&MetadataFilter::And(Vec::new())
));
assert!(!matches_metadata_filter(
&doc,
&MetadataFilter::Or(Vec::new())
));
}
#[test]
fn nested_not() {
let doc = json!({"score": 9});
let filter = MetadataFilter::Not(Box::new(MetadataFilter::Not(Box::new(gt(
"score",
Value::Integer(5),
)))));
assert!(matches_metadata_filter(&doc, &filter));
let filter = MetadataFilter::Not(Box::new(MetadataFilter::Or(vec![
lt("score", Value::Integer(5)),
MetadataFilter::eq("score", Value::Integer(9)),
])));
assert!(!matches_metadata_filter(&doc, &filter));
}
#[test]
fn msgpack_matches_field_map() {
let fields = HashMap::from([
("score".to_owned(), Value::Integer(9)),
("kind".to_owned(), Value::from("road")),
("closed".to_owned(), Value::Bool(false)),
]);
let bytes = nodedb_types::json_msgpack::value_to_msgpack(&Value::Object(fields.clone()))
.expect("encode");
let cases = [
vec![gt("score", Value::Integer(5))],
vec![lt("score", Value::Integer(5))],
vec![
MetadataFilter::In {
field: "kind".into(),
values: vec![Value::from("road"), Value::from("rail")],
},
MetadataFilter::Not(Box::new(MetadataFilter::eq("closed", true))),
],
vec![MetadataFilter::eq("missing", Value::Null)],
vec![lt("missing", Value::Integer(5))],
Vec::new(),
];
for filters in cases {
assert_eq!(
matches_all_msgpack(&bytes, &filters),
Ok(matches_all_fields(&fields, &filters)),
"{filters:?}"
);
}
}
#[test]
fn empty_msgpack_evaluates_as_empty_object() {
assert_eq!(
matches_all_msgpack(&[], &[MetadataFilter::eq("missing", Value::Null)]),
Ok(true)
);
assert_eq!(
matches_all_msgpack(&[], &[lt("score", Value::Integer(5))]),
Ok(false)
);
assert_eq!(matches_all_msgpack(&[], &[]), Ok(true));
}
fn corrupt_score_map() -> Vec<u8> {
let mut bytes =
nodedb_types::json_msgpack::json_to_msgpack(&json!({"score": 9})).expect("encode");
let last = bytes.len() - 1;
bytes[last] = 0xc1;
bytes
}
#[test]
fn a_named_field_that_does_not_decode_is_an_error() {
let bytes = corrupt_score_map();
let result = matches_all_msgpack(&bytes, &[gt("score", Value::Integer(5))]);
assert!(result.is_err(), "{result:?}");
assert_eq!(
matches_all_msgpack(&bytes, &[]),
Ok(true),
"no filter reads nothing"
);
}
#[test]
fn bytes_that_are_not_a_map_are_an_error() {
let list = nodedb_types::json_msgpack::json_to_msgpack(&json!([1, 2])).expect("encode");
assert_eq!(
matches_all_msgpack(&list, &[MetadataFilter::eq("k", Value::Null)]),
Err(PropertyMapError::NotAMap)
);
assert_eq!(
matches_all_msgpack(&[0x82, 0xa1, b'k', 0x01], &[gt("k", Value::Integer(0))]),
Err(PropertyMapError::NotAMap)
);
}
#[test]
fn integers_past_two_pow_53_filter_exactly() {
let fields = HashMap::from([("ts".to_owned(), Value::Integer(9_007_199_254_740_993))]);
let at = Value::Integer(9_007_199_254_740_992);
assert!(!matches_metadata_fields(
&fields,
&MetadataFilter::eq("ts", at.clone())
));
assert!(matches_metadata_fields(&fields, >("ts", at.clone())));
assert!(!matches_metadata_fields(&fields, <("ts", at)));
}
#[test]
fn ordered_comparisons_across_kinds_are_unordered() {
let fields = HashMap::from([
("text".to_owned(), Value::from("n/a")),
("list".to_owned(), Value::Array(vec![Value::Integer(9)])),
("num".to_owned(), Value::Integer(9)),
(
"obj".to_owned(),
Value::Object(HashMap::from([("a".to_owned(), Value::Integer(1))])),
),
("flag".to_owned(), Value::Bool(true)),
]);
let unordered = [
("text", Value::Integer(5)),
("list", Value::Integer(5)),
("num", Value::from("abc")),
("num", Value::Array(vec![Value::Integer(1)])),
("obj", Value::Integer(0)),
("obj", Value::from("a")),
("list", Value::from("a")),
("flag", Value::from("yes")),
];
for (field, value) in unordered {
for filter in [
gt(field, value.clone()),
lt(field, value.clone()),
MetadataFilter::Gte {
field: field.into(),
value: value.clone(),
},
MetadataFilter::Lte {
field: field.into(),
value: value.clone(),
},
] {
assert!(!matches_metadata_fields(&fields, &filter), "{filter:?}");
}
}
}
#[test]
fn coerced_kinds_order_and_compare_alike() {
let fields = HashMap::from([
("n".to_owned(), Value::from("10")),
("flag".to_owned(), Value::Bool(true)),
("name".to_owned(), Value::from("bob")),
(
"id".to_owned(),
Value::Uuid("550e8400-e29b-41d4-a716-446655440000".into()),
),
]);
assert!(matches_metadata_fields(
&fields,
>("n", Value::Integer(9))
));
assert!(matches_metadata_fields(
&fields,
&MetadataFilter::eq("n", Value::Integer(10))
));
assert!(matches_metadata_fields(
&fields,
>("flag", Value::Integer(0))
));
assert!(matches_metadata_fields(
&fields,
&MetadataFilter::eq("flag", Value::Integer(1))
));
assert!(matches_metadata_fields(
&fields,
>("name", Value::from("alice"))
));
assert!(matches_metadata_fields(
&fields,
<("name", Value::from("carol"))
));
let id = Value::from("550e8400-e29b-41d4-a716-446655440000");
assert!(matches_metadata_fields(
&fields,
&MetadataFilter::eq("id", id.clone())
));
assert!(matches_metadata_fields(
&fields,
&MetadataFilter::Gte {
field: "id".into(),
value: id,
}
));
}
#[test]
fn a_non_numeric_string_fails_a_numeric_order_over_msgpack() {
let bytes =
nodedb_types::json_msgpack::json_to_msgpack(&json!({"score": "n/a"})).expect("encode");
assert_eq!(
matches_all_msgpack(&bytes, &[gt("score", Value::Integer(5))]),
Ok(false)
);
assert_eq!(
matches_all_msgpack(&bytes, &[lt("score", Value::Integer(5))]),
Ok(false)
);
}
}