From 1e1abfa10048e1ea1ae856c8573c5d4ffae0dabc Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Tue, 30 Jan 2024 21:22:32 +0100 Subject: [PATCH] Add support for list/array conversion in JavaScript evaluator A StringObjectDictionaryConverter has been added, which converts all IList fields of an object to array fields. This enhances the interaction of JS expressions with list types, allowing common Array methods to be applied directly. Furthermore, defaults have been added to the ExpressionEvaluatorOptions parameters to improve usability. Lastly, adjustments were made to ensure variables are correctly set while evaluating expressions. --- src/bundles/Elsa.Server.Web/Program.cs | 15 +++++-- .../Contracts/IJavaScriptEvaluator.cs | 2 +- .../StringObjectDictionaryConverter.cs | 40 +++++++++++++++++++ .../Services/JintJavaScriptEvaluator.cs | 13 +++--- .../ExpressionExecutionContextExtensions.cs | 12 +++--- .../JavaScriptListsAndArrays/Tests.cs | 40 +++++++++++++++++++ 6 files changed, 107 insertions(+), 15 deletions(-) create mode 100644 src/modules/Elsa.JavaScript/Helpers/StringObjectDictionaryConverter.cs create mode 100644 test/integration/Elsa.IntegrationTests/Scenarios/JavaScriptListsAndArrays/Tests.cs diff --git a/src/bundles/Elsa.Server.Web/Program.cs b/src/bundles/Elsa.Server.Web/Program.cs index 1c35e86c8..ab141ca62 100644 --- a/src/bundles/Elsa.Server.Web/Program.cs +++ b/src/bundles/Elsa.Server.Web/Program.cs @@ -26,8 +26,8 @@ const bool useDapper = false; const bool useProtoActor = false; const bool useHangfire = false; const bool useQuartz = true; -const bool useMassTransit = true; -const bool useMassTransitAzureServiceBus = false; +const bool useMassTransit = false; +const bool useMassTransitAzureServiceBus = true; const bool useMassTransitRabbitMq = false; var builder = WebApplication.CreateBuilder(args); @@ -133,7 +133,10 @@ services }); } - runtime.UseMassTransitDispatcher(); + if(useMassTransit) + { + runtime.UseMassTransitDispatcher(); + } runtime.WorkflowInboxCleanupOptions = options => configuration.GetSection("Runtime:WorkflowInboxCleanup").Bind(options); }) .UseEnvironments(environments => environments.EnvironmentsOptions = options => configuration.GetSection("Environments").Bind(options)) @@ -186,7 +189,11 @@ services ef.UseSqlite(sqliteConnectionString); }); - alterations.UseMassTransitDispatcher(); + if (useMassTransit) + { + alterations.UseMassTransitDispatcher(); + } + }) .UseWorkflowContexts(); diff --git a/src/modules/Elsa.JavaScript/Contracts/IJavaScriptEvaluator.cs b/src/modules/Elsa.JavaScript/Contracts/IJavaScriptEvaluator.cs index 675813923..4e7882e21 100644 --- a/src/modules/Elsa.JavaScript/Contracts/IJavaScriptEvaluator.cs +++ b/src/modules/Elsa.JavaScript/Contracts/IJavaScriptEvaluator.cs @@ -24,7 +24,7 @@ public interface IJavaScriptEvaluator string expression, Type returnType, ExpressionExecutionContext context, - ExpressionEvaluatorOptions options, + ExpressionEvaluatorOptions? options = default, Action? configureEngine = default, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/modules/Elsa.JavaScript/Helpers/StringObjectDictionaryConverter.cs b/src/modules/Elsa.JavaScript/Helpers/StringObjectDictionaryConverter.cs new file mode 100644 index 000000000..41b8f593a --- /dev/null +++ b/src/modules/Elsa.JavaScript/Helpers/StringObjectDictionaryConverter.cs @@ -0,0 +1,40 @@ +using System.Collections; + +namespace Elsa.JavaScript.Helpers; + +/// +/// Contains methods for converting dictionaries with string keys and object values by replacing IList fields with Array fields. +/// +public static class StringObjectDictionaryConverter +{ + /// + /// Recursively converts all IList fields of an ExpandoObject to Array fields. + /// This allows JS expressions to properly use Array methods on lists, such as .length, filter, etc. + /// + public static object? ConvertListsToArray(object? value) + { + if (value is not IDictionary dictionary) + return value; + + // Copy the dictionary to avoid modifying the original. + dictionary = new Dictionary(dictionary); + var keys = dictionary.Keys.ToList(); + foreach (var key in keys) + { + if (dictionary[key] is IList && dictionary[key].GetType().IsGenericType) + { + var list = (IList)dictionary[key]; + var elementType = dictionary[key].GetType().GetGenericArguments()[0]; + var array = Array.CreateInstance(elementType, list.Count); + list.CopyTo(array, 0); + dictionary[key] = array; + } + else + { + ConvertListsToArray(dictionary[key]); + } + } + + return dictionary; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.JavaScript/Services/JintJavaScriptEvaluator.cs b/src/modules/Elsa.JavaScript/Services/JintJavaScriptEvaluator.cs index 53adfb070..127043d00 100644 --- a/src/modules/Elsa.JavaScript/Services/JintJavaScriptEvaluator.cs +++ b/src/modules/Elsa.JavaScript/Services/JintJavaScriptEvaluator.cs @@ -5,6 +5,7 @@ using Elsa.Expressions.Helpers; using Elsa.Expressions.Models; using Elsa.Extensions; using Elsa.JavaScript.Contracts; +using Elsa.JavaScript.Helpers; using Elsa.JavaScript.Notifications; using Elsa.JavaScript.Options; using Elsa.Mediator.Contracts; @@ -37,7 +38,7 @@ public class JintJavaScriptEvaluator : IJavaScriptEvaluator public async Task EvaluateAsync(string expression, Type returnType, ExpressionExecutionContext context, - ExpressionEvaluatorOptions options, + ExpressionEvaluatorOptions? options = default, Action? configureEngine = default, CancellationToken cancellationToken = default) { @@ -47,8 +48,10 @@ public class JintJavaScriptEvaluator : IJavaScriptEvaluator return result.ConvertTo(returnType); } - private async Task GetConfiguredEngine(Action? configureEngine, ExpressionExecutionContext context, ExpressionEvaluatorOptions options, CancellationToken cancellationToken) + private async Task GetConfiguredEngine(Action? configureEngine, ExpressionExecutionContext context, ExpressionEvaluatorOptions? options, CancellationToken cancellationToken) { + options ??= new ExpressionEvaluatorOptions(); + var engine = new Engine(opts => { if (_jintOptions.AllowClrAccess) @@ -76,11 +79,11 @@ public class JintJavaScriptEvaluator : IJavaScriptEvaluator // Create output getters for each activity. CreateActivityOutputAccessors(engine, context); - + // Create argument getters for each argument. foreach (var argument in options.Arguments) engine.SetValue($"get{argument.Key}", (Func)(() => argument.Value)); - + // Add common functions. engine.SetValue("isNullOrWhiteSpace", (Func)(value => string.IsNullOrWhiteSpace(value))); engine.SetValue("isNullOrEmpty", (Func)(value => string.IsNullOrEmpty(value))); @@ -119,7 +122,7 @@ public class JintJavaScriptEvaluator : IJavaScriptEvaluator var inputs = context.GetWorkflowInputs(); foreach (var input in inputs) - engine.SetValue($"get{input.Name}", (Func)(() => input.Value)); + engine.SetValue($"get{input.Name}", (Func)(() => StringObjectDictionaryConverter.ConvertListsToArray(input.Value))); } private static void CreateVariableAccessors(Engine engine, ExpressionExecutionContext context) diff --git a/src/modules/Elsa.Workflows.Core/Extensions/ExpressionExecutionContextExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/ExpressionExecutionContextExtensions.cs index b93272793..9eef19bc6 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/ExpressionExecutionContextExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/ExpressionExecutionContextExtensions.cs @@ -292,10 +292,10 @@ public static class ExpressionExecutionContextExtensions select v; var variable = q.FirstOrDefault(); - - if(variable != null) + + if (variable != null) variable.Set(context, value); - + if (variable == null) CreateVariable(context, variableName, value); } @@ -362,7 +362,7 @@ public static class ExpressionExecutionContextExtensions var input = workflowExecutionContext.Input; return input.TryGetValue(name, out var value) ? value : default; } - + /// /// Returns the value of the specified input. /// @@ -393,7 +393,9 @@ public static class ExpressionExecutionContextExtensions /// public static IEnumerable GetActivityOutputs(this ExpressionExecutionContext context) { - var activityExecutionContext = context.GetActivityExecutionContext(); + if (!context.TryGetActivityExecutionContext(out var activityExecutionContext)) + yield break; + var useActivityName = activityExecutionContext.WorkflowExecutionContext.Workflow.CreatedWithModernTooling(); var activitiesWithOutputs = activityExecutionContext.GetActivitiesWithOutputs(); diff --git a/test/integration/Elsa.IntegrationTests/Scenarios/JavaScriptListsAndArrays/Tests.cs b/test/integration/Elsa.IntegrationTests/Scenarios/JavaScriptListsAndArrays/Tests.cs new file mode 100644 index 000000000..66b705160 --- /dev/null +++ b/test/integration/Elsa.IntegrationTests/Scenarios/JavaScriptListsAndArrays/Tests.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Dynamic; +using System.Threading.Tasks; +using Elsa.Expressions.Models; +using Elsa.Extensions; +using Elsa.JavaScript.Contracts; +using Elsa.Testing.Shared; +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using Xunit.Abstractions; + +namespace Elsa.IntegrationTests.Scenarios.JavaScriptListsAndArrays; + +public class Tests +{ + private readonly IServiceProvider _services; + private readonly IJavaScriptEvaluator _evaluator; + + public Tests(ITestOutputHelper testOutputHelper) + { + var testOutputHelper1 = testOutputHelper ?? throw new ArgumentNullException(nameof(testOutputHelper)); + _services = new TestApplicationBuilder(testOutputHelper1).Build(); + _evaluator = _services.GetRequiredService(); + } + + [Fact(DisplayName = "Workflow inputs containing .NET lists on dynamic objects are converted to arrays for use in JavaScript.")] + public async Task Test1() + { + dynamic dynamicObject = new ExpandoObject(); + + dynamicObject.List = new List { "a", "b", "c" }; + var script = "getObj().List.filter(x => x === 'b').length === 1"; + var context = new ExpressionExecutionContext(_services, new MemoryRegister()); + context.SetVariable("obj", (object)dynamicObject); + var result = await _evaluator.EvaluateAsync(script, typeof(bool), context); + + Assert.True((bool)result!); + } +} \ No newline at end of file