Cannot configure table-per-hierarchy in configuration classes

43 views Asked by At

I am trying to get EF Core 8.0.2's table-per-hierarchy working, with this simple program:

using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

using var context = new TestDbContext();
await context.Database.EnsureDeletedAsync();
await context.Database.EnsureCreatedAsync();

await context.SaveChangesAsync();

var items = await context.Items.OfType<ConcreteItemA>().ToListAsync();
foreach (var item in items)
{
    Console.WriteLine($"{item.Name} {item.Number:C2}");
}

public enum BaseItemType
{
    ConcreteA = 0,
    ConcreteB = 1
}

public abstract class BaseItem
{
    public int Id { get; set; }
    public string Name { get; set; }
    public BaseItemType Type { get; set; }
}

public class ConcreteItemA : BaseItem
{
    public ConcreteItemA()
    {
        Type = BaseItemType.ConcreteA;
    }
    public int Number { get; set; }
}

public class ConcreteItemB : BaseItem
{
    public ConcreteItemB()
    {
        Type = BaseItemType.ConcreteB;
    }
    public decimal Amount { get; set; }
}

public class TestDbContext : DbContext
{
    public DbSet<BaseItem> Items => Set<BaseItem>();

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder
            .UseSqlite()
            .LogTo(Console.WriteLine, LogLevel.Debug)
            .EnableDetailedErrors();
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfigurationsFromAssembly(GetType().Assembly);
    }
}

The entities are configured using these configuration classes:

public abstract class BaseItemTypeConfiguration<TEntity> : IEntityTypeConfiguration<TEntity> where TEntity : BaseItem
{
    public void Configure(EntityTypeBuilder<TEntity> builder)
    {
        builder.ToTable("Items")
            .HasDiscriminator(b => b.Type)
            .HasValue<ConcreteItemB>(BaseItemType.ConcreteB)
            .HasValue<ConcreteItemA>(BaseItemType.ConcreteA)
            .IsComplete();

        builder.HasKey(b => b.Id);

        builder.Property(b => b.Id).IsRequired().ValueGeneratedOnAdd();
        builder.Property(b => b.Name).IsRequired();

        ConfigureSubType(builder);
    }

    protected abstract void ConfigureSubType(EntityTypeBuilder<TEntity> builder);
}

public class ConcreteItemATypeConfiguration : BaseItemTypeConfiguration<ConcreteItemA>
{
    protected override void ConfigureSubType(EntityTypeBuilder<ConcreteItemA> builder)
    {
        builder.Property(b => b.Number).HasDefaultValue(42);
        builder.HasData(new ConcreteItemA { Name = "A", Number = 41 });
    }
}

public class ConcreteItemBTypeConfiguration : BaseItemTypeConfiguration<ConcreteItemB>
{
    protected override void ConfigureSubType(EntityTypeBuilder<ConcreteItemB> builder)
    {
        builder.Property(b => b.Amount).HasDefaultValue(42.0M);
        builder.HasData(new ConcreteItemB { Name = "B", Amount = 41.0M });
    }
}

When I run the app, it throws an InvalidOperationException with the following message:

Unable to create a 'DbContext' of type ''. The exception 'Cannot configure the discriminator value for entity type 'ConcreteItemB' because it doesn't derive from 'ConcreteItemA'.' was thrown while attempting to create an instance. For the different patterns supported at design time, see https://go.microsoft.com/fwlink/?linkid=851728

Is it not possible to do all configuration through the IEntityTypeConfiguration<T>? Do I need to put some of it into the OnModelCreating, and if so, which parts?

2

There are 2 answers

1
Steve Py On BEST ANSWER

This code won't work as you expect:

public void Configure(EntityTypeBuilder<TEntity> builder)
{
    builder.ToTable("Items")
        .HasDiscriminator(b => b.Type)
        .HasValue<ConcreteItemB>(BaseItemType.ConcreteB)
        .HasValue<ConcreteItemA>(BaseItemType.ConcreteA)
        .IsComplete();

    builder.HasKey(b => b.Id);

    builder.Property(b => b.Id).IsRequired().ValueGeneratedOnAdd();
    builder.Property(b => b.Name).IsRequired();

    ConfigureSubType(builder);
}

As a base class for the entity type config, that is going to get called for both concrete types, filling in for TEntity. What you likely need to do is update the signature to:

public void Configure(EntityTypeBuilder<Item> builder)

But you don't want it part of a base class entity type configuration, just an IEntityTypeConfiguration<Item>, non-inherited.

1
Mateus Zampol On

A bit too much generics lunacy, I recommend writing the configurations as such:

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

using var context = new TestDbContext();
await context.Database.EnsureDeletedAsync();
await context.Database.EnsureCreatedAsync();

await context.SaveChangesAsync();

var itemsA = await context.Items.OfType<ConcreteItemA>().ToListAsync();
var itemsB = await context.Items.OfType<ConcreteItemB>().ToListAsync();

Console.WriteLine("Items of type A:");
foreach (var item in itemsA)
{
    Console.WriteLine($"{item.Name} {item.Number}");
}
Console.WriteLine("Items of type B:");
foreach (var item in itemsB)
{
    Console.WriteLine($"{item.Name} {item.Amount}");
}

public enum BaseItemType
{
    ConcreteA = 0,
    ConcreteB = 1
}

public abstract class BaseItem
{
    public int Id { get; set; }
    public string Name { get; set; }
    public BaseItemType Type { get; set; }
}

public class ConcreteItemA : BaseItem
{
    public ConcreteItemA()
    {
        Type = BaseItemType.ConcreteA;
    }
    public int Number { get; set; }
}

public class ConcreteItemB : BaseItem
{
    public ConcreteItemB()
    {
        Type = BaseItemType.ConcreteB;
    }
    public decimal Amount { get; set; }
}

public class TestDbContext : DbContext
{
    public DbSet<BaseItem> Items => Set<BaseItem>();

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder
            .UseInMemoryDatabase("test");
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfigurationsFromAssembly(GetType().Assembly);
    }
}

The configuration classes change to this:

public abstract class BaseItemTypeConfiguration : IEntityTypeConfiguration<BaseItem>
{
    public void Configure(EntityTypeBuilder<BaseItem> builder)
    {
        builder.ToTable("Items")
            .HasDiscriminator(b => b.Type)
            .HasValue<ConcreteItemB>(BaseItemType.ConcreteB)
            .HasValue<ConcreteItemA>(BaseItemType.ConcreteA)
            .IsComplete();

        builder.HasKey(b => b.Id);

        builder.Property(b => b.Id).IsRequired().ValueGeneratedOnAdd();
        builder.Property(b => b.Name).IsRequired();
    }
}

public class ConcreteItemATypeConfiguration : IEntityTypeConfiguration<ConcreteItemA>
{
    public void Configure(EntityTypeBuilder<ConcreteItemA> builder)
    {
        builder.Property(b => b.Number).HasDefaultValue(42);
        builder.HasData(new ConcreteItemA() { Name = "A", Number = 41, Id = 1 });
    }
}

public class ConcreteItemBTypeConfiguration : IEntityTypeConfiguration<ConcreteItemB>
{
    public void Configure(EntityTypeBuilder<ConcreteItemB> builder)
    {
        builder.Property(b => b.Amount).HasDefaultValue(42.0M);
        builder.HasData(new ConcreteItemB() { Name = "B", Amount = 42.0M, Id = 2 });
    }
}