From a02bacbbde623db63ba8a9bd162ca21df7356071 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Wed, 23 Aug 2023 20:56:12 +0200 Subject: [PATCH] Support serialization of non(de)serializable values (#4356) --- .../Modules/Management/Extensions.cs | 2 +- .../Modules/Management/Extensions.cs | 2 +- .../Modules/Management/Extensions.cs | 2 +- .../Modules/Management/Extensions.cs | 2 +- .../Modules/Management/Extensions.cs | 2 +- ...> WorkflowManagementPersistenceFeature.cs} | 4 +- .../Runtime/ActivityExecutionLogStore.cs | 2 +- .../Runtime/WorkflowExecutionLogStore.cs | 20 ++++---- .../Converters/PolymorphicObjectConverter.cs | 12 ++++- .../Converters/SafeDictionaryConverter.cs | 51 +++++++++++++++++++ .../SafeDictionaryConverterFactory.cs | 26 ++++++++++ .../Providers/BasicSerializationProvider.cs | 19 +++++-- 12 files changed, 121 insertions(+), 23 deletions(-) rename src/modules/Elsa.EntityFrameworkCore/Modules/Management/{EFCoreWorkflowManagementPersistenceFeature.cs => WorkflowManagementPersistenceFeature.cs} (87%) create mode 100644 src/modules/Elsa.Workflows.Core/Serialization/Converters/SafeDictionaryConverter.cs create mode 100644 src/modules/Elsa.Workflows.Core/Serialization/Converters/SafeDictionaryConverterFactory.cs diff --git a/src/modules/Elsa.EntityFrameworkCore.MySql/Modules/Management/Extensions.cs b/src/modules/Elsa.EntityFrameworkCore.MySql/Modules/Management/Extensions.cs index 4ec08fc85..0cc4a26bc 100644 --- a/src/modules/Elsa.EntityFrameworkCore.MySql/Modules/Management/Extensions.cs +++ b/src/modules/Elsa.EntityFrameworkCore.MySql/Modules/Management/Extensions.cs @@ -23,7 +23,7 @@ public static partial class Extensions return feature; } - public static EFCoreWorkflowManagementPersistenceFeature UseMySql(this EFCoreWorkflowManagementPersistenceFeature feature, string connectionString, ElsaDbContextOptions? options = default) + public static WorkflowManagementPersistenceFeature UseMySql(this WorkflowManagementPersistenceFeature feature, string connectionString, ElsaDbContextOptions? options = default) { feature.DbContextOptionsBuilder = (_, db) => db.UseElsaMySql(connectionString, options); return feature; diff --git a/src/modules/Elsa.EntityFrameworkCore.PostgreSql/Modules/Management/Extensions.cs b/src/modules/Elsa.EntityFrameworkCore.PostgreSql/Modules/Management/Extensions.cs index 90159d0e9..bcbc3a1e1 100644 --- a/src/modules/Elsa.EntityFrameworkCore.PostgreSql/Modules/Management/Extensions.cs +++ b/src/modules/Elsa.EntityFrameworkCore.PostgreSql/Modules/Management/Extensions.cs @@ -23,7 +23,7 @@ public static partial class Extensions return feature; } - public static EFCoreWorkflowManagementPersistenceFeature UsePostgreSql(this EFCoreWorkflowManagementPersistenceFeature feature, string connectionString, ElsaDbContextOptions? options = default) + public static WorkflowManagementPersistenceFeature UsePostgreSql(this WorkflowManagementPersistenceFeature feature, string connectionString, ElsaDbContextOptions? options = default) { feature.DbContextOptionsBuilder = (_, db) => db.UseElsaPostgreSql(connectionString, options); return feature; diff --git a/src/modules/Elsa.EntityFrameworkCore.SqlServer/Modules/Management/Extensions.cs b/src/modules/Elsa.EntityFrameworkCore.SqlServer/Modules/Management/Extensions.cs index 192a04851..399a25336 100644 --- a/src/modules/Elsa.EntityFrameworkCore.SqlServer/Modules/Management/Extensions.cs +++ b/src/modules/Elsa.EntityFrameworkCore.SqlServer/Modules/Management/Extensions.cs @@ -20,7 +20,7 @@ public static partial class Extensions return feature; } - public static EFCoreWorkflowManagementPersistenceFeature UseSqlServer(this EFCoreWorkflowManagementPersistenceFeature feature, string connectionString, ElsaDbContextOptions? options = default) + public static WorkflowManagementPersistenceFeature UseSqlServer(this WorkflowManagementPersistenceFeature feature, string connectionString, ElsaDbContextOptions? options = default) { feature.DbContextOptionsBuilder = (_, db) => db.UseElsaSqlServer(connectionString, options); return feature; diff --git a/src/modules/Elsa.EntityFrameworkCore.Sqlite/Modules/Management/Extensions.cs b/src/modules/Elsa.EntityFrameworkCore.Sqlite/Modules/Management/Extensions.cs index 9c38005f2..c9900ec56 100644 --- a/src/modules/Elsa.EntityFrameworkCore.Sqlite/Modules/Management/Extensions.cs +++ b/src/modules/Elsa.EntityFrameworkCore.Sqlite/Modules/Management/Extensions.cs @@ -21,7 +21,7 @@ public static partial class Extensions return feature; } - public static EFCoreWorkflowManagementPersistenceFeature UseSqlite(this EFCoreWorkflowManagementPersistenceFeature feature, string connectionString = Constants.DefaultConnectionString, ElsaDbContextOptions? options = default) + public static WorkflowManagementPersistenceFeature UseSqlite(this WorkflowManagementPersistenceFeature feature, string connectionString = Constants.DefaultConnectionString, ElsaDbContextOptions? options = default) { feature.DbContextOptionsBuilder = (_, db) => db.UseElsaSqlite(connectionString, options); return feature; diff --git a/src/modules/Elsa.EntityFrameworkCore/Modules/Management/Extensions.cs b/src/modules/Elsa.EntityFrameworkCore/Modules/Management/Extensions.cs index 677d70f53..66c46c922 100644 --- a/src/modules/Elsa.EntityFrameworkCore/Modules/Management/Extensions.cs +++ b/src/modules/Elsa.EntityFrameworkCore/Modules/Management/Extensions.cs @@ -28,7 +28,7 @@ public static class WorkflowManagementFeatureExtensions /// /// Sets up the EF Core persistence provider. /// - public static WorkflowManagementFeature UseEntityFrameworkCore(this WorkflowManagementFeature feature, Action? configure = default) + public static WorkflowManagementFeature UseEntityFrameworkCore(this WorkflowManagementFeature feature, Action? configure = default) { feature.Module.Configure(configure); return feature; diff --git a/src/modules/Elsa.EntityFrameworkCore/Modules/Management/EFCoreWorkflowManagementPersistenceFeature.cs b/src/modules/Elsa.EntityFrameworkCore/Modules/Management/WorkflowManagementPersistenceFeature.cs similarity index 87% rename from src/modules/Elsa.EntityFrameworkCore/Modules/Management/EFCoreWorkflowManagementPersistenceFeature.cs rename to src/modules/Elsa.EntityFrameworkCore/Modules/Management/WorkflowManagementPersistenceFeature.cs index 382d26eea..3f702d065 100644 --- a/src/modules/Elsa.EntityFrameworkCore/Modules/Management/EFCoreWorkflowManagementPersistenceFeature.cs +++ b/src/modules/Elsa.EntityFrameworkCore/Modules/Management/WorkflowManagementPersistenceFeature.cs @@ -15,10 +15,10 @@ namespace Elsa.EntityFrameworkCore.Modules.Management; [DependsOn(typeof(WorkflowInstancesFeature))] [DependsOn(typeof(WorkflowDefinitionsFeature))] [PublicAPI] -public class EFCoreWorkflowManagementPersistenceFeature : PersistenceFeatureBase +public class WorkflowManagementPersistenceFeature : PersistenceFeatureBase { /// - public EFCoreWorkflowManagementPersistenceFeature(IModule module) : base(module) + public WorkflowManagementPersistenceFeature(IModule module) : base(module) { } diff --git a/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/ActivityExecutionLogStore.cs b/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/ActivityExecutionLogStore.cs index 4e596f3e7..efdc61e68 100644 --- a/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/ActivityExecutionLogStore.cs +++ b/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/ActivityExecutionLogStore.cs @@ -53,7 +53,7 @@ public class EFCoreActivityExecutionStore : IActivityExecutionStore private async ValueTask OnSaveAsync(RuntimeElsaDbContext dbContext, ActivityExecutionRecord entity, CancellationToken cancellationToken) { dbContext.Entry(entity).Property("SerializedActivityState").CurrentValue = entity.ActivityState != null ? (await _activityStateSerializer.SerializeAsync(entity.ActivityState, cancellationToken)).ToString() : default; - dbContext.Entry(entity).Property("SerializedOutputs").CurrentValue = entity.ActivityState != null ? (await _activityStateSerializer.SerializeAsync(entity.Outputs, cancellationToken)).ToString() : default; + dbContext.Entry(entity).Property("SerializedOutputs").CurrentValue = entity.Outputs != null ? (await _activityStateSerializer.SerializeAsync(entity.Outputs, cancellationToken)).ToString() : default; dbContext.Entry(entity).Property("SerializedException").CurrentValue = entity.Exception != null ? _payloadSerializer.Serialize(entity.Exception) : default; dbContext.Entry(entity).Property("SerializedPayload").CurrentValue = entity.Payload != null ? _payloadSerializer.Serialize(entity.Payload) : default; } diff --git a/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/WorkflowExecutionLogStore.cs b/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/WorkflowExecutionLogStore.cs index 26f91ddaa..6c89a4b54 100644 --- a/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/WorkflowExecutionLogStore.cs +++ b/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/WorkflowExecutionLogStore.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Elsa.EntityFrameworkCore.Common; using Elsa.Common.Models; using Elsa.Extensions; @@ -16,15 +17,17 @@ namespace Elsa.EntityFrameworkCore.Modules.Runtime; public class EFCoreWorkflowExecutionLogStore : IWorkflowExecutionLogStore { private readonly EntityStore _store; - private readonly IPayloadSerializer _serializer; + private readonly IPayloadSerializer _payloadSerializer; + private readonly IActivityStateSerializer _activityStateSerializer; /// /// Initializes a new instance of the class. /// - public EFCoreWorkflowExecutionLogStore(EntityStore store, IPayloadSerializer serializer) + public EFCoreWorkflowExecutionLogStore(EntityStore store, IPayloadSerializer payloadPayloadSerializer, IActivityStateSerializer activityStateSerializer) { _store = store; - _serializer = serializer; + _payloadSerializer = payloadPayloadSerializer; + _activityStateSerializer = activityStateSerializer; } /// @@ -70,11 +73,10 @@ public class EFCoreWorkflowExecutionLogStore : IWorkflowExecutionLogStore return await _store.DeleteWhereAsync(queryable => Filter(queryable, filter), cancellationToken); } - private ValueTask OnSaveAsync(RuntimeElsaDbContext dbContext, WorkflowExecutionLogRecord entity, CancellationToken cancellationToken) + private async ValueTask OnSaveAsync(RuntimeElsaDbContext dbContext, WorkflowExecutionLogRecord entity, CancellationToken cancellationToken) { - dbContext.Entry(entity).Property("SerializedActivityState").CurrentValue = entity.ActivityState != null ? _serializer.Serialize(entity.ActivityState) : default; - dbContext.Entry(entity).Property("SerializedPayload").CurrentValue = entity.Payload != null ? _serializer.Serialize(entity.Payload) : default; - return default; + dbContext.Entry(entity).Property("SerializedActivityState").CurrentValue = entity.ActivityState != null ? (await _activityStateSerializer.SerializeAsync(entity.ActivityState, cancellationToken)).ToString() : default; + dbContext.Entry(entity).Property("SerializedPayload").CurrentValue = entity.Payload != null ? (await _activityStateSerializer.SerializeAsync(entity.Payload, cancellationToken)).ToString() : default; } private async ValueTask OnLoadAsync(RuntimeElsaDbContext dbContext, WorkflowExecutionLogRecord? entity, CancellationToken cancellationToken) @@ -89,13 +91,13 @@ public class EFCoreWorkflowExecutionLogStore : IWorkflowExecutionLogStore private ValueTask LoadPayload(RuntimeElsaDbContext dbContext, WorkflowExecutionLogRecord entity) { var json = dbContext.Entry(entity).Property("SerializedPayload").CurrentValue; - return new(!string.IsNullOrEmpty(json) ? _serializer.Deserialize(json) : null); + return new(!string.IsNullOrEmpty(json) ? JsonSerializer.Deserialize(json) : null); } private ValueTask?> LoadActivityState(RuntimeElsaDbContext dbContext, WorkflowExecutionLogRecord entity) { var json = dbContext.Entry(entity).Property("SerializedActivityState").CurrentValue; - return new(!string.IsNullOrEmpty(json) ? _serializer.Deserialize>(json) : null); + return new(!string.IsNullOrEmpty(json) ? JsonSerializer.Deserialize>(json) : null); } private static IQueryable Filter(IQueryable queryable, WorkflowExecutionLogRecordFilter filter) => filter.Apply(queryable); diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicObjectConverter.cs b/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicObjectConverter.cs index 012432cf3..aa4f0bedc 100644 --- a/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicObjectConverter.cs +++ b/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicObjectConverter.cs @@ -6,6 +6,7 @@ using System.Dynamic; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; namespace Elsa.Workflows.Core.Serialization.Converters; @@ -42,7 +43,16 @@ public class PolymorphicObjectConverter : JsonConverter var isEnumerable = typeof(IEnumerable).IsAssignableFrom(targetType); if (!isEnumerable) - return JsonSerializer.Deserialize(ref reader, targetType, newOptions)!; + { + try + { + return JsonSerializer.Deserialize(ref reader, targetType, newOptions)!; + } + catch (NotSupportedException e) + { + return default!; + } + } // If the target type is a Newtonsoft.JObject, parse the JSON island. var isNewtonsoftObject = targetType == typeof(JObject); diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Converters/SafeDictionaryConverter.cs b/src/modules/Elsa.Workflows.Core/Serialization/Converters/SafeDictionaryConverter.cs new file mode 100644 index 000000000..0024bc9da --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Serialization/Converters/SafeDictionaryConverter.cs @@ -0,0 +1,51 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Elsa.Workflows.Core.Serialization.Converters; + +/// +/// A JSON converter that safely serializes a dictionary of objects, even if some of the objects are not serializable. +/// In that case, the converter will serialize a fallback object that contains the type name of the original object. +/// +public class SafeDictionaryConverter : JsonConverter> +{ + /// + public override IDictionary Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + // For simplicity, I'm assuming you're using the default deserialization for dictionaries. + // If you need custom deserialization logic, you can implement it here. + return JsonSerializer.Deserialize>(ref reader, options)!; + } + + /// + public override void Write(Utf8JsonWriter writer, IDictionary value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + foreach (var kvp in value) + { + if (kvp.Value == null!) continue; // Skip null values + + writer.WritePropertyName(kvp.Key); + + try + { + // Serialize the value to a temporary string. + var serializedValue = JsonSerializer.Serialize(kvp.Value, options); + + // Use the serialized string value to write to the main writer. + using var doc = JsonDocument.Parse(serializedValue); + doc.WriteTo(writer); + } + catch + { + // If serialization fails, write the fallback object. + writer.WriteStartObject(); + writer.WriteString("TypeName", kvp.Value.GetType().FullName); + writer.WriteEndObject(); + } + } + + writer.WriteEndObject(); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Converters/SafeDictionaryConverterFactory.cs b/src/modules/Elsa.Workflows.Core/Serialization/Converters/SafeDictionaryConverterFactory.cs new file mode 100644 index 000000000..63a7f19ba --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Serialization/Converters/SafeDictionaryConverterFactory.cs @@ -0,0 +1,26 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Elsa.Workflows.Core.Serialization.Converters; + +/// +/// A JSON converter factory that creates instances. +/// +public class SafeDictionaryConverterFactory : JsonConverterFactory +{ + /// + public override bool CanConvert(Type typeToConvert) + { + // Check if the type is assignable to IDictionary + return typeof(IDictionary).IsAssignableFrom(typeToConvert); + } + + /// + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + if (CanConvert(typeToConvert)) + return new SafeDictionaryConverter(); + + throw new InvalidOperationException($"Cannot convert {typeToConvert}"); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Providers/BasicSerializationProvider.cs b/src/modules/Elsa.Workflows.Core/Serialization/Providers/BasicSerializationProvider.cs index e83797477..e99a7ad50 100644 --- a/src/modules/Elsa.Workflows.Core/Serialization/Providers/BasicSerializationProvider.cs +++ b/src/modules/Elsa.Workflows.Core/Serialization/Providers/BasicSerializationProvider.cs @@ -1,6 +1,8 @@ using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; using Elsa.Expressions.Services; +using Elsa.Extensions; using Elsa.Workflows.Core.Contracts; using Elsa.Workflows.Core.Serialization.Converters; @@ -25,8 +27,13 @@ public class BasicSerializationProvider : ISerializationProvider if (CanSerialize(value, out var jsonElement)) return new(jsonElement); - var type = JsonSerializer.SerializeToElement(value!.GetType().Name); - return new(type); + var unserializableTypeHolder = new JsonObject() + { + ["UnserializableType"] = value!.GetType().GetSimpleAssemblyQualifiedName() + }; + + var serializedTypeHolder = JsonSerializer.SerializeToElement(unserializableTypeHolder); + return new(serializedTypeHolder); } private static bool CanSerialize(object? obj, out JsonElement jsonElement) @@ -44,19 +51,21 @@ public class BasicSerializationProvider : ISerializationProvider jsonElement = JsonSerializer.SerializeToElement(obj, options); return true; } - catch (JsonException) + catch (Exception) { jsonElement = default; return false; } } - + private static JsonSerializerOptions CreateOptions() => new() { + ReferenceHandler = ReferenceHandler.Preserve, Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase), - new TypeJsonConverter(WellKnownTypeRegistry.CreateDefault()) + new TypeJsonConverter(WellKnownTypeRegistry.CreateDefault()), + new SafeDictionaryConverterFactory() } }; } \ No newline at end of file