Improve Elsa workflow expression serialization (#4930)

* Improve Elsa workflow expression serialization

Added serialization support for expressions in Elsa workflows, enabling serialization and deserialization to maintain consistent types across sessions. Updated relevant test cases for validation.

* Remove PR workflow from GitHub actions

The PR workflow has been removed from GitHub actions.
This commit is contained in:
Sipke Schoorstra 2024-02-12 18:47:25 +01:00 committed by GitHub
parent fa32e00bce
commit 3bad8a5b1b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 149 additions and 80 deletions

View file

@ -1,43 +0,0 @@
# ------------------------------------------------------------------------------
# <auto-generated>
#
# This code was generated.
#
# - To turn off auto-generation set:
#
# [CustomGitHubActions (AutoGenerate = false)]
#
# - To trigger manual generation invoke:
#
# nuke --generate-configuration GitHubActions_pr --host GitHubActions
#
# </auto-generated>
# ------------------------------------------------------------------------------
name: pr
on:
pull_request:
branches:
- main
paths:
- '**/*'
- '!docs/**/*'
- '!package.json'
- '!package-lock.json'
- '!readme.md'
jobs:
ubuntu-latest:
name: ubuntu-latest
runs-on: ubuntu-latest
steps:
- if: ${{ runner.os == 'Windows' }}
name: 'Use GNU tar'
shell: cmd
run: |
echo "Adding GNU tar to PATH"
echo C:\Program Files\Git\usr\bin>>"%GITHUB_PATH%"
- uses: actions/checkout@v3
- name: 'Run: Compile, Test, Pack'
run: ./build.cmd Compile Test Pack

View file

@ -292,7 +292,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "pipelines", "pipelines", "{
.github\workflows\elsa-server.yml = .github\workflows\elsa-server.yml
.github\workflows\elsa-studio.yml = .github\workflows\elsa-studio.yml
.github\workflows\packages.yml = .github\workflows\packages.yml
.github\workflows\pr.yml = .github\workflows\pr.yml
EndProjectSection
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "_build", "build\_build.csproj", "{99F2B1DA-2F69-4D70-A2A3-AC985AD91EC4}"

View file

@ -1,6 +1,5 @@
using System.Collections;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Dynamic;
using System.Globalization;
using System.Text.Json;

View file

@ -1,10 +1,12 @@
using Elsa.Expressions.Contracts;
using Elsa.Expressions.Helpers;
using Elsa.Expressions.Models;
using JetBrains.Annotations;
namespace Elsa.Expressions;
/// <inheritdoc />
[UsedImplicitly]
public class LiteralExpressionHandler : IExpressionHandler
{
private readonly IWellKnownTypeRegistry _wellKnownTypeRegistry;

View file

@ -41,4 +41,9 @@ public class ExpressionDescriptor
/// Gets or sets the memory block reference factory.
/// </summary>
public Func<MemoryBlockReference> MemoryBlockReferenceFactory { get; set; } = () => new MemoryBlockReference();
/// <summary>
/// Gets or sets the expression deserialization function.
/// </summary>
public Func<ExpressionSerializationContext, Expression> Deserialize { get; set; } = default!;
}

View file

@ -0,0 +1,5 @@
using System.Text.Json;
namespace Elsa.Expressions.Models;
public record ExpressionSerializationContext(JsonElement JsonElement, JsonSerializerOptions Options, Type MemoryBlockType);

View file

@ -1,3 +1,4 @@
using System.Text.Json;
using Elsa.Expressions.Contracts;
using Elsa.Expressions.Models;
using Elsa.Workflows.Memory;

View file

@ -39,27 +39,17 @@ public class InputJsonConverter<T> : JsonConverter<Input<T>>
var expressionElement = doc.RootElement.TryGetProperty("expression", out var expressionElementValue) ? expressionElementValue : default;
var expressionTypeNameElement = expressionElement.ValueKind != JsonValueKind.Undefined ? expressionElement.TryGetProperty("type", out var expressionTypeNameElementValue) ? expressionTypeNameElementValue : default : default;
var expressionTypeName = expressionTypeNameElement.ValueKind != JsonValueKind.Undefined ? expressionTypeNameElement.GetString() ?? "Literal" : "Literal";
var expressionDescriptor = _expressionDescriptorRegistry.Find(expressionTypeName);
var memoryBlockReference = expressionDescriptor?.MemoryBlockReferenceFactory();
var memoryBlockReferenceType = memoryBlockReference?.GetType();
var expressionValueElement = expressionElement.TryGetProperty("value", out var expressionElementValueValue) ? expressionElementValueValue : default;
var expressionValue = expressionValueElement.ValueKind switch
{
JsonValueKind.String => expressionValueElement.GetString(),
JsonValueKind.False => false,
JsonValueKind.True => true,
JsonValueKind.Number => expressionValueElement.GetDouble(),
JsonValueKind.Undefined => default,
_ => memoryBlockReferenceType != null ? expressionValueElement.Deserialize(memoryBlockReferenceType, options)! : default
};
var expression = new Expression(expressionTypeName, expressionValue);
var expressionTypeName = expressionTypeNameElement.ValueKind != JsonValueKind.Undefined ? expressionTypeNameElement.GetString() ?? "Literal" : default;
var expressionDescriptor = expressionTypeName != null ? _expressionDescriptorRegistry.Find(expressionTypeName) : default;
var memoryBlockReference = expressionDescriptor?.MemoryBlockReferenceFactory?.Invoke();
if (memoryBlockReference == null)
return default!;
var memoryBlockType = memoryBlockReference.GetType();
var context = new ExpressionSerializationContext(expressionElement, options, memoryBlockType);
var expression = expressionDescriptor!.Deserialize(context);
return (Input<T>)Activator.CreateInstance(typeof(Input<T>), expression, memoryBlockReference)!;
}
@ -73,10 +63,10 @@ public class InputJsonConverter<T> : JsonConverter<Input<T>>
var expression = value.Expression;
var expressionType = expression?.Type;
var expressionDescriptor = expressionType != null ? _expressionDescriptorRegistry.Find(expressionType) : default;
if (expressionDescriptor == null)
throw new JsonException($"Could not find an expression descriptor for expression type '{expressionType}'.");
var targetType = value.Type;
var expressionValue = expressionDescriptor.IsSerializable ? expression : null;

View file

@ -1,3 +1,4 @@
using System.Text.Json;
using Elsa.Expressions;
using Elsa.Expressions.Contracts;
using Elsa.Expressions.Models;
@ -21,35 +22,85 @@ public class DefaultExpressionDescriptorProvider : IExpressionDescriptorProvider
yield return CreateVariableDescriptor();
}
private ExpressionDescriptor CreateLiteralDescriptor() => CreateDescriptor<LiteralExpressionHandler>("Literal", "Literal", isBrowsable: false);
private ExpressionDescriptor CreateLiteralDescriptor()
{
return CreateDescriptor<LiteralExpressionHandler>(
"Literal",
"Literal",
isBrowsable: false,
memoryBlockReferenceFactory: () => new Literal(),
deserialize: (context) =>
{
var elementValue = context.JsonElement.TryGetProperty("value", out var v) ? v : default;
var value = (object?)(elementValue.ValueKind switch
{
JsonValueKind.String => elementValue.GetString(),
JsonValueKind.Number => elementValue.GetDecimal(),
JsonValueKind.True => true,
JsonValueKind.False => false,
_ => v.GetString()
});
return new Expression("Literal", value);
});
}
private ExpressionDescriptor CreateObjectDescriptor() => CreateDescriptor<ObjectExpressionHandler>("Object", "Object", monacoLanguage: "json", isBrowsable: false);
[Obsolete("Use Object instead.")]
private ExpressionDescriptor CreateJsonDescriptor() => CreateDescriptor<ObjectExpressionHandler>("Json", "Json", monacoLanguage: "json", isBrowsable: false);
private ExpressionDescriptor CreateDelegateDescriptor() => CreateDescriptor<DelegateExpressionHandler>("Delegate", "Delegate", false, false);
private ExpressionDescriptor CreateVariableDescriptor() => CreateDescriptor<VariableExpressionHandler>("Variable", "Variable", isBrowsable: false, memoryBlockReferenceFactory: () => new Variable());
private ExpressionDescriptor CreateVariableDescriptor()
{
return CreateDescriptor<VariableExpressionHandler>(
"Variable",
"Variable",
isBrowsable: false,
memoryBlockReferenceFactory: () => new Variable(),
deserialize: context =>
{
var expressionValueElement = context.JsonElement.TryGetProperty("value", out var expressionElementValueValue) ? expressionElementValueValue : default;
var expressionValue = expressionValueElement.Deserialize(context.MemoryBlockType, context.Options);
return new Expression("Variable", expressionValue);
}
);
}
private static ExpressionDescriptor CreateDescriptor<THandler>(
string type,
string expressionType,
string displayName,
bool isSerializable = true,
bool isBrowsable = true,
string? monacoLanguage = null,
Func<MemoryBlockReference>? memoryBlockReferenceFactory = default) where THandler : IExpressionHandler
Func<MemoryBlockReference>? memoryBlockReferenceFactory = default,
Func<ExpressionSerializationContext, Expression>? deserialize = default)
where THandler : IExpressionHandler
{
var descriptor = new ExpressionDescriptor
{
Type = type,
Type = expressionType,
DisplayName = displayName,
IsSerializable = isSerializable,
IsBrowsable = isBrowsable,
HandlerFactory = sp => ActivatorUtilities.GetServiceOrCreateInstance<THandler>(sp),
MemoryBlockReferenceFactory = memoryBlockReferenceFactory ?? (() => new MemoryBlockReference())
MemoryBlockReferenceFactory = memoryBlockReferenceFactory ?? (() => new MemoryBlockReference()),
Deserialize = deserialize ??
(context =>
{
return context.JsonElement.ValueKind == JsonValueKind.Object
? context.JsonElement.Deserialize<Expression>((JsonSerializerOptions?)context.Options)!
: new Expression(expressionType, null!);
})
};
if (monacoLanguage != null)
descriptor.Properties = new { MonacoLanguage = monacoLanguage }.ToDictionary();
descriptor.Properties = new
{
MonacoLanguage = monacoLanguage
}.ToDictionary();
return descriptor;
}

View file

@ -0,0 +1,41 @@
using System.Text.Json.Serialization;
using Elsa.Expressions.Models;
using Elsa.Extensions;
using Elsa.Workflows.Memory;
using Elsa.Workflows.Models;
namespace Elsa.Workflows.IntegrationTests.Serialization.VariableExpressions;
/// <inheritdoc />
public class NumberActivity : CodeActivity
{
/// <inheritdoc />
[JsonConstructor]
public NumberActivity()
{
}
/// <inheritdoc />
public NumberActivity(Variable<int> variable)
{
Number = new(variable);
}
/// <inheritdoc />
public NumberActivity(Literal<int> literal)
{
Number = new(literal);
}
/// <summary>
/// Gets or sets the number.
/// </summary>
public Input<int> Number { get; set; } = default!;
/// <inheritdoc />
protected override void Execute(ActivityExecutionContext context)
{
var number = Number.Get(context);
Console.WriteLine(number.ToString());
}
}

View file

@ -18,23 +18,35 @@ public class Tests
{
private readonly IWorkflowSerializer _workflowSerializer;
private readonly IWorkflowBuilder _workflowBuilder;
private readonly IWorkflowRunner _workflowRunner;
/// <summary>
/// Initializes a new instance of the <see cref="Tests"/> class.
/// </summary>
public Tests(ITestOutputHelper testOutputHelper)
{
var serviceProvider = new TestApplicationBuilder(testOutputHelper).Build();
_workflowSerializer = serviceProvider.GetRequiredService<IWorkflowSerializer>();
IWorkflowBuilderFactory workflowBuilderFactory = serviceProvider.GetRequiredService<IWorkflowBuilderFactory>();
_workflowBuilder = workflowBuilderFactory.CreateBuilder();
_workflowRunner = serviceProvider.GetRequiredService<IWorkflowRunner>();
}
/// <summary>
/// Variable types remain intact after serialization.
/// </summary>
[Fact(DisplayName = "Variable types remain intact after serialization")]
public async Task Test1()
{
var workflow = await _workflowBuilder.BuildWorkflowAsync<SampleWorkflow>();
var serialized = _workflowSerializer.Serialize(workflow);
var deserializedWorkflow = _workflowSerializer.Deserialize(serialized);
var rehydratedWriteLine = (WriteLine)((Flowchart)deserializedWorkflow.Root).Activities.ElementAt(0);
var rehydratedWriteLine1 = (WriteLine)((Sequence)deserializedWorkflow.Root).Activities.ElementAt(0);
var rehydratedNumberActivity1 = (NumberActivity)((Sequence)deserializedWorkflow.Root).Activities.ElementAt(2);
Assert.IsType<Variable<string>>(rehydratedWriteLine.Text.Expression!.Value);
Assert.IsType<Variable<string>>(rehydratedWriteLine1.Text.Expression!.Value);
Assert.IsType<Variable<int>>(rehydratedNumberActivity1.Number.Expression!.Value);
await _workflowRunner.RunAsync(workflow);
}
}

View file

@ -1,6 +1,5 @@
using Elsa.Workflows;
using Elsa.Expressions.Models;
using Elsa.Workflows.Activities;
using Elsa.Workflows.Activities.Flowchart.Activities;
using Elsa.Workflows.Contracts;
namespace Elsa.Workflows.IntegrationTests.Serialization.VariableExpressions;
@ -9,16 +8,24 @@ class SampleWorkflow : WorkflowBase
{
protected override void Build(IWorkflowBuilder workflow)
{
var variable1 = workflow.WithVariable<string>("Some Value");
var variable1 = workflow.WithVariable("Some variable");
var variable2 = workflow.WithVariable(42);
var literal1 = new Literal<string>("Some literal");
var literal2 = new Literal<int>(84);
var writeLine1 = new WriteLine(variable1);
var writeLine2 = new WriteLine(literal1);
var numberActivity1 = new NumberActivity(variable2);
var numberActivity2 = new NumberActivity(literal2);
workflow.Root = new Flowchart
workflow.Root = new Sequence
{
Activities =
{
writeLine1
},
Start = writeLine1
writeLine1,
writeLine2,
numberActivity1,
numberActivity2
}
};
}
}