clarkmcc / cel-rust

Common Expression Language interpreter written in Rust
https://crates.io/crates/cel-interpreter
MIT License
377 stars 21 forks source link

Magic function parameters #11

Closed clarkmcc closed 1 year ago

clarkmcc commented 1 year ago

Originally this issue started with utilities for making it easier to write functions by providing function resolvers (shown below) but eventually evolved into Axum-style magic function parameters.


Function value resolvers

Instead of

let expr = ftx.arg(0)?;
let value = ftx.resolve(&expr)?;
// or
let value = ftx.resolve_arg(0)?;

Resolvers allow us to do things like

let value = ftx.resolve(Argument(0))?;

and hopefully a few more other handy things all from the same resolve function as this PR goes along.

The previous example was for single expression resolution. You can also resolve multiple. Instead of

let values = ftx.resolve_all();

we can do this using the same resolve function.

let values = ftx.resolve(Arguments)?;

Target value resolvers

Previously you had to do

Ok(match ftx.target()? {
        Value::String(v) => v.parse::<f64>().map(Value::Float).unwrap(),
        ...
})

to unpack the target. Now you can ask for specific types by

let target = ftx.target::<Rc<String>>()?;

or just this if you need the old behavior.

Ok(match ftx.target::<Value>()? {
        Value::String(v) => v.parse::<f64>().map(Value::Float).unwrap(),
        ...
})
clarkmcc commented 1 year ago

Explored the idea of generic type resolvers with const generics but they're not going to be compatible with more advanced resolvers that require more parameters. They're probably not that much easier to read anyways.

fn add(ftx: FunctionContext) -> ResolveResult {
    let a = ftx.resolve::<Argument<0>>()?;
    let b = ftx.resolve::<Argument<1>>()?;
    Ok(a + b)
}

// instead of

fn add(ftx: FunctionContext) -> ResolveResult {
    let a = ftx.resolve(Argument(0))?;
    let b = ftx.resolve(Argument(1))?;
    Ok(a + b)
}
clarkmcc commented 1 year ago

Now that I think about it, I kind of want to just cut out the middle man and support Axum-style functions. Here's the foundations coming together

fn greet(name: Rc<String>) -> String {
    format!("Hello, {}!", name)
}

trait Callable<T> {
    fn call(&self, ftx: FunctionContext) -> Result<Value>;
}

trait FromValue {
    fn from(expr: &Value) -> Result<Self>
    where
        Self: Sized;
}

trait IntoResolveResult {
    fn into_resolve_result(self) -> ResolveResult;
}

impl<F, T1, T2, T3> Callable<(T1, T2)> for F
    where
        F: Fn(T1, T2) -> T3 + 'static,
        T1: FromValue,
        T2: FromValue,
        T3: IntoResolveResult,
{
    fn call(&self, ftx: FunctionContext) -> ResolveResult {
        let arg1 = ftx.resolve(Argument(0))?;
        let arg2 = ftx.resolve(Argument(1))?;
        let t1 = T1::from(&arg1)?;
        let t2 = T2::from(&arg2)?;
        self(t1, t2).into_resolve_result()
    }
}
clarkmcc commented 1 year ago

Can't figure out a way to store the Callables somewhere and call them later... gonna give my mind a break

clarkmcc commented 1 year ago

Okay, making progress. So we can support a much more ergonomic function syntax now. The following functions can be registered and are supported

// Primitive arguments
fn add(a: i32, b: i32) -> i32 {
    a + b
}

// Falible operations
fn try() -> Result<Value> {
  // maybe fail
}

// Methods on types
fn starts_with(Target(target): Target<Rc<String>>, prefix: Rc<String>) -> bool {
    target.starts_with(prefix.as_str())
}
clarkmcc commented 1 year ago

Tests are working. I want to benchmark a few cloning compromises I had to make after I beat my head against lifetimes this week.

clarkmcc commented 1 year ago

Benchmarks on function calls regressed 10-30% but are still generally in the nanosecond range which is far better performance then cel-go. criterion.zip