0

I'm trying to implement an Enum list based on the idea from this answer. My goal is to be able to use an Enum inside my Domain, and have it converted to a class instance when saving and retrieving it from the database.

Using the code as it is (source below), I get a DbUpdateException with the message:

Violation of PRIMARY KEY constraint 'PK_dbo.Faculty'. Cannot insert duplicate key in object 'dbo.Faculty'. The duplicate key value is (0). The statement has been terminated.

Which is expected, since I'm newing up every instance of Faculty.

To fix it, I tried the solutions from a few questions on this, with no success. They suggested to either attach the entity or to set it's state to Unchanged. So I tried overriding SaveChanges() and use:

ChangeTracker.Entries<Faculty>().ToList().ForEach(x => x.State = EntityState.Unchanged);

and

ChangeTracker.Entries<Faculty>().ToList()
    .ForEach(x => Entry(x.Entity).State = EntityState.Unchanged);

and even

ChangeTracker.Entries<Department>().ToList().ForEach(department =>
{
    foreach (var faculty in department.Entity.Faculties)
    {
        Entry(faculty).State = EntityState.Unchanged;
    }
});

But all of them throw an InvalidOperationException with the message:

Additional information: Saving or accepting changes failed because more than one entity of type 'TestEnum.Entities.Faculty' have the same primary key value. Ensure that explicitly set primary key values are unique. Ensure that database-generated primary keys are configured correctly in the database and in the Entity Framework model. Use the Entity Designer for Database First/Model First configuration. Use the 'HasDatabaseGeneratedOption" fluent API or 'DatabaseGeneratedAttribute' for Code First configuration.

How can I instruct EF to not try and insert these into the database? I need this implementation to work inside SaveChanges() as I'm following DDD design rules and keeping Infrastructure separated from Domain logic.

The code is as follows:

class Program
{
    static void Main(string[] args)
    {
        using (var dbContext = new MyContext())
        {
            var example = new Department();
            example.AddFaculty(FacultyEnum.Eng);
            example.AddFaculty(FacultyEnum.Math);

            dbContext.Department.Add(example);

            var example2 = new Department();
            example2.AddFaculty(FacultyEnum.Math);

            dbContext.Department.Add(example2);

            dbContext.SaveChanges();

            var exampleFromDb1 = dbContext.Department.Find(1);
            var exampleFromDb2 = dbContext.Department.Find(2);
        }
    }
}

public enum FacultyEnum
{
    [Description("English")]
    Eng,
    [Description("Mathematics")]
    Math,
    [Description("Economy")]
    Eco,
}

public class Department
{
    public int Id { get; set; }
    public virtual ICollection<Faculty> Faculties { get; set; }

    public Department()
    {
        Faculties = new List<Faculty>();
    }

    public void AddFaculty(FacultyEnum faculty)
    {
        Faculties.Add(faculty);
    }
}

public class Faculty
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Description { get; set; }

    private Faculty(FacultyEnum @enum)
    {
        Id = (int)@enum;
        Name = @enum.ToString();
        Description = @enum.GetEnumDescription();
    }

    protected Faculty() { } //For EF

    public static implicit operator Faculty(FacultyEnum @enum) => new Faculty(@enum);

    public static implicit operator FacultyEnum(Faculty faculty) => (FacultyEnum)faculty.Id;
}

public class MyContext : DbContext
{
    public DbSet<Department> Department { get; set; }
    public DbSet<Faculty> Faculty { get; set; }

    public MyContext()
        : base(nameOrConnectionString: GetConnectionString())
    {
        Database.SetInitializer(new MyDbInitializer());
    }

    public int SaveSeed()
    {
        return base.SaveChanges();
    }

    protected override void OnModelCreating(DbModelBuilder modelBuilder)
    {
        modelBuilder.Conventions.Remove<PluralizingTableNameConvention>();
        modelBuilder.Conventions.Remove<OneToManyCascadeDeleteConvention>();
        modelBuilder.Conventions.Remove<ManyToManyCascadeDeleteConvention>();

        modelBuilder.Properties<string>()
            .Configure(p => p.HasMaxLength(100));

        modelBuilder.Configurations.Add(new DepartmentConfiguration());
        modelBuilder.Configurations.Add(new FacultyConfiguration());

        base.OnModelCreating(modelBuilder);
    }

    private static string GetConnectionString()
    {
        return @"Data Source=(localdb)\MSSQLLocalDB;Initial Catalog=TestEnum;Integrated Security=True;Connect Timeout=30;Encrypt=False;TrustServerCertificate=True;ApplicationIntent=ReadWrite;MultiSubnetFailover=False;MultipleActiveResultSets=true;";
    }
}

public class MyDbInitializer : DropCreateDatabaseIfModelChanges<MyContext>
{
    protected override void Seed(MyContext context)
    {
        context.Faculty.SeedEnumValues<Faculty, FacultyEnum>(@enum => @enum);
        context.SaveSeed();
    }
}

public class DepartmentConfiguration : EntityTypeConfiguration<Department>
{
    public DepartmentConfiguration()
    {
        HasMany(x => x.Faculties)
            .WithMany();
    }
}

public class FacultyConfiguration : EntityTypeConfiguration<Faculty>
{
    public FacultyConfiguration()
    {
        Property(x => x.Id)
            .HasDatabaseGeneratedOption(DatabaseGeneratedOption.None);
    }
}

public static class Extensions
{
    public static string GetEnumDescription<TEnum>(this TEnum item)
        => item.GetType()
               .GetField(item.ToString())
               .GetCustomAttributes(typeof(DescriptionAttribute), false)
               .Cast<DescriptionAttribute>()
               .FirstOrDefault()?.Description ?? string.Empty;

    public static void SeedEnumValues<T, TEnum>(this IDbSet<T> dbSet, Func<TEnum, T> converter)
        where T : class => Enum.GetValues(typeof(TEnum))
                               .Cast<object>()
                               .Select(value => converter((TEnum)value))
                               .ToList()
                               .ForEach(instance => dbSet.AddOrUpdate(instance));
}
Marcos Dimitrio
  • 6,651
  • 5
  • 38
  • 62

1 Answers1

0

When we use a new instance of a class, EF will always try to insert it into the DB. One way to get around this is by "undoing" what is stored inside the ChangeTracker so it only uses entities loaded from the DB.

So what I did was override the SaveChanges() method, loop through each Department inside ChangeTracker, grab a list of faculty Ids of each, clear the ChangeTracker entries for all Faculty objects and then add them again to their departments, but now using the entities found in either ChangeTracker or through Find().

It looked a bit inefficient, so I ran a test, one using this method and another loading each Faculty from the Db on each run. I ran it 10,000 times, the results are an average of three runs:

Enum list:    77642 ms
Normal class: 70619 ms

As you can see, there's about 10% of speed penalty for using this method, so it's up to you to decide the cost/benefit for your application. To me, the added expressiveness compensates the cost, since my application won't get anywhere near that many operations.

Besides MyContext, some other classes changed from the original post, and I also expanded the console test to cover all use cases, so I'm posting the full solution below.

class Program
{
    static void Main(string[] args)
    {
        var id = 0;

        using (var dbContext = new MyContext())
        {
            var department = new Department();
            department.AddFaculty(FacultyEnum.Eng);
            department.AddFaculty(FacultyEnum.Math);
            dbContext.Department.Add(department);

            var department2 = new Department();
            department2.AddFaculty(FacultyEnum.Math);
            dbContext.Department.Add(department2);

            dbContext.SaveChanges();
            id = department.Id;
        }

        using (var dbContext = new MyContext())
        {
            var department = dbContext.Department.Find(id);
            department.AddFaculty(FacultyEnum.Eco);
            dbContext.SaveChanges();
        }

        using (var dbContext = new MyContext())
        {
            var department = dbContext.Department.Find(id);
            var faculty = department.Faculties.Where(x => x.Id == (int)FacultyEnum.Eng).FirstOrDefault();
            department.Faculties.Remove(faculty);
            dbContext.SaveChanges();
        }

        using (var dbContext = new MyContext())
        {
            var department = dbContext.Department.Find(id);
            Console.WriteLine($"Department Id {department.Id} has these faculties:");
            foreach (var faculty in department.Faculties)
            {
                Console.WriteLine($"- {faculty.Id}");
            }
        }

        Console.ReadKey();
    }
}

public class MyContext : DbContext
{
    public DbSet<Department> Department { get; set; }
    public DbSet<Faculty> Faculty { get; set; }

    public MyContext()
        : base(nameOrConnectionString: GetConnectionString())
    {
        Database.SetInitializer(new MyDbInitializer());
    }

    public override int SaveChanges()
    {
        CleanUpFaculties();

        return base.SaveChanges();
    }

    private void CleanUpFaculties()
    {
        var departments = ChangeTracker
            .Entries<Department>()
            .Select(x => x.Entity)
            .ToList();

        var cachedDataToReload = departments
            .Select(department => new
            {
                Department = department,
                FacultyIds = department.Faculties.Select(faculty => faculty.Id).ToList(),
            })
            .ToList();

        CleanUpFacultiesOnChangeTracker();

        foreach (var item in cachedDataToReload)
        {
            var faculties = LoadFacultiesFromDb(item.FacultyIds);

            typeof(Department).GetProperty("Faculties")
                .SetValue(item.Department, faculties);
        }
    }

    private void CleanUpFacultiesOnChangeTracker()
    {
        var changedEntries = ChangeTracker.Entries<Faculty>().Where(x => x.State != EntityState.Unchanged).ToList();

        foreach (var entry in changedEntries)
        {
            switch (entry.State)
            {
                case EntityState.Modified:
                    entry.CurrentValues.SetValues(entry.OriginalValues);
                    entry.State = EntityState.Unchanged;
                    break;
                case EntityState.Added:
                    entry.State = EntityState.Detached;
                    break;
                case EntityState.Deleted:
                    entry.State = EntityState.Unchanged;
                    break;
            }
        }
    }

    private ICollection<Faculty> LoadFacultiesFromDb(IEnumerable<FacultyEnum> facultyIds)
    {
        var destination = new List<Faculty>();

        foreach (var id in facultyIds)
        {
            var newFaculty = ChangeTracker
                .Entries<Faculty>()
                .Where(x => x.State == EntityState.Unchanged && x.Entity.Id == id)
                .FirstOrDefault()
                ?.Entity;

            if (newFaculty == null)
            {
                newFaculty = Set<Faculty>().Find(id) ?? id;
            }

            destination.Add(newFaculty);
        }

        return destination;
    }

    protected override void OnModelCreating(DbModelBuilder modelBuilder)
    {
        modelBuilder.Conventions.Remove<PluralizingTableNameConvention>();
        modelBuilder.Conventions.Remove<OneToManyCascadeDeleteConvention>();
        modelBuilder.Conventions.Remove<ManyToManyCascadeDeleteConvention>();

        modelBuilder.Properties<string>()
            .Configure(p => p.HasMaxLength(100));

        modelBuilder.Configurations.Add(new DepartmentConfiguration());
        modelBuilder.Configurations.Add(new FacultyConfiguration());

        base.OnModelCreating(modelBuilder);
    }

    private static string GetConnectionString()
    {
        return @"Data Source=(localdb)\MSSQLLocalDB;Initial Catalog=TestEnum;Integrated Security=True;Connect Timeout=30;Encrypt=False;TrustServerCertificate=True;ApplicationIntent=ReadWrite;MultiSubnetFailover=False;MultipleActiveResultSets=true;";
    }
}

public class MyDbInitializer : DropCreateDatabaseIfModelChanges<MyContext>
{
    protected override void Seed(MyContext context)
    {
        context.Faculty.SeedEnumValues<Faculty, FacultyEnum>(theEnum => theEnum);
        context.SaveChanges();

        base.Seed(context);
    }
}

public class DepartmentConfiguration : EntityTypeConfiguration<Department>
{
    public DepartmentConfiguration()
    {
        HasMany(x => x.Faculties)
            .WithMany();
    }
}

public class FacultyConfiguration : EntityTypeConfiguration<Faculty>
{
    public FacultyConfiguration()
    {
        Property(x => x.Id)
            .HasDatabaseGeneratedOption(DatabaseGeneratedOption.None);
    }
}

public class Department
{
    public int Id { get; private set; }
    public virtual ICollection<Faculty> Faculties { get; private set; }

    public Department()
    {
        Faculties = new List<Faculty>();
    }

    public void AddFaculty(FacultyEnum faculty)
    {
        Faculties.Add(faculty);
    }
}

public class Faculty
{
    public FacultyEnum Id { get; private set; }
    public string Name { get; private set; }
    public string Description { get; private set; }

    private Faculty(FacultyEnum theEnum)
    {
        Id = theEnum;
        Name = theEnum.ToString();
        Description = theEnum.Description();
    }

    protected Faculty() { } //For EF

    public static implicit operator Faculty(FacultyEnum theEnum) => new Faculty(theEnum);

    public static implicit operator FacultyEnum(Faculty faculty) => faculty.Id;
}

public enum FacultyEnum
{
    [Description("English")]
    Eng,
    [Description("Mathematics")]
    Math,
    [Description("Economy")]
    Eco,
}

public static class Extensions
{
    public static string Description<TEnum>(this TEnum item)
        => item.GetType()
               .GetField(item.ToString())
               .GetCustomAttributes(typeof(DescriptionAttribute), false)
               .Cast<DescriptionAttribute>()
               .FirstOrDefault()?.Description ?? string.Empty;

    public static void SeedEnumValues<T, TEnum>(this IDbSet<T> dbSet, Func<TEnum, T> converter)
        where T : class => Enum.GetValues(typeof(TEnum))
                               .Cast<object>()
                               .Select(value => converter((TEnum)value))
                               .ToList()
                               .ForEach(instance => dbSet.AddOrUpdate(instance));
}

The output will be:

// Department Id 1 has these faculties: 
// - Math
// - Eco
Marcos Dimitrio
  • 6,651
  • 5
  • 38
  • 62