diff --git a/.cargo/config.toml b/.cargo/config.toml index 09f70a0241..618d8ba332 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -4,12 +4,14 @@ git-fetch-with-cli = true [registries.crates-io] protocol = "git" -# `ruvector-filter` carries `#![recursion_limit = "4096"]` (PR #389) to -# survive trait-resolution overflow when serde_json's `Serializer` blanket -# impl recurses through the crate's expression types. The deeper resolution -# in turn drives rustc's own process stack past the default 8 MB on x86_64 -# Linux, so cargo test/cargo check must run with a larger thread stack. -# 16 MB is rustc's documented suggested value when this happens. +# `ruvector-filter` no longer needs a raised stack: its `FilterExpression` +# logical variants (`And`/`Or`/`Not`) are struct-shaped now, which removed +# the trait-resolution recursion that used to overflow rustc's process +# stack, and `cargo test -p ruvector-filter` builds and runs clean even at +# a 2 MB `RUST_MIN_STACK` (well under the 8 MB default). This setting is +# kept anyway because no workspace-wide measurement has been done to show +# every other crate is fine without it — treat it as still load-bearing +# for the workspace as a whole until that measurement happens. [env] RUST_MIN_STACK = "16777216" diff --git a/crates/ruvector-filter/src/evaluator.rs b/crates/ruvector-filter/src/evaluator.rs index 9af3486460..92c8ac4071 100644 --- a/crates/ruvector-filter/src/evaluator.rs +++ b/crates/ruvector-filter/src/evaluator.rs @@ -41,9 +41,9 @@ impl<'a> FilterEvaluator<'a> { top_left, bottom_right, } => self.evaluate_geo_bbox(field, *top_left, *bottom_right), - FilterExpression::And(filters) => self.evaluate_and(filters), - FilterExpression::Or(filters) => self.evaluate_or(filters), - FilterExpression::Not(filter) => self.evaluate_not(filter), + FilterExpression::And { exprs } => self.evaluate_and(exprs), + FilterExpression::Or { exprs } => self.evaluate_or(exprs), + FilterExpression::Not { expr } => self.evaluate_not(expr), FilterExpression::Exists { field } => self.evaluate_exists(field), FilterExpression::IsNull { field } => self.evaluate_is_null(field), } @@ -103,9 +103,9 @@ impl<'a> FilterEvaluator<'a> { FilterExpression::Match { field, text } => Self::get_field_value(payload, field) .and_then(|v| v.as_str()) .is_some_and(|s| s.to_lowercase().contains(&text.to_lowercase())), - FilterExpression::And(filters) => filters.iter().all(|f| self.matches(payload, f)), - FilterExpression::Or(filters) => filters.iter().any(|f| self.matches(payload, f)), - FilterExpression::Not(filter) => !self.matches(payload, filter), + FilterExpression::And { exprs } => exprs.iter().all(|f| self.matches(payload, f)), + FilterExpression::Or { exprs } => exprs.iter().any(|f| self.matches(payload, f)), + FilterExpression::Not { expr } => !self.matches(payload, expr), FilterExpression::Exists { field } => Self::get_field_value(payload, field).is_some(), FilterExpression::IsNull { field } => { Self::get_field_value(payload, field).is_none_or(|v| v.is_null()) diff --git a/crates/ruvector-filter/src/expression.rs b/crates/ruvector-filter/src/expression.rs index c19257f24c..5c6d3ddd03 100644 --- a/crates/ruvector-filter/src/expression.rs +++ b/crates/ruvector-filter/src/expression.rs @@ -64,9 +64,15 @@ pub enum FilterExpression { }, // Logical operators - And(Vec), - Or(Vec), - Not(Box), + And { + exprs: Vec, + }, + Or { + exprs: Vec, + }, + Not { + expr: Box, + }, // Existence check Exists { @@ -176,19 +182,21 @@ impl FilterExpression { /// Create an AND filter pub fn and(filters: Vec) -> Self { - Self::And(filters) + Self::And { exprs: filters } } /// Create an OR filter pub fn or(filters: Vec) -> Self { - Self::Or(filters) + Self::Or { exprs: filters } } /// Create a NOT filter // Public API constructor mirrors `and`/`or`; not the `std::ops::Not` trait. #[allow(clippy::should_implement_trait)] pub fn not(filter: FilterExpression) -> Self { - Self::Not(Box::new(filter)) + Self::Not { + expr: Box::new(filter), + } } /// Create an EXISTS filter @@ -231,12 +239,12 @@ impl FilterExpression { | Self::IsNull { field } => { fields.push(field.clone()); } - Self::And(exprs) | Self::Or(exprs) => { + Self::And { exprs } | Self::Or { exprs } => { for expr in exprs { expr.collect_fields(fields); } } - Self::Not(expr) => { + Self::Not { expr } => { expr.collect_fields(fields); } } @@ -257,7 +265,7 @@ mod tests { FilterExpression::eq("status", json!("active")), FilterExpression::gte("age", json!(18)), ]); - assert!(matches!(filter, FilterExpression::And(_))); + assert!(matches!(filter, FilterExpression::And { .. })); } #[test] @@ -281,4 +289,37 @@ mod tests { let deserialized: FilterExpression = serde_json::from_str(&json).unwrap(); assert!(matches!(deserialized, FilterExpression::Eq { .. })); } + + #[test] + fn test_logical_operator_round_trip() { + let filter = FilterExpression::and(vec![ + FilterExpression::eq("status", json!("active")), + FilterExpression::not(FilterExpression::or(vec![ + FilterExpression::lt("score", json!(10)), + FilterExpression::exists("banned"), + ])), + ]); + + // Exact wire shape, per the `#[serde(tag = "type", rename_all = "snake_case")]` + // enum definition: each variant is `{"type": , ...struct fields}`. + let expected = json!({ + "type": "and", + "exprs": [ + {"type": "eq", "field": "status", "value": "active"}, + {"type": "not", "expr": { + "type": "or", + "exprs": [ + {"type": "lt", "field": "score", "value": 10}, + {"type": "exists", "field": "banned"}, + ] + }}, + ] + }); + + let actual = serde_json::to_value(&filter).unwrap(); + assert_eq!(actual, expected); + + let decoded: FilterExpression = serde_json::from_value(actual).unwrap(); + assert_eq!(serde_json::to_value(&decoded).unwrap(), expected); + } } diff --git a/crates/ruvector-filter/src/lib.rs b/crates/ruvector-filter/src/lib.rs index 5da9ac64fc..8ef8f65ee5 100644 --- a/crates/ruvector-filter/src/lib.rs +++ b/crates/ruvector-filter/src/lib.rs @@ -1,5 +1,3 @@ -#![recursion_limit = "4096"] - //! # rUvector Filter //! //! Advanced payload indexing and filtering for rUvector.