From 5b9c322a4322ba066691338882e54a4f6e7e5772 Mon Sep 17 00:00:00 2001 From: Ricardo Zanini Date: Fri, 24 Jan 2025 18:13:24 -0500 Subject: [PATCH] Fix #222 - (WIP): Basic raw implementation for DSL 1.0.0 Signed-off-by: Ricardo Zanini --- expr/expr.go | 95 ++++++ impl/context.go | 93 ++++++ impl/impl.go | 90 +++++ impl/task.go | 43 +++ impl/task_set_test.go | 401 +++++++++++++++++++++++ impl/task_test.go | 73 +++++ impl/testdata/chained_set_tasks.yaml | 15 + impl/testdata/concatenating_strings.yaml | 17 + impl/testdata/conditional_logic.yaml | 12 + impl/testdata/sequential_set_colors.yaml | 15 + impl/utils.go | 24 ++ model/runtime_expression.go | 18 +- 12 files changed, 880 insertions(+), 16 deletions(-) create mode 100644 expr/expr.go create mode 100644 impl/context.go create mode 100644 impl/impl.go create mode 100644 impl/task.go create mode 100644 impl/task_set_test.go create mode 100644 impl/task_test.go create mode 100644 impl/testdata/chained_set_tasks.yaml create mode 100644 impl/testdata/concatenating_strings.yaml create mode 100644 impl/testdata/conditional_logic.yaml create mode 100644 impl/testdata/sequential_set_colors.yaml create mode 100644 impl/utils.go diff --git a/expr/expr.go b/expr/expr.go new file mode 100644 index 0000000..e54fc3e --- /dev/null +++ b/expr/expr.go @@ -0,0 +1,95 @@ +package expr + +import ( + "errors" + "fmt" + "github.com/itchyny/gojq" + "strings" +) + +// IsStrictExpr returns true if the string is enclosed in `${ }` +func IsStrictExpr(expression string) bool { + return strings.HasPrefix(expression, "${") && strings.HasSuffix(expression, "}") +} + +// Sanitize processes the expression to ensure it's ready for evaluation +// It removes `${}` if present and replaces single quotes with double quotes +func Sanitize(expression string) string { + // Remove `${}` enclosure if present + if IsStrictExpr(expression) { + expression = strings.TrimSpace(expression[2 : len(expression)-1]) + } + + // Replace single quotes with double quotes + expression = strings.ReplaceAll(expression, "'", "\"") + + return expression +} + +// IsValid tries to parse and check if the given value is a valid expression +func IsValid(expression string) bool { + expression = Sanitize(expression) + _, err := gojq.Parse(expression) + return err == nil +} + +// TraverseAndEvaluate recursively processes and evaluates all expressions in a JSON-like structure +func TraverseAndEvaluate(node interface{}, input map[string]interface{}) (interface{}, error) { + switch v := node.(type) { + case map[string]interface{}: + // Traverse map + for key, value := range v { + evaluatedValue, err := TraverseAndEvaluate(value, input) + if err != nil { + return nil, err + } + v[key] = evaluatedValue + } + return v, nil + + case []interface{}: + // Traverse array + for i, value := range v { + evaluatedValue, err := TraverseAndEvaluate(value, input) + if err != nil { + return nil, err + } + v[i] = evaluatedValue + } + return v, nil + + case string: + // Check if the string is a runtime expression (e.g., ${ .some.path }) + if IsStrictExpr(v) { + return EvaluateJQExpression(Sanitize(v), input) + } + return v, nil + + default: + // Return other types as-is + return v, nil + } +} + +// EvaluateJQExpression evaluates a jq expression against a given JSON input +func EvaluateJQExpression(expression string, input map[string]interface{}) (interface{}, error) { + // Parse the sanitized jq expression + query, err := gojq.Parse(expression) + if err != nil { + return nil, fmt.Errorf("failed to parse jq expression: %s, error: %w", expression, err) + } + + // Compile and evaluate the expression + iter := query.Run(input) + result, ok := iter.Next() + if !ok { + return nil, errors.New("no result from jq evaluation") + } + + // Check if an error occurred during evaluation + if err, isErr := result.(error); isErr { + return nil, fmt.Errorf("jq evaluation error: %w", err) + } + + return result, nil +} diff --git a/impl/context.go b/impl/context.go new file mode 100644 index 0000000..93d4872 --- /dev/null +++ b/impl/context.go @@ -0,0 +1,93 @@ +package impl + +import ( + "context" + "errors" + "sync" +) + +type ctxKey string + +const executorCtxKey ctxKey = "executorContext" + +// ExecutorContext to not confound with Workflow Context as "$context" in the specification. +// This holds the necessary data for the workflow execution within the instance. +type ExecutorContext struct { + mu sync.Mutex + Input map[string]interface{} + Output map[string]interface{} + // Context or `$context` passed through the task executions see https://github.com/serverlessworkflow/specification/blob/main/dsl.md#data-flow + Context map[string]interface{} +} + +// SetWorkflowCtx safely sets the $context +func (execCtx *ExecutorContext) SetWorkflowCtx(wfCtx map[string]interface{}) { + execCtx.mu.Lock() + defer execCtx.mu.Unlock() + execCtx.Context = wfCtx +} + +// GetWorkflowCtx safely retrieves the $context +func (execCtx *ExecutorContext) GetWorkflowCtx() map[string]interface{} { + execCtx.mu.Lock() + defer execCtx.mu.Unlock() + return execCtx.Context +} + +// SetInput safely sets the input map +func (execCtx *ExecutorContext) SetInput(input map[string]interface{}) { + execCtx.mu.Lock() + defer execCtx.mu.Unlock() + execCtx.Input = input +} + +// GetInput safely retrieves the input map +func (execCtx *ExecutorContext) GetInput() map[string]interface{} { + execCtx.mu.Lock() + defer execCtx.mu.Unlock() + return execCtx.Input +} + +// SetOutput safely sets the output map +func (execCtx *ExecutorContext) SetOutput(output map[string]interface{}) { + execCtx.mu.Lock() + defer execCtx.mu.Unlock() + execCtx.Output = output +} + +// GetOutput safely retrieves the output map +func (execCtx *ExecutorContext) GetOutput() map[string]interface{} { + execCtx.mu.Lock() + defer execCtx.mu.Unlock() + return execCtx.Output +} + +// UpdateOutput allows adding or updating a single key-value pair in the output map +func (execCtx *ExecutorContext) UpdateOutput(key string, value interface{}) { + execCtx.mu.Lock() + defer execCtx.mu.Unlock() + if execCtx.Output == nil { + execCtx.Output = make(map[string]interface{}) + } + execCtx.Output[key] = value +} + +// GetOutputValue safely retrieves a single key from the output map +func (execCtx *ExecutorContext) GetOutputValue(key string) (interface{}, bool) { + execCtx.mu.Lock() + defer execCtx.mu.Unlock() + value, exists := execCtx.Output[key] + return value, exists +} + +func WithExecutorContext(parent context.Context, wfCtx *ExecutorContext) context.Context { + return context.WithValue(parent, executorCtxKey, wfCtx) +} + +func GetExecutorContext(ctx context.Context) (*ExecutorContext, error) { + wfCtx, ok := ctx.Value(executorCtxKey).(*ExecutorContext) + if !ok { + return nil, errors.New("workflow context not found") + } + return wfCtx, nil +} diff --git a/impl/impl.go b/impl/impl.go new file mode 100644 index 0000000..604ed8f --- /dev/null +++ b/impl/impl.go @@ -0,0 +1,90 @@ +package impl + +import ( + "context" + "fmt" + "github.com/serverlessworkflow/sdk-go/v3/model" +) + +type StatusPhase string + +const ( + PendingStatus StatusPhase = "pending" + RunningStatus StatusPhase = "running" + WaitingStatus StatusPhase = "waiting" + CancelledStatus StatusPhase = "cancelled" + FaultedStatus StatusPhase = "faulted" + CompletedStatus StatusPhase = "completed" +) + +var _ WorkflowRunner = &workflowRunnerImpl{} + +type WorkflowRunner interface { + GetWorkflow() *model.Workflow + Run(input map[string]interface{}) (output map[string]interface{}, err error) +} + +func NewDefaultRunner(workflow *model.Workflow) WorkflowRunner { + // later we can implement the opts pattern to define context timeout, deadline, cancel, etc. + // also fetch from the workflow model this information + ctx := WithExecutorContext(context.Background(), &ExecutorContext{}) + return &workflowRunnerImpl{ + Workflow: workflow, + Context: ctx, + } +} + +type workflowRunnerImpl struct { + Workflow *model.Workflow + Context context.Context +} + +func (wr *workflowRunnerImpl) GetWorkflow() *model.Workflow { + return wr.Workflow +} + +// Run the workflow. +// TODO: Sync execution, we think about async later +func (wr *workflowRunnerImpl) Run(input map[string]interface{}) (output map[string]interface{}, err error) { + output = make(map[string]interface{}) + if input == nil { + input = make(map[string]interface{}) + } + + // TODO: validates input via wr.Workflow.Input.Schema + + wfCtx, err := GetExecutorContext(wr.Context) + if err != nil { + return nil, err + } + wfCtx.SetInput(input) + wfCtx.SetOutput(output) + + // TODO: process wr.Workflow.Input.From, the result we set to WorkFlowCtx + wfCtx.SetWorkflowCtx(input) + + // Run tasks + // For each task, execute. + if wr.Workflow.Do != nil { + for _, taskItem := range *wr.Workflow.Do { + switch task := taskItem.Task.(type) { + case *model.SetTask: + exec, err := NewSetTaskExecutor(taskItem.Key, task) + if err != nil { + return nil, err + } + output, err = exec.Exec(wfCtx.GetWorkflowCtx()) + if err != nil { + return nil, err + } + wfCtx.SetWorkflowCtx(output) + default: + return nil, fmt.Errorf("workflow does not support task '%T' named '%s'", task, taskItem.Key) + } + } + } + + // Process output and return + + return output, err +} diff --git a/impl/task.go b/impl/task.go new file mode 100644 index 0000000..a50a995 --- /dev/null +++ b/impl/task.go @@ -0,0 +1,43 @@ +package impl + +import ( + "fmt" + "github.com/serverlessworkflow/sdk-go/v3/expr" + "github.com/serverlessworkflow/sdk-go/v3/model" +) + +var _ TaskExecutor = &SetTaskExecutor{} + +type TaskExecutor interface { + Exec(input map[string]interface{}) (map[string]interface{}, error) +} + +type SetTaskExecutor struct { + Task *model.SetTask + TaskName string +} + +func NewSetTaskExecutor(taskName string, task *model.SetTask) (*SetTaskExecutor, error) { + if task == nil || task.Set == nil { + return nil, fmt.Errorf("no set configuration provided for SetTask %s", taskName) + } + return &SetTaskExecutor{ + Task: task, + TaskName: taskName, + }, nil +} + +func (s *SetTaskExecutor) Exec(input map[string]interface{}) (output map[string]interface{}, err error) { + setObject := deepClone(s.Task.Set) + result, err := expr.TraverseAndEvaluate(setObject, input) + if err != nil { + return nil, fmt.Errorf("failed to execute Set task '%s': %w", s.TaskName, err) + } + + output, ok := result.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("expected output to be a map[string]interface{}, but got a different type. Got: %v", result) + } + + return output, nil +} diff --git a/impl/task_set_test.go b/impl/task_set_test.go new file mode 100644 index 0000000..269653c --- /dev/null +++ b/impl/task_set_test.go @@ -0,0 +1,401 @@ +package impl + +import ( + "github.com/serverlessworkflow/sdk-go/v3/model" + "github.com/stretchr/testify/assert" + "reflect" + "testing" +) + +func TestSetTaskExecutor_Exec(t *testing.T) { + input := map[string]interface{}{ + "configuration": map[string]interface{}{ + "size": map[string]interface{}{ + "width": 6, + "height": 6, + }, + "fill": map[string]interface{}{ + "red": 69, + "green": 69, + "blue": 69, + }, + }, + } + + setTask := &model.SetTask{ + Set: map[string]interface{}{ + "shape": "circle", + "size": "${ .configuration.size }", + "fill": "${ .configuration.fill }", + }, + } + + executor, err := NewSetTaskExecutor("task1", setTask) + assert.NoError(t, err) + + output, err := executor.Exec(input) + assert.NoError(t, err) + + expectedOutput := map[string]interface{}{ + "shape": "circle", + "size": map[string]interface{}{ + "width": 6, + "height": 6, + }, + "fill": map[string]interface{}{ + "red": 69, + "green": 69, + "blue": 69, + }, + } + + if !reflect.DeepEqual(output, expectedOutput) { + t.Errorf("expected %v, got %v", expectedOutput, output) + } +} + +func TestSetTaskExecutor_StaticValues(t *testing.T) { + input := map[string]interface{}{} + + setTask := &model.SetTask{ + Set: map[string]interface{}{ + "status": "completed", + "count": 10, + }, + } + + executor, err := NewSetTaskExecutor("task_static", setTask) + assert.NoError(t, err) + + output, err := executor.Exec(input) + assert.NoError(t, err) + + expectedOutput := map[string]interface{}{ + "status": "completed", + "count": 10, + } + + if !reflect.DeepEqual(output, expectedOutput) { + t.Errorf("expected %v, got %v", expectedOutput, output) + } +} + +func TestSetTaskExecutor_RuntimeExpressions(t *testing.T) { + input := map[string]interface{}{ + "user": map[string]interface{}{ + "firstName": "John", + "lastName": "Doe", + }, + } + + setTask := &model.SetTask{ + Set: map[string]interface{}{ + "fullName": "${ \"\\(.user.firstName) \\(.user.lastName)\" }", + }, + } + + executor, err := NewSetTaskExecutor("task_runtime_expr", setTask) + assert.NoError(t, err) + + output, err := executor.Exec(input) + assert.NoError(t, err) + + expectedOutput := map[string]interface{}{ + "fullName": "John Doe", + } + + if !reflect.DeepEqual(output, expectedOutput) { + t.Errorf("expected %v, got %v", expectedOutput, output) + } +} + +func TestSetTaskExecutor_NestedStructures(t *testing.T) { + input := map[string]interface{}{ + "order": map[string]interface{}{ + "id": 12345, + "items": []interface{}{"item1", "item2"}, + }, + } + + setTask := &model.SetTask{ + Set: map[string]interface{}{ + "orderDetails": map[string]interface{}{ + "orderId": "${ .order.id }", + "itemCount": "${ .order.items | length }", + }, + }, + } + + executor, err := NewSetTaskExecutor("task_nested_structures", setTask) + assert.NoError(t, err) + + output, err := executor.Exec(input) + assert.NoError(t, err) + + expectedOutput := map[string]interface{}{ + "orderDetails": map[string]interface{}{ + "orderId": 12345, + "itemCount": 2, + }, + } + + if !reflect.DeepEqual(output, expectedOutput) { + t.Errorf("expected %v, got %v", expectedOutput, output) + } +} + +func TestSetTaskExecutor_StaticAndDynamicValues(t *testing.T) { + input := map[string]interface{}{ + "config": map[string]interface{}{ + "threshold": 100, + }, + "metrics": map[string]interface{}{ + "current": 75, + }, + } + + setTask := &model.SetTask{ + Set: map[string]interface{}{ + "status": "active", + "remaining": "${ .config.threshold - .metrics.current }", + }, + } + + executor, err := NewSetTaskExecutor("task_static_dynamic", setTask) + assert.NoError(t, err) + + output, err := executor.Exec(input) + assert.NoError(t, err) + + expectedOutput := map[string]interface{}{ + "status": "active", + "remaining": 25, + } + + if !reflect.DeepEqual(output, expectedOutput) { + t.Errorf("expected %v, got %v", expectedOutput, output) + } +} + +func TestSetTaskExecutor_MissingInputData(t *testing.T) { + input := map[string]interface{}{} + + setTask := &model.SetTask{ + Set: map[string]interface{}{ + "value": "${ .missingField }", + }, + } + + executor, err := NewSetTaskExecutor("task_missing_input", setTask) + assert.NoError(t, err) + + output, err := executor.Exec(input) + assert.NoError(t, err) + assert.Nil(t, output["value"]) +} + +func TestSetTaskExecutor_ExpressionsWithFunctions(t *testing.T) { + input := map[string]interface{}{ + "values": []interface{}{1, 2, 3, 4, 5}, + } + + setTask := &model.SetTask{ + Set: map[string]interface{}{ + "sum": "${ .values | map(.) | add }", + }, + } + + executor, err := NewSetTaskExecutor("task_expr_functions", setTask) + assert.NoError(t, err) + + output, err := executor.Exec(input) + assert.NoError(t, err) + + expectedOutput := map[string]interface{}{ + "sum": 15, + } + + if !reflect.DeepEqual(output, expectedOutput) { + t.Errorf("expected %v, got %v", expectedOutput, output) + } +} + +func TestSetTaskExecutor_ConditionalExpressions(t *testing.T) { + input := map[string]interface{}{ + "temperature": 30, + } + + setTask := &model.SetTask{ + Set: map[string]interface{}{ + "weather": "${ if .temperature > 25 then 'hot' else 'cold' end }", + }, + } + + executor, err := NewSetTaskExecutor("task_conditional_expr", setTask) + assert.NoError(t, err) + + output, err := executor.Exec(input) + assert.NoError(t, err) + + expectedOutput := map[string]interface{}{ + "weather": "hot", + } + + if !reflect.DeepEqual(output, expectedOutput) { + t.Errorf("expected %v, got %v", expectedOutput, output) + } +} + +func TestSetTaskExecutor_ArrayDynamicIndex(t *testing.T) { + input := map[string]interface{}{ + "items": []interface{}{"apple", "banana", "cherry"}, + "index": 1, + } + + setTask := &model.SetTask{ + Set: map[string]interface{}{ + "selectedItem": "${ .items[.index] }", + }, + } + + executor, err := NewSetTaskExecutor("task_array_indexing", setTask) + assert.NoError(t, err) + + output, err := executor.Exec(input) + assert.NoError(t, err) + + expectedOutput := map[string]interface{}{ + "selectedItem": "banana", + } + + if !reflect.DeepEqual(output, expectedOutput) { + t.Errorf("expected %v, got %v", expectedOutput, output) + } +} + +func TestSetTaskExecutor_NestedConditionalLogic(t *testing.T) { + input := map[string]interface{}{ + "age": 20, + } + + setTask := &model.SetTask{ + Set: map[string]interface{}{ + "status": "${ if .age < 18 then 'minor' else if .age < 65 then 'adult' else 'senior' end end }", + }, + } + + executor, err := NewSetTaskExecutor("task_nested_condition", setTask) + assert.NoError(t, err) + + output, err := executor.Exec(input) + assert.NoError(t, err) + + expectedOutput := map[string]interface{}{ + "status": "adult", + } + + if !reflect.DeepEqual(output, expectedOutput) { + t.Errorf("expected %v, got %v", expectedOutput, output) + } +} + +func TestSetTaskExecutor_DefaultValues(t *testing.T) { + input := map[string]interface{}{} + + setTask := &model.SetTask{ + Set: map[string]interface{}{ + "value": "${ .missingField // 'defaultValue' }", + }, + } + + executor, err := NewSetTaskExecutor("task_default_values", setTask) + assert.NoError(t, err) + + output, err := executor.Exec(input) + assert.NoError(t, err) + + expectedOutput := map[string]interface{}{ + "value": "defaultValue", + } + + if !reflect.DeepEqual(output, expectedOutput) { + t.Errorf("expected %v, got %v", expectedOutput, output) + } +} + +func TestSetTaskExecutor_ComplexNestedStructures(t *testing.T) { + input := map[string]interface{}{ + "config": map[string]interface{}{ + "dimensions": map[string]interface{}{ + "width": 10, + "height": 5, + }, + }, + "meta": map[string]interface{}{ + "color": "blue", + }, + } + + setTask := &model.SetTask{ + Set: map[string]interface{}{ + "shape": map[string]interface{}{ + "type": "rectangle", + "width": "${ .config.dimensions.width }", + "height": "${ .config.dimensions.height }", + "color": "${ .meta.color }", + "area": "${ .config.dimensions.width * .config.dimensions.height }", + }, + }, + } + + executor, err := NewSetTaskExecutor("task_complex_nested", setTask) + assert.NoError(t, err) + + output, err := executor.Exec(input) + assert.NoError(t, err) + + expectedOutput := map[string]interface{}{ + "shape": map[string]interface{}{ + "type": "rectangle", + "width": 10, + "height": 5, + "color": "blue", + "area": 50, + }, + } + + if !reflect.DeepEqual(output, expectedOutput) { + t.Errorf("expected %v, got %v", expectedOutput, output) + } +} + +func TestSetTaskExecutor_MultipleExpressions(t *testing.T) { + input := map[string]interface{}{ + "user": map[string]interface{}{ + "name": "Alice", + "email": "alice@example.com", + }, + } + + setTask := &model.SetTask{ + Set: map[string]interface{}{ + "username": "${ .user.name }", + "contact": "${ .user.email }", + }, + } + + executor, err := NewSetTaskExecutor("task_multiple_expr", setTask) + assert.NoError(t, err) + + output, err := executor.Exec(input) + assert.NoError(t, err) + + expectedOutput := map[string]interface{}{ + "username": "Alice", + "contact": "alice@example.com", + } + + if !reflect.DeepEqual(output, expectedOutput) { + t.Errorf("expected %v, got %v", expectedOutput, output) + } +} diff --git a/impl/task_test.go b/impl/task_test.go new file mode 100644 index 0000000..78b89a9 --- /dev/null +++ b/impl/task_test.go @@ -0,0 +1,73 @@ +package impl + +import ( + "github.com/serverlessworkflow/sdk-go/v3/parser" + "github.com/stretchr/testify/assert" + "io/ioutil" + "path/filepath" + "testing" +) + +// runWorkflowTest is a reusable test function for workflows +func runWorkflowTest(t *testing.T, workflowPath string, input, expectedOutput map[string]interface{}) { + // Read the workflow YAML from the testdata directory + yamlBytes, err := ioutil.ReadFile(filepath.Clean(workflowPath)) + assert.NoError(t, err, "Failed to read workflow YAML file") + + // Parse the YAML workflow + workflow, err := parser.FromYAMLSource(yamlBytes) + assert.NoError(t, err, "Failed to parse workflow YAML") + + // Initialize the workflow runner + runner := NewDefaultRunner(workflow) + + // Run the workflow + output, err := runner.Run(input) + + // Assertions + assert.NoError(t, err) + assert.Equal(t, expectedOutput, output, "Workflow output mismatch") +} + +// TestWorkflowRunner_Run_YAML validates multiple workflows +func TestWorkflowRunner_Run_YAML(t *testing.T) { + // Workflow 1: Chained Set Tasks + t.Run("Chained Set Tasks", func(t *testing.T) { + workflowPath := "./testdata/chained_set_tasks.yaml" + input := map[string]interface{}{} + expectedOutput := map[string]interface{}{ + "tripled": float64(60), + } + runWorkflowTest(t, workflowPath, input, expectedOutput) + }) + + // Workflow 2: Concatenating Strings + t.Run("Concatenating Strings", func(t *testing.T) { + workflowPath := "./testdata/concatenating_strings.yaml" + input := map[string]interface{}{} + expectedOutput := map[string]interface{}{ + "fullName": "John Doe", + } + runWorkflowTest(t, workflowPath, input, expectedOutput) + }) + + // Workflow 3: Conditional Logic + t.Run("Conditional Logic", func(t *testing.T) { + workflowPath := "./testdata/conditional_logic.yaml" + input := map[string]interface{}{} + expectedOutput := map[string]interface{}{ + "weather": "hot", + } + runWorkflowTest(t, workflowPath, input, expectedOutput) + }) + + t.Run("Conditional Logic", func(t *testing.T) { + workflowPath := "./testdata/sequential_set_colors.yaml" + // Define the input and expected output + input := map[string]interface{}{} + expectedOutput := map[string]interface{}{ + "colors": []interface{}{"red", "green", "blue"}, + } + runWorkflowTest(t, workflowPath, input, expectedOutput) + }) +} diff --git a/impl/testdata/chained_set_tasks.yaml b/impl/testdata/chained_set_tasks.yaml new file mode 100644 index 0000000..b1388dd --- /dev/null +++ b/impl/testdata/chained_set_tasks.yaml @@ -0,0 +1,15 @@ +document: + name: chained-workflow + dsl: '1.0.0-alpha5' + namespace: default + version: '1.0.0' +do: + - task1: + set: + baseValue: 10 + - task2: + set: + doubled: "${ .baseValue * 2 }" + - task3: + set: + tripled: "${ .doubled * 3 }" diff --git a/impl/testdata/concatenating_strings.yaml b/impl/testdata/concatenating_strings.yaml new file mode 100644 index 0000000..a0b3a84 --- /dev/null +++ b/impl/testdata/concatenating_strings.yaml @@ -0,0 +1,17 @@ +document: + name: concatenating-strings + dsl: '1.0.0-alpha5' + namespace: default + version: '1.0.0' +do: + - task1: + set: + firstName: "John" + lastName: "" + - task2: + set: + firstName: "${ .firstName }" + lastName: "Doe" + - task3: + set: + fullName: "${ .firstName + ' ' + .lastName }" diff --git a/impl/testdata/conditional_logic.yaml b/impl/testdata/conditional_logic.yaml new file mode 100644 index 0000000..dfff8f8 --- /dev/null +++ b/impl/testdata/conditional_logic.yaml @@ -0,0 +1,12 @@ +document: + name: conditional-logic + dsl: '1.0.0-alpha5' + namespace: default + version: '1.0.0' +do: + - task1: + set: + temperature: 30 + - task2: + set: + weather: "${ if .temperature > 25 then 'hot' else 'cold' end }" diff --git a/impl/testdata/sequential_set_colors.yaml b/impl/testdata/sequential_set_colors.yaml new file mode 100644 index 0000000..73162cc --- /dev/null +++ b/impl/testdata/sequential_set_colors.yaml @@ -0,0 +1,15 @@ +document: + dsl: '1.0.0-alpha5' + namespace: default + name: do + version: '1.0.0' +do: + - setRed: + set: + colors: ${ .colors + ["red"] } + - setGreen: + set: + colors: ${ .colors + ["green"] } + - setBlue: + set: + colors: ${ .colors + ["blue"] } \ No newline at end of file diff --git a/impl/utils.go b/impl/utils.go new file mode 100644 index 0000000..fa80ef9 --- /dev/null +++ b/impl/utils.go @@ -0,0 +1,24 @@ +package impl + +// Deep clone a map to avoid modifying the original object +func deepClone(obj map[string]interface{}) map[string]interface{} { + clone := make(map[string]interface{}) + for key, value := range obj { + clone[key] = deepCloneValue(value) + } + return clone +} + +func deepCloneValue(value interface{}) interface{} { + if m, ok := value.(map[string]interface{}); ok { + return deepClone(m) + } + if s, ok := value.([]interface{}); ok { + clonedSlice := make([]interface{}, len(s)) + for i, v := range s { + clonedSlice[i] = deepCloneValue(v) + } + return clonedSlice + } + return value +} diff --git a/model/runtime_expression.go b/model/runtime_expression.go index c67a3ef..f7ba5c8 100644 --- a/model/runtime_expression.go +++ b/model/runtime_expression.go @@ -17,8 +17,7 @@ package model import ( "encoding/json" "fmt" - "github.com/itchyny/gojq" - "strings" + "github.com/serverlessworkflow/sdk-go/v3/expr" ) // RuntimeExpression represents a runtime expression. @@ -34,22 +33,9 @@ func NewExpr(runtimeExpression string) *RuntimeExpression { return &RuntimeExpression{Value: runtimeExpression} } -// preprocessExpression removes `${}` if present and returns the inner content. -func preprocessExpression(expression string) string { - if strings.HasPrefix(expression, "${") && strings.HasSuffix(expression, "}") { - return strings.TrimSpace(expression[2 : len(expression)-1]) - } - return expression // Return the expression as-is if `${}` are not present -} - // IsValid checks if the RuntimeExpression value is valid, handling both with and without `${}`. func (r *RuntimeExpression) IsValid() bool { - // Preprocess to extract content inside `${}` if present - processedExpr := preprocessExpression(r.Value) - - // Validate the processed expression using gojq - _, err := gojq.Parse(processedExpr) - return err == nil + return expr.IsValid(r.Value) } // UnmarshalJSON implements custom unmarshalling for RuntimeExpression.