feat: passthrough nodes (#261)

* feat: passthrough nodes

* update bindings

* update model

* update exclusion

* fix merge strategy

* fix

* fix merge

* add tests

* fix walk

* fix merge

* fix

* fix: add tests

* fix types

---------

Co-authored-by: Ivan Miletic <vnmiletic@gmail.com>
This commit is contained in:
stefan-gorules
2024-10-23 15:59:31 +02:00
committed by GitHub
co-authored by Ivan Miletic
parent 8a7e72bf53
commit 47812143a0
38 changed files with 1438 additions and 251 deletions
+2 -2
View File
@@ -52,8 +52,8 @@ jobs:
- uses: actions/checkout@v3
- name: Install Rust
run: rustup install 1.80
- run: cargo test --workspace --all-features --exclude zen-ffi --exclude zen-nodejs
- run: cargo test --workspace --all-features --exclude zen-ffi --exclude zen-nodejs --release
- run: cargo test --workspace --all-features --exclude zen-ffi --exclude zen-nodejs --exclude zen-python
- run: cargo test --workspace --all-features --exclude zen-ffi --exclude zen-nodejs --exclude zen-python --release
build:
name: cargo +${{ matrix.rust }} build
+1 -1
View File
@@ -24,7 +24,7 @@ impl Default for DynamicCustomNode {
}
impl CustomNodeAdapter for DynamicCustomNode {
async fn handle(&self, request: CustomNodeRequest<'_>) -> NodeResult {
async fn handle(&self, request: CustomNodeRequest) -> NodeResult {
match self {
DynamicCustomNode::Noop(cn) => cn.handle(request).await,
DynamicCustomNode::Native(cn) => cn.handle(request).await,
+1 -1
View File
@@ -50,7 +50,7 @@ impl GoCustomNode {
}
impl CustomNodeAdapter for GoCustomNode {
async fn handle(&self, request: CustomNodeRequest<'_>) -> NodeResult {
async fn handle(&self, request: CustomNodeRequest) -> NodeResult {
let Some(handler) = self.handler else {
return Err(anyhow!("go handler not found"));
};
+1 -1
View File
@@ -49,7 +49,7 @@ impl NativeCustomNode {
}
impl CustomNodeAdapter for NativeCustomNode {
async fn handle(&self, request: CustomNodeRequest<'_>) -> NodeResult {
async fn handle(&self, request: CustomNodeRequest) -> NodeResult {
let Ok(request_value) = serde_json::to_string(&request) else {
return Err(anyhow!("failed to serialize request json"));
};
+2 -1
View File
@@ -1,2 +1,3 @@
*.node
*.internal.js
*.internal.js
temp.d.ts
+1 -1
View File
@@ -71,7 +71,7 @@
"url": "git+https://github.com/gorules/zen.git"
},
"scripts": {
"build": "napi build --dts false --platform --release",
"build": "napi build --dts temp.d.ts --platform --release",
"build:debug": "napi build --platform --js index.js --dts index.d.ts",
"watch": "cargo watch --ignore '{index.js,index.d.ts}' -- npm run build:debug",
"test": "jest",
+1 -1
View File
@@ -21,7 +21,7 @@ impl CustomNode {
}
impl CustomNodeAdapter for CustomNode {
async fn handle(&self, request: CustomNodeRequest<'_>) -> NodeResult {
async fn handle(&self, request: CustomNodeRequest) -> NodeResult {
let Some(function) = &self.function else {
return Err(anyhow!("Custom function is undefined"));
};
+9 -9
View File
@@ -1,9 +1,9 @@
use std::collections::HashMap;
use json_dotpath::DotPaths;
use napi::anyhow::{anyhow, Context};
use napi_derive::napi;
use serde_json::Value;
use std::collections::HashMap;
use std::sync::Arc;
use zen_engine::handler::custom_node_adapter::CustomDecisionNode;
use zen_engine::{DecisionGraphResponse, DecisionGraphTrace};
@@ -65,16 +65,16 @@ pub struct DecisionNode {
pub id: String,
pub name: String,
pub kind: String,
pub config: Value,
pub config: Arc<Value>,
}
impl From<CustomDecisionNode<'_>> for DecisionNode {
fn from(value: CustomDecisionNode<'_>) -> Self {
impl From<CustomDecisionNode> for DecisionNode {
fn from(value: CustomDecisionNode) -> Self {
Self {
id: value.id.to_string(),
name: value.name.to_string(),
kind: value.kind.to_string(),
config: value.config.clone(),
id: value.id,
name: value.name,
kind: value.kind,
config: value.config,
}
}
}
+1 -1
View File
@@ -32,7 +32,7 @@ fn extract_custom_node_response(py: Python<'_>, result: PyObject) -> NodeResult
}
impl CustomNodeAdapter for PyCustomNode {
async fn handle(&self, request: CustomNodeRequest<'_>) -> NodeResult {
async fn handle(&self, request: CustomNodeRequest) -> NodeResult {
let Some(callable) = &self.0 else {
return Err(anyhow!("Custom node handler not provided"));
};
+7 -9
View File
@@ -3,6 +3,7 @@ use json_dotpath::DotPaths;
use pyo3::{pyclass, pymethods, PyObject, PyResult, Python, ToPyObject};
use serde::Serialize;
use serde_json::Value;
use std::sync::Arc;
use crate::value::{value_to_object, PyValue};
use zen_engine::handler::custom_node_adapter::{
@@ -16,15 +17,15 @@ struct CustomDecisionNode {
pub id: String,
pub name: String,
pub kind: String,
pub config: Value,
pub config: Arc<Value>,
}
impl From<BaseCustomDecisionNode<'_>> for CustomDecisionNode {
impl From<BaseCustomDecisionNode> for CustomDecisionNode {
fn from(value: BaseCustomDecisionNode) -> Self {
Self {
id: value.id.to_string(),
name: value.name.to_string(),
kind: value.kind.to_string(),
id: value.id,
name: value.name,
kind: value.kind,
config: value.config.clone(),
}
}
@@ -42,10 +43,7 @@ pub struct PyNodeRequest {
}
impl PyNodeRequest {
pub fn from_request(
py: Python,
value: CustomNodeRequest<'_>,
) -> pythonize::Result<PyNodeRequest> {
pub fn from_request(py: Python, value: CustomNodeRequest) -> pythonize::Result<PyNodeRequest> {
let inner_node = value.node.into();
let node_val = serde_json::to_value(&inner_node).unwrap();
+1 -1
View File
@@ -17,7 +17,7 @@ thiserror = { workspace = true }
bincode = { workspace = true, optional = true }
petgraph = { workspace = true }
serde_json = { workspace = true, features = ["arbitrary_precision"] }
serde = { workspace = true, features = ["derive"] }
serde = { workspace = true, features = ["derive", "rc"] }
once_cell = { workspace = true }
json_dotpath = { workspace = true }
rust_decimal = { workspace = true, features = ["maths-nopanic"] }
+2 -2
View File
@@ -82,12 +82,12 @@ where
options: EvaluationOptions,
) -> Result<DecisionGraphResponse, Box<EvaluationError>> {
let mut decision_graph = DecisionGraph::try_new(DecisionGraphConfig {
content: self.content.clone(),
max_depth: options.max_depth.unwrap_or(5),
trace: options.trace.unwrap_or_default(),
loader: Arc::new(CachedLoader::from(self.loader.clone())),
adapter: self.adapter.clone(),
iteration: 0,
content: &self.content,
})?;
Ok(decision_graph.evaluate(context).await?)
@@ -95,12 +95,12 @@ where
pub fn validate(&self) -> Result<(), DecisionGraphValidationError> {
let decision_graph = DecisionGraph::try_new(DecisionGraphConfig {
content: self.content.clone(),
max_depth: 1,
trace: false,
loader: Arc::new(CachedLoader::from(self.loader.clone())),
adapter: self.adapter.clone(),
iteration: 0,
content: &self.content,
})?;
decision_graph.validate()
+21 -22
View File
@@ -4,44 +4,43 @@ use anyhow::anyhow;
use json_dotpath::DotPaths;
use serde::Serialize;
use serde_json::Value;
use std::ops::Deref;
use std::sync::Arc;
use zen_expression::variable::Variable;
use zen_tmpl::TemplateRenderError;
pub trait CustomNodeAdapter {
fn handle(
&self,
request: CustomNodeRequest<'_>,
) -> impl std::future::Future<Output = NodeResult>;
fn handle(&self, request: CustomNodeRequest) -> impl std::future::Future<Output = NodeResult>;
}
#[derive(Default, Debug)]
pub struct NoopCustomNode;
impl CustomNodeAdapter for NoopCustomNode {
async fn handle(&self, _: CustomNodeRequest<'_>) -> NodeResult {
async fn handle(&self, _: CustomNodeRequest) -> NodeResult {
Err(anyhow!("Custom node handler not provided"))
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CustomNodeRequest<'a> {
pub struct CustomNodeRequest {
pub input: Variable,
pub node: CustomDecisionNode<'a>,
pub node: CustomDecisionNode,
}
impl<'a> TryFrom<&'a NodeRequest<'a>> for CustomNodeRequest<'a> {
impl TryFrom<NodeRequest> for CustomNodeRequest {
type Error = ();
fn try_from(value: &'a NodeRequest<'a>) -> Result<Self, Self::Error> {
fn try_from(value: NodeRequest) -> Result<Self, Self::Error> {
Ok(Self {
input: value.input.clone(),
node: value.node.try_into()?,
node: value.node.deref().try_into()?,
})
}
}
impl<'a> CustomNodeRequest<'a> {
impl CustomNodeRequest {
pub fn get_field(&self, path: &str) -> Result<Option<Variable>, TemplateRenderError> {
let Some(selected_value) = self.get_field_raw(path) else {
return Ok(None);
@@ -62,26 +61,26 @@ impl<'a> CustomNodeRequest<'a> {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CustomDecisionNode<'a> {
pub id: &'a str,
pub name: &'a str,
pub kind: &'a str,
pub config: &'a Value,
pub struct CustomDecisionNode {
pub id: String,
pub name: String,
pub kind: String,
pub config: Arc<Value>,
}
impl<'a> TryFrom<&'a DecisionNode> for CustomDecisionNode<'a> {
impl TryFrom<&DecisionNode> for CustomDecisionNode {
type Error = ();
fn try_from(value: &'a DecisionNode) -> Result<Self, Self::Error> {
fn try_from(value: &DecisionNode) -> Result<Self, Self::Error> {
let DecisionNodeKind::CustomNode { content } = &value.kind else {
return Err(());
};
Ok(Self {
id: &value.id,
name: &value.name,
kind: &content.kind,
config: &content.config,
id: value.id.clone(),
name: value.name.clone(),
kind: content.kind.clone(),
config: content.config.clone(),
})
}
}
+22 -68
View File
@@ -3,15 +3,13 @@ use crate::handler::function::function::Function;
use crate::handler::graph::{DecisionGraph, DecisionGraphConfig};
use crate::handler::node::{NodeRequest, NodeResponse, NodeResult};
use crate::loader::DecisionLoader;
use crate::model::{DecisionNodeKind, TransformExecutionMode};
use anyhow::{anyhow, Context};
use serde_json::Value;
use crate::model::DecisionNodeKind;
use anyhow::anyhow;
use std::future::Future;
use std::ops::Deref;
use std::pin::Pin;
use std::rc::Rc;
use std::sync::Arc;
use zen_expression::{Isolate, Variable};
use tokio::sync::Mutex;
pub struct DecisionHandler<L: DecisionLoader + 'static, A: CustomNodeAdapter + 'static> {
trace: bool,
@@ -40,7 +38,7 @@ impl<L: DecisionLoader + 'static, A: CustomNodeAdapter + 'static> DecisionHandle
pub fn handle<'s, 'arg, 'recursion>(
&'s self,
request: &'arg NodeRequest<'_>,
request: NodeRequest,
) -> Pin<Box<dyn Future<Output = NodeResult> + 'recursion>>
where
's: 'recursion,
@@ -52,11 +50,9 @@ impl<L: DecisionLoader + 'static, A: CustomNodeAdapter + 'static> DecisionHandle
_ => Err(anyhow!("Unexpected node type")),
}?;
let mut isolate = Isolate::new();
let sub_decision = self.loader.load(&content.key).await?;
let mut sub_tree = DecisionGraph::try_new(DecisionGraphConfig {
content: sub_decision.deref(),
let sub_tree = DecisionGraph::try_new(DecisionGraphConfig {
content: sub_decision,
max_depth: self.max_depth,
loader: self.loader.clone(),
adapter: self.adapter.clone(),
@@ -65,69 +61,27 @@ impl<L: DecisionLoader + 'static, A: CustomNodeAdapter + 'static> DecisionHandle
})?
.with_function(self.js_function.clone());
let input_data = match &content.transform_attributes.input_field {
None => request.input.clone(),
Some(input_field) => {
isolate.set_environment(request.input.clone());
isolate.run_standard(input_field.as_str())?
}
};
let sub_tree_mutex = Arc::new(Mutex::new(sub_tree));
let mut trace_data: Option<Value> = None;
let mut output_data = match &content.transform_attributes.execution_mode {
TransformExecutionMode::Single => {
let response = sub_tree
.evaluate(request.input.clone())
.await
.map_err(|e| e.source)?;
content
.transform_attributes
.run_with(request.input, |input| {
let sub_tree_mutex = sub_tree_mutex.clone();
if self.trace {
trace_data.replace(
serde_json::to_value(response.trace)
.context("Failed to serialize trace")?,
);
}
async move {
let mut sub_tree_ref = sub_tree_mutex.lock().await;
response.result
}
TransformExecutionMode::Loop => {
let input_array_ref = input_data.as_array().context("Expected an array")?;
let input_array = input_array_ref.borrow();
let mut output_array = Vec::with_capacity(input_array.len());
let mut trace_datum = Vec::with_capacity(input_array.len());
for input in input_array.iter() {
let response = sub_tree
.evaluate(input.clone())
sub_tree_ref
.evaluate(input)
.await
.map_err(|e| e.source)?;
output_array.push(response.result);
trace_datum.push(response.trace);
.map(|r| NodeResponse {
output: r.result,
trace_data: serde_json::to_value(r.trace).ok(),
})
.map_err(|e| e.source)
}
if self.trace {
trace_data.replace(
serde_json::to_value(trace_datum)
.context("Failed to parse trace data")?,
);
}
Variable::from_array(output_array)
}
};
if let Some(output_path) = &content.transform_attributes.output_path {
let new_output_data = Variable::empty_object();
new_output_data.dot_insert(output_path.as_str(), output_data);
output_data = new_output_data;
}
Ok(NodeResponse {
output: output_data,
trace_data,
})
})
.await
})
}
}
+48 -17
View File
@@ -1,15 +1,16 @@
use crate::handler::node::{NodeRequest, NodeResponse, NodeResult};
use crate::model::DecisionNodeKind;
use crate::model::{DecisionNodeKind, ExpressionNodeContent};
use ahash::{HashMap, HashMapExt};
use std::sync::Arc;
use anyhow::{anyhow, Context};
use serde::Serialize;
use tokio::sync::Mutex;
use zen_expression::variable::Variable;
use zen_expression::Isolate;
pub struct ExpressionHandler<'a> {
pub struct ExpressionHandler {
trace: bool,
isolate: Isolate<'a>,
}
#[derive(Debug, Serialize)]
@@ -17,26 +18,62 @@ struct ExpressionTrace {
result: String,
}
impl<'a> ExpressionHandler<'a> {
impl ExpressionHandler {
pub fn new(trace: bool) -> Self {
Self {
trace,
isolate: Isolate::new(),
}
Self { trace }
}
pub async fn handle(&mut self, request: &'a NodeRequest<'_>) -> NodeResult {
pub async fn handle(&mut self, request: NodeRequest) -> NodeResult {
let content = match &request.node.kind {
DecisionNodeKind::ExpressionNode { content } => Ok(content),
_ => Err(anyhow!("Unexpected node type")),
}?;
let inner_handler_mutex = Arc::new(Mutex::new(ExpressionHandlerInner::new(self.trace)));
content
.transform_attributes
.run_with(request.input, |input| {
let inner_handler_mutex = inner_handler_mutex.clone();
async move {
let mut inner_handler_ref = inner_handler_mutex.lock().await;
inner_handler_ref.handle(input, content).await
}
})
.await
}
}
struct ExpressionHandlerInner<'a> {
isolate: Isolate<'a>,
trace: bool,
}
impl<'a> ExpressionHandlerInner<'a> {
pub fn new(trace: bool) -> Self {
Self {
isolate: Isolate::new(),
trace,
}
}
async fn handle(&mut self, input: Variable, content: &'a ExpressionNodeContent) -> NodeResult {
let result = Variable::empty_object();
let mut trace_map = self.trace.then(|| HashMap::<&str, ExpressionTrace>::new());
self.isolate.set_environment(request.input.depth_clone(1));
self.isolate.set_environment(input.depth_clone(1));
for expression in &content.expressions {
let value = self.evaluate_expression(&expression.value)?;
if expression.key.is_empty() || expression.value.is_empty() {
continue;
}
let value = self
.isolate
.run_standard(&expression.value)
.with_context(|| {
format!(r#"Failed to evaluate expression: "{}""#, &expression.value)
})?;
if let Some(tmap) = &mut trace_map {
tmap.insert(
&expression.key,
@@ -66,10 +103,4 @@ impl<'a> ExpressionHandler<'a> {
.context("Failed to serialize trace data")?,
})
}
fn evaluate_expression(&mut self, expression: &'a str) -> anyhow::Result<Variable> {
self.isolate
.run_standard(expression)
.with_context(|| format!(r#"Failed to evaluate expression: "{expression}""#))
}
}
+1 -1
View File
@@ -43,7 +43,7 @@ impl FunctionHandler {
}
}
pub async fn handle(&self, request: &NodeRequest<'_>) -> NodeResult {
pub async fn handle(&self, request: NodeRequest) -> NodeResult {
let content = match &request.node.kind {
DecisionNodeKind::FunctionNode { content } => match content {
FunctionNodeContent::Version2(content) => Ok(content),
@@ -58,7 +58,7 @@ impl<Loader: DecisionLoader + 'static, Adapter: CustomNodeAdapter + 'static> Run
let load_result = loader.load(key.as_str()).await;
let decision_content = load_result.or_throw(&ctx)?;
let mut sub_tree = DecisionGraph::try_new(DecisionGraphConfig {
content: &decision_content,
content: decision_content,
max_depth,
loader,
adapter,
+1 -1
View File
@@ -22,7 +22,7 @@ impl FunctionHandler {
Self { trace, runtime }
}
pub async fn handle(&self, request: &NodeRequest<'_>) -> NodeResult {
pub async fn handle(&self, request: NodeRequest) -> NodeResult {
let content = match &request.node.kind {
DecisionNodeKind::FunctionNode { content } => match content {
FunctionNodeContent::Version1(content) => Ok(content),
+25 -30
View File
@@ -25,8 +25,8 @@ use std::time::Instant;
use thiserror::Error;
use zen_expression::variable::Variable;
pub struct DecisionGraph<'a, L: DecisionLoader + 'static, A: CustomNodeAdapter + 'static> {
graph: StableDiDecisionGraph<'a>,
pub struct DecisionGraph<L: DecisionLoader + 'static, A: CustomNodeAdapter + 'static> {
graph: StableDiDecisionGraph,
adapter: Arc<A>,
loader: Arc<L>,
trace: bool,
@@ -35,18 +35,18 @@ pub struct DecisionGraph<'a, L: DecisionLoader + 'static, A: CustomNodeAdapter +
runtime: Option<Rc<Function>>,
}
pub struct DecisionGraphConfig<'a, L: DecisionLoader + 'static, A: CustomNodeAdapter + 'static> {
pub struct DecisionGraphConfig<L: DecisionLoader + 'static, A: CustomNodeAdapter + 'static> {
pub loader: Arc<L>,
pub adapter: Arc<A>,
pub content: &'a DecisionContent,
pub content: Arc<DecisionContent>,
pub trace: bool,
pub iteration: u8,
pub max_depth: u8,
}
impl<'a, L: DecisionLoader + 'static, A: CustomNodeAdapter + 'static> DecisionGraph<'a, L, A> {
impl<L: DecisionLoader + 'static, A: CustomNodeAdapter + 'static> DecisionGraph<L, A> {
pub fn try_new(
config: DecisionGraphConfig<'a, L, A>,
config: DecisionGraphConfig<L, A>,
) -> Result<Self, DecisionGraphValidationError> {
let content = config.content;
let mut graph = StableDiDecisionGraph::new();
@@ -54,7 +54,7 @@ impl<'a, L: DecisionLoader + 'static, A: CustomNodeAdapter + 'static> DecisionGr
for node in &content.nodes {
let node_id = node.id.clone();
let node_index = graph.add_node(node);
let node_index = graph.add_node(node.clone());
index_map.insert(node_id, node_index);
}
@@ -68,7 +68,7 @@ impl<'a, L: DecisionLoader + 'static, A: CustomNodeAdapter + 'static> DecisionGr
DecisionGraphValidationError::MissingNode(edge.target_id.to_string())
})?;
graph.add_edge(source_index.clone(), target_index.clone(), edge);
graph.add_edge(source_index.clone(), target_index.clone(), edge.clone());
}
Ok(Self {
@@ -117,13 +117,6 @@ impl<'a, L: DecisionLoader + 'static, A: CustomNodeAdapter + 'static> DecisionGr
));
}
let output_count = self.node_kind_count(DecisionNodeKind::OutputNode);
if output_count < 1 {
return Err(DecisionGraphValidationError::InvalidOutputCount(
output_count as u32,
));
}
if is_cyclic_directed(&self.graph) {
return Err(DecisionGraphValidationError::CyclicGraph);
}
@@ -171,7 +164,7 @@ impl<'a, L: DecisionLoader + 'static, A: CustomNodeAdapter + 'static> DecisionGr
continue;
}
let node = self.graph[nid];
let node = (&self.graph[nid]).clone();
let start = Instant::now();
macro_rules! trace {
@@ -222,7 +215,7 @@ impl<'a, L: DecisionLoader + 'static, A: CustomNodeAdapter + 'static> DecisionGr
})?;
let node_request = NodeRequest {
node,
node: node.clone(),
iteration: self.iteration,
input: walker.incoming_node_data(&self.graph, nid, true),
};
@@ -233,7 +226,7 @@ impl<'a, L: DecisionLoader + 'static, A: CustomNodeAdapter + 'static> DecisionGr
self.iteration,
self.max_depth,
)
.handle(&node_request)
.handle(node_request.clone())
.await
.map_err(|e| NodeError {
source: e.into(),
@@ -246,7 +239,7 @@ impl<'a, L: DecisionLoader + 'static, A: CustomNodeAdapter + 'static> DecisionGr
})?;
function_v1::FunctionHandler::new(self.trace, runtime)
.handle(&node_request)
.handle(node_request.clone())
.await
.map_err(|e| NodeError {
source: e.into(),
@@ -270,7 +263,7 @@ impl<'a, L: DecisionLoader + 'static, A: CustomNodeAdapter + 'static> DecisionGr
}
DecisionNodeKind::DecisionNode { .. } => {
let node_request = NodeRequest {
node,
node: node.clone(),
iteration: self.iteration,
input: walker.incoming_node_data(&self.graph, nid, true),
};
@@ -282,7 +275,7 @@ impl<'a, L: DecisionLoader + 'static, A: CustomNodeAdapter + 'static> DecisionGr
self.adapter.clone(),
self.runtime.clone(),
)
.handle(&node_request)
.handle(node_request.clone())
.await
.map_err(|e| NodeError {
source: e.into(),
@@ -304,13 +297,13 @@ impl<'a, L: DecisionLoader + 'static, A: CustomNodeAdapter + 'static> DecisionGr
}
DecisionNodeKind::DecisionTableNode { .. } => {
let node_request = NodeRequest {
node,
node: node.clone(),
iteration: self.iteration,
input: walker.incoming_node_data(&self.graph, nid, true),
};
let res = DecisionTableHandler::new(self.trace)
.handle(&node_request)
.handle(node_request.clone())
.await
.map_err(|e| NodeError {
node_id: node.id.clone(),
@@ -319,6 +312,7 @@ impl<'a, L: DecisionLoader + 'static, A: CustomNodeAdapter + 'static> DecisionGr
node_request.input.dot_remove("$nodes");
res.output.dot_remove("$nodes");
res.output.dot_remove("$");
trace!({
input: node_request.input,
@@ -332,13 +326,13 @@ impl<'a, L: DecisionLoader + 'static, A: CustomNodeAdapter + 'static> DecisionGr
}
DecisionNodeKind::ExpressionNode { .. } => {
let node_request = NodeRequest {
node,
node: node.clone(),
iteration: self.iteration,
input: walker.incoming_node_data(&self.graph, nid, true),
};
let res = ExpressionHandler::new(self.trace)
.handle(&node_request)
.handle(node_request.clone())
.await
.map_err(|e| NodeError {
node_id: node.id.clone(),
@@ -360,14 +354,14 @@ impl<'a, L: DecisionLoader + 'static, A: CustomNodeAdapter + 'static> DecisionGr
}
DecisionNodeKind::CustomNode { .. } => {
let node_request = NodeRequest {
node,
node: node.clone(),
iteration: self.iteration,
input: walker.incoming_node_data(&self.graph, nid, true),
};
let res = self
.adapter
.handle(CustomNodeRequest::try_from(&node_request).unwrap())
.handle(CustomNodeRequest::try_from(node_request.clone()).unwrap())
.await
.map_err(|e| NodeError {
node_id: node.id.clone(),
@@ -390,9 +384,10 @@ impl<'a, L: DecisionLoader + 'static, A: CustomNodeAdapter + 'static> DecisionGr
}
}
Err(NodeError {
node_id: "".to_string(),
source: anyhow!("Graph did not halt. Missing output node."),
Ok(DecisionGraphResponse {
result: walker.ending_variables(&self.graph),
performance: format!("{:?}", root_start.elapsed()),
trace: node_traces,
})
}
}
+4 -3
View File
@@ -2,6 +2,7 @@ use crate::model::DecisionNode;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::fmt::{Display, Formatter};
use std::sync::Arc;
use thiserror::Error;
use zen_expression::variable::Variable;
@@ -12,11 +13,11 @@ pub struct NodeResponse {
pub trace_data: Option<Value>,
}
#[derive(Debug, Serialize)]
pub struct NodeRequest<'a> {
#[derive(Debug, Serialize, Clone)]
pub struct NodeRequest {
pub input: Variable,
pub iteration: u8,
pub node: &'a DecisionNode,
pub node: Arc<DecisionNode>,
}
#[derive(Error, Debug)]
+42 -12
View File
@@ -1,10 +1,12 @@
use ahash::HashMap;
use anyhow::{anyhow, Context};
use std::sync::Arc;
use crate::handler::node::{NodeRequest, NodeResponse, NodeResult};
use crate::handler::table::{RowOutput, RowOutputKind};
use crate::model::{DecisionNodeKind, DecisionTableContent, DecisionTableHitPolicy};
use serde::Serialize;
use tokio::sync::Mutex;
use zen_expression::variable::Variable;
use zen_expression::Isolate;
@@ -18,12 +20,34 @@ struct RowResult {
}
#[derive(Debug)]
pub struct DecisionTableHandler<'a> {
pub struct DecisionTableHandler {
trace: bool,
}
impl DecisionTableHandler {
pub fn new(trace: bool) -> Self {
Self { trace }
}
pub async fn handle(&mut self, request: NodeRequest) -> NodeResult {
let content = match &request.node.kind {
DecisionNodeKind::DecisionTableNode { content } => Ok(content),
_ => Err(anyhow!("Unexpected node type")),
}?;
let inner_handler = DecisionTableHandlerInner::new(self.trace);
inner_handler
.handle(request.input.depth_clone(1), content)
.await
}
}
struct DecisionTableHandlerInner<'a> {
isolate: Isolate<'a>,
trace: bool,
}
impl<'a> DecisionTableHandler<'a> {
impl<'a> DecisionTableHandlerInner<'a> {
pub fn new(trace: bool) -> Self {
Self {
isolate: Isolate::new(),
@@ -31,18 +55,24 @@ impl<'a> DecisionTableHandler<'a> {
}
}
pub async fn handle(&mut self, request: &'a NodeRequest<'_>) -> NodeResult {
let content = match &request.node.kind {
DecisionNodeKind::DecisionTableNode { content } => Ok(content),
_ => Err(anyhow!("Unexpected node type")),
}?;
pub async fn handle(self, input: Variable, content: &'a DecisionTableContent) -> NodeResult {
let self_mutex = Arc::new(Mutex::new(self));
self.isolate.set_environment(request.input.depth_clone(1));
content
.transform_attributes
.run_with(input, |input| {
let self_mutex = self_mutex.clone();
async move {
let mut self_ref = self_mutex.lock().await;
match &content.hit_policy {
DecisionTableHitPolicy::First => self.handle_first_hit(&content).await,
DecisionTableHitPolicy::Collect => self.handle_collect(&content).await,
}
self_ref.isolate.set_environment(input);
match &content.hit_policy {
DecisionTableHitPolicy::First => self_ref.handle_first_hit(&content).await,
DecisionTableHitPolicy::Collect => self_ref.handle_collect(&content).await,
}
}
})
.await
}
async fn handle_first_hit(&mut self, content: &'a DecisionTableContent) -> NodeResult {
+44 -15
View File
@@ -3,11 +3,12 @@ use fixedbitset::FixedBitSet;
use petgraph::data::DataMap;
use petgraph::matrix_graph::Zero;
use petgraph::prelude::{EdgeIndex, NodeIndex, StableDiGraph};
use petgraph::visit::{EdgeRef, IntoNeighbors, IntoNodeIdentifiers, Reversed, VisitMap, Visitable};
use petgraph::visit::{EdgeRef, IntoNodeIdentifiers, VisitMap, Visitable};
use petgraph::{Incoming, Outgoing};
use serde_json::json;
use std::rc::Rc;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::Instant;
use crate::config::ZEN_CONFIG;
@@ -18,7 +19,7 @@ use crate::DecisionGraphTrace;
use zen_expression::variable::Variable;
use zen_expression::Isolate;
pub(crate) type StableDiDecisionGraph<'a> = StableDiGraph<&'a DecisionNode, &'a DecisionEdge>;
pub(crate) type StableDiDecisionGraph = StableDiGraph<Arc<DecisionNode>, Arc<DecisionEdge>>;
pub(crate) struct GraphWalker {
ordered: FixedBitSet,
@@ -43,8 +44,8 @@ impl GraphWalker {
// find all initial nodes (nodes without incoming edges)
self.to_visit
.extend(g.node_identifiers().filter(move |&nid| {
g.neighbors_directed(nid, Incoming).count().is_zero()
&& !g.neighbors_directed(nid, Outgoing).count().is_zero()
g.node_weight(nid)
.is_some_and(|n| n.kind == DecisionNodeKind::InputNode)
}));
}
@@ -72,6 +73,20 @@ impl GraphWalker {
self.node_data.get(&node_id).cloned()
}
pub fn ending_variables(&self, g: &StableDiDecisionGraph) -> Variable {
g.node_indices()
.filter(|nid| {
self.ordered.is_visited(nid)
&& g.neighbors_directed(*nid, Outgoing).count().is_zero()
})
.fold(Variable::empty_object(), |mut acc, curr| {
match self.node_data.get(&curr) {
None => acc,
Some(data) => acc.merge(data),
}
})
}
pub fn get_all_node_data(&self, g: &StableDiDecisionGraph) -> Variable {
let node_values = self
.node_data
@@ -130,11 +145,18 @@ impl GraphWalker {
}
// Take an unvisited element and find which of its neighbors are next
while let Some(nid) = self.to_visit.pop() {
let decision_node = *g.node_weight(nid)?;
let decision_node = g.node_weight(nid)?.clone();
if self.ordered.is_visited(&nid) {
continue;
}
if !self.all_dependencies_resolved(g, nid) {
self.to_visit.push(nid);
self.to_visit
.extend(self.get_unresolved_dependencies(g, nid));
continue;
}
self.ordered.visit(nid);
if let DecisionNodeKind::SwitchNode { content } = &decision_node.kind {
@@ -211,22 +233,29 @@ impl GraphWalker {
}
}
for neigh in g.neighbors(nid) {
// Look at each neighbor, and those that only have incoming edges
// from the already ordered list, they are the next to visit.
if Reversed(&*g)
.neighbors(neigh)
.all(|b| self.ordered.is_visited(&b))
{
self.to_visit.push(neigh);
}
}
let successors = g.neighbors_directed(nid, Outgoing);
self.to_visit.extend(successors);
return Some(nid);
}
None
}
fn all_dependencies_resolved(&self, g: &StableDiDecisionGraph, nid: NodeIndex) -> bool {
g.neighbors_directed(nid, Incoming)
.all(|dep| self.ordered.is_visited(&dep))
}
fn get_unresolved_dependencies(
&self,
g: &StableDiDecisionGraph,
nid: NodeIndex,
) -> Vec<NodeIndex> {
g.neighbors_directed(nid, Incoming)
.filter(|dep| !self.ordered.is_visited(dep))
.collect()
}
}
fn switch_statement_evaluate<'a>(
+1
View File
@@ -130,6 +130,7 @@ pub mod handler;
pub mod loader;
#[path = "model/mod.rs"]
pub mod model;
mod util;
pub use config::ZEN_CONFIG;
pub use decision::Decision;
+32 -4
View File
@@ -1,14 +1,15 @@
use ahash::HashMap;
use serde::{Deserialize, Serialize};
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value;
use std::sync::Arc;
/// JDM Decision model
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "bincode", derive(bincode::Encode, bincode::Decode))]
#[serde(rename_all = "camelCase")]
pub struct DecisionContent {
pub nodes: Vec<DecisionNode>,
pub edges: Vec<DecisionEdge>,
pub nodes: Vec<Arc<DecisionNode>>,
pub edges: Vec<Arc<DecisionEdge>>,
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
@@ -86,6 +87,8 @@ pub struct DecisionTableContent {
pub inputs: Vec<DecisionTableInputField>,
pub outputs: Vec<DecisionTableOutputField>,
pub hit_policy: DecisionTableHitPolicy,
#[serde(flatten)]
pub transform_attributes: TransformAttributes,
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
@@ -102,6 +105,7 @@ pub enum DecisionTableHitPolicy {
pub struct DecisionTableInputField {
pub id: String,
pub name: String,
#[serde(default, deserialize_with = "empty_string_is_none")]
pub field: Option<String>,
}
@@ -119,6 +123,8 @@ pub struct DecisionTableOutputField {
#[serde(rename_all = "camelCase")]
pub struct ExpressionNodeContent {
pub expressions: Vec<Expression>,
#[serde(flatten)]
pub transform_attributes: TransformAttributes,
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
@@ -160,10 +166,14 @@ pub enum SwitchStatementHitPolicy {
#[cfg_attr(feature = "bincode", derive(bincode::Encode, bincode::Decode))]
#[serde(rename_all = "camelCase")]
pub struct TransformAttributes {
#[serde(default, deserialize_with = "empty_string_is_none")]
pub input_field: Option<String>,
#[serde(default, deserialize_with = "empty_string_is_none")]
pub output_path: Option<String>,
#[serde(default)]
pub execution_mode: TransformExecutionMode,
#[serde(default)]
pub pass_through: bool,
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, Default)]
@@ -179,7 +189,7 @@ pub enum TransformExecutionMode {
#[serde(rename_all = "camelCase")]
pub struct CustomNodeContent {
pub kind: String,
pub config: Value,
pub config: Arc<Value>,
}
#[cfg(feature = "bincode")]
@@ -225,3 +235,21 @@ impl<'__de> ::bincode::BorrowDecode<'__de> for CustomNodeContent {
Ok(Self { kind, config })
}
}
fn empty_string_is_none<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum StringOrNull {
String(String),
Null,
}
match StringOrNull::deserialize(deserializer)? {
StringOrNull::String(s) if s.trim().is_empty() => Ok(None),
StringOrNull::String(s) => Ok(Some(s)),
StringOrNull::Null => Ok(None),
}
}
+1
View File
@@ -0,0 +1 @@
mod transform_attribute;
@@ -0,0 +1,95 @@
use crate::handler::node::{NodeResponse, NodeResult};
use crate::model::{TransformAttributes, TransformExecutionMode};
use anyhow::Context;
use serde_json::Value;
use std::future::Future;
use zen_expression::{Isolate, Variable};
impl TransformAttributes {
pub(crate) async fn run_with<F, Fut>(&self, node_input: Variable, evaluate: F) -> NodeResult
where
F: Fn(Variable) -> Fut,
Fut: Future<Output = NodeResult>,
{
let input = match &self.input_field {
None => node_input.clone(),
Some(input_field) => {
let mut isolate = Isolate::new();
isolate.set_environment(node_input.clone());
let calculated_input = isolate.run_standard(input_field.as_str())?;
let nodes = node_input.dot("$nodes").unwrap_or(Variable::Null);
match &calculated_input {
Variable::Array(arr) => {
let arr = arr.borrow();
let s: Vec<_> = arr
.iter()
.map(|v| {
let new_v = v.depth_clone(1);
new_v.dot_insert("$nodes", nodes.clone());
new_v
})
.collect();
Variable::from_array(s)
}
_ => {
let new_input = calculated_input.depth_clone(1);
new_input.dot_insert("$nodes", nodes);
new_input
}
}
}
};
let mut trace_data: Option<Value> = None;
let mut output = match self.execution_mode {
TransformExecutionMode::Single => {
let response = evaluate(input).await?;
if let Some(td) = response.trace_data {
trace_data.replace(td);
}
response.output.dot_remove("$nodes");
response.output
}
TransformExecutionMode::Loop => {
let input_array_ref = input.as_array().context("Expected an array")?;
let input_array = input_array_ref.borrow();
let mut output_array = Vec::with_capacity(input_array.len());
let mut trace_datum = Vec::with_capacity(input_array.len());
for input in input_array.iter() {
let mut response = evaluate(input.clone()).await?;
if let Some(td) = response.trace_data {
trace_datum.push(td);
}
if self.pass_through {
response.output = input.clone().merge_clone(&response.output);
}
response.output.dot_remove("$nodes");
output_array.push(response.output);
}
trace_data.replace(Value::Array(trace_datum));
Variable::from_array(output_array)
}
};
if let Some(output_path) = &self.output_path {
let new_output = Variable::empty_object();
new_output.dot_insert(output_path.as_str(), output);
output = new_output;
}
if self.pass_through {
let mut node_input = node_input;
output = node_input.merge_clone(&output)
}
Ok(NodeResponse { output, trace_data })
}
}
-7
View File
@@ -87,11 +87,4 @@ fn decision_validation() {
missing_input_error,
DecisionGraphValidationError::InvalidInputCount(_)
));
let missing_output_decision = Decision::from(load_test_data("error-missing-output.json"));
let missing_output_error = missing_output_decision.validate().unwrap_err();
assert!(matches!(
missing_output_error,
DecisionGraphValidationError::InvalidOutputCount(_)
));
}
+24 -12
View File
@@ -8,7 +8,7 @@ use std::path::Path;
use std::sync::Arc;
use tokio::runtime::Builder;
use zen_engine::loader::{LoaderError, MemoryLoader};
use zen_engine::model::{DecisionContent, DecisionNodeKind, FunctionNodeContent};
use zen_engine::model::{DecisionContent, DecisionNode, DecisionNodeKind, FunctionNodeContent};
use zen_engine::Variable;
use zen_engine::{DecisionEngine, EvaluationError, EvaluationOptions};
@@ -158,24 +158,36 @@ fn engine_with_trace() {
#[tokio::test]
#[cfg_attr(miri, ignore)]
async fn engine_function_imports() {
let mut function_content = load_test_data("function.json");
let function_content = load_test_data("function.json");
let imports_js_path = Path::new("js").join("imports.js");
let mut replace_buffer = load_raw_test_data(imports_js_path.to_str().unwrap());
let mut replace_data = String::new();
replace_buffer.read_to_string(&mut replace_data).unwrap();
function_content.nodes.iter_mut().for_each(|node| {
if let DecisionNodeKind::FunctionNode { content, .. } = &mut node.kind {
match content {
FunctionNodeContent::Version1(content) => {
let _ = std::mem::replace(content, replace_data.clone());
}
_ => {}
}
}
});
let new_nodes = function_content
.nodes
.into_iter()
.map(|node| match &node.kind {
DecisionNodeKind::FunctionNode { .. } => {
let new_kind = DecisionNodeKind::FunctionNode {
content: FunctionNodeContent::Version1(replace_data.clone()),
};
Arc::new(DecisionNode {
id: node.id.clone(),
name: node.name.clone(),
kind: new_kind,
})
}
_ => node,
})
.collect::<Vec<_>>();
let function_content = DecisionContent {
edges: function_content.edges,
nodes: new_nodes,
};
let decision = DecisionEngine::default().create_decision(function_content.into());
let response = decision.evaluate(json!({}).into()).await.unwrap();
@@ -264,7 +264,7 @@ impl TypesProvider {
},
Operator::Comparison(comp) => match comp {
ComparisonOperator::Equal => {
if !left_type.omit_const().satisfies(&right_type.omit_const()) {
if !left_type.omit_const().satisfies(&right_type.omit_const()) && !left_type.is_null() && !right_type.is_null() {
on_fly_error.replace(format!(
"Hint: Expression will always evaluate to `false` because `{left_type}` != `{right_type}`."
));
@@ -273,7 +273,7 @@ impl TypesProvider {
V(VariableType::Bool)
},
ComparisonOperator::NotEqual => {
if !left_type.omit_const().satisfies(&right_type.omit_const()) {
if !left_type.omit_const().satisfies(&right_type.omit_const()) && !left_type.is_null() && !right_type.is_null() {
on_fly_error.replace(format!(
"Hint: Expression will always evaluate to `true` because `{left_type}` != `{right_type}`."
));
+5 -1
View File
@@ -128,7 +128,11 @@ impl<'arena, 'token_ref, Flavor> Parser<'arena, 'token_ref, Flavor> {
}
pub(crate) fn prev_token_end(&self) -> u32 {
match self.tokens.get(self.position() - 1) {
let Some(pos) = self.position().checked_sub(1) else {
return self.token_start();
};
match self.tokens.get(pos) {
None => self.token_start(),
Some(t) => t.span.1,
}
+87 -24
View File
@@ -166,11 +166,18 @@ impl Variable {
}
pub fn merge(&mut self, patch: &Variable) -> Variable {
merge_variables(self, patch, true);
let _ = merge_variables(self, patch, true, MergeStrategy::InPlace);
self.shallow_clone()
}
pub fn merge_clone(&mut self, patch: &Variable) -> Variable {
let mut new_self = self.shallow_clone();
let _ = merge_variables(&mut new_self, patch, true, MergeStrategy::CloneOnWrite);
new_self
}
pub fn shallow_clone(&self) -> Self {
match self {
Variable::Null => Variable::Null,
@@ -228,40 +235,96 @@ impl Clone for Variable {
}
}
fn merge_variables(doc: &mut Variable, patch: &Variable, top_level: bool) {
if !patch.is_object() && !patch.is_array() && top_level {
return;
#[derive(Copy, Clone)]
enum MergeStrategy {
InPlace,
CloneOnWrite,
}
fn merge_variables(
doc: &mut Variable,
patch: &Variable,
top_level: bool,
strategy: MergeStrategy,
) -> bool {
if patch.is_array() && top_level {
*doc = patch.shallow_clone();
return true;
}
if !patch.is_object() && top_level {
return false;
}
if doc.is_object() && patch.is_object() {
let map_ref = doc.as_object().unwrap();
let doc_ref = doc.as_object().unwrap();
let patch_ref = patch.as_object().unwrap();
if Rc::ptr_eq(&map_ref, &patch_ref) {
return;
if Rc::ptr_eq(&doc_ref, &patch_ref) {
return false;
}
let mut map = map_ref.borrow_mut();
let patch = patch_ref.borrow();
for (key, value) in patch.deref() {
if value == &Variable::Null {
map.remove(key.as_str());
} else {
let entry = map.entry(key.to_string()).or_insert(Variable::Null);
merge_variables(entry, value, false)
match strategy {
MergeStrategy::InPlace => {
let mut map = doc_ref.borrow_mut();
for (key, value) in patch.deref() {
if value == &Variable::Null {
map.remove(key.as_str());
} else {
let entry = map.entry(key.to_string()).or_insert(Variable::Null);
merge_variables(entry, value, false, strategy);
}
}
return true;
}
MergeStrategy::CloneOnWrite => {
let mut changed = false;
let mut new_map = None;
for (key, value) in patch.deref() {
// Get or create the new map if we haven't yet
let map = if let Some(ref mut m) = new_map {
m
} else {
let m = doc_ref.borrow().clone();
new_map = Some(m);
new_map.as_mut().unwrap()
};
if value == &Variable::Null {
// Remove null values
if map.remove(key.as_str()).is_some() {
changed = true;
}
} else {
// Handle nested merging
let entry = map.entry(key.to_string()).or_insert(Variable::Null);
if merge_variables(entry, value, false, strategy) {
changed = true;
}
}
}
// Only update doc if changes were made
if changed {
if let Some(new_map) = new_map {
*doc = Variable::Object(Rc::new(RefCell::new(new_map)));
}
return true;
}
return false;
}
}
} else if doc.is_array() && patch.is_array() {
let arr_ref = doc.as_array().unwrap();
let patch_ref = patch.as_array().unwrap();
if Rc::ptr_eq(&arr_ref, &patch_ref) {
return;
} else {
let new_value = patch.shallow_clone();
if *doc != new_value {
*doc = new_value;
return true;
}
let mut arr = arr_ref.borrow_mut();
let patch = patch_ref.borrow();
arr.extend(patch.iter().map(|s| s.shallow_clone()));
} else {
*doc = patch.shallow_clone();
return false;
}
}
+8 -1
View File
@@ -62,7 +62,7 @@ impl VariableType {
(VariableType::Bool, VariableType::Bool) => true,
(VariableType::String, VariableType::String) => true,
(VariableType::Number, VariableType::Number) => true,
(VariableType::Array(a1), VariableType::Array(a2)) => a1 == a2,
(VariableType::Array(a1), VariableType::Array(a2)) => a1.satisfies(a2),
(VariableType::Object(o1), VariableType::Object(o2)) => o1
.iter()
.all(|(k, v)| o2.get(k).is_some_and(|tv| v.satisfies(tv))),
@@ -149,6 +149,13 @@ impl VariableType {
(_, _) => VariableType::Any,
}
}
pub fn is_null(&self) -> bool {
match self {
VariableType::Null => true,
_ => false,
}
}
}
#[cfg(test)]
+93
View File
@@ -0,0 +1,93 @@
{
"tests": [
{
"input": {
"a": 1,
"b": 2,
"extra": "This should not pass through"
},
"output": {
"sum": 3
}
},
{
"input": {
"a": 5,
"b": 3,
"sum": 100
},
"output": {
"sum": 8
}
},
{
"input": {
"a": 10,
"b": 20,
"c": 30,
"d": 40
},
"output": {
"sum": 30
}
},
{
"input": {
"a": 7,
"b": 3,
"passThrough": "This should not appear in output"
},
"output": {
"sum": 10
}
},
{
"input": {
"a": -5,
"b": 5,
"negative": true
},
"output": {
"sum": 0
}
}
],
"nodes": [
{
"type": "inputNode",
"id": "deced339-bace-452a-8db0-777f038bffe8",
"name": "request",
"position": {
"x": 145,
"y": 235
}
},
{
"type": "expressionNode",
"content": {
"expressions": [
{
"id": "d54641c2-5f24-4140-a9c4-8453542a622a",
"key": "sum",
"value": "a + b"
}
],
"passThrough": false
},
"id": "6b9cfc7e-4776-4b3f-8a19-c2a4d7874770",
"name": "expression1",
"position": {
"x": 475,
"y": 235
}
}
],
"edges": [
{
"id": "639f3a3a-7545-4e98-aa79-7a9bd9644af1",
"sourceId": "deced339-bace-452a-8db0-777f038bffe8",
"type": "edge",
"targetId": "6b9cfc7e-4776-4b3f-8a19-c2a4d7874770"
}
]
}
+94
View File
@@ -0,0 +1,94 @@
{
"tests": [
{
"input": {
"customer": {
"firstName": "John",
"lastName": "Doe",
"age": 30
},
"order": {
"id": "ORD-001",
"total": 100
}
},
"output": {
"customer": {
"firstName": "John",
"lastName": "Doe",
"age": 30,
"fullName": "John Doe"
},
"order": {
"id": "ORD-001",
"total": 100
}
}
},
{
"input": {
"customer": {
"firstName": "Jane",
"lastName": "Smith",
"age": 25
},
"order": {
"id": "ORD-002",
"total": 150
}
},
"output": {
"customer": {
"firstName": "Jane",
"lastName": "Smith",
"age": 25,
"fullName": "Jane Smith"
},
"order": {
"id": "ORD-002",
"total": 150
}
}
}
],
"nodes": [
{
"type": "inputNode",
"id": "input-node",
"name": "request",
"position": {
"x": 100,
"y": 100
}
},
{
"type": "expressionNode",
"id": "expression-node-1",
"name": "customerFullName",
"position": {
"x": 300,
"y": 100
},
"content": {
"inputField": "customer",
"outputPath": "customer",
"expressions": [
{
"id": "07795ded-cb9b-4165-9b5e-783b066dda61",
"key": "fullName",
"value": "`${firstName} ${lastName}`"
}
],
"passThrough": true
}
}
],
"edges": [
{
"id": "edge-1",
"sourceId": "input-node",
"targetId": "expression-node-1",
"type": "edge"
}
]
}
+94
View File
@@ -0,0 +1,94 @@
{
"tests": [
{
"input": {
"cart": {
"items": [
{
"name": "Apple",
"price": 0.5,
"quantity": 3
},
{
"name": "Banana",
"price": 0.3,
"quantity": 5
},
{
"name": "Orange",
"price": 0.7,
"quantity": 2
}
],
"customerId": "CUST-001"
}
},
"output": {
"cart": {
"items": [
{
"name": "Apple",
"price": 0.5,
"quantity": 3,
"totalPrice": 1.5
},
{
"name": "Banana",
"price": 0.3,
"quantity": 5,
"totalPrice": 1.5
},
{
"name": "Orange",
"price": 0.7,
"quantity": 2,
"totalPrice": 1.4
}
],
"customerId": "CUST-001"
}
}
}
],
"nodes": [
{
"type": "inputNode",
"id": "input-node",
"name": "request",
"position": {
"x": 100,
"y": 100
}
},
{
"type": "expressionNode",
"id": "expression-node-1",
"name": "calculateItemTotal",
"position": {
"x": 300,
"y": 100
},
"content": {
"inputField": "cart.items",
"outputPath": "cart.items",
"executionMode": "loop",
"expressions": [
{
"id": "total-price-exp",
"key": "totalPrice",
"value": "price * quantity"
}
],
"passThrough": true
}
}
],
"edges": [
{
"id": "edge-1",
"sourceId": "input-node",
"targetId": "expression-node-1",
"type": "edge"
}
]
}
@@ -0,0 +1,108 @@
{
"tests": [
{
"input": {
"a": 1,
"b": 2,
"sum": []
},
"output": {
"a": 1,
"b": 2,
"sum": 3
}
},
{
"input": {
"a": 5,
"b": 3,
"sum": 100
},
"output": {
"a": 5,
"b": 3,
"sum": 8
}
},
{
"input": {
"a": 10,
"b": 20,
"extra": "This should pass through"
},
"output": {
"a": 10,
"b": 20,
"sum": 30,
"extra": "This should pass through"
}
},
{
"input": {
"a": 7,
"b": 3,
"sum": "Original",
"passThrough": "Test"
},
"output": {
"a": 7,
"b": 3,
"sum": 10,
"passThrough": "Test"
}
},
{
"input": {
"a": 1,
"b": 1,
"c": 3,
"d": 4
},
"output": {
"a": 1,
"b": 1,
"c": 3,
"d": 4,
"sum": 2
}
}
],
"nodes": [
{
"type": "inputNode",
"id": "deced339-bace-452a-8db0-777f038bffe8",
"name": "request",
"position": {
"x": 145,
"y": 235
}
},
{
"type": "expressionNode",
"content": {
"expressions": [
{
"id": "d54641c2-5f24-4140-a9c4-8453542a622a",
"key": "sum",
"value": "a + b"
}
],
"passThrough": true
},
"id": "6b9cfc7e-4776-4b3f-8a19-c2a4d7874770",
"name": "expression1",
"position": {
"x": 475,
"y": 235
}
}
],
"edges": [
{
"id": "639f3a3a-7545-4e98-aa79-7a9bd9644af1",
"sourceId": "deced339-bace-452a-8db0-777f038bffe8",
"type": "edge",
"targetId": "6b9cfc7e-4776-4b3f-8a19-c2a4d7874770"
}
]
}
+313
View File
@@ -0,0 +1,313 @@
{
"tests": [
{
"input": {
"customer": {
"name": "John Doe",
"country": "US"
},
"items": [
{
"name": "Laptop",
"amount": 1000,
"group": "electronics"
},
{
"name": "Mouse",
"amount": 50,
"group": "accessories"
}
]
},
"output": {
"customer": {
"country": "US",
"name": "John Doe"
},
"exprItems": [
{
"amount": 50,
"discount": 5,
"group": "accessories",
"name": "Mouse"
}
],
"exprItemsSum": 50,
"items": [
{
"amount": 1000,
"group": "electronics",
"name": "Laptop"
},
{
"amount": 50,
"group": "accessories",
"name": "Mouse"
}
],
"tblItems": [
{
"amount": 1000,
"group": "electronics",
"name": "Laptop"
},
{
"amount": 50,
"discount": 5,
"group": "accessories",
"name": "Mouse"
}
],
"tblItemsSum": 1050
}
},
{
"input": {
"customer": {
"name": "John Doe",
"country": "US"
},
"items": [
{
"name": "Laptop",
"amount": 1000,
"group": "electronics"
},
{
"name": "Mouse",
"amount": 50,
"group": "accessories_new"
}
]
},
"output": {
"customer": {
"country": "US",
"name": "John Doe"
},
"exprItems": [],
"exprItemsSum": 0,
"items": [
{
"amount": 1000,
"group": "electronics",
"name": "Laptop"
},
{
"amount": 50,
"group": "accessories_new",
"name": "Mouse"
}
],
"tblItems": [
{
"amount": 1000,
"group": "electronics",
"name": "Laptop"
},
{
"amount": 50,
"group": "accessories_new",
"name": "Mouse"
}
],
"tblItemsSum": 1050
}
},
{
"input": {
"customer": {
"name": "John Doe",
"country": "US"
},
"items": [
{
"name": "Laptop",
"amount": 1000,
"group": "electronics"
},
{
"name": "Mouse",
"amount": 51,
"group": "accessories"
}
]
},
"output": {
"customer": {
"country": "US",
"name": "John Doe"
},
"exprItems": [
{
"amount": 51,
"discount": 5,
"group": "accessories",
"name": "Mouse"
}
],
"exprItemsSum": 51,
"items": [
{
"amount": 1000,
"group": "electronics",
"name": "Laptop"
},
{
"amount": 51,
"group": "accessories",
"name": "Mouse"
}
],
"tblItems": [
{
"amount": 1000,
"group": "electronics",
"name": "Laptop"
},
{
"amount": 51,
"discount": 5,
"group": "accessories",
"name": "Mouse"
}
],
"tblItemsSum": 1051
}
}
],
"contentType": "application/vnd.gorules.decision",
"nodes": [
{
"id": "be2a55f4-cdd8-482e-b53c-a947cbb7d7a0",
"name": "request",
"type": "inputNode",
"position": {
"x": 125,
"y": 265
}
},
{
"id": "b8ebb212-e290-4131-9703-0bb7b1fd2328",
"name": "table",
"type": "decisionTableNode",
"content": {
"rules": [
{
"_id": "fd81538a-7451-4eb7-a25c-01c3afbebbdf",
"e06432d2-4609-425b-ad99-9ca27db08130": "5",
"e9090e94-051c-4c27-be58-baac03041c2b": "amount > 40 and group == 'accessories' and $nodes.request.customer.country == 'US'"
}
],
"inputs": [
{
"id": "e9090e94-051c-4c27-be58-baac03041c2b",
"name": "Input"
}
],
"outputs": [
{
"id": "e06432d2-4609-425b-ad99-9ca27db08130",
"name": "Discount",
"field": "discount"
}
],
"hitPolicy": "first",
"inputField": "items",
"outputPath": "tblItems",
"passThrough": true,
"executionMode": "loop"
},
"position": {
"x": 490,
"y": 170
}
},
{
"id": "5f56e61b-5687-4758-8524-8121df13b2ba",
"name": "expr",
"type": "expressionNode",
"content": {
"inputField": "filter(items,#.group == 'accessories')",
"outputPath": "exprItems",
"expressions": [
{
"id": "fcec4ba0-0279-46f5-99c4-fe4bfb6d842c",
"key": "discount",
"value": "amount > 40 and group == 'accessories' ? 5 : 0"
}
],
"passThrough": true,
"executionMode": "loop"
},
"position": {
"x": 490,
"y": 265
}
},
{
"type": "expressionNode",
"content": {
"expressions": [
{
"id": "504955cc-8ea1-4cf1-a53b-1bf874dd51b1",
"key": "tblItemsSum",
"value": "sum(map(tblItems, #.amount))"
}
],
"passThrough": true
},
"id": "8ceca45e-e6ff-4613-984c-5846e53c5a39",
"name": "sum",
"position": {
"x": 785,
"y": 170
}
},
{
"type": "expressionNode",
"content": {
"expressions": [
{
"id": "504955cc-8ea1-4cf1-a53b-1bf874dd51b1",
"key": "exprItemsSum",
"value": "sum(map(exprItems, #.amount))"
}
],
"passThrough": true
},
"id": "3814877d-0169-45dc-b4f5-5f9eade2e4f1",
"name": "sum",
"position": {
"x": 785,
"y": 265
}
}
],
"edges": [
{
"id": "565a3028-2cd3-4d35-b46a-c5a776776d9e",
"type": "edge",
"sourceId": "be2a55f4-cdd8-482e-b53c-a947cbb7d7a0",
"targetId": "5f56e61b-5687-4758-8524-8121df13b2ba"
},
{
"id": "b2f6aff4-2e3e-4e49-a8ff-c4b63f65e71e",
"type": "edge",
"sourceId": "be2a55f4-cdd8-482e-b53c-a947cbb7d7a0",
"targetId": "b8ebb212-e290-4131-9703-0bb7b1fd2328"
},
{
"id": "69267b59-5f70-4cd2-825b-c1920901ac56",
"sourceId": "b8ebb212-e290-4131-9703-0bb7b1fd2328",
"type": "edge",
"targetId": "8ceca45e-e6ff-4613-984c-5846e53c5a39"
},
{
"id": "01303153-a825-43d0-8127-73e4bac1514e",
"sourceId": "5f56e61b-5687-4758-8524-8121df13b2ba",
"type": "edge",
"targetId": "3814877d-0169-45dc-b4f5-5f9eade2e4f1"
}
]
}
+243
View File
@@ -0,0 +1,243 @@
{
"tests": [
{
"input": {
"country": "US",
"currency": "EUR",
"amount": 100
},
"output": {
"cat_2": 4,
"currency": "EUR",
"percent": 4,
"cat_1": 3,
"country": "US",
"fee": 0.6,
"amount": 100
}
},
{
"input": {
"country": "US",
"currency": "USD",
"amount": 110
},
"output": {
"percent": 1,
"amount": 110,
"currency": "USD",
"fee": 0.5,
"country": "US"
}
},
{
"input": {
"country": "CA",
"currency": "AUD",
"amount": 110
},
"output": {
"currency": "AUD",
"percent": 3,
"country": "CA",
"cat_1": 3,
"fee": 0.7,
"amount": 110,
"cat_2": 4
}
}
],
"contentType": "application/vnd.gorules.decision",
"nodes": [
{
"id": "e0f3612a-6019-4647-a682-4f3019b67fce",
"name": "request",
"type": "inputNode",
"position": {
"x": 165,
"y": 120
}
},
{
"id": "3a801d19-b611-4163-bfdd-f247a689a275",
"name": "pricing",
"type": "decisionTableNode",
"content": {
"rules": [
{
"_id": "6d08b48b-fe57-4efe-8d97-1bce5fcc75e8",
"1a981ce1-aa27-460c-a875-250d93d5cdbb": "0.5",
"6c6ac20d-cb40-4af9-b8f2-b5fc409467b6": "country == 'US' and currency == 'USD'",
"d4cb5b78-e7a2-4214-8e0c-0f5499d51395": "1"
},
{
"_id": "9d9103e9-a4e6-4a28-b4a1-49e19839727f",
"1a981ce1-aa27-460c-a875-250d93d5cdbb": "1",
"6c6ac20d-cb40-4af9-b8f2-b5fc409467b6": "country == 'US' and currency == 'CAD'",
"d4cb5b78-e7a2-4214-8e0c-0f5499d51395": ""
}
],
"inputs": [
{
"id": "6c6ac20d-cb40-4af9-b8f2-b5fc409467b6",
"name": "Input"
}
],
"outputs": [
{
"id": "1a981ce1-aa27-460c-a875-250d93d5cdbb",
"name": "Fee",
"field": "fee"
},
{
"id": "d4cb5b78-e7a2-4214-8e0c-0f5499d51395",
"name": "Percent",
"field": "percent"
}
],
"hitPolicy": "first",
"passThrough": true
},
"position": {
"x": 430,
"y": 120
}
},
{
"id": "bf832d94-d01d-4842-a60b-3db28375bccb",
"name": "fee set?",
"type": "switchNode",
"content": {
"hitPolicy": "first",
"statements": [
{
"id": "94664df3-8233-4019-8381-affec373bffe",
"condition": "percent == null",
"isDefault": false
},
{
"id": "718244e6-9471-4d20-af18-fce456d36f7e",
"condition": "",
"isDefault": true
}
]
},
"position": {
"x": 710,
"y": 120
}
},
{
"id": "09ecc83e-f89b-491b-9bfb-39311323ab94",
"name": "vars",
"type": "expressionNode",
"content": {
"expressions": [
{
"id": "f7e02818-b2aa-4659-9095-4dbebda02a64",
"key": "cat_1",
"value": "3"
},
{
"id": "7cebe9d1-5b10-44c3-8f74-36faafa1bde1",
"key": "cat_2",
"value": "4"
}
],
"passThrough": true
},
"position": {
"x": 985,
"y": 120
}
},
{
"id": "5e3c541a-7cf3-4765-b4c0-e20d07510379",
"name": "set percent",
"type": "decisionTableNode",
"content": {
"rules": [
{
"_id": "34388171-596a-4159-bc8b-493a9858e992",
"da65cb09-9dd2-4a08-af3c-f59569f999e2": "currency == 'AUD'",
"c706d872-d774-461c-bd4e-0e26dc6f6f5f": "cat_1",
"bbedc722-3422-40f0-bbd1-15d407d9b348": "0.7"
},
{
"_id": "dbe9b32c-73be-4d16-b67e-fcc087a38a86",
"da65cb09-9dd2-4a08-af3c-f59569f999e2": "currency == 'EUR'",
"c706d872-d774-461c-bd4e-0e26dc6f6f5f": "cat_2",
"bbedc722-3422-40f0-bbd1-15d407d9b348": "0.6"
}
],
"inputs": [
{
"id": "da65cb09-9dd2-4a08-af3c-f59569f999e2",
"name": "Input"
}
],
"outputs": [
{
"id": "c706d872-d774-461c-bd4e-0e26dc6f6f5f",
"name": "Percent",
"field": "percent"
},
{
"id": "bbedc722-3422-40f0-bbd1-15d407d9b348",
"field": "fee",
"name": "Fee"
}
],
"hitPolicy": "first",
"passThrough": true
},
"position": {
"x": 1260,
"y": 120
}
},
{
"id": "1a8c6564-c566-44ff-b07a-bce27d7f5a02",
"name": "response",
"type": "outputNode",
"position": {
"x": 985,
"y": 225
}
}
],
"edges": [
{
"id": "540ab38b-c0b1-4147-8826-c764766738a5",
"type": "edge",
"sourceId": "3a801d19-b611-4163-bfdd-f247a689a275",
"targetId": "bf832d94-d01d-4842-a60b-3db28375bccb"
},
{
"id": "47000d98-4e3c-4276-a479-6febfe4740cb",
"type": "edge",
"sourceId": "e0f3612a-6019-4647-a682-4f3019b67fce",
"targetId": "3a801d19-b611-4163-bfdd-f247a689a275"
},
{
"id": "b577da62-7398-46d0-a8ce-8ca23f08e20a",
"type": "edge",
"sourceId": "bf832d94-d01d-4842-a60b-3db28375bccb",
"targetId": "09ecc83e-f89b-491b-9bfb-39311323ab94",
"sourceHandle": "94664df3-8233-4019-8381-affec373bffe"
},
{
"id": "2032b991-a0d0-4ba3-859d-7e1a805c7d8b",
"type": "edge",
"sourceId": "09ecc83e-f89b-491b-9bfb-39311323ab94",
"targetId": "5e3c541a-7cf3-4765-b4c0-e20d07510379"
},
{
"id": "2361a49d-2c8d-4763-b638-057640b80dfb",
"type": "edge",
"sourceId": "bf832d94-d01d-4842-a60b-3db28375bccb",
"targetId": "1a8c6564-c566-44ff-b07a-bce27d7f5a02",
"sourceHandle": "718244e6-9471-4d20-af18-fce456d36f7e"
}
]
}