C# - Using Entity Framework Core 3 HasConversion to convert a field to JSON in .Net Core 3.1

Viewed 3410

I was trying to accomplish the conversion dynamically across all of the models I have in my project:

DbContext.cs

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    var entityTypes = modelBuilder.Model.GetEntityTypes().ToList();
    foreach (var entityType in entityTypes)
    {
        foreach (var property in entityType.ClrType.GetProperties().Where(x => x != null && x.GetCustomAttribute<HasJsonConversionAttribute>() != null))
        {
            modelBuilder.Entity(entityType.ClrType)
                .Property(property.PropertyType, property.Name)
                .HasJsonConversion();
        }
    }

    base.OnModelCreating(modelBuilder);
}

Then created a data annotation attribute to mark my fields in my model as "Json"

public class HasJsonConversionAttribute : Attribute {}

This is my extension method used to convert the Json to object and back to Json

public static class SqlExtensions
{
    public static PropertyBuilder HasJsonConversion(this PropertyBuilder propertyBuilder)
    {
        ParameterExpression parameter1 = Expression.Parameter(propertyBuilder.Metadata.ClrType, "v");

        MethodInfo methodInfo1 = typeof(Newtonsoft.Json.JsonConvert).GetMethod("SerializeObject", types: new Type[] { typeof(object) });
        MethodCallExpression expression1 = Expression.Call(methodInfo1 ?? throw new Exception("Method not found"), parameter1);

        ParameterExpression parameter2 = Expression.Parameter(typeof(string), "v");
        MethodInfo methodInfo2 = typeof(Newtonsoft.Json.JsonConvert).GetMethod("DeserializeObject", 1, BindingFlags.Static | BindingFlags.Public, Type.DefaultBinder, CallingConventions.Any, types: new Type[] { typeof(string) }, null)?.MakeGenericMethod(propertyBuilder.Metadata.ClrType) ?? throw new Exception("Method not found");
        MethodCallExpression expression2 = Expression.Call(methodInfo2, parameter2);
        
        var converter = Activator.CreateInstance(typeof(ValueConverter<,>)
                                 .MakeGenericType(propertyBuilder.Metadata.ClrType, typeof(string)), new object[]
        {
            Expression.Lambda( expression1,parameter1),
            Expression.Lambda( expression2,parameter2),
            (ConverterMappingHints) null
        });

        propertyBuilder.HasConversion(converter as ValueConverter);
        return propertyBuilder;
    }
}

For simplicity I'm using this User model:

public class User : IEntityTypeConfiguration<User>
{
    public void Configure(EntityTypeBuilder<User> builder)
    {
        // Apply some settings defined in custom annotations in the model properties
        //builder.ApplyCustomAnnotationsAndConfigs(this);
    }
    
    [Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int Id { get; set; }
    public string Username { get; set; }

    [HasJsonConversion]
    public List<LocalizedName> Values { get; set; }
}

and This is the class that I want to convert to and from JSON:

public class LocalizedName
{
    public string Value { get; set; }
    public string Code { get; set; }
}

Now here's the problem I'm facing, it keeps on detecting LocalizedName object as another model that doesn't have a Key and throws an error telling me to add a Key/PK even though this is not flagged as a model.

Now if I execute this from the User -> Configure() it will work BUT it'll show me other issues like the model loses its own relationships and associations with other models and now it throws me other set of errors which I didn't even have in the first place.

I've also noticed that EF removes LocalizedName from the property list and shows it under Navigation properties list. And lastly, I've checked the OnModelCreating -> modelBuilder.Model.GetEntityTypes() and noticed that EF is treating it as a new Model which is kinda weird.

Is there a way I can tell EF to mark this as a string/json field instead of EF auto-assuming it's a model?

Any help would be appreciated.

3 Answers

So I ended up using this NuGet package from https://github.com/Innofactor/EfCoreJsonValueConverter

Then in my DbContext.cs:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Ignore<LocalizedName>(); //<--- Ignore this model from being added by convention
    modelBuilder.AddJsonFields(); //<--- Auto add all json fields in all models
}

Then in my User model:

public class User : IEntityTypeConfiguration<User>
{
    public void Configure(EntityTypeBuilder<User> builder) {}
    
    [Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int Id { get; set; }
    public string Username { get; set; }

    [JsonField] //<------ Add this attribute
    public List<LocalizedName> Values { get; set; }
}

Now it works as expected.

You can ignore your entity in OnModelCreating like this:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
   modelBuilder.Ignore<LocalizedName>();
   //rest of your code
}

And for ignore all model that has HasJsonConversion Attribute you can do this (I didn't test this):

       var entityTypes = modelBuilder.Model.GetEntityTypes().ToList();
        foreach (var entityType in entityTypes)
        {
            foreach (var property in entityType.ClrType.GetProperties().Where(x => x != null && x.GetCustomAttribute<HasJsonConversionAttribute>() != null))
            {
                modelBuilder.Entity(entityType.ClrType)
                    .Property(property.PropertyType, property.Name)
                    .HasJsonConversion();

                modelBuilder.Ignore(property.PropertyType);
            }
        }
 public static class JsonConversionExtensions
{

    public static PropertyBuilder<T> HasJsonConversion<T>(this PropertyBuilder<T> propertyBuilder, string columnType = null, string columnName = "", JsonSerializerSettings settings = null)
    {
        var converter = new ValueConverter<T, string>(
            v => JsonConvert.SerializeObject(v, settings),
            v => JsonConvert.DeserializeObject<T>(v, settings));

        var comparer = new ValueComparer<T?>(
              (l, r) => JsonConvert.SerializeObject(l, settings) == JsonConvert.SerializeObject(r, settings),
              v => v == null ? 0 : JsonConvert.SerializeObject(v, settings).GetHashCode(),
              v => JsonConvert.DeserializeObject<T?>(JsonConvert.SerializeObject(v, settings), settings));

        propertyBuilder.HasConversion(converter);
        if (columnType != null) propertyBuilder.HasColumnType(columnType);

        if (columnName == "")
            propertyBuilder.HasColumnName($"Json_{propertyBuilder.Metadata.Name}");
        else if (columnName != null)
            propertyBuilder.HasColumnName(columnName);

        propertyBuilder.Metadata.SetValueConverter(converter);
        propertyBuilder.Metadata.SetValueComparer(comparer);

        return propertyBuilder;
    }

    public static PropertyBuilder<TEnum?> HasByteEnumConversion<TEnum>(this PropertyBuilder<TEnum?> propertyBuilder) where TEnum : struct
    {
        propertyBuilder
                .HasConversion(x => Convert.ToByte(x), x => Enum.Parse<TEnum>(x.ToString()));

        return propertyBuilder;
    }

    public static PropertyBuilder<TEnum> HasByteEnumConversion<TEnum>(this PropertyBuilder<TEnum> propertyBuilder) where TEnum : struct //Enum
    {
        propertyBuilder
                .IsRequired()
                .HasValueGenerator((p, e) => throw new NullReferenceException($@"The column ""{ p.Name }"" in ""{e.DisplayName()}"" does not allow nulls."))
                .HasConversion(x => Convert.ToByte(x), x => Enum.Parse<TEnum>(x.ToString()));

        return propertyBuilder;
    }
}
Related