From bd17b09c5a545d944e72154d324356ca2848f366 Mon Sep 17 00:00:00 2001 From: Kevin Altman Date: Wed, 17 Sep 2025 13:10:16 -0400 Subject: [PATCH 1/5] adds functionality to add custom operations --- Cargo.toml | 4 + README.md | 38 +++ examples/custom_operators.rs | 136 ++++++++++ src/lib.rs | 215 ++++++++++++++++ src/op/mod.rs | 468 +++++++++++++++++++++++++++++------ 5 files changed, 787 insertions(+), 74 deletions(-) create mode 100644 examples/custom_operators.rs diff --git a/Cargo.toml b/Cargo.toml index e467e6a..3756e5e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,10 @@ name = "jsonlogic" path = "src/bin.rs" required-features = ["cmdline"] +[[example]] +name = "custom_operators" +path = "examples/custom_operators.rs" + [features] cmdline = ["anyhow", "clap"] default = [] diff --git a/README.md b/README.md index 74ff115..ca778e3 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,44 @@ fn main() { } ``` +#### Custom Operations + +You can add your own operations at runtime using the `add_operation`, `add_lazy_operation`, and `add_data_operation` functions: + +```rust +use jsonlogic_rs::{add_operation, apply, NumParams}; +use serde_json::{json, Value}; + +fn main() { + // Add a custom "double" operation + add_operation("double", |args| { + if let Some(Value::Number(n)) = args.first() { + if let Some(num) = n.as_f64() { + return Ok(json!(num * 2.0)); + } + } + Ok(Value::Null) + }, NumParams::Exactly(1)); + + // Use the custom operation in a rule + let result = apply(&json!({"double": [21]}), &json!({})).unwrap(); + assert_eq!(result, json!(42.0)); + + // Custom operations take precedence over built-in ones + add_operation("==", |_args| Ok(json!("custom!")), NumParams::Exactly(2)); + let result = apply(&json!({"==": [1, 1]}), &json!({})).unwrap(); + assert_eq!(result, json!("custom!")); +} +``` + +Three types of custom operations are supported: + +- **Regular Operations** (`add_operation`): Receive evaluated arguments, like built-in arithmetic operators +- **Lazy Operations** (`add_lazy_operation`): Receive data context and unevaluated arguments, allowing control over evaluation flow (like `if`, `and`, `or`) +- **Data Operations** (`add_data_operation`): Similar to lazy operations but designed for data access patterns (like `var`, `missing`) + +See the [examples/custom_operators.rs](examples/custom_operators.rs) file for more comprehensive examples. + ### Javascript ```js diff --git a/examples/custom_operators.rs b/examples/custom_operators.rs new file mode 100644 index 0000000..6b788ec --- /dev/null +++ b/examples/custom_operators.rs @@ -0,0 +1,136 @@ +// Example: Using Dynamic Custom Operators with json-logic-rs +// +// This example demonstrates how to add custom operations at runtime +// that can be used in JsonLogic rules. + +use jsonlogic_rs::{add_operation, add_lazy_operation, add_data_operation, apply, clear_operations, NumParams}; +use serde_json::{json, Value}; + +fn main() -> Result<(), Box> { + println!("=== Custom Operations Demo ===\n"); + + // 1. Add a simple custom operation + println!("1. Adding a custom 'double' operation..."); + add_operation("double", |args| { + if let Some(Value::Number(n)) = args.first() { + if let Some(num) = n.as_f64() { + return Ok(json!(num * 2.0)); + } + } + Ok(Value::Null) + }, NumParams::Exactly(1)); + + let result = apply(&json!({"double": [21]}), &json!({}))?; + println!(" Rule: {{\"double\": [21]}}"); + println!(" Result: {}", result); + println!(" Expected: 42\n"); + + // 2. Add a custom lazy operation (controls evaluation) + println!("2. Adding a custom 'conditional_log' lazy operation..."); + add_lazy_operation("conditional_log", |data, args| { + if args.len() >= 2 { + let condition = &args[0]; + let message = &args[1]; + + // Only evaluate condition if it's true + if let Value::Bool(true) = condition { + if let Value::String(msg) = message { + println!(" LOG: {}", msg); + } + return Ok(json!(true)); + } + } + Ok(json!(false)) + }, NumParams::Exactly(2)); + + let result = apply(&json!({"conditional_log": [true, "Hello from JsonLogic!"]}), &json!({}))?; + println!(" Rule: {{\"conditional_log\": [true, \"Hello from JsonLogic!\"]}}"); + println!(" Result: {}\n", result); + + // 3. Add a custom data operation (accesses context data) + println!("3. Adding a custom 'upper' data operation..."); + add_data_operation("upper", |data, args| { + if let Some(Value::String(field_name)) = args.first() { + if let Value::Object(obj) = data { + if let Some(Value::String(value)) = obj.get(field_name) { + return Ok(json!(value.to_uppercase())); + } + } + } + Ok(Value::Null) + }, NumParams::Exactly(1)); + + let result = apply( + &json!({"upper": ["name"]}), + &json!({"name": "json-logic-rs"}) + )?; + println!(" Rule: {{\"upper\": [\"name\"]}}"); + println!(" Data: {{\"name\": \"json-logic-rs\"}}"); + println!(" Result: {}\n", result); + + // 4. Demonstrate operator precedence (custom overrides built-in) + println!("4. Overriding built-in '==' operator..."); + println!(" Before override:"); + let result = apply(&json!({"==": [1, 1]}), &json!({}))?; + println!(" Rule: {{\"==\": [1, 1]}} -> {}", result); + + add_operation("==", |args| { + // Custom equality that always returns "CUSTOM!" + Ok(json!("CUSTOM!")) + }, NumParams::Exactly(2)); + + println!(" After adding custom override:"); + let result = apply(&json!({"==": [1, 1]}), &json!({}))?; + println!(" Rule: {{\"==\": [1, 1]}} -> {}\n", result); + + // 5. Complex example: Custom math operations + println!("5. Adding complex math operations..."); + + // Power operation + add_operation("pow", |args| { + if args.len() == 2 { + if let (Some(Value::Number(base)), Some(Value::Number(exp))) = (args.get(0), args.get(1)) { + if let (Some(b), Some(e)) = (base.as_f64(), exp.as_f64()) { + return Ok(json!(b.powf(e))); + } + } + } + Ok(Value::Null) + }, NumParams::Exactly(2)); + + // Square root operation + add_operation("sqrt", |args| { + if let Some(Value::Number(n)) = args.first() { + if let Some(num) = n.as_f64() { + if num >= 0.0 { + return Ok(json!(num.sqrt())); + } + } + } + Ok(Value::Null) + }, NumParams::Exactly(1)); + + // Test complex expression: sqrt(pow(3, 2) + pow(4, 2)) + let result = apply(&json!({ + "sqrt": [{ + "+": [ + {"pow": [3, 2]}, + {"pow": [4, 2]} + ] + }] + }), &json!({}))?; + + println!(" Rule: {{\"sqrt\": [{{\"+ \": [{{\"pow\": [3, 2]}}, {{\"pow\": [4, 2]}}]}}]}}"); + println!(" Result: {} (should be 5.0)\n", result); + + // 6. Clean up + println!("6. Cleaning up custom operations..."); + clear_operations(); + + // Verify built-in operators work again + let result = apply(&json!({"==": [1, 1]}), &json!({}))?; + println!(" After cleanup, built-in '==' works again: {}", result); + + println!("\n=== Demo Complete ==="); + Ok(()) +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 35103f9..5506bb1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,8 @@ mod op; mod value; use error::Error; +use op::{DataOperatorFn, DynamicDataOperator, DynamicLazyOperator, DynamicOperator, LazyOperatorFn, OperatorFn, get_custom_operator_registry}; +pub use op::NumParams; use value::{Evaluated, Parsed}; const NULL: Value = Value::Null; @@ -89,6 +91,156 @@ pub fn apply(value: &Value, data: &Value) -> Result { parsed.evaluate(data).map(Value::from) } +/// Add a custom operation that can be used in JsonLogic rules. +/// +/// This function allows you to register custom operators at runtime that will be +/// available for evaluation. Dynamic operators take precedence over static ones +/// if they share the same name. +/// +/// # Arguments +/// * `name` - The name of the operator (e.g., "my_op") +/// * `operator` - The function that implements the operator logic +/// * `num_params` - Parameter validation rules for the operator +/// +/// # Example +/// ``` +/// use jsonlogic_rs::{add_operation, NumParams}; +/// use serde_json::{json, Value}; +/// +/// // Register a custom "double" operation +/// add_operation("double", |args| { +/// if let Some(Value::Number(n)) = args.first() { +/// if let Some(num) = n.as_f64() { +/// return Ok(json!(num * 2.0)); +/// } +/// } +/// Ok(Value::Null) +/// }, NumParams::Exactly(1)); +/// ``` +pub fn add_operation(name: &str, operator: OperatorFn, num_params: NumParams) { + let registry = get_custom_operator_registry(); + let dynamic_op = DynamicOperator::new(name, operator, num_params); + + if let Ok(mut ops) = registry.operators.write() { + ops.insert(name.to_string(), dynamic_op); + } +} + +/// Add a custom lazy operation that can be used in JsonLogic rules. +/// +/// Lazy operators receive the data context and unevaluated arguments, +/// allowing them to control evaluation flow (like `if`, `and`, `or`). +/// +/// # Arguments +/// * `name` - The name of the operator (e.g., "my_lazy_op") +/// * `operator` - The function that implements the operator logic +/// * `num_params` - Parameter validation rules for the operator +/// +/// # Example +/// ``` +/// use jsonlogic_rs::{add_lazy_operation, NumParams}; +/// use serde_json::{json, Value}; +/// +/// // Register a custom "debug" operation that logs and returns the first argument +/// add_lazy_operation("debug", |data, args| { +/// println!("Debug: data={:?}, args={:?}", data, args); +/// Ok(args.first().map(|v| (*v).clone()).unwrap_or(Value::Null)) +/// }, NumParams::AtLeast(1)); +/// ``` +pub fn add_lazy_operation(name: &str, operator: LazyOperatorFn, num_params: NumParams) { + let registry = get_custom_operator_registry(); + let dynamic_op = DynamicLazyOperator::new(name, operator, num_params); + + if let Ok(mut ops) = registry.lazy_operators.write() { + ops.insert(name.to_string(), dynamic_op); + } +} + +/// Add a custom data operation that can be used in JsonLogic rules. +/// +/// Data operators receive both the data context and arguments, similar to +/// lazy operators, but are used for data access patterns (like `var`, `missing`). +/// +/// # Arguments +/// * `name` - The name of the operator (e.g., "my_data_op") +/// * `operator` - The function that implements the operator logic +/// * `num_params` - Parameter validation rules for the operator +/// +/// # Example +/// ``` +/// use jsonlogic_rs::{add_data_operation, NumParams}; +/// use serde_json::{json, Value}; +/// +/// // Register a custom "env" operation that reads environment variables +/// add_data_operation("env", |_data, args| { +/// if let Some(Value::String(var_name)) = args.first() { +/// match std::env::var(var_name) { +/// Ok(value) => Ok(json!(value)), +/// Err(_) => Ok(Value::Null), +/// } +/// } else { +/// Ok(Value::Null) +/// } +/// }, NumParams::Exactly(1)); +/// ``` +pub fn add_data_operation(name: &str, operator: DataOperatorFn, num_params: NumParams) { + let registry = get_custom_operator_registry(); + let dynamic_op = DynamicDataOperator::new(name, operator, num_params); + + if let Ok(mut ops) = registry.data_operators.write() { + ops.insert(name.to_string(), dynamic_op); + } +} + +/// Remove a custom operation by name. +/// +/// This function removes a dynamically registered operator from all operator maps. +/// It will not affect built-in static operators. +/// +/// # Arguments +/// * `name` - The name of the operator to remove +/// +/// # Returns +/// * `true` if an operator was removed, `false` if no operator with that name was found +pub fn remove_operation(name: &str) -> bool { + let registry = get_custom_operator_registry(); + let mut removed = false; + + if let Ok(mut ops) = registry.operators.write() { + removed |= ops.remove(name).is_some(); + } + + if let Ok(mut ops) = registry.lazy_operators.write() { + removed |= ops.remove(name).is_some(); + } + + if let Ok(mut ops) = registry.data_operators.write() { + removed |= ops.remove(name).is_some(); + } + + removed +} + +/// Clear all custom operations. +/// +/// This function removes all dynamically registered operators, but does not +/// affect built-in static operators. +pub fn clear_operations() { + let registry = get_custom_operator_registry(); + + if let Ok(mut ops) = registry.operators.write() { + ops.clear(); + } + + if let Ok(mut ops) = registry.lazy_operators.write() { + ops.clear(); + } + + if let Ok(mut ops) = registry.data_operators.write() { + ops.clear(); + } +} + #[cfg(test)] mod jsonlogic_tests { use super::*; @@ -1400,4 +1552,67 @@ mod jsonlogic_tests { fn test_in_op() { in_cases().into_iter().for_each(assert_jsonlogic) } + + #[test] + fn test_custom_operations() { + // Test custom regular operation + add_operation("double", |args| { + if let Some(Value::Number(n)) = args.first() { + if let Some(num) = n.as_f64() { + return Ok(json!(num * 2.0)); + } + } + Ok(Value::Null) + }, NumParams::Exactly(1)); + + let result = apply(&json!({"double": [5]}), &json!({})).unwrap(); + assert_eq!(result, json!(10.0)); + + // Test custom lazy operation + add_lazy_operation("first_truthy", |_data, args| { + for arg in args { + if let Value::Bool(true) = arg { + return Ok(json!(true)); + } + if let Value::Number(n) = arg { + if n.as_f64().unwrap_or(0.0) != 0.0 { + return Ok((*arg).clone()); + } + } + } + Ok(Value::Null) + }, NumParams::AtLeast(1)); + + let result = apply(&json!({"first_truthy": [false, 0, 42, true]}), &json!({})).unwrap(); + assert_eq!(result, json!(42)); + + // Test custom data operation + add_data_operation("get_field", |data, args| { + if let Some(Value::String(field)) = args.first() { + if let Value::Object(obj) = data { + return Ok(obj.get(field).cloned().unwrap_or(Value::Null)); + } + } + Ok(Value::Null) + }, NumParams::Exactly(1)); + + let result = apply(&json!({"get_field": ["name"]}), &json!({"name": "test"})).unwrap(); + assert_eq!(result, json!("test")); + + // Test overriding built-in operators + add_operation("==", |args| { + // Custom equality that always returns false + Ok(json!(false)) + }, NumParams::Exactly(2)); + + let result = apply(&json!({"==": [1, 1]}), &json!({})).unwrap(); + assert_eq!(result, json!(false)); // Our custom operator overrides the built-in + + // Clean up + clear_operations(); + + // After clearing, built-in operators should work again + let result = apply(&json!({"==": [1, 1]}), &json!({})).unwrap(); + assert_eq!(result, json!(true)); + } } diff --git a/src/op/mod.rs b/src/op/mod.rs index fa65a31..757f419 100644 --- a/src/op/mod.rs +++ b/src/op/mod.rs @@ -11,7 +11,9 @@ use phf::phf_map; use serde_json::{Map, Value}; +use std::collections::HashMap; use std::fmt; +use std::sync::{Once, RwLock}; use crate::error::Error; use crate::value::to_number_value; @@ -229,6 +231,35 @@ pub const LAZY_OPERATOR_MAP: phf::Map<&'static str, LazyOperator> = phf_map! { }, }; +/// Registry for dynamically registered custom operators +pub struct CustomOperatorRegistry { + pub operators: RwLock>, + pub lazy_operators: RwLock>, + pub data_operators: RwLock>, +} + +impl CustomOperatorRegistry { + fn new() -> Self { + Self { + operators: RwLock::new(HashMap::new()), + lazy_operators: RwLock::new(HashMap::new()), + data_operators: RwLock::new(HashMap::new()), + } + } +} + +static CUSTOM_OPERATOR_REGISTRY_INIT: Once = Once::new(); +static mut CUSTOM_OPERATOR_REGISTRY: Option = None; + +pub fn get_custom_operator_registry() -> &'static CustomOperatorRegistry { + unsafe { + CUSTOM_OPERATOR_REGISTRY_INIT.call_once(|| { + CUSTOM_OPERATOR_REGISTRY = Some(CustomOperatorRegistry::new()); + }); + CUSTOM_OPERATOR_REGISTRY.as_ref().unwrap() + } +} + #[derive(Debug, Clone)] pub enum NumParams { None, @@ -279,16 +310,44 @@ pub struct Operator { operator: OperatorFn, num_params: NumParams, } + +/// Dynamic version of Operator that can be stored in HashMap +#[derive(Clone, Debug)] +pub struct DynamicOperator { + symbol: String, + operator: OperatorFn, + num_params: NumParams, +} impl Operator { pub fn execute(&self, items: &Vec<&Value>) -> Result { (self.operator)(items) } } + +impl DynamicOperator { + pub fn new(symbol: &str, operator: OperatorFn, num_params: NumParams) -> Self { + Self { + symbol: symbol.to_string(), + operator, + num_params, + } + } + + pub fn execute(&self, items: &Vec<&Value>) -> Result { + (self.operator)(items) + } +} impl CommonOperator for Operator { fn param_info(&self) -> &NumParams { &self.num_params } } + +impl CommonOperator for DynamicOperator { + fn param_info(&self) -> &NumParams { + &self.num_params + } +} impl fmt::Debug for Operator { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("Operator") @@ -303,16 +362,44 @@ pub struct LazyOperator { operator: LazyOperatorFn, num_params: NumParams, } + +/// Dynamic version of LazyOperator that can be stored in HashMap +#[derive(Clone, Debug)] +pub struct DynamicLazyOperator { + symbol: String, + operator: LazyOperatorFn, + num_params: NumParams, +} impl LazyOperator { pub fn execute(&self, data: &Value, items: &Vec<&Value>) -> Result { (self.operator)(data, items) } } + +impl DynamicLazyOperator { + pub fn new(symbol: &str, operator: LazyOperatorFn, num_params: NumParams) -> Self { + Self { + symbol: symbol.to_string(), + operator, + num_params, + } + } + + pub fn execute(&self, data: &Value, items: &Vec<&Value>) -> Result { + (self.operator)(data, items) + } +} impl CommonOperator for LazyOperator { fn param_info(&self) -> &NumParams { &self.num_params } } + +impl CommonOperator for DynamicLazyOperator { + fn param_info(&self) -> &NumParams { + &self.num_params + } +} impl fmt::Debug for LazyOperator { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("Operator") @@ -332,16 +419,44 @@ pub struct DataOperator { operator: DataOperatorFn, num_params: NumParams, } + +/// Dynamic version of DataOperator that can be stored in HashMap +#[derive(Clone, Debug)] +pub struct DynamicDataOperator { + symbol: String, + operator: DataOperatorFn, + num_params: NumParams, +} impl DataOperator { pub fn execute(&self, data: &Value, items: &Vec<&Value>) -> Result { (self.operator)(data, items) } } + +impl DynamicDataOperator { + pub fn new(symbol: &str, operator: DataOperatorFn, num_params: NumParams) -> Self { + Self { + symbol: symbol.to_string(), + operator, + num_params, + } + } + + pub fn execute(&self, data: &Value, items: &Vec<&Value>) -> Result { + (self.operator)(data, items) + } +} impl CommonOperator for DataOperator { fn param_info(&self) -> &NumParams { &self.num_params } } + +impl CommonOperator for DynamicDataOperator { + fn param_info(&self) -> &NumParams { + &self.num_params + } +} impl fmt::Debug for DataOperator { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("Operator") @@ -351,132 +466,238 @@ impl fmt::Debug for DataOperator { } } -type OperatorFn = fn(&Vec<&Value>) -> Result; -type LazyOperatorFn = fn(&Value, &Vec<&Value>) -> Result; -type DataOperatorFn = fn(&Value, &Vec<&Value>) -> Result; +pub type OperatorFn = fn(&Vec<&Value>) -> Result; +pub type LazyOperatorFn = fn(&Value, &Vec<&Value>) -> Result; +pub type DataOperatorFn = fn(&Value, &Vec<&Value>) -> Result; /// An operation that doesn't do any recursive parsing or evaluation. /// /// Any operator functions used must handle parsing of values themselves. #[derive(Debug)] -pub struct LazyOperation<'a> { - operator: &'a LazyOperator, - arguments: Vec, +pub enum LazyOperation<'a> { + Static { + operator: &'a LazyOperator, + arguments: Vec, + }, + Dynamic { + operator: DynamicLazyOperator, + arguments: Vec, + }, } impl<'a> Parser<'a> for LazyOperation<'a> { fn from_value(value: &'a Value) -> Result, Error> { - op_from_map(&LAZY_OPERATOR_MAP, value).and_then(|opt| { - opt.map(|op| { - Ok(LazyOperation { - operator: op.op, - arguments: op.args.into_iter().map(|v| v.clone()).collect(), - }) - }) - .transpose() - }) + let registry = get_custom_operator_registry(); + + match op_from_static_and_dynamic( + &LAZY_OPERATOR_MAP, + ®istry.lazy_operators, + value, + )? { + Some(Either::Left(op)) => Ok(Some(LazyOperation::Static { + operator: op.op, + arguments: op.args.into_iter().map(|v| v.clone()).collect(), + })), + Some(Either::Right(op)) => Ok(Some(LazyOperation::Dynamic { + operator: op.op, + arguments: op.args.into_iter().map(|v| v.clone()).collect(), + })), + None => Ok(None), + } } fn evaluate(&self, data: &'a Value) -> Result { - self.operator - .execute(data, &self.arguments.iter().collect()) - .map(Evaluated::New) + match self { + LazyOperation::Static { + operator, + arguments, + } => operator.execute(data, &arguments.iter().collect()), + LazyOperation::Dynamic { + operator, + arguments, + } => operator.execute(data, &arguments.iter().collect()), + } + .map(Evaluated::New) } } impl From> for Value { fn from(op: LazyOperation) -> Value { let mut rv = Map::with_capacity(1); - rv.insert( - op.operator.symbol.into(), - Value::Array(op.arguments.clone()), - ); + match op { + LazyOperation::Static { + operator, + arguments, + } => { + rv.insert(operator.symbol.into(), Value::Array(arguments)); + } + LazyOperation::Dynamic { + operator, + arguments, + } => { + rv.insert(operator.symbol.clone(), Value::Array(arguments)); + } + } Value::Object(rv) } } #[derive(Debug)] -pub struct Operation<'a> { - operator: &'a Operator, - arguments: Vec>, +pub enum Operation<'a> { + Static { + operator: &'a Operator, + arguments: Vec>, + }, + Dynamic { + operator: DynamicOperator, + arguments: Vec>, + }, } impl<'a> Parser<'a> for Operation<'a> { fn from_value(value: &'a Value) -> Result, Error> { - op_from_map(&OPERATOR_MAP, value).and_then(|opt| { - opt.map(|op| { - Ok(Operation { - operator: op.op, - arguments: Parsed::from_values(op.args)?, - }) - }) - .transpose() - }) + let registry = get_custom_operator_registry(); + + match op_from_static_and_dynamic(&OPERATOR_MAP, ®istry.operators, value)? { + Some(Either::Left(op)) => Ok(Some(Operation::Static { + operator: op.op, + arguments: Parsed::from_values(op.args)?, + })), + Some(Either::Right(op)) => Ok(Some(Operation::Dynamic { + operator: op.op, + arguments: Parsed::from_values(op.args)?, + })), + None => Ok(None), + } } /// Evaluate the operation after recursively evaluating any nested operations fn evaluate(&self, data: &'a Value) -> Result { - let arguments = self - .arguments - .iter() - .map(|value| value.evaluate(data).map(Value::from)) - .collect::, Error>>()?; - self.operator - .execute(&arguments.iter().collect()) - .map(Evaluated::New) + let arguments = match self { + Operation::Static { arguments, .. } + | Operation::Dynamic { arguments, .. } => arguments, + } + .iter() + .map(|value| value.evaluate(data).map(Value::from)) + .collect::, Error>>()?; + + match self { + Operation::Static { operator, .. } => { + operator.execute(&arguments.iter().collect()) + } + Operation::Dynamic { operator, .. } => { + operator.execute(&arguments.iter().collect()) + } + } + .map(Evaluated::New) } } impl From> for Value { fn from(op: Operation) -> Value { let mut rv = Map::with_capacity(1); - let values = op - .arguments - .into_iter() - .map(Value::from) - .collect::>(); - rv.insert(op.operator.symbol.into(), Value::Array(values)); + match op { + Operation::Static { + operator, + arguments, + } => { + let values = arguments + .into_iter() + .map(Value::from) + .collect::>(); + rv.insert(operator.symbol.into(), Value::Array(values)); + } + Operation::Dynamic { + operator, + arguments, + } => { + let values = arguments + .into_iter() + .map(Value::from) + .collect::>(); + rv.insert(operator.symbol.clone(), Value::Array(values)); + } + } Value::Object(rv) } } #[derive(Debug)] -pub struct DataOperation<'a> { - operator: &'a DataOperator, - arguments: Vec>, +pub enum DataOperation<'a> { + Static { + operator: &'a DataOperator, + arguments: Vec>, + }, + Dynamic { + operator: DynamicDataOperator, + arguments: Vec>, + }, } impl<'a> Parser<'a> for DataOperation<'a> { fn from_value(value: &'a Value) -> Result, Error> { - op_from_map(&DATA_OPERATOR_MAP, value).and_then(|opt| { - opt.map(|op| { - Ok(DataOperation { - operator: op.op, - arguments: Parsed::from_values(op.args)?, - }) - }) - .transpose() - }) + let registry = get_custom_operator_registry(); + + match op_from_static_and_dynamic( + &DATA_OPERATOR_MAP, + ®istry.data_operators, + value, + )? { + Some(Either::Left(op)) => Ok(Some(DataOperation::Static { + operator: op.op, + arguments: Parsed::from_values(op.args)?, + })), + Some(Either::Right(op)) => Ok(Some(DataOperation::Dynamic { + operator: op.op, + arguments: Parsed::from_values(op.args)?, + })), + None => Ok(None), + } } /// Evaluate the operation after recursively evaluating any nested operations fn evaluate(&self, data: &'a Value) -> Result { - let arguments = self - .arguments - .iter() - .map(|value| value.evaluate(data).map(Value::from)) - .collect::, Error>>()?; - self.operator - .execute(data, &arguments.iter().collect()) - .map(Evaluated::New) + let arguments = match self { + DataOperation::Static { arguments, .. } + | DataOperation::Dynamic { arguments, .. } => arguments, + } + .iter() + .map(|value| value.evaluate(data).map(Value::from)) + .collect::, Error>>()?; + + match self { + DataOperation::Static { operator, .. } => { + operator.execute(data, &arguments.iter().collect()) + } + DataOperation::Dynamic { operator, .. } => { + operator.execute(data, &arguments.iter().collect()) + } + } + .map(Evaluated::New) } } impl From> for Value { fn from(op: DataOperation) -> Value { let mut rv = Map::with_capacity(1); - let values = op - .arguments - .into_iter() - .map(Value::from) - .collect::>(); - rv.insert(op.operator.symbol.into(), Value::Array(values)); + match op { + DataOperation::Static { + operator, + arguments, + } => { + let values = arguments + .into_iter() + .map(Value::from) + .collect::>(); + rv.insert(operator.symbol.into(), Value::Array(values)); + } + DataOperation::Dynamic { + operator, + arguments, + } => { + let values = arguments + .into_iter() + .map(Value::from) + .collect::>(); + rv.insert(operator.symbol.clone(), Value::Array(values)); + } + } Value::Object(rv) } } @@ -486,6 +707,105 @@ struct OpArgs<'a, 'b, T> { args: Vec<&'b Value>, } +struct DynamicOpArgs<'b, T> { + op: T, + args: Vec<&'b Value>, +} + +/// Enhanced lookup that checks both static PHF maps and dynamic HashMaps +fn op_from_static_and_dynamic<'a, 'b, T, D>( + static_map: &'a phf::Map<&'static str, T>, + dynamic_map: &'a RwLock>, + value: &'b Value, +) -> Result, DynamicOpArgs<'b, D>>>, Error> +where + T: CommonOperator, + D: CommonOperator + Clone, +{ + let obj = match value { + Value::Object(obj) => obj, + _ => return Ok(None), + }; + // With just one key. + if obj.len() != 1 { + return Ok(None); + }; + + // We've already validated the length to be one, so any error + // here is super unexpected. + let key = obj.keys().next().ok_or_else(|| { + Error::UnexpectedError(format!( + "could not get first key from len(1) object: {:?}", + obj + )) + })?; + let val = obj.get(key).ok_or_else(|| { + Error::UnexpectedError(format!( + "could not get value for key '{}' from len(1) object: {:?}", + key, obj + )) + })?; + + // First check dynamic operators (they take precedence) + let dynamic_ops = dynamic_map.read().map_err(|_| { + Error::UnexpectedError( + "Failed to acquire read lock on dynamic operators".to_string(), + ) + })?; + + if let Some(op) = dynamic_ops.get(key) { + let param_info = op.param_info(); + let args = extract_args(val, param_info, key)?; + return Ok(Some(Either::Right(DynamicOpArgs { + op: op.clone(), + args, + }))); + } + + drop(dynamic_ops); // Release the lock early + + // Then check static operators + if let Some(op) = static_map.get(key.as_str()) { + let param_info = op.param_info(); + let args = extract_args(val, param_info, key)?; + return Ok(Some(Either::Left(OpArgs { op, args }))); + } + + Ok(None) +} + +fn extract_args<'a>( + val: &'a Value, + param_info: &NumParams, + key: &str, +) -> Result, Error> { + let err_for_non_unary = || { + Err(Error::InvalidOperation { + key: key.to_string(), + reason: "Arguments to non-unary operations must be arrays".into(), + }) + }; + + // If args value is not an array, and the operator is unary, + // the value is treated as a unary argument array. + let args = match val { + Value::Array(args) => args.iter().collect::>(), + _ => match param_info.can_accept_unary() { + true => vec![val], + false => return err_for_non_unary(), + }, + }; + + param_info.check_len(&args.len())?; + Ok(args) +} + +/// Helper enum to handle either static or dynamic operator results +enum Either { + Left(L), + Right(R), +} + fn op_from_map<'a, 'b, T: CommonOperator>( map: &'a phf::Map<&'static str, T>, value: &'b Value, From 32ba18361c9eea752e0f10fb9090ea6ceba7eb80 Mon Sep 17 00:00:00 2001 From: Kevin Altman Date: Wed, 17 Sep 2025 13:27:02 -0400 Subject: [PATCH 2/5] adds error handling for custom operations --- README.md | 52 +++++++-- examples/custom_operators.rs | 220 +++++++++++++++++++++++++---------- src/lib.rs | 169 ++++++++++++++++++++------- 3 files changed, 329 insertions(+), 112 deletions(-) diff --git a/README.md b/README.md index ca778e3..257e074 100644 --- a/README.md +++ b/README.md @@ -98,28 +98,47 @@ fn main() { You can add your own operations at runtime using the `add_operation`, `add_lazy_operation`, and `add_data_operation` functions: ```rust -use jsonlogic_rs::{add_operation, apply, NumParams}; +use jsonlogic_rs::{add_operation, apply, NumParams, Error}; use serde_json::{json, Value}; -fn main() { - // Add a custom "double" operation +fn main() -> Result<(), Box> { + // Add a custom "double" operation with proper error handling add_operation("double", |args| { - if let Some(Value::Number(n)) = args.first() { - if let Some(num) = n.as_f64() { - return Ok(json!(num * 2.0)); + match args.first() { + Some(Value::Number(n)) => { + if let Some(num) = n.as_f64() { + Ok(json!(num * 2.0)) + } else { + Err(Error::InvalidArgument { + value: (*n).clone().into(), + operation: "double".to_string(), + reason: "Number cannot be converted to f64".to_string(), + }) + } } + Some(other) => Err(Error::InvalidArgument { + value: other.clone(), + operation: "double".to_string(), + reason: "Expected a number".to_string(), + }), + None => Err(Error::InvalidArgument { + value: Value::Null, + operation: "double".to_string(), + reason: "Missing required argument".to_string(), + }), } - Ok(Value::Null) }, NumParams::Exactly(1)); // Use the custom operation in a rule - let result = apply(&json!({"double": [21]}), &json!({})).unwrap(); + let result = apply(&json!({"double": [21]}), &json!({}))?; assert_eq!(result, json!(42.0)); // Custom operations take precedence over built-in ones add_operation("==", |_args| Ok(json!("custom!")), NumParams::Exactly(2)); - let result = apply(&json!({"==": [1, 1]}), &json!({})).unwrap(); + let result = apply(&json!({"==": [1, 1]}), &json!({}))?; assert_eq!(result, json!("custom!")); + + Ok(()) } ``` @@ -129,6 +148,21 @@ Three types of custom operations are supported: - **Lazy Operations** (`add_lazy_operation`): Receive data context and unevaluated arguments, allowing control over evaluation flow (like `if`, `and`, `or`) - **Data Operations** (`add_data_operation`): Similar to lazy operations but designed for data access patterns (like `var`, `missing`) +#### Error Handling + +The `Error` type is exported for proper error handling in custom operations. You can return specific error types from your custom operators: + +```rust +use jsonlogic_rs::Error; + +// Custom operators can return structured errors +Err(Error::InvalidArgument { + value: invalid_value.clone(), + operation: "my_operation".to_string(), + reason: "Expected a positive number".to_string(), +}) +``` + See the [examples/custom_operators.rs](examples/custom_operators.rs) file for more comprehensive examples. ### Javascript diff --git a/examples/custom_operators.rs b/examples/custom_operators.rs index 6b788ec..87d8397 100644 --- a/examples/custom_operators.rs +++ b/examples/custom_operators.rs @@ -3,7 +3,10 @@ // This example demonstrates how to add custom operations at runtime // that can be used in JsonLogic rules. -use jsonlogic_rs::{add_operation, add_lazy_operation, add_data_operation, apply, clear_operations, NumParams}; +use jsonlogic_rs::{ + add_data_operation, add_lazy_operation, add_operation, apply, clear_operations, + Error, NumParams, +}; use serde_json::{json, Value}; fn main() -> Result<(), Box> { @@ -11,14 +14,18 @@ fn main() -> Result<(), Box> { // 1. Add a simple custom operation println!("1. Adding a custom 'double' operation..."); - add_operation("double", |args| { - if let Some(Value::Number(n)) = args.first() { - if let Some(num) = n.as_f64() { - return Ok(json!(num * 2.0)); + add_operation( + "double", + |args| { + if let Some(Value::Number(n)) = args.first() { + if let Some(num) = n.as_f64() { + return Ok(json!(num * 2.0)); + } } - } - Ok(Value::Null) - }, NumParams::Exactly(1)); + Ok(Value::Null) + }, + NumParams::Exactly(1), + ); let result = apply(&json!({"double": [21]}), &json!({}))?; println!(" Rule: {{\"double\": [21]}}"); @@ -27,42 +34,53 @@ fn main() -> Result<(), Box> { // 2. Add a custom lazy operation (controls evaluation) println!("2. Adding a custom 'conditional_log' lazy operation..."); - add_lazy_operation("conditional_log", |data, args| { - if args.len() >= 2 { - let condition = &args[0]; - let message = &args[1]; - - // Only evaluate condition if it's true - if let Value::Bool(true) = condition { - if let Value::String(msg) = message { - println!(" LOG: {}", msg); + add_lazy_operation( + "conditional_log", + |data, args| { + if args.len() >= 2 { + let condition = &args[0]; + let message = &args[1]; + + // Only evaluate condition if it's true + if let Value::Bool(true) = condition { + if let Value::String(msg) = message { + println!(" LOG: {}", msg); + } + return Ok(json!(true)); } - return Ok(json!(true)); } - } - Ok(json!(false)) - }, NumParams::Exactly(2)); + Ok(json!(false)) + }, + NumParams::Exactly(2), + ); - let result = apply(&json!({"conditional_log": [true, "Hello from JsonLogic!"]}), &json!({}))?; + let result = apply( + &json!({"conditional_log": [true, "Hello from JsonLogic!"]}), + &json!({}), + )?; println!(" Rule: {{\"conditional_log\": [true, \"Hello from JsonLogic!\"]}}"); println!(" Result: {}\n", result); // 3. Add a custom data operation (accesses context data) println!("3. Adding a custom 'upper' data operation..."); - add_data_operation("upper", |data, args| { - if let Some(Value::String(field_name)) = args.first() { - if let Value::Object(obj) = data { - if let Some(Value::String(value)) = obj.get(field_name) { - return Ok(json!(value.to_uppercase())); + add_data_operation( + "upper", + |data, args| { + if let Some(Value::String(field_name)) = args.first() { + if let Value::Object(obj) = data { + if let Some(Value::String(value)) = obj.get(field_name) { + return Ok(json!(value.to_uppercase())); + } } } - } - Ok(Value::Null) - }, NumParams::Exactly(1)); + Ok(Value::Null) + }, + NumParams::Exactly(1), + ); let result = apply( &json!({"upper": ["name"]}), - &json!({"name": "json-logic-rs"}) + &json!({"name": "json-logic-rs"}), )?; println!(" Rule: {{\"upper\": [\"name\"]}}"); println!(" Data: {{\"name\": \"json-logic-rs\"}}"); @@ -74,10 +92,14 @@ fn main() -> Result<(), Box> { let result = apply(&json!({"==": [1, 1]}), &json!({}))?; println!(" Rule: {{\"==\": [1, 1]}} -> {}", result); - add_operation("==", |args| { - // Custom equality that always returns "CUSTOM!" - Ok(json!("CUSTOM!")) - }, NumParams::Exactly(2)); + add_operation( + "==", + |args| { + // Custom equality that always returns "CUSTOM!" + Ok(json!("CUSTOM!")) + }, + NumParams::Exactly(2), + ); println!(" After adding custom override:"); let result = apply(&json!({"==": [1, 1]}), &json!({}))?; @@ -87,38 +109,51 @@ fn main() -> Result<(), Box> { println!("5. Adding complex math operations..."); // Power operation - add_operation("pow", |args| { - if args.len() == 2 { - if let (Some(Value::Number(base)), Some(Value::Number(exp))) = (args.get(0), args.get(1)) { - if let (Some(b), Some(e)) = (base.as_f64(), exp.as_f64()) { - return Ok(json!(b.powf(e))); + add_operation( + "pow", + |args| { + if args.len() == 2 { + if let (Some(Value::Number(base)), Some(Value::Number(exp))) = + (args.get(0), args.get(1)) + { + if let (Some(b), Some(e)) = (base.as_f64(), exp.as_f64()) { + return Ok(json!(b.powf(e))); + } } } - } - Ok(Value::Null) - }, NumParams::Exactly(2)); + Ok(Value::Null) + }, + NumParams::Exactly(2), + ); // Square root operation - add_operation("sqrt", |args| { - if let Some(Value::Number(n)) = args.first() { - if let Some(num) = n.as_f64() { - if num >= 0.0 { - return Ok(json!(num.sqrt())); + add_operation( + "sqrt", + |args| { + if let Some(Value::Number(n)) = args.first() { + if let Some(num) = n.as_f64() { + if num >= 0.0 { + return Ok(json!(num.sqrt())); + } } } - } - Ok(Value::Null) - }, NumParams::Exactly(1)); + Ok(Value::Null) + }, + NumParams::Exactly(1), + ); // Test complex expression: sqrt(pow(3, 2) + pow(4, 2)) - let result = apply(&json!({ - "sqrt": [{ - "+": [ - {"pow": [3, 2]}, - {"pow": [4, 2]} - ] - }] - }), &json!({}))?; + let result = apply( + &json!({ + "sqrt": [{ + "+": [ + {"pow": [3, 2]}, + {"pow": [4, 2]} + ] + }] + }), + &json!({}), + )?; println!(" Rule: {{\"sqrt\": [{{\"+ \": [{{\"pow\": [3, 2]}}, {{\"pow\": [4, 2]}}]}}]}}"); println!(" Result: {} (should be 5.0)\n", result); @@ -131,6 +166,71 @@ fn main() -> Result<(), Box> { let result = apply(&json!({"==": [1, 1]}), &json!({}))?; println!(" After cleanup, built-in '==' works again: {}", result); + // 7. Demonstrate proper error handling + println!("7. Demonstrating proper error handling..."); + + // Add an operation that validates input and returns meaningful errors + add_operation( + "safe_divide", + |args| match (args.get(0), args.get(1)) { + (Some(Value::Number(a)), Some(Value::Number(b))) => { + let dividend = a.as_f64().ok_or_else(|| Error::InvalidArgument { + value: (*a).clone().into(), + operation: "safe_divide".to_string(), + reason: "First argument must be a valid number".to_string(), + })?; + + let divisor = b.as_f64().ok_or_else(|| Error::InvalidArgument { + value: (*b).clone().into(), + operation: "safe_divide".to_string(), + reason: "Second argument must be a valid number".to_string(), + })?; + + if divisor == 0.0 { + return Err(Error::InvalidArgument { + value: json!(divisor), + operation: "safe_divide".to_string(), + reason: "Division by zero is not allowed".to_string(), + }); + } + + Ok(json!(dividend / divisor)) + } + (Some(non_number), _) => Err(Error::InvalidArgument { + value: (*non_number).clone(), + operation: "safe_divide".to_string(), + reason: "First argument must be a number".to_string(), + }), + (_, Some(non_number)) => Err(Error::InvalidArgument { + value: (*non_number).clone(), + operation: "safe_divide".to_string(), + reason: "Second argument must be a number".to_string(), + }), + _ => Err(Error::InvalidArgument { + value: Value::Null, + operation: "safe_divide".to_string(), + reason: "Missing required arguments".to_string(), + }), + }, + NumParams::Exactly(2), + ); + + // Test successful division + let result = apply(&json!({"safe_divide": [10, 2]}), &json!({}))?; + println!(" Rule: {{\"safe_divide\": [10, 2]}} -> {}", result); + + // Test error handling - division by zero + match apply(&json!({"safe_divide": [10, 0]}), &json!({})) { + Ok(_) => println!(" ERROR: Division by zero should have failed!"), + Err(e) => println!(" Caught expected error: {}", e), + } + + // Test error handling - invalid argument type + match apply(&json!({"safe_divide": [10, "hello"]}), &json!({})) { + Ok(_) => println!(" ERROR: Invalid argument should have failed!"), + Err(e) => println!(" Caught expected error: {}", e), + } + println!("\n=== Demo Complete ==="); Ok(()) -} \ No newline at end of file +} diff --git a/src/lib.rs b/src/lib.rs index 5506bb1..b25418f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,16 +7,19 @@ pub mod js_op; mod op; mod value; -use error::Error; -use op::{DataOperatorFn, DynamicDataOperator, DynamicLazyOperator, DynamicOperator, LazyOperatorFn, OperatorFn, get_custom_operator_registry}; -pub use op::NumParams; +pub use error::Error; +use op::{ + get_custom_operator_registry, DynamicDataOperator, DynamicLazyOperator, + DynamicOperator, +}; +pub use op::{DataOperatorFn, LazyOperatorFn, NumParams, OperatorFn}; use value::{Evaluated, Parsed}; const NULL: Value = Value::Null; trait Parser<'a>: Sized + Into { - fn from_value(value: &'a Value) -> Result, Error>; - fn evaluate(&self, data: &'a Value) -> Result; + fn from_value(value: &'a Value) -> Result, error::Error>; + fn evaluate(&self, data: &'a Value) -> Result; } #[cfg(feature = "wasm")] @@ -86,7 +89,7 @@ pub mod python_iface { /// Run JSONLogic for the given operation and data. /// -pub fn apply(value: &Value, data: &Value) -> Result { +pub fn apply(value: &Value, data: &Value) -> Result { let parsed = Parsed::from_value(&value)?; parsed.evaluate(data).map(Value::from) } @@ -104,17 +107,34 @@ pub fn apply(value: &Value, data: &Value) -> Result { /// /// # Example /// ``` -/// use jsonlogic_rs::{add_operation, NumParams}; +/// use jsonlogic_rs::{add_operation, NumParams, Error}; /// use serde_json::{json, Value}; /// -/// // Register a custom "double" operation +/// // Register a custom "double" operation with proper error handling /// add_operation("double", |args| { -/// if let Some(Value::Number(n)) = args.first() { -/// if let Some(num) = n.as_f64() { -/// return Ok(json!(num * 2.0)); +/// match args.first() { +/// Some(Value::Number(n)) => { +/// if let Some(num) = n.as_f64() { +/// Ok(json!(num * 2.0)) +/// } else { +/// Err(Error::InvalidArgument { +/// value: (*n).clone().into(), +/// operation: "double".to_string(), +/// reason: "Number cannot be converted to f64".to_string(), +/// }) +/// } /// } +/// Some(other) => Err(Error::InvalidArgument { +/// value: other.clone(), +/// operation: "double".to_string(), +/// reason: "Expected a number".to_string(), +/// }), +/// None => Err(Error::InvalidArgument { +/// value: Value::Null, +/// operation: "double".to_string(), +/// reason: "Missing required argument".to_string(), +/// }), /// } -/// Ok(Value::Null) /// }, NumParams::Exactly(1)); /// ``` pub fn add_operation(name: &str, operator: OperatorFn, num_params: NumParams) { @@ -1556,54 +1576,72 @@ mod jsonlogic_tests { #[test] fn test_custom_operations() { // Test custom regular operation - add_operation("double", |args| { - if let Some(Value::Number(n)) = args.first() { - if let Some(num) = n.as_f64() { - return Ok(json!(num * 2.0)); + add_operation( + "double", + |args| { + if let Some(Value::Number(n)) = args.first() { + if let Some(num) = n.as_f64() { + return Ok(json!(num * 2.0)); + } } - } - Ok(Value::Null) - }, NumParams::Exactly(1)); + Ok(Value::Null) + }, + NumParams::Exactly(1), + ); let result = apply(&json!({"double": [5]}), &json!({})).unwrap(); assert_eq!(result, json!(10.0)); // Test custom lazy operation - add_lazy_operation("first_truthy", |_data, args| { - for arg in args { - if let Value::Bool(true) = arg { - return Ok(json!(true)); - } - if let Value::Number(n) = arg { - if n.as_f64().unwrap_or(0.0) != 0.0 { - return Ok((*arg).clone()); + add_lazy_operation( + "first_truthy", + |_data, args| { + for arg in args { + if let Value::Bool(true) = arg { + return Ok(json!(true)); + } + if let Value::Number(n) = arg { + if n.as_f64().unwrap_or(0.0) != 0.0 { + return Ok((*arg).clone()); + } } } - } - Ok(Value::Null) - }, NumParams::AtLeast(1)); + Ok(Value::Null) + }, + NumParams::AtLeast(1), + ); - let result = apply(&json!({"first_truthy": [false, 0, 42, true]}), &json!({})).unwrap(); + let result = + apply(&json!({"first_truthy": [false, 0, 42, true]}), &json!({})).unwrap(); assert_eq!(result, json!(42)); // Test custom data operation - add_data_operation("get_field", |data, args| { - if let Some(Value::String(field)) = args.first() { - if let Value::Object(obj) = data { - return Ok(obj.get(field).cloned().unwrap_or(Value::Null)); + add_data_operation( + "get_field", + |data, args| { + if let Some(Value::String(field)) = args.first() { + if let Value::Object(obj) = data { + return Ok(obj.get(field).cloned().unwrap_or(Value::Null)); + } } - } - Ok(Value::Null) - }, NumParams::Exactly(1)); + Ok(Value::Null) + }, + NumParams::Exactly(1), + ); - let result = apply(&json!({"get_field": ["name"]}), &json!({"name": "test"})).unwrap(); + let result = + apply(&json!({"get_field": ["name"]}), &json!({"name": "test"})).unwrap(); assert_eq!(result, json!("test")); // Test overriding built-in operators - add_operation("==", |args| { - // Custom equality that always returns false - Ok(json!(false)) - }, NumParams::Exactly(2)); + add_operation( + "==", + |args| { + // Custom equality that always returns false + Ok(json!(false)) + }, + NumParams::Exactly(2), + ); let result = apply(&json!({"==": [1, 1]}), &json!({})).unwrap(); assert_eq!(result, json!(false)); // Our custom operator overrides the built-in @@ -1615,4 +1653,49 @@ mod jsonlogic_tests { let result = apply(&json!({"==": [1, 1]}), &json!({})).unwrap(); assert_eq!(result, json!(true)); } + + #[test] + fn test_error_handling_in_custom_operations() { + // Test that consumers can use the Error type for proper error handling + add_operation( + "test_error_unique", + |args| { + if let Some(Value::String(s)) = args.first() { + if s == "error" { + return Err(Error::InvalidArgument { + value: args[0].clone(), + operation: "test_error_unique".to_string(), + reason: "Custom error message".to_string(), + }); + } + } + Ok(json!("success")) + }, + NumParams::Exactly(1), + ); + + // Test successful case + let result = apply(&json!({"test_error_unique": [1]}), &json!({})).unwrap(); + assert_eq!(result, json!("success")); + + // Test custom error case + match apply(&json!({"test_error_unique": ["error"]}), &json!({})) { + Ok(_) => panic!("Should have failed!"), + Err(e) => { + // Verify we can match on the Error type + match e { + Error::InvalidArgument { + operation, reason, .. + } => { + assert_eq!(operation, "test_error_unique"); + assert_eq!(reason, "Custom error message"); + } + _ => panic!("Wrong error type: {:?}", e), + } + } + } + + // Clean up + clear_operations(); + } } From 5b169fcf09b97cc552d323f929c43fc7b6699365 Mon Sep 17 00:00:00 2001 From: Kevin Altman Date: Wed, 17 Sep 2025 13:34:13 -0400 Subject: [PATCH 3/5] demonstrate WrongArgumentCount errors --- README.md | 11 ++++++++ examples/custom_operators.rs | 36 ++++++++++++++++++++++++++ src/lib.rs | 49 ++++++++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+) diff --git a/README.md b/README.md index 257e074..e33e176 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,17 @@ Err(Error::InvalidArgument { }) ``` +**Automatic Parameter Validation**: The `NumParams` specification automatically validates argument counts. If users call your custom operation with the wrong number of arguments, they'll receive a `WrongArgumentCount` error before your function is called: + +```rust +match apply(&json!({"my_op": [1]}), &json!({})) { + Err(Error::WrongArgumentCount { expected, actual }) => { + println!("Expected {:?} arguments, got {}", expected, actual); + } + _ => {} +} +``` + See the [examples/custom_operators.rs](examples/custom_operators.rs) file for more comprehensive examples. ### Javascript diff --git a/examples/custom_operators.rs b/examples/custom_operators.rs index 87d8397..cb244c1 100644 --- a/examples/custom_operators.rs +++ b/examples/custom_operators.rs @@ -231,6 +231,42 @@ fn main() -> Result<(), Box> { Err(e) => println!(" Caught expected error: {}", e), } + // 8. Demonstrate parameter validation + println!("8. Demonstrating parameter validation..."); + + // Add an operation that requires exactly 2 arguments + add_operation( + "multiply", + |args| { + // We can assume args.len() == 2 because NumParams::Exactly(2) validates this + if let (Some(Value::Number(a)), Some(Value::Number(b))) = + (args.get(0), args.get(1)) + { + if let (Some(x), Some(y)) = (a.as_f64(), b.as_f64()) { + return Ok(json!(x * y)); + } + } + Ok(Value::Null) + }, + NumParams::Exactly(2), + ); + + // Test correct usage + let result = apply(&json!({"multiply": [3, 4]}), &json!({}))?; + println!(" Rule: {{\"multiply\": [3, 4]}} -> {}", result); + + // Test parameter validation - wrong argument count + match apply(&json!({"multiply": [3]}), &json!({})) { + Ok(_) => println!(" ERROR: Should have failed with wrong argument count!"), + Err(Error::WrongArgumentCount { expected, actual }) => { + println!( + " Caught parameter validation error: expected {:?}, got {}", + expected, actual + ); + } + Err(e) => println!(" Caught unexpected error: {}", e), + } + println!("\n=== Demo Complete ==="); Ok(()) } diff --git a/src/lib.rs b/src/lib.rs index b25418f..4724b9c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1698,4 +1698,53 @@ mod jsonlogic_tests { // Clean up clear_operations(); } + + #[test] + fn test_wrong_argument_count_error() { + // Register an operation that expects exactly 2 arguments + add_operation( + "test_two_args", + |args| { + if args.len() == 2 { + Ok(json!("success")) + } else { + panic!("Should not reach here - NumParams should validate first") + } + }, + NumParams::Exactly(2), + ); + + // Test correct argument count + let result = apply(&json!({"test_two_args": [1, 2]}), &json!({})).unwrap(); + assert_eq!(result, json!("success")); + + // Test wrong argument count - too few + match apply(&json!({"test_two_args": [1]}), &json!({})) { + Ok(_) => panic!("Should have failed with wrong argument count!"), + Err(Error::WrongArgumentCount { expected, actual }) => { + assert_eq!(actual, 1); + match expected { + NumParams::Exactly(n) => assert_eq!(n, 2), + _ => panic!("Expected NumParams::Exactly(2)"), + } + } + Err(e) => panic!("Wrong error type: {:?}", e), + } + + // Test wrong argument count - too many + match apply(&json!({"test_two_args": [1, 2, 3]}), &json!({})) { + Ok(_) => panic!("Should have failed with wrong argument count!"), + Err(Error::WrongArgumentCount { expected, actual }) => { + assert_eq!(actual, 3); + match expected { + NumParams::Exactly(n) => assert_eq!(n, 2), + _ => panic!("Expected NumParams::Exactly(2)"), + } + } + Err(e) => panic!("Wrong error type: {:?}", e), + } + + // Clean up + clear_operations(); + } } From 894f06ad4ff177dd37ffc61a0e4fd439f4c1f64c Mon Sep 17 00:00:00 2001 From: Kevin Altman Date: Wed, 17 Sep 2025 14:14:29 -0400 Subject: [PATCH 4/5] performance and safe concurrency --- Cargo.toml | 1 + README.md | 4 +- examples/custom_operators.rs | 18 ++-- src/error.rs | 3 + src/lib.rs | 166 +++++++++++++++++++++++++---------- src/op/mod.rs | 14 ++- 6 files changed, 138 insertions(+), 68 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 3756e5e..9e5d9c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ python = ["cpython"] wasm = ["wasm-bindgen"] [dependencies] +once_cell = "1.19" phf = {version = "~0.8.0", features = ["macros"]} serde_json = "~1.0.41" thiserror = "~1.0.11" diff --git a/README.md b/README.md index e33e176..4e75a3a 100644 --- a/README.md +++ b/README.md @@ -127,14 +127,14 @@ fn main() -> Result<(), Box> { reason: "Missing required argument".to_string(), }), } - }, NumParams::Exactly(1)); + }, NumParams::Exactly(1))?; // Use the custom operation in a rule let result = apply(&json!({"double": [21]}), &json!({}))?; assert_eq!(result, json!(42.0)); // Custom operations take precedence over built-in ones - add_operation("==", |_args| Ok(json!("custom!")), NumParams::Exactly(2)); + add_operation("==", |_args| Ok(json!("custom!")), NumParams::Exactly(2))?; let result = apply(&json!({"==": [1, 1]}), &json!({}))?; assert_eq!(result, json!("custom!")); diff --git a/examples/custom_operators.rs b/examples/custom_operators.rs index cb244c1..574a826 100644 --- a/examples/custom_operators.rs +++ b/examples/custom_operators.rs @@ -25,7 +25,7 @@ fn main() -> Result<(), Box> { Ok(Value::Null) }, NumParams::Exactly(1), - ); + )?; let result = apply(&json!({"double": [21]}), &json!({}))?; println!(" Rule: {{\"double\": [21]}}"); @@ -52,7 +52,7 @@ fn main() -> Result<(), Box> { Ok(json!(false)) }, NumParams::Exactly(2), - ); + )?; let result = apply( &json!({"conditional_log": [true, "Hello from JsonLogic!"]}), @@ -76,7 +76,7 @@ fn main() -> Result<(), Box> { Ok(Value::Null) }, NumParams::Exactly(1), - ); + )?; let result = apply( &json!({"upper": ["name"]}), @@ -99,7 +99,7 @@ fn main() -> Result<(), Box> { Ok(json!("CUSTOM!")) }, NumParams::Exactly(2), - ); + )?; println!(" After adding custom override:"); let result = apply(&json!({"==": [1, 1]}), &json!({}))?; @@ -124,7 +124,7 @@ fn main() -> Result<(), Box> { Ok(Value::Null) }, NumParams::Exactly(2), - ); + )?; // Square root operation add_operation( @@ -140,7 +140,7 @@ fn main() -> Result<(), Box> { Ok(Value::Null) }, NumParams::Exactly(1), - ); + )?; // Test complex expression: sqrt(pow(3, 2) + pow(4, 2)) let result = apply( @@ -160,7 +160,7 @@ fn main() -> Result<(), Box> { // 6. Clean up println!("6. Cleaning up custom operations..."); - clear_operations(); + clear_operations()?; // Verify built-in operators work again let result = apply(&json!({"==": [1, 1]}), &json!({}))?; @@ -213,7 +213,7 @@ fn main() -> Result<(), Box> { }), }, NumParams::Exactly(2), - ); + )?; // Test successful division let result = apply(&json!({"safe_divide": [10, 2]}), &json!({}))?; @@ -249,7 +249,7 @@ fn main() -> Result<(), Box> { Ok(Value::Null) }, NumParams::Exactly(2), - ); + )?; // Test correct usage let result = apply(&json!({"multiply": [3, 4]}), &json!({}))?; diff --git a/src/error.rs b/src/error.rs index 6b586b7..7640a81 100644 --- a/src/error.rs +++ b/src/error.rs @@ -35,4 +35,7 @@ pub enum Error { #[error("Wrong argument count - expected: {expected:?}, actual: {actual:?}")] WrongArgumentCount { expected: NumParams, actual: usize }, + + #[error("Registry operation failed - {reason}")] + RegistryError { reason: String }, } diff --git a/src/lib.rs b/src/lib.rs index 4724b9c..b3b8cbb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -137,13 +137,23 @@ pub fn apply(value: &Value, data: &Value) -> Result { /// } /// }, NumParams::Exactly(1)); /// ``` -pub fn add_operation(name: &str, operator: OperatorFn, num_params: NumParams) { +pub fn add_operation( + name: &str, + operator: OperatorFn, + num_params: NumParams, +) -> Result<(), Error> { let registry = get_custom_operator_registry(); let dynamic_op = DynamicOperator::new(name, operator, num_params); - if let Ok(mut ops) = registry.operators.write() { - ops.insert(name.to_string(), dynamic_op); - } + registry + .operators + .write() + .map_err(|_| Error::RegistryError { + reason: "Failed to acquire operator registry lock".to_string(), + })? + .insert(name.to_string(), dynamic_op); + + Ok(()) } /// Add a custom lazy operation that can be used in JsonLogic rules. @@ -167,13 +177,23 @@ pub fn add_operation(name: &str, operator: OperatorFn, num_params: NumParams) { /// Ok(args.first().map(|v| (*v).clone()).unwrap_or(Value::Null)) /// }, NumParams::AtLeast(1)); /// ``` -pub fn add_lazy_operation(name: &str, operator: LazyOperatorFn, num_params: NumParams) { +pub fn add_lazy_operation( + name: &str, + operator: LazyOperatorFn, + num_params: NumParams, +) -> Result<(), Error> { let registry = get_custom_operator_registry(); let dynamic_op = DynamicLazyOperator::new(name, operator, num_params); - if let Ok(mut ops) = registry.lazy_operators.write() { - ops.insert(name.to_string(), dynamic_op); - } + registry + .lazy_operators + .write() + .map_err(|_| Error::RegistryError { + reason: "Failed to acquire lazy operator registry lock".to_string(), + })? + .insert(name.to_string(), dynamic_op); + + Ok(()) } /// Add a custom data operation that can be used in JsonLogic rules. @@ -203,13 +223,23 @@ pub fn add_lazy_operation(name: &str, operator: LazyOperatorFn, num_params: NumP /// } /// }, NumParams::Exactly(1)); /// ``` -pub fn add_data_operation(name: &str, operator: DataOperatorFn, num_params: NumParams) { +pub fn add_data_operation( + name: &str, + operator: DataOperatorFn, + num_params: NumParams, +) -> Result<(), Error> { let registry = get_custom_operator_registry(); let dynamic_op = DynamicDataOperator::new(name, operator, num_params); - if let Ok(mut ops) = registry.data_operators.write() { - ops.insert(name.to_string(), dynamic_op); - } + registry + .data_operators + .write() + .map_err(|_| Error::RegistryError { + reason: "Failed to acquire data operator registry lock".to_string(), + })? + .insert(name.to_string(), dynamic_op); + + Ok(()) } /// Remove a custom operation by name. @@ -221,44 +251,78 @@ pub fn add_data_operation(name: &str, operator: DataOperatorFn, num_params: NumP /// * `name` - The name of the operator to remove /// /// # Returns -/// * `true` if an operator was removed, `false` if no operator with that name was found -pub fn remove_operation(name: &str) -> bool { +/// * `Ok(true)` if an operator was removed, `Ok(false)` if no operator with that name was found +/// * `Err(Error::RegistryError)` if lock acquisition failed +pub fn remove_operation(name: &str) -> Result { let registry = get_custom_operator_registry(); let mut removed = false; - if let Ok(mut ops) = registry.operators.write() { - removed |= ops.remove(name).is_some(); - } - - if let Ok(mut ops) = registry.lazy_operators.write() { - removed |= ops.remove(name).is_some(); - } - - if let Ok(mut ops) = registry.data_operators.write() { - removed |= ops.remove(name).is_some(); - } - - removed + removed |= registry + .operators + .write() + .map_err(|_| Error::RegistryError { + reason: "Failed to acquire operator registry lock".to_string(), + })? + .remove(name) + .is_some(); + + removed |= registry + .lazy_operators + .write() + .map_err(|_| Error::RegistryError { + reason: "Failed to acquire lazy operator registry lock".to_string(), + })? + .remove(name) + .is_some(); + + removed |= registry + .data_operators + .write() + .map_err(|_| Error::RegistryError { + reason: "Failed to acquire data operator registry lock".to_string(), + })? + .remove(name) + .is_some(); + + Ok(removed) } /// Clear all custom operations. /// /// This function removes all dynamically registered operators, but does not /// affect built-in static operators. -pub fn clear_operations() { +/// +/// # Returns +/// * `Ok(())` if all operations were cleared successfully +/// * `Err(Error::RegistryError)` if lock acquisition failed +pub fn clear_operations() -> Result<(), Error> { let registry = get_custom_operator_registry(); - if let Ok(mut ops) = registry.operators.write() { - ops.clear(); - } - - if let Ok(mut ops) = registry.lazy_operators.write() { - ops.clear(); - } - - if let Ok(mut ops) = registry.data_operators.write() { - ops.clear(); - } + registry + .operators + .write() + .map_err(|_| Error::RegistryError { + reason: "Failed to acquire operator registry lock".to_string(), + })? + .clear(); + + registry + .lazy_operators + .write() + .map_err(|_| Error::RegistryError { + reason: "Failed to acquire lazy operator registry lock".to_string(), + })? + .clear(); + + registry + .data_operators + .write() + .map_err(|_| Error::RegistryError { + reason: "Failed to acquire data operator registry lock".to_string(), + })? + .clear(); + + Ok(()) } #[cfg(test)] @@ -1587,7 +1651,8 @@ mod jsonlogic_tests { Ok(Value::Null) }, NumParams::Exactly(1), - ); + ) + .unwrap(); let result = apply(&json!({"double": [5]}), &json!({})).unwrap(); assert_eq!(result, json!(10.0)); @@ -1609,7 +1674,8 @@ mod jsonlogic_tests { Ok(Value::Null) }, NumParams::AtLeast(1), - ); + ) + .unwrap(); let result = apply(&json!({"first_truthy": [false, 0, 42, true]}), &json!({})).unwrap(); @@ -1627,7 +1693,8 @@ mod jsonlogic_tests { Ok(Value::Null) }, NumParams::Exactly(1), - ); + ) + .unwrap(); let result = apply(&json!({"get_field": ["name"]}), &json!({"name": "test"})).unwrap(); @@ -1641,13 +1708,14 @@ mod jsonlogic_tests { Ok(json!(false)) }, NumParams::Exactly(2), - ); + ) + .unwrap(); let result = apply(&json!({"==": [1, 1]}), &json!({})).unwrap(); assert_eq!(result, json!(false)); // Our custom operator overrides the built-in // Clean up - clear_operations(); + clear_operations().unwrap(); // After clearing, built-in operators should work again let result = apply(&json!({"==": [1, 1]}), &json!({})).unwrap(); @@ -1672,7 +1740,8 @@ mod jsonlogic_tests { Ok(json!("success")) }, NumParams::Exactly(1), - ); + ) + .unwrap(); // Test successful case let result = apply(&json!({"test_error_unique": [1]}), &json!({})).unwrap(); @@ -1696,7 +1765,7 @@ mod jsonlogic_tests { } // Clean up - clear_operations(); + clear_operations().unwrap(); } #[test] @@ -1712,7 +1781,8 @@ mod jsonlogic_tests { } }, NumParams::Exactly(2), - ); + ) + .unwrap(); // Test correct argument count let result = apply(&json!({"test_two_args": [1, 2]}), &json!({})).unwrap(); @@ -1745,6 +1815,6 @@ mod jsonlogic_tests { } // Clean up - clear_operations(); + clear_operations().unwrap(); } } diff --git a/src/op/mod.rs b/src/op/mod.rs index 757f419..17c9962 100644 --- a/src/op/mod.rs +++ b/src/op/mod.rs @@ -9,11 +9,12 @@ // as operators. They were originally done differently because there wasn't // yet a LazyOperator concept. +use once_cell::sync::Lazy; use phf::phf_map; use serde_json::{Map, Value}; use std::collections::HashMap; use std::fmt; -use std::sync::{Once, RwLock}; +use std::sync::RwLock; use crate::error::Error; use crate::value::to_number_value; @@ -248,16 +249,11 @@ impl CustomOperatorRegistry { } } -static CUSTOM_OPERATOR_REGISTRY_INIT: Once = Once::new(); -static mut CUSTOM_OPERATOR_REGISTRY: Option = None; +static CUSTOM_OPERATOR_REGISTRY: Lazy = + Lazy::new(CustomOperatorRegistry::new); pub fn get_custom_operator_registry() -> &'static CustomOperatorRegistry { - unsafe { - CUSTOM_OPERATOR_REGISTRY_INIT.call_once(|| { - CUSTOM_OPERATOR_REGISTRY = Some(CustomOperatorRegistry::new()); - }); - CUSTOM_OPERATOR_REGISTRY.as_ref().unwrap() - } + &CUSTOM_OPERATOR_REGISTRY } #[derive(Debug, Clone)] From 77c66e9c83726e1da47607ab9af70caa9dd1bfdc Mon Sep 17 00:00:00 2001 From: Kevin Altman Date: Wed, 17 Sep 2025 14:17:19 -0400 Subject: [PATCH 5/5] removes warning this PR introduced --- src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index b3b8cbb..2e3f0d9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1703,7 +1703,7 @@ mod jsonlogic_tests { // Test overriding built-in operators add_operation( "==", - |args| { + |_args| { // Custom equality that always returns false Ok(json!(false)) },