Veselin Todorov
Veselin Todorov

Reputation: 53

EF Core property value conversion not working in a derived entity configuration

I have a generic EF BaseEntityConfiguration class that is used for setting up base properties (like the primary key, properties used for soft deletion, query filters, etc.) and a derived configuration for an entity that stores System.Type and a JSON property. If I don't use the generic class and just implement the IEntityTypeConfiguration then the value conversion works and there are no errors. However if I inherit from the base class, I get EF Core issues about saving Type and object without any conversion. Other configurations that inherit from the base class and don't need conversions work fine.

The error:

Error: The property 'MessageLog.Data' could not be mapped because it is of type 'object', which is not a supported primitive type or a valid entity type. Either explicitly map this property, or ignore it using the '[NotMapped]' attribute or by using 'EntityTypeBuilder.Ignore' in 'OnModelCreating'.

public class MessageLogConfiguration
        //: IEntityTypeConfiguration<MessageLog>
        : BaseEntityConfiguration<MessageLog, int>
    {
        public MessageLogConfiguration(ILogger<MessageLogConfiguration> logger)
           : base(logger)
        { }

        public override void Configure(EntityTypeBuilder<MessageLog> builder)
        {
            base.Configure(builder);

            //builder
            //    .HasKey(x => x.Id);


            builder
                .Property(m => m.MessageId)
                .IsRequired();

            builder
                .Property(m => m.Data)
                .HasJsonConversion()
                .IsRequired();

            builder
                .Property(m => m.Type)
                .IsRequired()
                .HasConversion(
                    t => t.AssemblyQualifiedName,
                    t => Type.GetType(t!)!);

            builder.HasIndex(m => m.MessageId).IsUnique();

        }
    }
public abstract class BaseEntityConfiguration<TEntity, TId> : IEntityTypeConfiguration<TEntity>
        where TEntity : Entity<TId>
        where TId : struct
    {
        protected BaseEntityConfiguration(ILogger<BaseEntityConfiguration<TEntity, TId>> logger)
        {
            this.Logger = logger;
        }

        protected ILogger<BaseEntityConfiguration<TEntity, TId>> Logger { get; }

        public virtual void Configure(EntityTypeBuilder<TEntity> builder)
        {
            builder
                .HasKey(x => x.Id);

            if (typeof(IAuditableEntity).IsAssignableFrom(builder.Metadata.ClrType))
            {
                Logger.LogTrace($" > Configure properties for {nameof(IAuditableEntity)}'");
                builder.Property(nameof(IAuditableEntity.CreatedOn)).IsRequired().ValueGeneratedOnAdd();
                builder.Property(nameof(IAuditableEntity.CreatedBy)).IsRequired().HasMaxLength(255);
                builder.Property(nameof(IAuditableEntity.ModifiedOn)).IsRequired(false);
                builder.Property(nameof(IAuditableEntity.ModifiedBy)).IsRequired(false).HasMaxLength(255);
            }

            if (typeof(ISoftDeletableEntity).IsAssignableFrom(builder.Metadata.ClrType))
            {
                Logger.LogTrace($" > Configure properties for {nameof(ISoftDeletableEntity)}'");
                builder.Property(nameof(ISoftDeletableEntity.DeletedAt)).IsRequired(false);
                builder.Property(nameof(ISoftDeletableEntity.DeletedBy)).IsRequired(false);
                builder.HasQueryFilter(m => EF.Property<int?>(m, nameof(ISoftDeletableEntity.DeletedBy)) == null);
            }
        }
    }
public class MessageLog : AuditableEntity<int>
    {
        public MessageLog(string messageId, object data, MessageLogType messageLogType)
        {
            this.MessageId = messageId;
            this.Type = data.GetType();
            this.Data = data;
            this.MessageLogType = messageLogType;
        }

        private MessageLog(string messageId)
        {
            this.MessageId = messageId;
            this.Type = default!;
            this.Data = default!;
            this.MessageLogType = default!;
        }



        public string MessageId { get; private set; }

        public Type Type { get; private set; }

        public MessageLogType MessageLogType { get; private set; }

        public object Data { get; private set; }
    }
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;
        }
    }

DbContext

 protected override void OnModelCreating(ModelBuilder builder)
        {
            builder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());

            base.OnModelCreating(builder);
        }

Upvotes: 5

Views: 3752

Answers (1)

dglozano
dglozano

Reputation: 6607

TL;DR

Try adding an empty constructor to your IEntityTypeConfiguration implementations. Otherwise, if you still want to use DI in your entity type configurations, it might be worth looking at this issue.


I don't think the injected logger in your IEntityTypeConfiguration will work out together with ApplyConfigurationsFromAssembly. From the source code of that method, it seems that while using reflection to search for the configuration classes it requires an empty constructor so that it can instantiate them.

EF core source code for ApplyConfigurationsFromAssembly

Since your IEntityTypeConfigurations lack a default empty constructor, the ApplyConfigurationsFromAssembly is probably not picking them up.

If you still want to use DI in your entity type configurations, it might be worth looking at this issue, where @ajcvickers gives a detailed explanation on how to do it.

This is a copy/pasta of the Github issue answer code:

public abstract class EntityTypeConfigurationDependency
{
    public abstract void Configure(ModelBuilder modelBuilder);
}

public abstract class EntityTypeConfigurationDependency<TEntity>
    : EntityTypeConfigurationDependency, IEntityTypeConfiguration<TEntity> 
    where TEntity : class
{
    public abstract void Configure(EntityTypeBuilder<TEntity> builder);

    public override void Configure(ModelBuilder modelBuilder) 
        => Configure(modelBuilder.Entity<TEntity>());
}

public class Blog
{
    public int Pk { get; set; }
    public ICollection<Post> Posts { get; set; }
}

public class BlogConfiguration : EntityTypeConfigurationDependency<Blog>
{
    public override void Configure(EntityTypeBuilder<Blog> builder)
    {
        builder.HasKey(e => e.Pk);
    }
}

public class Post
{
    public int Pk { get; set; }
    public Blog Blog { get; set; }
}

public class PostConfiguration : EntityTypeConfigurationDependency<Post>
{
    public override void Configure(EntityTypeBuilder<Post> builder)
    {
        builder.HasKey(e => e.Pk);
    }
}

public class Program
{
    private static ILoggerFactory ContextLoggerFactory
        => LoggerFactory.Create(b => b.AddConsole().SetMinimumLevel(LogLevel.Information));

    public static void Main()
    {
        var services = new ServiceCollection()
            .AddDbContext<SomeDbContext>(
                b => b.UseSqlServer(Your.ConnectionString)
                    .EnableSensitiveDataLogging()
                    .UseLoggerFactory(ContextLoggerFactory));
        
        foreach (var type in typeof(SomeDbContext).Assembly.DefinedTypes
            .Where(t => !t.IsAbstract
                        && !t.IsGenericTypeDefinition
                        && typeof(EntityTypeConfigurationDependency).IsAssignableFrom(t)))
        {
            services.AddSingleton(typeof(EntityTypeConfigurationDependency), type);
        }

        var serviceProvider = services.BuildServiceProvider();
        
        using (var scope = serviceProvider.CreateScope())
        {
            var context = scope.ServiceProvider.GetService<SomeDbContext>();

            context.Database.EnsureDeleted();
            context.Database.EnsureCreated();
        }
    }
}

public class SomeDbContext : DbContext
{
    private readonly IEnumerable<EntityTypeConfigurationDependency> _configurations;

    public SomeDbContext(
        DbContextOptions<SomeDbContext> options,
        IEnumerable<EntityTypeConfigurationDependency> configurations)
        : base(options)
    {
        _configurations = configurations;
    }

    public DbSet<Blog> Blogs { get; set; }
    public DbSet<Post> Posts { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        foreach (var entityTypeConfiguration in _configurations)
        {
            entityTypeConfiguration.Configure(modelBuilder);
        }
    }
}

Upvotes: 5

Related Questions