Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for collections and dictionaries of input #5429

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Elsa.Workflows;
using Elsa.Workflows.Models;
using System.Collections;

// ReSharper disable once CheckNamespace
namespace Elsa.Extensions;
Expand Down Expand Up @@ -43,6 +44,22 @@ public static IEnumerable<InputDescriptor> GetNakedInputPropertyDescriptors(this
return inputLookup;
}

/// <summary>
/// Returns each collection of input from the specified activity.
/// </summary>
public static IDictionary<string, ICollection?> GetCollectionOfInputProperties(this ActivityDescriptor activityDescriptor, IActivity activity)
{
return activityDescriptor.Inputs.Where(x => x.IsCollectionOfInput).ToDictionary(x => x.Name, x => (ICollection?)x.ValueGetter(activity));
}

/// <summary>
/// Returns each dictionary with input value from the specified activity.
/// </summary>
public static IDictionary<string, IDictionary?> GetDictionaryWithValueOfInputProperties(this ActivityDescriptor activityDescriptor, IActivity activity)
{
return activityDescriptor.Inputs.Where(x => x.IsDictionaryWithValueOfInput).ToDictionary(x => x.Name, x => (IDictionary?)x.ValueGetter(activity));
}

/// <summary>
/// Returns each input descriptor from the specified activity.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Collections;
using System.Linq.Expressions;
using Elsa.Expressions.Contracts;
using Elsa.Expressions.Helpers;
Expand Down Expand Up @@ -105,6 +106,61 @@ public static async Task EvaluateInputPropertiesAsync(this ActivityExecutionCont
context.ExpressionExecutionContext.Set(memoryReference, value!);
}
}
else if (inputDescriptor.IsCollectionOfInput)
{
ICollection? collectionOfInput = input as ICollection;
if (collectionOfInput != null)
{
// create a temporary list of values - this will be serialized and added to context.ActivityState below
var listOfValues = new List<object?>();
foreach (Input? wrappedInput in collectionOfInput)
{
var evaluator = context.GetRequiredService<IExpressionEvaluator>();
var expressionExecutionContext = context.ExpressionExecutionContext;
object? currentValue = wrappedInput?.Expression != null ? await evaluator.EvaluateAsync(wrappedInput, expressionExecutionContext) : defaultValue;

var memoryReference = wrappedInput?.MemoryBlockReference();

// When input is created from an activity provider, there may be no memory block reference.
if (memoryReference?.Id != null!)
{
// Declare the input memory block on the current context.
context.ExpressionExecutionContext.Set(memoryReference, currentValue!);
}

listOfValues.Add(currentValue);
}
value = listOfValues;
}
}
else if (inputDescriptor.IsDictionaryWithValueOfInput)
{
IDictionary? dictionaryWithValueOfInput = input as IDictionary;
if (dictionaryWithValueOfInput != null)
{
// create a temporary dictionary of values - this will be serialized and added to context.ActivityState below
var mapOfValues = new Dictionary<object, object?>();
foreach (DictionaryEntry entry in dictionaryWithValueOfInput)
{
Input? wrappedInput = entry.Value as Input;
var evaluator = context.GetRequiredService<IExpressionEvaluator>();
var expressionExecutionContext = context.ExpressionExecutionContext;
object? currentValue = wrappedInput?.Expression != null ? await evaluator.EvaluateAsync(wrappedInput, expressionExecutionContext) : defaultValue;

var memoryReference = wrappedInput?.MemoryBlockReference();

// When input is created from an activity provider, there may be no memory block reference.
if (memoryReference?.Id != null!)
{
// Declare the input memory block on the current context.
context.ExpressionExecutionContext.Set(memoryReference, currentValue!);
}

mapOfValues.Add(entry.Key, currentValue);
}
value = mapOfValues;
}
}
else
{
value = input;
Expand Down
14 changes: 14 additions & 0 deletions src/modules/Elsa.Workflows.Core/Models/InputDescriptor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ public InputDescriptor(
Func<IActivity, object?> valueGetter,
Action<IActivity, object?> valueSetter,
bool isWrapped,
bool isCollectionOfInput,
bool isDictionaryWithValueOfInput,
string uiHint,
string displayName,
string? description = default,
Expand All @@ -41,6 +43,8 @@ public InputDescriptor(
ValueGetter = valueGetter;
ValueSetter = valueSetter;
IsWrapped = isWrapped;
IsCollectionOfInput = isCollectionOfInput;
IsDictionaryWithValueOfInput = isDictionaryWithValueOfInput;
UIHint = uiHint;
DisplayName = displayName;
Description = description;
Expand All @@ -63,6 +67,16 @@ public InputDescriptor(
/// </summary>
public bool IsWrapped { get; set; }

/// <summary>
/// True if the property is a collection of <see cref="Input{T}"/>, false otherwise.
/// </summary>
public bool IsCollectionOfInput { get; set; }

/// <summary>
/// True if the property is a dictionary with <see cref="Input{T}"/> values, false otherwise.
/// </summary>
public bool IsDictionaryWithValueOfInput { get; set; }

/// <summary>
/// A string value that hints at what UI control might be used to render in a UI tool.
/// </summary>
Expand Down
41 changes: 40 additions & 1 deletion src/modules/Elsa.Workflows.Core/Services/ActivityDescriber.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,42 @@ where typeof(IActivity).IsAssignableFrom(prop.PropertyType)

/// <inheritdoc />
public IEnumerable<PropertyInfo> GetInputProperties([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type activityType) =>
activityType.GetProperties().Where(x => typeof(Input).IsAssignableFrom(x.PropertyType) || x.GetCustomAttribute<InputAttribute>() != null).DistinctBy(x => x.Name);
activityType.GetProperties().Where(x => typeof(Input).IsAssignableFrom(x.PropertyType) || x.GetCustomAttribute<InputAttribute>() != null || IsCollectionOfType(x, typeof(Input)) || IsDictionaryTypeWithValueOfType(x, typeof(Input))).DistinctBy(x => x.Name);

private bool IsCollectionOfType(PropertyInfo propertyInfo, Type type)
{
if (!propertyInfo.PropertyType.IsGenericType)
{
return false;
}

Type[] genericTypes = propertyInfo.PropertyType.GenericTypeArguments;
if (genericTypes.Length != 1 || !type.IsAssignableFrom(genericTypes[0]))
{
return false;
}

Type? collectionType = typeof(ICollection<>).MakeGenericType(genericTypes);
return collectionType?.IsAssignableFrom(propertyInfo.PropertyType) ?? false;
}

private bool IsDictionaryTypeWithValueOfType(PropertyInfo propertyInfo, Type type)
{
if (!propertyInfo.PropertyType.IsGenericType)
{
return false;
}

Type[] genericTypes = propertyInfo.PropertyType.GenericTypeArguments;
if (genericTypes.Length != 2 || !type.IsAssignableFrom(genericTypes[1]))
{
return false;
}

Type? dictionaryType = typeof(IDictionary<,>).MakeGenericType(genericTypes);
return dictionaryType?.IsAssignableFrom(propertyInfo.PropertyType) ?? false;
}


/// <inheritdoc />
public IEnumerable<PropertyInfo> GetOutputProperties([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type activityType) =>
Expand Down Expand Up @@ -154,6 +189,8 @@ public async Task<InputDescriptor> DescribeInputPropertyAsync(PropertyInfo prope
var isWrappedProperty = typeof(Input).IsAssignableFrom(propertyType);
var autoEvaluate = inputAttribute?.AutoEvaluate ?? true;
var wrappedPropertyType = !isWrappedProperty ? propertyType : propertyInfo.PropertyType.GenericTypeArguments[0];
var isCollectionOfInput = IsCollectionOfType(propertyInfo, typeof(Input));
var isDictionaryWithValueOfInput = IsDictionaryTypeWithValueOfType(propertyInfo, typeof(Input));

if (wrappedPropertyType.IsNullableType())
wrappedPropertyType = wrappedPropertyType.GetTypeOfNullable();
Expand All @@ -167,6 +204,8 @@ public async Task<InputDescriptor> DescribeInputPropertyAsync(PropertyInfo prope
propertyInfo.GetValue,
propertyInfo.SetValue,
isWrappedProperty,
isCollectionOfInput,
isDictionaryWithValueOfInput,
GetUIHint(wrappedPropertyType, inputAttribute),
inputAttribute?.DisplayName ?? propertyInfo.Name.Humanize(LetterCasing.Title),
descriptionAttribute?.Description ?? inputAttribute?.Description,
Expand Down
63 changes: 43 additions & 20 deletions src/modules/Elsa.Workflows.Core/Services/IdentityGraphService.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Elsa.Expressions.Models;
using Elsa.Extensions;
using Elsa.Workflows.Activities;
using Elsa.Workflows.Models;
Expand Down Expand Up @@ -52,32 +53,54 @@ public async Task AssignInputOutputsAsync(IActivity activity)
return;
}


var inputDictionary = activityDescriptor.GetWrappedInputProperties(activity);

foreach (var (inputName, input) in inputDictionary)
{
var blockReference = input?.MemoryBlockReference();

if (blockReference == null!)
continue;

if (string.IsNullOrEmpty(blockReference.Id))
blockReference.Id = $"{activity.Id}:input-{inputName.Humanize().Kebaberize()}";
}

AssignBlockReference(input?.MemoryBlockReference(), () => $"{activity.Id}:input-{inputName.Humanize().Kebaberize()}");
}

var collectionOfInputDictionary = activityDescriptor.GetCollectionOfInputProperties(activity);
foreach (var (inputName, collectionOfInput) in collectionOfInputDictionary)
{
if (collectionOfInput != null)
{
int i = 0;
foreach (Input? input in collectionOfInput)
{
AssignBlockReference(input?.MemoryBlockReference(), () => $"{activity.Id}:input-{inputName.Humanize().Kebaberize()}:{++i}");
}
}
}

var dictionaryOfInputDictionary = activityDescriptor.GetDictionaryWithValueOfInputProperties(activity);
foreach (var (inputName, dictionaryWithValueOfInput) in dictionaryOfInputDictionary)
{
if (dictionaryWithValueOfInput != null)
{
int i = 0;
foreach (Input? input in dictionaryWithValueOfInput.Values)
{
AssignBlockReference(input?.MemoryBlockReference(), () => $"{activity.Id}:input-{inputName.Humanize().Kebaberize()}:{++i}");
}
}
}

var outputs = activity.GetOutputs();

foreach (var output in outputs)
{
var blockReference = output.Value.MemoryBlockReference();

if (blockReference == null!)
continue;

if (string.IsNullOrEmpty(blockReference.Id))
blockReference.Id = $"{activity.Id}:output-{output.Name.Humanize().Kebaberize()}";
{
AssignBlockReference(output?.Value.MemoryBlockReference(), () => $"{activity.Id}:output-{output!.Name.Humanize().Kebaberize()}");
}
}
}

private void AssignBlockReference(MemoryBlockReference? blockReference, Func<string> idFactory)
{
if (blockReference == null!)
return;

if (string.IsNullOrEmpty(blockReference.Id))
blockReference.Id = idFactory();
}

/// <inheritdoc />
public void AssignVariables(IVariableContainer activity)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using Elsa.Testing.Shared;
using Microsoft.Extensions.DependencyInjection;
using Xunit.Abstractions;

namespace Elsa.Workflows.IntegrationTests.Activities.CollectionInputs;
public class CollectionsOfInputTests
{
private readonly IWorkflowRunner _workflowRunner;
private readonly CapturingTextWriter _capturingTextWriter = new();
private readonly IServiceProvider _services;

public CollectionsOfInputTests(ITestOutputHelper testOutputHelper)
{
_services = new TestApplicationBuilder(testOutputHelper).WithCapturingTextWriter(_capturingTextWriter).Build();
_workflowRunner = _services.GetRequiredService<IWorkflowRunner>();
}

[Fact]
public async Task CollectionOfInputsTest()
{
await _services.PopulateRegistriesAsync();
await _workflowRunner.RunAsync<WriteMultiLineWorkflow>();
var lines = _capturingTextWriter.Lines.ToArray();
Assert.Equal(new[] { "banana", "orange", "apple" }, lines);
}

[Fact]
public async Task DictionaryOfInputValuesTest()
{
await _services.PopulateRegistriesAsync();
await _workflowRunner.RunAsync<DynamicArgumentsWorkflow>();
var lines = _capturingTextWriter.Lines.ToArray();
Assert.Equal(new[] { "name: Frank (string)", "isAdmin: False (bool)", "age: 42 (double)" }, lines);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using System.Runtime.CompilerServices;
using Elsa.Workflows.Models;

namespace Elsa.Workflows.IntegrationTests.Activities.CollectionInputs;

public class DynamicArguments : CodeActivity
{
public DynamicArguments([CallerFilePath] string? source = default, [CallerLineNumber] int? line = default) : base(source, line) { }

public DynamicArguments(Dictionary<string, Input<object>> arguments, [CallerFilePath] string? source = default, [CallerLineNumber] int? line = default) : this(source, line) => Arguments = arguments;

public Dictionary<string, Input<object>>? Arguments { get; set; } = default;

protected override void Execute(ActivityExecutionContext context)
{
var provider = context.GetService<IStandardOutStreamProvider>() ?? new StandardOutStreamProvider(Console.Out);
var textWriter = provider.GetTextWriter();

if (Arguments != null)
{
foreach (var argument in Arguments)
{
var name = argument.Key;
string value = context.Get(argument.Value) switch
{
bool boolValue => $"{boolValue} (bool)",
string stringValue => $"{stringValue} (string)",
double doubleValue => $"{doubleValue} (double)",
_ => throw new NotImplementedException(),
};

textWriter.WriteLine($"{name}: {value}");
}
}
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using Elsa.Expressions.Models;
using Elsa.Workflows.Activities;
using Elsa.Workflows.Memory;
using Elsa.Workflows.Models;

namespace Elsa.Workflows.IntegrationTests.Activities.CollectionInputs;
class DynamicArgumentsWorkflow : WorkflowBase
{
protected override void Build(IWorkflowBuilder workflow)
{
var nameVariable = new Variable<string>("name", "Frank");

workflow.Root = new Sequence
{
Variables = { nameVariable },
Activities =
{
new DynamicArguments(new Dictionary<string, Input<object>>()
{
{ "name", new Input<object>(new Expression("JavaScript", "getVariable('name')")) },
{ "isAdmin", new Input<object>(false) },
{ "age", new Input<object>(new Expression("JavaScript", "let age = 30 + 12; age;")) }
})
}
};
}
}
Loading
Loading