use filt_rs::{Filter, FilterValue, Filterable};
use rstest::rstest;
struct Document {
title: String,
pages: f64,
published: bool,
rating: Option<f64>,
tags: Vec<&'static str>,
}
impl Default for Document {
fn default() -> Self {
Self {
title: "The Rust Book".to_string(),
pages: 552.0,
published: true,
rating: Some(4.8),
tags: vec!["rust", "programming", "free"],
}
}
}
impl Filterable for Document {
fn get(&self, key: &str) -> FilterValue<'_> {
match key {
"doc.title" => self.title.as_str().into(),
"doc.pages" => self.pages.into(),
"doc.published" => self.published.into(),
"doc.rating" => self.rating.into(),
"doc.tags" => self
.tags
.iter()
.map(|&t| t.into())
.collect::<Vec<FilterValue<'_>>>()
.into(),
"doc.nan" => f64::NAN.into(),
_ => FilterValue::Null,
}
}
}
fn matches(filter: &str) -> bool {
Filter::new(filter)
.expect("the filter should parse")
.matches(&Document::default())
.expect("the filter should evaluate")
}
fn parse_error(filter: &str) -> String {
Filter::new(filter)
.expect_err("the filter should fail to parse")
.to_string()
}
mod construction {
use super::*;
#[test]
fn accepts_both_str_and_string() {
Filter::new("true").expect("&str should parse");
Filter::new(String::from("true")).expect("String should parse");
}
#[test]
fn empty_filters_are_rejected() {
assert!(parse_error("").contains("end of your filter expression"));
assert!(parse_error(" \t\n ").contains("end of your filter expression"));
}
#[test]
fn filters_are_send_and_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<Filter>();
}
#[test]
fn filters_can_be_shared_across_threads() {
let filter = Filter::new("doc.pages > 100").expect("parse filter");
std::thread::scope(|scope| {
for _ in 0..4 {
scope.spawn(|| {
assert!(filter.matches(&Document::default()).unwrap());
});
}
});
}
#[test]
fn a_filter_can_be_reused_across_many_objects() {
let filter = Filter::new("doc.pages > 100").expect("parse filter");
for pages in 0..1000 {
let doc = Document {
pages: pages as f64,
..Document::default()
};
assert_eq!(filter.matches(&doc).unwrap(), pages > 100);
}
}
}
mod literals_and_truthiness {
use super::*;
#[rstest]
#[case("true", true)]
#[case("false", false)]
#[case("null", false)]
#[case("0", false)]
#[case("0.0", false)]
#[case("42", true)]
#[case("0.001", true)]
#[case("\"\"", false)]
#[case("\"false\"", true)] #[case("[]", false)]
#[case("[false]", true)] fn literal_truthiness(#[case] filter: &str, #[case] expected: bool) {
assert_eq!(matches(filter), expected);
}
#[rstest]
#[case("007 == 7", true)] #[case("30 == 30.0", true)] #[case("0.5 < 1", true)]
#[case("4.8 == 4.8", true)]
fn number_semantics(#[case] filter: &str, #[case] expected: bool) {
assert_eq!(matches(filter), expected);
}
#[test]
fn negative_numbers_are_not_literals() {
assert!(parse_error("-5 == null").contains("unexpected '-'"));
assert!(matches("0 - 5 < 0"));
assert!(matches("0 - 5 == 0 - 5"));
}
}
mod arithmetic {
use super::*;
#[rstest]
#[case("1 + 2 == 3", true)]
#[case("doc.pages - 2 == 550", true)]
#[case("doc.pages + 100 > 600", true)] #[case("1 + 2 + 3 - 4 == 2", true)] #[case("doc.pages + null == null", true)] #[case("\"a\" + \"b\" == null", true)] fn arithmetic_semantics(#[case] filter: &str, #[case] expected: bool) {
assert_eq!(matches(filter), expected);
}
#[test]
fn hyphenated_property_names_are_not_subtraction() {
assert!(matches("doc.pages-2 == null"));
assert!(matches("doc.pages - 2 == 550"));
assert!(matches("asset.source-code == null"));
}
#[test]
fn there_is_no_unary_minus_or_plus() {
assert!(parse_error("-5").contains("unexpected '-'"));
assert!(parse_error("+5").contains("unexpected '+'"));
assert!(parse_error("doc.pages > -5").contains("unexpected '-'"));
}
}
mod properties {
use super::*;
#[rstest]
#[case("doc.title", true)]
#[case("doc.published", true)]
#[case("doc.rating", true)]
#[case("missing", false)]
fn property_truthiness(#[case] filter: &str, #[case] expected: bool) {
assert_eq!(matches(filter), expected);
}
#[test]
fn missing_properties_are_null() {
assert!(matches("missing == null"));
assert!(matches("!(missing != null)"));
assert!(matches("no.such.property-name == null"));
}
#[test]
fn option_none_properties_are_null() {
let doc = Document {
rating: None,
..Document::default()
};
let filter = Filter::new("doc.rating == null").expect("parse filter");
assert!(filter.matches(&doc).unwrap());
}
#[rstest]
#[case("infra")] #[case("containsx")] #[case("truthy")] #[case("nullable")] fn keyword_prefixed_identifiers_are_properties(#[case] name: &str) {
assert!(matches(&format!("{name} == null")));
}
#[test]
fn keywords_cannot_be_used_as_property_names() {
assert!(Filter::new("contains == null").is_err());
}
}
mod strings {
use super::*;
#[rstest]
#[case(r#"doc.title == "the rust book""#, true)] #[case(r#"doc.title == "THE RUST BOOK""#, true)]
#[case(r#"doc.title contains "RUST""#, true)]
#[case(r#"doc.title startswith "the""#, true)]
#[case(r#"doc.title endswith "BOOK""#, true)]
fn string_operations_ignore_case(#[case] filter: &str, #[case] expected: bool) {
assert_eq!(matches(filter), expected);
}
#[test]
fn escaped_quotes_round_trip() {
assert!(matches(r#""say \"hi\"" == "say \"hi\"""#));
assert!(matches(r#""say \"hi\"" contains "\"hi\"""#));
}
#[test]
fn hashed_raw_strings_carry_embedded_quotes() {
assert!(matches(r##"r#"{"status":"ok"}"# == r#"{"status":"ok"}"#"##));
assert!(matches(r##"r#"{"k":1}"# == "{\"k\":1}""##));
assert!(matches(r##"r#"a"b"# == "a\"b""##));
}
#[test]
fn unicode_strings_are_supported() {
let doc = Document {
title: "Jürgen's Café Guide ☕".to_string(),
..Document::default()
};
let filter = Filter::new(r#"doc.title contains "café""#).expect("parse filter");
assert!(filter.matches(&doc).unwrap());
let filter = Filter::new(r#"doc.title contains "☕""#).expect("parse filter");
assert!(filter.matches(&doc).unwrap());
}
#[test]
fn equality_uses_unicode_case_folding() {
let doc = Document {
title: "JÜRGEN".to_string(),
..Document::default()
};
let eq = Filter::new(r#"doc.title == "jürgen""#).expect("parse filter");
assert!(eq.matches(&doc).unwrap());
let contains = Filter::new(r#"doc.title contains "jürgen""#).expect("parse filter");
assert!(contains.matches(&doc).unwrap());
}
#[test]
fn multi_character_case_folds_are_supported() {
assert!(matches(r#""straße" == "STRASSE""#));
assert!(matches(r#""groß" endswith "SS""#));
assert!(matches(r#""ss" in "groß""#));
}
#[test]
fn strings_spanning_lines_are_supported() {
assert!(matches("\"multi\nline\" contains \"multi\""));
}
#[test]
fn greek_sigma_forms_are_interchangeable() {
assert!(matches(r#""ΛΟΓΟΣ" endswith "Σ""#));
assert!(matches(r#""ΛΟΓΟΣ" endswith "ς""#));
assert!(matches(r#""λογος" contains "ΓΟΣ""#));
}
}
mod tuples {
use super::*;
#[rstest]
#[case(r#"doc.tags contains "rust""#, true)]
#[case(r#"doc.tags contains "RUST""#, true)] #[case(r#"doc.tags contains "go""#, false)]
#[case(r#""rust" in doc.tags"#, true)]
#[case(r#"doc.tags == ["rust", "programming", "free"]"#, true)]
#[case(r#"doc.tags == ["rust", "programming"]"#, false)] #[case(r#"doc.tags == ["free", "programming", "rust"]"#, false)] #[case(r#"doc.tags != []"#, true)]
fn tuple_operations(#[case] filter: &str, #[case] expected: bool) {
assert_eq!(matches(filter), expected);
}
#[test]
fn tuples_may_contain_mixed_literal_types() {
assert!(matches(r#"[1, "two", true, null] contains "TWO""#));
assert!(matches(r#"null in [1, "two", true, null]"#));
}
#[test]
fn nested_tuples_are_not_supported() {
assert!(parse_error("[[1]]").contains("unexpected '['"));
}
#[test]
fn expressions_inside_tuples_are_not_supported() {
assert!(Filter::new("[doc.pages]").is_err());
assert!(Filter::new("[1 + 2]").is_err());
}
}
mod comparisons {
use super::*;
#[rstest]
#[case("doc.pages > 100", true)]
#[case("doc.pages >= 552", true)]
#[case("doc.pages < 1000", true)]
#[case("doc.pages <= 551", false)]
#[case("true > false", true)] #[case("\"abc\" < \"abd\"", true)] fn ordering(#[case] filter: &str, #[case] expected: bool) {
assert_eq!(matches(filter), expected);
}
#[rstest]
#[case("doc.pages > \"100\"")] #[case("doc.pages < \"100\"")]
#[case("doc.title > 5")] #[case("doc.published > null")] #[case("doc.pages == doc.title")] fn mismatched_types_never_match(#[case] filter: &str) {
assert!(!matches(filter));
}
#[rstest]
#[case("doc.nan == doc.nan", false)] #[case("doc.nan != doc.nan", true)]
#[case("doc.nan > 0", false)]
#[case("doc.nan < 0", false)]
#[case("doc.nan", true)] fn nan_semantics(#[case] filter: &str, #[case] expected: bool) {
assert_eq!(matches(filter), expected);
}
#[test]
fn comparisons_cannot_be_chained() {
assert!(parse_error("1 < 2 < 3").contains("unexpected '<'"));
assert!(parse_error("true == true == true").contains("unexpected '=='"));
}
}
mod logic {
use super::*;
#[rstest]
#[case("doc.published && doc.pages > 100 && doc.rating >= 4", true)]
#[case("!doc.published || doc.pages > 100", true)]
#[case("!(doc.published && doc.pages < 100)", true)]
#[case("!!doc.published", true)]
#[case("!!!doc.published", false)]
fn combinations(#[case] filter: &str, #[case] expected: bool) {
assert_eq!(matches(filter), expected);
}
#[test]
fn not_binds_tighter_than_comparisons() {
assert!(matches("!doc.published == false"));
}
#[test]
fn and_binds_tighter_than_or() {
assert!(matches("true || true && false"));
assert!(!matches("(true || true) && false"));
}
#[test]
fn logical_operators_return_operand_values() {
assert!(matches(r#"(doc.title && doc.pages) == 552"#));
assert!(matches(r#"(null || doc.title) == "the rust book""#));
}
#[test]
fn deeply_nested_groups_parse_and_evaluate() {
let depth = 100;
let filter = format!("{}true{}", "(".repeat(depth), ")".repeat(depth));
assert!(matches(&filter));
}
#[test]
fn long_operator_chains_parse_and_evaluate() {
let filter = vec!["doc.published"; 500].join(" && ");
assert!(matches(&filter));
let filter = vec!["missing"; 500].join(" || ");
assert!(!matches(&filter));
}
}
mod functions {
use super::*;
#[test]
fn unknown_functions_fail_at_parse_time() {
let error = parse_error("nope()");
assert!(
error.contains("unknown function 'nope()'"),
"expected an unknown-function error, got: {error}"
);
#[cfg(feature = "chrono")]
assert!(
error.contains("now()"),
"expected the error to list the supported functions, got: {error}"
);
assert!(
error.contains("trim()"),
"expected the error to list the supported functions, got: {error}"
);
}
#[test]
fn unclosed_function_calls_are_rejected() {
assert!(parse_error("now(1").contains("didn't find the closing ')'"));
assert!(parse_error("now(").contains("end of your filter expression"));
}
#[cfg(not(feature = "chrono"))]
#[test]
fn now_requires_the_chrono_feature() {
assert!(parse_error("now()").contains("'chrono' feature"));
}
#[rstest]
#[case(r#"trim(" hello ") == "hello""#, true)]
#[case(r#"trim("hello") == "hello""#, true)]
#[case(r#"trim(" a b ") == "a b""#, true)] #[case(r#"trim(" ") == """#, true)]
#[case(r#"trim(doc.title) == "the rust book""#, true)] #[case("trim(doc.pages) == null", true)] #[case("trim(doc.tags) == null", true)]
fn trim_strips_surrounding_whitespace(#[case] filter: &str, #[case] expected: bool) {
assert_eq!(matches(filter), expected);
}
#[test]
fn trim_handles_string_properties_with_whitespace() {
let doc = Document {
title: " The Rust Book ".to_string(),
..Document::default()
};
let filter = Filter::new(r#"trim(doc.title) == "The Rust Book""#).expect("parse filter");
assert!(filter.matches(&doc).unwrap());
}
#[test]
fn trim_requires_exactly_one_argument() {
assert!(parse_error("trim()").contains("expects 1 argument, but your filter provided 0"));
assert!(
parse_error("trim(doc.title, doc.pages)")
.contains("expects 1 argument, but your filter provided 2")
);
}
}
mod durations {
use super::*;
#[rstest]
#[case("5x")]
#[case("5mm")]
#[case("5min")]
#[case("1h30")]
fn malformed_durations_are_rejected(#[case] filter: &str) {
let error = parse_error(filter);
assert!(
error.contains("duration"),
"expected a duration error for '{filter}', got: {error}"
);
}
#[cfg(not(feature = "chrono"))]
#[test]
fn duration_literals_require_the_chrono_feature() {
assert!(parse_error("5m").contains("'chrono' feature"));
assert!(parse_error("uploaded.age > 1h30m").contains("'chrono' feature"));
}
}
#[cfg(feature = "chrono")]
mod datetimes {
use super::*;
use chrono::{Duration, Utc};
struct Event {
timestamp: chrono::DateTime<Utc>,
}
impl Filterable for Event {
fn get(&self, key: &str) -> FilterValue<'_> {
match key {
"event.timestamp" => self.timestamp.into(),
_ => FilterValue::Null,
}
}
}
#[test]
fn events_can_be_filtered_by_relative_time() {
let filter = Filter::new("event.timestamp > now() - 5m").expect("parse filter");
let recent = Event {
timestamp: Utc::now(),
};
assert!(filter.matches(&recent).expect("run filter"));
let stale = Event {
timestamp: Utc::now() - Duration::minutes(10),
};
assert!(!filter.matches(&stale).expect("run filter"));
}
#[test]
fn now_is_evaluated_at_filtering_time() {
let filter = Filter::new("now() - event.timestamp >= 1ms").expect("parse filter");
let event = Event {
timestamp: Utc::now(),
};
std::thread::sleep(std::time::Duration::from_millis(5));
assert!(filter.matches(&event).expect("run filter"));
}
#[rstest]
#[case("now() + 1h > now()", true)]
#[case("now() - now() < 1s", true)]
#[case("now() - now() > 0s - 1s", true)] #[case("5m < 1h", true)]
#[case("60s == 1m", true)]
#[case("1h30m == 90m", true)]
#[case("500ms + 500ms == 1s", true)]
#[case("1w == 7d", true)]
#[case("now() == 5m", false)] fn datetime_and_duration_semantics(#[case] filter: &str, #[case] expected: bool) {
assert_eq!(matches(filter), expected);
}
#[test]
fn now_rejects_arguments_at_parse_time() {
assert!(parse_error("now(1)").contains("expects 0 arguments, but your filter provided 1"));
}
}
mod failure_modes {
use super::*;
#[rstest]
#[case("doc.pages >", "end of your filter expression")]
#[case("&& true", "unexpected '&&'")]
#[case("true && ", "end of your filter expression")]
#[case("(true || false", "didn't find the closing ')'")]
#[case("[1, 2", "didn't find the closing ']'")]
#[case("a = b", "orphaned '='")]
#[case("a & b", "orphaned '&'")]
#[case("a | b", "orphaned '|'")]
#[case("\"unterminated", "without finding the closing quote")]
#[case("!", "end of your filter expression")]
fn parse_errors_are_descriptive(#[case] filter: &str, #[case] message: &str) {
let error = parse_error(filter);
assert!(
error.contains(message),
"expected the error for '{filter}' to contain '{message}', got: {error}"
);
}
#[test]
fn errors_include_accurate_locations_across_lines() {
let error = parse_error("doc.published &&\ndoc.pages =");
assert!(
error.contains("line 2, column 11"),
"expected a line 2 location in: {error}"
);
}
#[test]
fn trailing_tokens_are_rejected() {
assert!(parse_error("true false").contains("unexpected 'false'"));
let error = parse_error(r#"true "oops""#);
assert!(
error.contains(r#"unexpected '"oops"'"#),
"expected the quoted string in: {error}"
);
}
#[test]
fn malformed_numbers_are_rejected() {
assert!(parse_error("1.x").contains("unexpected '.x'"));
}
#[test]
fn errors_offer_remediation_advice() {
let error = parse_error("a & b");
assert!(
error.contains("'&&' operator"),
"expected remediation advice in: {error}"
);
}
}
mod formatting {
use super::*;
#[test]
fn display_preserves_the_original_expression() {
let raw = r#"doc.published && doc.title contains "rust""#;
let filter = Filter::new(raw).expect("parse filter");
assert_eq!(filter.to_string(), raw);
assert_eq!(filter.raw(), raw);
}
#[test]
fn debug_shows_the_parse_tree() {
let filter = Filter::new("a || b && c").expect("parse filter");
assert_eq!(
format!("{filter:?}"),
"(|| (property a) (&& (property b) (property c)))"
);
}
}