128

I am creating a reusable library using .NET Core (targeting .NETStandard 1.4) and I am using Entity Framework Core (and new to both). I have an entity class that looks like:

public class Campaign
{
    [Key]
    public Guid Id { get; set; }

    [Required]
    [MaxLength(50)]
    public string Name { get; set; }

    public JObject ExtendedData { get; set; }
}

and I have a DbContext class that defines the DbSet:

public DbSet<Campaign> Campaigns { get; set; }

(I am also using the Repository pattern with DI, but I don't think that is relevant.)

My unit tests give me this error:

System.InvalidOperationException: Unable to determine the relationship represented by navigation property 'JToken.Parent' of type 'JContainer'. Either manually configure the relationship, or ignore this property from the model..

Is there a way to indicate that this is not a relationship but should be stored as a big string?

TheMisir
  • 4,083
  • 1
  • 27
  • 37
Alex
  • 2,795
  • 3
  • 21
  • 21
  • I think you should change the type of `ExtendedData` to `string` and then store the stringified JSON – Michael Jun 29 '17 at 15:53
  • 2
    @Michael I thought about that but I'd like to ensure that it is always valid JSON. – Alex Jun 29 '17 at 16:00
  • 1
    @Alex - If that is the only concern to check if it is valid JSON, for simplicity you could add a parsing to the set-method of your property (i.e. try to deserialize it) - and throw an InvalidDataException or an JsonSerializationException if it is not valid. – Matt Jul 03 '20 at 08:54

13 Answers13

214

Going to answer this one differently.

Ideally the domain model should have no idea how data is stored. Adding backing fields and extra [NotMapped] properties is actually coupling your domain model to your infrastructure.

Remember - your domain is king, and not the database. The database is just being used to store parts of your domain.

Instead you can use EF Core's HasConversion() method on the EntityTypeBuilder object to convert between your type and JSON.

Given these 2 domain models:

public class Person
{
    public int Id { get; set; }

    [Required]
    [MaxLength(50)]
    public string FirstName { get; set; }

    [Required]
    [MaxLength(50)]
    public string LastName { get; set; }

    [Required]
    public DateTime DateOfBirth { get; set; }

    public IList<Address> Addresses { get; set; }      
}

public class Address
{
    public string Type { get; set; }
    public string Company { get; set; }
    public string Number { get; set; }
    public string Street { get; set; }
    public string City { get; set; }
}

I have only added attributes that the domain is interested in - and not details that the DB would be interested in; I.E there is no [Key].

My DbContext has the following IEntityTypeConfiguration for the Person:

public class PersonsConfiguration : IEntityTypeConfiguration<Person>
{
    public void Configure(EntityTypeBuilder<Person> builder)
    {
        // This Converter will perform the conversion to and from Json to the desired type
        builder.Property(e => e.Addresses).HasConversion(
            v => JsonConvert.SerializeObject(v, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }),
            v => JsonConvert.DeserializeObject<IList<Address>>(v, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }));
    }
}

With this method you can completely decouple your domain from your infrastructure. No need for all the backing field & extra properties.

TheMisir
  • 4,083
  • 1
  • 27
  • 37
Darren Wainwright
  • 30,247
  • 21
  • 76
  • 127
  • Thanks @Darren! EF Core Value Conversions look like the way to go (although they appear to be new in EF Core 2.1). – Alex Aug 01 '18 at 14:30
  • When I try this with JObject I get an ambiguous match found exception. Something about the JObject is messing witt the reflection that ef core is using. – Aaron Smith Aug 20 '18 at 12:53
  • 1
    If I add the [NotMapped] attribute to the JObject property, and then include the conversion it appears to work correctly – Aaron Smith Aug 20 '18 at 14:09
  • 78
    Be careful with this approach: EF Core marks an entity as modified only if the field is assigned to. So if you use `person.Addresses.Add`, the entity won't be flagged as updated; you'll need to call the property setter `person.Addresses = updatedAddresses`. – Métoule Dec 05 '18 at 13:43
  • @Métoule - correct, you wouldn't want to rely on any of the out-of-the-box functionality that takes care of an entities state. – Darren Wainwright Dec 05 '18 at 15:54
  • Suppose the Address class had some property that is not a primitive type say Zipcode , then the conversion fails (It fails in general with nested objects), how to solve this problem using this approach? – Zack ISSOIR Mar 14 '19 at 17:01
  • 1
    @ZackISSOIR - Haven't tried it, though feel that you would follow the same idea as above, just target the nested property. – Darren Wainwright Mar 19 '19 at 17:43
  • 1
    @DarrenWainwright I agree the model should know nothing about how its stored, but unfortunately, we're stuck using [NotMapped] if we go the IEntityTypeConfiguration route still because no equivalent exists. Unless I'm just not seeing it anywhere using the EntityTypeBuilder. – Kilhoffer May 28 '19 at 21:21
  • 12
    This method worked for me, but you need to apply this configuration as well! modelBuilder.ApplyConfiguration(new PersonsConfiguration()); – CodeThief Feb 21 '20 at 13:49
  • 3
    @Métoule - My Solution Fixes this issue you need a ValueComparer. See my answer below. – Robert Raboud May 13 '20 at 12:42
  • The example would be clearer if instead of a list of addresses you would have a dynamic object of some sort - that was what the original question was about, using some extended properties – Adrian Nasui Jun 14 '20 at 16:12
  • 6
    Trying this on ef core 3.1, getting the following error. Any idea on how to fix ? "The entity type 'Address' requires a primary key to be defined. If you intended to use a keyless entity type call 'HasNoKey()'." – Nairooz NIlafdeen Jul 08 '20 at 14:56
  • I'm not following if you have Address type why you are storing this as JSON, I thought usually JSON column in used on anonymous types? But if you want to store in the same table I think you can use Address as value object and configure in EF Core as OwnsMany() so it saves in the same Person table – Jamaxack Jul 27 '20 at 11:58
  • Useful for value objects since they don't need to be marked as modified. – Efe Dec 11 '21 at 00:42
83

The key to making the the Change Tracker function correctly is to implement a ValueComparer as well as a ValueConverter. Below is an extension to implement such:

public static class ValueConversionExtensions
{
    public static PropertyBuilder<T> HasJsonConversion<T>(this PropertyBuilder<T> propertyBuilder) where T : class, new()
    {
        ValueConverter<T, string> converter = new ValueConverter<T, string>
        (
            v => JsonConvert.SerializeObject(v),
            v => JsonConvert.DeserializeObject<T>(v) ?? new T()
        );

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

        propertyBuilder.HasConversion(converter);
        propertyBuilder.Metadata.SetValueConverter(converter);
        propertyBuilder.Metadata.SetValueComparer(comparer);
        propertyBuilder.HasColumnType("jsonb");

        return propertyBuilder;
    }
}

Example of how this works.

public class Person
{
    public int Id { get; set; }

    [Required]
    [MaxLength(50)]
    public string FirstName { get; set; }

    [Required]
    [MaxLength(50)]
    public string LastName { get; set; }

    [Required]
    public DateTime DateOfBirth { get; set; }

    public List<Address> Addresses { get; set; }      
}

public class Address
{
    public string Type { get; set; }
    public string Company { get; set; }
    public string Number { get; set; }
    public string Street { get; set; }
    public string City { get; set; }
}

public class PersonsConfiguration : IEntityTypeConfiguration<Person>
{
    public void Configure(EntityTypeBuilder<Person> builder)
    {
        // This Converter will perform the conversion to and from Json to the desired type
        builder.Property(e => e.Addresses).HasJsonConversion<IList<Address>>();
    }
}

This will make the ChangeTracker function correctly.

Robert Raboud
  • 1,004
  • 8
  • 7
  • Nice solution! Tried it and it works. One slip up in the code though; the converter has a type constraint for `class` so you can't use it on `IList
    `. It needs to be a concrete type like `IList
    `. An important thing to remember here is that you can only query on the JSON data using hand written SQL, resulting in rather complex SQL with CTEs and such.
    – Marnix van Valen Dec 11 '19 at 10:26
  • 2
    Nice catch, I fixed the code. This is actually part of new article I am writing and new nuget package to handle this. I am working on a generic solution for adding metadata without the need to continually modify the entity. I use a Dictionary to store the metadata via name value pairs. And yes this solution does not lend itself to easily query the metadata. – Robert Raboud Dec 12 '19 at 13:44
  • 1
    @RobertRaboud: Any news on that article or the nuget package? Would appreciate it very much – Max R. Jun 17 '20 at 09:07
  • @RobertRaboud the solution is super! Article would be wonderful to have as a resource for all – but I'm not sold and don't think the general JSON blob is a good way to go. You still want to keep the fields small, and remember while EF Core doesn't *yet* have JSON mappings, SQL Server itself DOES (and you could write your own) – so future-proofing is better to have more light-weight JSON fields not one big dictionary. Of course there is an exception for this, but probably more for the 5-10% of scenarios rather than the +90% – Marchy Jun 22 '20 at 14:46
  • 17
    For SQL Server, i had to change `jsonb` to `nvarchar(max)`. As recommended by [microsoft](https://learn.microsoft.com/en-us/sql/relational-databases/json/store-json-documents-in-sql-tables?view=sql-server-ver15) – Learner Aug 06 '20 at 07:32
  • 1
    Is the generic constraint `where T : class` required? Won't it work for structs? – Jacek Feb 03 '22 at 16:23
45

@Michael's answer got me on track but I implemented it a little differently. I ended up storing the value as a string in a private property and using it as a "Backing Field". The ExtendedData property then converted JObject to a string on set and vice versa on get:

public class Campaign
{
    // https://learn.microsoft.com/en-us/ef/core/modeling/backing-field
    private string _extendedData;

    [Key]
    public Guid Id { get; set; }

    [Required]
    [MaxLength(50)]
    public string Name { get; set; }

    [NotMapped]
    public JObject ExtendedData
    {
        get
        {
            return JsonConvert.DeserializeObject<JObject>(string.IsNullOrEmpty(_extendedData) ? "{}" : _extendedData);
        }
        set
        {
            _extendedData = value.ToString();
        }
    }
}

To set _extendedData as a backing field, I added this to my context:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Campaign>()
        .Property<string>("ExtendedDataStr")
        .HasField("_extendedData");
}

Update: Darren's answer to use EF Core Value Conversions (new to EF Core 2.1 - which didn't exist at the time of this answer) seems to be the best way to go at this point.

Alex
  • 2,795
  • 3
  • 21
  • 21
  • that could also be an option. Both of these approaches are in EF core documentation. Backing field looks cleaner but can be not so easy to understand later :) – Michael Jun 29 '17 at 19:46
  • 2
    Both of them are right approach. The only difference is, since EF core supports shadow properties, backing field approach would utilize a shadow property and avoid having extra property in your domain model. :) – Smit Jun 30 '17 at 19:59
  • How to use this with jquery? – Kavin404 Nov 29 '19 at 10:21
  • 6
    @Kavin404 - you do not use this with JQuery. EF Core is a .NET data access technology and JQuery is a front-end (browser) javascript framework. – Alex Dec 06 '19 at 23:57
  • This is the correct answer if you're just trying to store JObject data. It's completely nonsense that this cannot be serialized directly. I'm literally trying to store arbitrary json, why do I need to convert it? – Captain Prinny Jan 07 '22 at 20:45
13

For those using EF 2.1 there is a nice little NuGet package EfCoreJsonValueConverter that makes it pretty simple.

using Innofactor.EfCoreJsonValueConverter;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

public class Campaign
{
    [Key]
    public Guid Id { get; set; }

    [Required]
    [MaxLength(50)]
    public string Name { get; set; }

    public JObject ExtendedData { get; set; }
}

public class CampaignConfiguration : IEntityTypeConfiguration<Campaign> 
{
    public void Configure(EntityTypeBuilder<Campaign> builder) 
    {
        builder
            .Property(application => application.ExtendedData)
            .HasJsonValueConversion();
    }
}
Massimiliano Kraus
  • 3,638
  • 5
  • 27
  • 47
sjclark76
  • 394
  • 3
  • 8
  • the easiest solutions so far. Here's my implementation with it: https://stackoverflow.com/a/65781229/326904 – Desolator Jan 18 '21 at 22:46
  • Thank you! I hate all the answers saying to test with production database. Yuck. This allows for testing with an in memory sqlite database, very nice! – jjxtra Dec 31 '21 at 20:42
9

I have made a solution based on Robert Raboud's contribution. The change made by me is that my implementation uses a HasJsonConversion method that depends on the System.Text.Json package rather than Newtonsofts library:

    public static PropertyBuilder<T> HasJsonConversion<T>(this PropertyBuilder<T> propertyBuilder) where T : class, new()
    {
        var options = new JsonSerializerOptions
        {
            PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
            WriteIndented = true,
            AllowTrailingCommas = true,
            PropertyNameCaseInsensitive = true
        };

        ValueConverter<T, string> converter = new ValueConverter<T, string>
        (
            v => JsonSerializer.Serialize(v, options),
            v => JsonSerializer.Deserialize<T>(v, options) ?? new T()
        );

        ValueComparer<T> comparer = new ValueComparer<T>
        (
            (l, r) => JsonSerializer.Serialize(l, options) == JsonSerializer.Serialize(r, options),
            v => v == null ? 0 : JsonSerializer.Serialize(v, options).GetHashCode(),
            v => JsonSerializer.Deserialize<T>(JsonSerializer.Serialize(v, options), options)
        );

        propertyBuilder.HasConversion(converter);
        propertyBuilder.Metadata.SetValueConverter(converter);
        propertyBuilder.Metadata.SetValueComparer(comparer);
        propertyBuilder.HasColumnType("LONGTEXT");

        return propertyBuilder;
    }

Note also that this implementation expects for the column to be LONGTEXT since I am using a MySQL setup.

mstaal
  • 590
  • 9
  • 25
  • This won't work for primitive collections such as string[], but will be supported in EF core 8. E.g. array types don't satisfy the generic constraints class, new() – nh43de May 15 '23 at 04:04
7

Here's something I used

Model

public class FacilityModel 
{
    public string Name { get; set; } 
    public JObject Values { get; set; } 
}

Entity

[Table("facility", Schema = "public")]
public class Facility 
{
     public string Name { get; set; } 
     public Dictionary<string, string> Values { get; set; } = new Dictionary<string, string>();
}

Mapping

this.CreateMap<Facility, FacilityModel>().ReverseMap();

DBContext

base.OnModelCreating(builder); 
        builder.Entity<Facility>()
        .Property(b => b.Values)
        .HasColumnType("jsonb")
        .HasConversion(
        v => JsonConvert.SerializeObject(v),
        v => JsonConvert.DeserializeObject<Dictionary<string, string>>(v));
5

Could you try something like this?

    [NotMapped]
    private JObject extraData;

    [NotMapped]
    public JObject ExtraData
    {
        get { return extraData; }
        set { extraData = value; }
    }

    [Column("ExtraData")]
    public string ExtraDataStr
    {
        get
        {
            return this.extraData.ToString();
        }
        set
        {
            this.extraData = JsonConvert.DeserializeObject<JObject>(value);
        }
    }

here is the migration output:

ExtraData = table.Column<string>(nullable: true),
Michael
  • 1,027
  • 10
  • 22
  • I think this is the right direction - looks like I can use "Backing Fields" to accomplish this (https://learn.microsoft.com/en-us/ef/core/modeling/backing-field). – Alex Jun 29 '17 at 17:13
  • Do we need to annotate extraData as a backing field? (according to https://learn.microsoft.com/en-us/ef/core/modeling/backing-field?tabs=data-annotations) However, the `[BackingField(...)]` attribute isn't part of the usual attributes described here: https://learn.microsoft.com/en-us/ef/core/modeling/entity-properties?tabs=data-annotations%2Cfluent-api%2Cwithout-nrt – Matt Jul 03 '20 at 09:09
  • This solution was built for older EF Core. Now you can annotate extraData as backing field (but you'll need to change the logic slightly) or even use shadow properties (https://learn.microsoft.com/en-us/ef/core/modeling/shadow-properties ). Or you can use as is if it still works. – Michael Jul 05 '20 at 06:43
5

For developers, who work with EF Core 3.1 and meet such error ("The entity type 'XXX' requires a primary key to be defined. If you intended to use a keyless entity type call 'HasNoKey()'.") the solution is:

Move .HasConversion() method with it's lambda from: OrderConfiguration : IEntityTypeConfiguration<T> to OnModelCreating(ModelBuilder modelBuilder) in your DataContext.

Cata Hotea
  • 1,811
  • 1
  • 9
  • 19
Jack
  • 71
  • 2
  • 5
4

----------------- 2022 Update -----------------

Hey there,

Just sharing an update from Dec/2022.

Recently, EF Core 7.0 was launched, containing one of the most awaited features called JSON Columns.

This new feature allows us for mapping aggregates (written from .NET types) into JSON documents.

Just remember that, In EF Core, aggregate types are defined using Owned Entity Types. https://learn.microsoft.com/en-us/ef/core/modeling/owned-entities

Let's consider this scenario: A object called "LogDetail", that needs to be stored into a single column as a JSON, inside another object (or table) called "Log".

public class Log : BaseEntity
{
    public string TraceID { get; set; } = string.Empty;
    public string Code { get; set; } = string.Empty;
    public LogDetail LogDetail { get; set; } = null!;
    public string IpAddress { get; set; } = string.Empty;
}


public class LogDetail
{
    public string InnerException { get; set; } = null!;
    public string MemberMap { get; set; } = null!;
    public string Message { get; set; } = null!;
    public string Source { get; set; } = null!;
    public string StackTrace { get; set; } = null!;
    public string TypeMap { get; set; } = null!;
    public string Path { get; set; } = null!;
}

By the end, all that you need is to configure the behavior on your configuration map class:

public sealed class LogMap : IEntityTypeConfiguration<Log>
{
    public override void Configure(EntityTypeBuilder<Log> builder)
    {
        /* Owned Type Configurations */
        builder.OwnsOne(e => e.LogDetail, options =>
        {
            options.ToJson("LOG_DETAIL");
        });

    }
}
Rafael Cronemberger
  • 231
  • 1
  • 4
  • 11
3

For those who are working on entity framework core 5.0 and above. below can work if you are getting error like below

The entity type '{EntityName}' requires a primary key to be defined. If you intended to use a keyless entity type, call 'HasNoKey' in 'OnModelCreating'. The error is asking to define a primary key on the model

Try this

protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder
            .Entity<Person>(
                eb =>
                {
                    eb.Property(p => p.Addresses).HasConversion(

        v => JsonConvert.SerializeObject(v, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }),
        v => JsonConvert.DeserializeObject<IList<Address>>(v, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore })
                        );
                });
    }
Rushabh Master
  • 450
  • 4
  • 13
2

// DbContext

  protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            var entityTypes = modelBuilder.Model.GetEntityTypes();
            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);
        }


Create an attribute to handle the properties of the entities.


public class HasJsonConversionAttribute : System.Attribute
    {

    }

Create extention class to find Josn properties

    public static class ValueConversionExtensions
    {
        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(typeof(List<AttributeValue>), typeof(string)), new object[]
                {
                    Expression.Lambda( expression1,parameter1),
                    Expression.Lambda( expression2,parameter2),
                    (ConverterMappingHints) null
                });

            propertyBuilder.HasConversion(converter as ValueConverter);

            return propertyBuilder;
        }
    }

Entity example

 public class Attribute
    {
        [Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
        public int Id { get; set; }
        public string Name { get; set; }

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

    public class AttributeValue
    {
        public string Value { get; set; }
        public IList<AttributeValueTranslation> Translations { get; set; }
    }

    public class AttributeValueTranslation
    {
        public string Translation { get; set; }

        public string CultureName { get; set; }
    }

Download Source

  • 1
    for some reason this is not working properly with EFCore 3. Checking the values of entityTypes it considers the Values in Attribute class as an entity with no key. – Desolator Jan 14 '21 at 20:42
0

The comment by @Métoule:

Be careful with this approach: EF Core marks an entity as modified only if the field is assigned to. So if you use person.Addresses.Add, the entity won't be flagged as updated; you'll need to call the property setter person.Addresses = updatedAddresses.

made me take a different approach so that this fact is obvious: use Getter and Setter methods, rather than a property.

public void SetExtendedData(JObject extendedData) {
    ExtendedData = JsonConvert.SerializeObject(extendedData);
    _deserializedExtendedData = extendedData;
}

//just to prevent deserializing more than once unnecessarily
private JObject _deserializedExtendedData;

public JObject GetExtendedData() {
    if (_extendedData != null) return _deserializedExtendedData;
    _deserializedExtendedData = string.IsNullOrEmpty(ExtendedData) ? null : JsonConvert.DeserializeObject<JObject>(ExtendedData);
    return _deserializedExtendedData;
}

You could theoretically do this:

campaign.GetExtendedData().Add(something);

But it's much more clear that That Doesn't Do What You Think It Does™.

If you're using database-first and using some kind of class auto-generator for EF, then the classes will usually be declared as partial, so you can add this stuff in a separate file that won't get blown away the next time you update your classes from your database.

Gabriel Luci
  • 38,328
  • 4
  • 55
  • 84
0

Use JsonProperty.EFCore. You can install it as a nuget package.

Example:

using JsonProperty.EFCore;

//Create product
var product = new Product() { Name = "Car" };

//AddRange
product.Attributes.AddRange(new Dictionary<string, object>() {
    //You can add values ​​of different types if the base type is object 
    {"MaxSpeed",300},{ "Engine capacity",3.5}, { "ElectroCar",false }
});

//Add
product.Attributes.Add("Color", "Red");

//Edit
product.Attributes.Edit(attrs=>{
    attrs["Color"] = "Blue";
    return attrs;
});

//Entity model
public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
    //Json container property. Same type as JsonDictionary<string,object>
    public JsonDictionary Attributes{ get; set; } = new();
}