mirror of
https://github.com/gorules/zen.git
synced 2026-10-04 00:02:18 +00:00
feat: expose expressions through bindings; (#86)
* feat: expose expressions through bindings; * fix fmt
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
Vendored
+2
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
mod decision;
|
||||
mod engine;
|
||||
mod expression;
|
||||
mod loader;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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" }
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user