Skip to content

Commit

Permalink
Add support for collections and dictionaries of input
Browse files Browse the repository at this point in the history
  • Loading branch information
Bob Hauser committed Dec 16, 2024
1 parent 5d92c57 commit a7a1b8b
Show file tree
Hide file tree
Showing 10 changed files with 323 additions and 21 deletions.
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

0 comments on commit a7a1b8b

Please sign in to comment.