feat: expose expressions through bindings; (#86)

* feat: expose expressions through bindings;

* fix fmt
This commit is contained in:
stefan-gorules
2023-09-14 16:07:55 +02:00
committed by GitHub
parent 1cef5bb263
commit 1b0ff9f6f1
14 changed files with 241 additions and 44 deletions
+2
View File
@@ -1,4 +1,5 @@
[workspace]
resolver = "2"
members = [
"core/*",
"bindings/*"
@@ -18,6 +19,7 @@ petgraph = "0.6.4"
phf = "0.11.1"
regex = "1.9.1"
regex-lite = "0.1.0"
serde = "1.0.188"
serde_json = "1.0.96"
hashbrown = "0.13.2"
rust_decimal = "1.29.1"
+1
View File
@@ -15,6 +15,7 @@ napi-derive = "2.13.0"
serde_json = "1.0.96"
futures = "0.3.28"
zen-engine = { path = "../../core/engine" }
zen-expression = { path = "../../core/expression" }
[build-dependencies]
napi-build = "2.0.1"
+2
View File
@@ -10,6 +10,8 @@ export interface ZenEvaluateOptions {
export interface ZenEngineOptions {
loader?: (key: string) => Promise<Buffer>
}
export function evaluateExpression(expression: string, context?: any | undefined | null): Promise<any>
export function evaluateUnaryExpression(expression: string, context: any): Promise<any>
export class ZenDecision {
constructor()
evaluate(context: any, opts?: ZenEvaluateOptions | undefined | null): Promise<any>
+3 -1
View File
@@ -246,7 +246,9 @@ if (!nativeBinding) {
throw new Error(`Failed to load native binding`)
}
const { ZenDecision, ZenEngine } = nativeBinding
const { ZenDecision, ZenEngine, evaluateExpression, evaluateUnaryExpression } = nativeBinding
module.exports.ZenDecision = ZenDecision
module.exports.ZenEngine = ZenEngine
module.exports.evaluateExpression = evaluateExpression
module.exports.evaluateUnaryExpression = evaluateUnaryExpression
+50
View File
@@ -0,0 +1,50 @@
use napi::anyhow::anyhow;
use napi_derive::napi;
use serde_json::Value;
use zen_expression::isolate::Isolate;
#[allow(dead_code)]
#[napi]
pub async fn evaluate_expression(
expression: String,
context: Option<Value>,
) -> napi::Result<Value> {
let result: Value = napi::tokio::spawn(async move {
let isolate = Isolate::default();
if let Some(ctx) = context {
isolate.inject_env(&ctx);
}
isolate.run_standard(expression.as_str())
})
.await
.map_err(|_| anyhow!("Hook timed out"))?
.map_err(|e| anyhow!(serde_json::to_string(&e).unwrap_or_else(|_| e.to_string())))?;
Ok(result)
}
#[allow(dead_code)]
#[napi]
pub async fn evaluate_unary_expression(expression: String, context: Value) -> napi::Result<Value> {
let Some(context_object) = context.as_object() else {
return Err(anyhow!("Context must be an object").into());
};
if !context_object.contains_key("$") {
return Err(anyhow!("Context must contain '$' reference.").into());
}
let result: Value = napi::tokio::spawn(async move {
let isolate = Isolate::default();
isolate.inject_env(&context);
isolate.run_unary(expression.as_str())
})
.await
.map_err(|_| anyhow!("Hook timed out"))?
.map_err(|e| anyhow!(serde_json::to_string(&e).unwrap_or_else(|_| e.to_string())))?;
Ok(result)
}
+1
View File
@@ -1,3 +1,4 @@
mod decision;
mod engine;
mod expression;
mod loader;
+61 -32
View File
@@ -1,4 +1,4 @@
import {ZenEngine} from "../index";
import {ZenEngine, evaluateExpression, evaluateUnaryExpression} from "../index";
import fs from 'fs/promises';
import path from 'path';
import {describe, expect, it, jest} from "@jest/globals";
@@ -10,43 +10,72 @@ const loader = async (key: string) => fs.readFile(path.join(testDataRoot, key))
jest.useRealTimers();
describe('ZenEngine', () => {
it('Evaluates decisions using loader', async () => {
const engine = new ZenEngine({
loader
});
it('Evaluates decisions using loader', async () => {
const engine = new ZenEngine({
loader
});
const r1 = await engine.evaluate('function.json', {input: 5});
const r2 = await engine.evaluate('table.json', {input: 2});
const r3 = await engine.evaluate('table.json', {input: 12});
const r1 = await engine.evaluate('function.json', {input: 5});
const r2 = await engine.evaluate('table.json', {input: 2});
const r3 = await engine.evaluate('table.json', {input: 12});
expect(r1.result.output).toEqual(10);
expect(r2.result.output).toEqual(0);
expect(r3.result.output).toEqual(10);
}, 10000);
expect(r1.result.output).toEqual(10);
expect(r2.result.output).toEqual(0);
expect(r3.result.output).toEqual(10);
}, 10000);
it('Evaluates decisions using getDecision', async () => {
const engine = new ZenEngine({
loader,
});
it('Evaluates decisions using getDecision', async () => {
const engine = new ZenEngine({
loader,
});
const functionDecision = await engine.getDecision('function.json');
const tableDecision = await engine.getDecision('table.json');
const functionDecision = await engine.getDecision('function.json');
const tableDecision = await engine.getDecision('table.json');
const r1 = await functionDecision.evaluate({input: 10});
const r2 = await tableDecision.evaluate({input: 5});
const r3 = await tableDecision.evaluate({input: 12});
const r1 = await functionDecision.evaluate({input: 10});
const r2 = await tableDecision.evaluate({input: 5});
const r3 = await tableDecision.evaluate({input: 12});
expect(r1.result.output).toEqual(20);
expect(r2.result.output).toEqual(0);
expect(r3.result.output).toEqual(10);
}, 10000);
expect(r1.result.output).toEqual(20);
expect(r2.result.output).toEqual(0);
expect(r3.result.output).toEqual(10);
}, 10000);
it('Creates a decision from contents', async () => {
const engine = new ZenEngine();
const functionContent = await fs.readFile(path.join(testDataRoot, 'function.json'));
const functionDecision = engine.createDecision(functionContent);
it('Creates a decision from contents', async () => {
const engine = new ZenEngine();
const functionContent = await fs.readFile(path.join(testDataRoot, 'function.json'));
const functionDecision = engine.createDecision(functionContent);
const r = await functionDecision.evaluate({input: 15});
expect(r.result.output).toEqual(30);
}, 10000)
const r = await functionDecision.evaluate({input: 15});
expect(r.result.output).toEqual(30);
}, 10000)
})
describe('Expressions', () => {
it('Evaluates standard expressions', async () => {
const expressions = [
{expression: '1 + 1', result: 2},
{expression: 'a > b', context: {a: 5, b: 3}, result: true},
{expression: 'sum(a)', context: {a: [1, 2, 3, 4]}, result: 10},
{expression: 'contains("some", "none")', result: false},
{expression: 'matches("test@email.com", "\\w+@\\w+\\.com")', result: true},
];
for (const {expression, result, context} of expressions) {
expect(await evaluateExpression(expression, context)).toEqual(result);
}
});
it('Evaluates unary expressions', async () => {
const expressions = [
{expression: '>= 5', context: {$: 5}, result: true},
{expression: '< 5', context: {$: 5}, result: false},
{expression: '"FR", "ES"', context: {$: 'GB'}, result: false},
{expression: 'contains($, "some")', context: {$: 'some-string'}, result: true},
];
for (const {expression, result, context} of expressions) {
expect(await evaluateUnaryExpression(expression, context)).toEqual(result);
}
});
});
+4 -3
View File
@@ -12,9 +12,10 @@ crate-type = ["cdylib"]
[dependencies]
async-trait = "0.1.68"
anyhow = "1.0.71"
pyo3 = { version = "0.18.3", features = ["anyhow", "serde"] }
pythonize = "0.18.0"
pyo3 = { version = "0.19.2", features = ["anyhow", "serde"] }
pythonize = "0.19.0"
serde = "1.0.163"
serde_json = "1.0.96"
futures = "0.3.28"
zen-engine = { path = "../../core/engine" }
zen-engine = { path = "../../core/engine" }
zen-expression = { path = "../../core/expression" }
+8 -1
View File
@@ -28,7 +28,6 @@ class ZenEngine(unittest.TestCase):
r2 = tableDecision.evaluate({"input": 5})
r3 = tableDecision.evaluate({"input": 12})
print(r1)
self.assertEqual(r1["result"]["output"], 20)
self.assertEqual(r2["result"]["output"], 0)
self.assertEqual(r3["result"]["output"], 10)
@@ -42,5 +41,13 @@ class ZenEngine(unittest.TestCase):
r = functionDecision.evaluate({"input": 15})
self.assertEqual(r["result"]["output"], 30)
def test_evaluate_expression(self):
result = zen.evaluate_expression("sum(a)", { "a": [1, 2, 3, 4] })
self.assertEqual(result, 10)
def test_evaluate_unary_expression(self):
result = zen.evaluate_unary_expression("'FR', 'ES', 'GB'", { "$": "GB" })
self.assertEqual(result, True)
# run the test
unittest.main()
+58
View File
@@ -0,0 +1,58 @@
use anyhow::{anyhow, Context};
use pyo3::types::PyDict;
use pyo3::{pyfunction, PyObject, PyResult, Python, ToPyObject};
use pythonize::depythonize;
use serde_json::Value;
use zen_expression::isolate::Isolate;
use crate::value::PyValue;
#[pyfunction]
pub fn evaluate_expression(
py: Python,
expression: String,
ctx: Option<&PyDict>,
) -> PyResult<PyObject> {
let isolate = Isolate::default();
let ctx = ctx
.map(|ctx| depythonize(ctx))
.transpose()
.context("Failed to convert context")?;
if let Some(env_value) = ctx {
isolate.inject_env(&env_value);
}
let result = isolate
.run_standard(expression.as_str())
.map_err(|e| anyhow!(serde_json::to_string(&e).unwrap_or_else(|_| e.to_string())))?;
Ok(PyValue(result).to_object(py))
}
#[pyfunction]
pub fn evaluate_unary_expression(
py: Python,
expression: String,
ctx: &PyDict,
) -> PyResult<PyObject> {
let env_value: Value = depythonize(ctx).context("Failed to convert context")?;
let Some(env_object) = env_value.as_object() else {
return Err(anyhow!("Context must be an object").into());
};
if !env_object.contains_key("$") {
return Err(anyhow!("Context must contain '$' reference.").into());
}
let isolate = Isolate::default();
isolate.inject_env(&env_value);
let result = isolate
.run_unary(expression.as_str())
.map_err(|e| anyhow!(serde_json::to_string(&e).unwrap_or_else(|_| e.to_string())))?;
Ok(PyValue(result).to_object(py))
}
+6 -1
View File
@@ -1,10 +1,12 @@
use crate::decision::PyZenDecision;
use crate::engine::PyZenEngine;
use crate::expression::{evaluate_expression, evaluate_unary_expression};
use pyo3::types::PyModule;
use pyo3::{pymodule, PyResult, Python};
use pyo3::{pymodule, wrap_pyfunction, PyResult, Python};
mod decision;
mod engine;
mod expression;
mod loader;
mod value;
@@ -12,5 +14,8 @@ mod value;
fn zen(_py: Python, m: &PyModule) -> PyResult<()> {
m.add_class::<PyZenEngine>()?;
m.add_class::<PyZenDecision>()?;
m.add_function(wrap_pyfunction!(evaluate_expression, m)?)?;
m.add_function(wrap_pyfunction!(evaluate_unary_expression, m)?)?;
Ok(())
}
+6 -6
View File
@@ -11,22 +11,22 @@ repository = "https://github.com/gorules/zen.git"
doctest = false
[dependencies]
async-recursion = "1.0.4"
async-recursion = "1.0.5"
anyhow = { workspace = true }
thiserror = { workspace = true }
async-trait = { workspace = true }
bincode = { workspace = true, optional = true }
petgraph = { workspace = true }
serde_json = { workspace = true, features = ["arbitrary_precision"] }
serde = { version = "1.0.163", features = ["derive"] }
serde_v8 = { version = "0.103.0" }
once_cell = { version = "1.17.2" }
serde = { workspace = true, features = ["derive"] }
serde_v8 = { version = "0.121.0" }
once_cell = { version = "1.18.0" }
futures = "0.3.28"
v8 = { version = "0.74.0" }
v8 = { version = "0.76.0" }
zen-expression = { path = "../expression", version = "0.10.0" }
[dev-dependencies]
tokio = { version = "1.28.2", features = ["rt", "macros"] }
tokio = { version = "1.32.0", features = ["rt", "macros"] }
criterion = { workspace = true, features = ["async_futures"] }
[features]
+1
View File
@@ -18,6 +18,7 @@ once_cell = { workspace = true }
phf = { workspace = true, features = ["macros"] }
regex = { workspace = true, optional = true }
regex-lite = { workspace = true, optional = true }
serde = { workspace = true }
serde_json = { workspace = true, features = ["arbitrary_precision"] }
thiserror = { workspace = true }
rust_decimal = { workspace = true, features = ["maths-nopanic"] }
+38
View File
@@ -7,6 +7,8 @@ use std::rc::Rc;
use ahash::AHasher;
use bumpalo::Bump;
use serde::ser::SerializeMap;
use serde::{Serialize, Serializer};
use serde_json::Value;
use thiserror::Error;
@@ -43,6 +45,42 @@ pub enum IsolateError {
ReferenceError,
}
impl Serialize for IsolateError {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut map = serializer.serialize_map(None)?;
match &self {
IsolateError::ReferenceError => {
map.serialize_entry("type", "referenceError")?;
}
IsolateError::ValueCastError => {
map.serialize_entry("type", "valueCastError")?;
}
IsolateError::LexerError { source } => {
map.serialize_entry("type", "lexerError")?;
map.serialize_entry("source", source.to_string().as_str())?;
}
IsolateError::ParserError { source } => {
map.serialize_entry("type", "parserError")?;
map.serialize_entry("source", source.to_string().as_str())?;
}
IsolateError::CompilerError { source } => {
map.serialize_entry("type", "compilerError")?;
map.serialize_entry("source", source.to_string().as_str())?;
}
IsolateError::VMError { source } => {
map.serialize_entry("type", "vmError")?;
map.serialize_entry("source", source.to_string().as_str())?;
}
}
map.end()
}
}
#[derive(Debug)]
pub struct Isolate<'a> {
lexer: Lexer<'a>,