Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -303,8 +303,10 @@ jobs:
uses: actions/setup-dotnet@v1
with:
dotnet-version: |
6.0.x
8.0.x
9.0.x
10.0.x
- name: Restore dependencies
run: dotnet restore
- name: Build
Expand Down
55 changes: 48 additions & 7 deletions src/WorkflowCore.DSL/Services/DefinitionLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -318,12 +318,14 @@ private void AttachDirectlyOutput(KeyValuePair<string, string> output, WorkflowS
propertyInfo = dataType.GetProperty("Item");
targetProperty = Expression.Property(dataParameter, propertyInfo, Expression.Constant(output.Key));

var compiledSourceExpr = sourceExpr.Compile();

Action<IStepBody, object> acn = (pStep, pData) =>
{
object resolvedValue;
try
{
resolvedValue = sourceExpr.Compile().DynamicInvoke(pStep);
resolvedValue = compiledSourceExpr.DynamicInvoke(pStep);
}
catch (TargetInvocationException ex)
{
Expand Down Expand Up @@ -377,13 +379,16 @@ private void AttachNestedOutput(KeyValuePair<string, string> output, WorkflowSte
}
propertyInfo = ((PropertyInfo)memberExpression.Member).PropertyType.GetProperty("Item");

var targetExpr = Expression.Lambda(memberExpression, dataParameter);
var compiledTargetExpr = targetExpr.Compile();
var compiledSourceExpr = sourceExpr.Compile();

Action<IStepBody, object> acn = (pStep, pData) =>
{
var targetExpr = Expression.Lambda(memberExpression, dataParameter);
object data;
try
{
data = targetExpr.Compile().DynamicInvoke(pData);
data = compiledTargetExpr.DynamicInvoke(pData);
}
catch (TargetInvocationException ex)
{
Expand All @@ -392,7 +397,7 @@ private void AttachNestedOutput(KeyValuePair<string, string> output, WorkflowSte
object resolvedValue;
try
{
resolvedValue = sourceExpr.Compile().DynamicInvoke(pStep);
resolvedValue = compiledSourceExpr.DynamicInvoke(pStep);
}
catch (TargetInvocationException ex)
{
Expand Down Expand Up @@ -470,12 +475,14 @@ private static Action<IStepBody, object, IStepExecutionContext> BuildScalarInput
throw new WorkflowDefinitionLoadException($"Error parsing input expression '{expr}' for property '{input.Key}': {ex.Message}", ex);
}

var compiledExpr = sourceExpr.Compile();

void acn(IStepBody pStep, object pData, IStepExecutionContext pContext)
{
object resolvedValue;
try
{
resolvedValue = sourceExpr.Compile().DynamicInvoke(pData, pContext, Environment.GetEnvironmentVariables());
resolvedValue = compiledExpr.DynamicInvoke(pData, pContext, Environment.GetEnvironmentVariables());
}
catch (TargetInvocationException ex)
{
Expand Down Expand Up @@ -505,6 +512,40 @@ void acn(IStepBody pStep, object pData, IStepExecutionContext pContext)

private static Action<IStepBody, object, IStepExecutionContext> BuildObjectInputAction(KeyValuePair<string, object> input, ParameterExpression dataParameter, ParameterExpression contextParameter, ParameterExpression environmentVarsParameter, PropertyInfo stepProperty)
{
// Pre-compile all @-prefixed property expressions at definition load time
var compiledExpressions = new Dictionary<string, Delegate>();
var templateObj = JObject.FromObject(input.Value);
var scanStack = new Stack<JObject>();
scanStack.Push(templateObj);

while (scanStack.Count > 0)
{
var subobj = scanStack.Pop();
foreach (var prop in subobj.Properties())
{
if (prop.Name.StartsWith("@"))
{
var exprText = prop.Value.ToString();
if (!compiledExpressions.ContainsKey(exprText))
{
LambdaExpression sourceExpr;
try
{
sourceExpr = DynamicExpressionParser.ParseLambda(ParsingConfig, false, new[] { dataParameter, contextParameter, environmentVarsParameter }, typeof(object), TransformExpression(exprText));
}
catch (Exception ex) when (ex is System.Linq.Dynamic.Core.Exceptions.ParseException || ex is InvalidOperationException)
{
throw new WorkflowDefinitionLoadException($"Error parsing input expression '{exprText}': {ex.Message}", ex);
}
compiledExpressions[exprText] = sourceExpr.Compile();
}
}
}

foreach (var child in subobj.Children<JObject>())
scanStack.Push(child);
Comment on lines +545 to +546

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, but this is intentional here: the pre-scan deliberately mirrors the exact same Children<JObject>() traversal the runtime acn uses below, so the pre-compiled compiledExpressions dictionary is guaranteed to contain a key for every @-property the runtime scan will later look up — no KeyNotFound risk. The nested-object limitation you spotted is pre-existing behavior on master (this PR only lifts the compile step out of the closure to fix the .NET 10 InvalidProgramException); making the template walk recurse into nested objects is a separate behavior change I would rather not fold into this fix.

}

void acn(IStepBody pStep, object pData, IStepExecutionContext pContext)
{
var stack = new Stack<JObject>();
Expand All @@ -518,11 +559,11 @@ void acn(IStepBody pStep, object pData, IStepExecutionContext pContext)
{
if (prop.Name.StartsWith("@"))
{
var sourceExpr = DynamicExpressionParser.ParseLambda(ParsingConfig, false, new[] { dataParameter, contextParameter, environmentVarsParameter }, typeof(object), TransformExpression(prop.Value.ToString()));
var exprText = prop.Value.ToString();
object resolvedValue;
try
Comment on lines 560 to 564

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This Children<JObject>() line is unchanged from master — it is the existing runtime traversal, not something introduced by this PR. Scope here is limited to the .NET 10 compile fix, so I am keeping the traversal behavior identical. Happy to open a follow-up to properly recurse nested objects/array elements (in both the pre-scan and this loop together) if we want to support nested @ expressions.

{
resolvedValue = sourceExpr.Compile().DynamicInvoke(pData, pContext, Environment.GetEnvironmentVariables());
resolvedValue = compiledExpressions[exprText].DynamicInvoke(pData, pContext, Environment.GetEnvironmentVariables());
}
catch (TargetInvocationException ex)
{
Expand Down
20 changes: 11 additions & 9 deletions src/WorkflowCore/Models/MemberMapParameter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ public class MemberMapParameter : IStepParameter
{
private readonly LambdaExpression _source;
private readonly LambdaExpression _target;
private readonly Delegate _compiledSource;

public MemberMapParameter(LambdaExpression source, LambdaExpression target)
{
Expand All @@ -17,44 +18,45 @@ public MemberMapParameter(LambdaExpression source, LambdaExpression target)

_source = source;
_target = target;
_compiledSource = source.Compile();
}

private void Assign(object sourceObject, LambdaExpression sourceExpr, object targetObject, LambdaExpression targetExpr, IStepExecutionContext context)
private void Assign(object sourceObject, object targetObject, IStepExecutionContext context)
{
object resolvedValue = null;

switch (sourceExpr.Parameters.Count)
switch (_source.Parameters.Count)
{
case 1:
resolvedValue = sourceExpr.Compile().DynamicInvoke(sourceObject);
resolvedValue = _compiledSource.DynamicInvoke(sourceObject);
break;
case 2:
resolvedValue = sourceExpr.Compile().DynamicInvoke(sourceObject, context);
resolvedValue = _compiledSource.DynamicInvoke(sourceObject, context);
break;
default:
throw new ArgumentException();
}

if (resolvedValue == null)
{
var defaultAssign = Expression.Lambda(Expression.Assign(targetExpr.Body, Expression.Default(targetExpr.ReturnType)), targetExpr.Parameters.Single());
var defaultAssign = Expression.Lambda(Expression.Assign(_target.Body, Expression.Default(_target.ReturnType)), _target.Parameters.Single());
defaultAssign.Compile().DynamicInvoke(targetObject);
return;
}

var valueExpr = Expression.Convert(Expression.Constant(resolvedValue), targetExpr.ReturnType);
var assign = Expression.Lambda(Expression.Assign(targetExpr.Body, valueExpr), targetExpr.Parameters.Single());
var valueExpr = Expression.Convert(Expression.Constant(resolvedValue), _target.ReturnType);
var assign = Expression.Lambda(Expression.Assign(_target.Body, valueExpr), _target.Parameters.Single());
assign.Compile().DynamicInvoke(targetObject);
}

public void AssignInput(object data, IStepBody body, IStepExecutionContext context)
{
Assign(data, _source, body, _target, context);
Assign(data, body, context);
}

public void AssignOutput(object data, IStepBody body, IStepExecutionContext context)
{
Assign(body, _source, data, _target, context);
Assign(body, data, context);
}
}
}
2 changes: 1 addition & 1 deletion test/Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<TargetFrameworks>net6.0;net8.0</TargetFrameworks>
<TargetFrameworks>net6.0;net8.0;net10.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<GenerateAssemblyConfigurationAttribute>false</GenerateAssemblyConfigurationAttribute>
<GenerateAssemblyCompanyAttribute>false</GenerateAssemblyCompanyAttribute>
<GenerateAssemblyProductAttribute>false</GenerateAssemblyProductAttribute>
<TargetFrameworks>net6.0</TargetFrameworks>
<TargetFrameworks>net6.0;net8.0;net10.0</TargetFrameworks>
</PropertyGroup>

<ItemGroup>
Expand Down
7 changes: 7 additions & 0 deletions test/WorkflowCore.TestAssets/DataTypes/ScalarInputData.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace WorkflowCore.TestAssets.DataTypes
{
public class ScalarInputData
{
public string MessageId { get; set; }
}
}
17 changes: 17 additions & 0 deletions test/WorkflowCore.TestAssets/Steps/ScalarInputStep.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using WorkflowCore.Interface;
using WorkflowCore.Models;

namespace WorkflowCore.TestAssets.Steps
{
public class ScalarInputStep : StepBody
{
public string MessageId { get; set; }

public string Status { get; set; }

public override ExecutionResult Run(IStepExecutionContext context)
{
return ExecutionResult.Next();
}
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
using FakeItEasy;
using FluentAssertions;
using Newtonsoft.Json;
using System;
using System.Linq;
using WorkflowCore.Interface;
using WorkflowCore.Models;
using WorkflowCore.Services.DefinitionStorage;
using WorkflowCore.TestAssets.DataTypes;
using WorkflowCore.TestAssets.Steps;
using Xunit;

namespace WorkflowCore.UnitTests.Services.DefinitionStorage
Expand Down Expand Up @@ -71,6 +73,47 @@ public void ParseDefinitionInputException()
Assert.Throws<ArgumentException>(() => _subject.LoadDefinition(TestAssets.Utils.GetTestDefinitionJsonMissingInputProperty(), Deserializers.Json));
}

// Regression test for issue #1428: a scalar variable-binding input plus a
// scalar string-literal input. The compiled input expressions used to be
// built inside a closure and recompiled on every invocation, which produced
// an InvalidProgramException on .NET 10. Loading the definition and then
// assigning the inputs (as WorkflowExecutor.ExecuteStep does) must succeed
// and resolve both values.
[Fact(DisplayName = "Should evaluate scalar variable and string-literal inputs")]
public void ParseAndAssignScalarInputs()
{
var dataType = typeof(ScalarInputData).AssemblyQualifiedName;
var stepType = typeof(ScalarInputStep).AssemblyQualifiedName;

var json =
"{" +
"\"Id\": \"Issue1428\", \"Version\": 1," +
"\"DataType\": " + JsonConvert.ToString(dataType) + "," +
"\"Steps\": [{" +
"\"Id\": \"UpdateStatus\"," +
"\"Name\": \"Update internal status\"," +
"\"StepType\": " + JsonConvert.ToString(stepType) + "," +
"\"Inputs\": {" +
"\"MessageId\": \"data.MessageId\"," +
"\"Status\": \"\\\"waits-for-batching\\\"\"" +
"}" +
"}]}";

var def = _subject.LoadDefinition(json, Deserializers.Json);

var step = def.Steps.Single(s => s.ExternalId == "UpdateStatus");
step.Inputs.Count.Should().Be(2);

var body = new ScalarInputStep();
var data = new ScalarInputData { MessageId = "msg-42" };

foreach (var input in step.Inputs)
input.AssignInput(data, body, null);

body.MessageId.Should().Be("msg-42");
body.Status.Should().Be("waits-for-batching");
}

private bool MatchTestDefinition(WorkflowDefinition def)
{
//TODO: make this better
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<GenerateAssemblyConfigurationAttribute>false</GenerateAssemblyConfigurationAttribute>
<GenerateAssemblyCompanyAttribute>false</GenerateAssemblyCompanyAttribute>
<GenerateAssemblyProductAttribute>false</GenerateAssemblyProductAttribute>
<TargetFrameworks>net6.0</TargetFrameworks>
<TargetFrameworks>net6.0;net8.0;net10.0</TargetFrameworks>
</PropertyGroup>

<ItemGroup>
Expand Down
Loading