ChangeTracker.DetectChanges() issue with DDD-style enumerations
See original GitHub issueFirst off, many thanks for the amazing tools you are building!
We are implementing enumeration classes as per this link: https://docs.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/enumeration-classes-over-enum-types
The enumerations are seeded at application startup, while ChangeTracker_Tracked and ChangeTracker_StateChanged events ensure enumeration objects are always in a detached state.
You can download a working solution here: https://github.com/vardalis/ChangeTrackerIssue
TL;DR
When I run ChangeTracker.DetectChanges() after I have made a change to a single entity I get the expected outcome. When I make an additional change to a different (unrelated) entity and then run ChangeTracker.DetectChanges() the change to the first entity is reset.
var mainEntities = actContext.MainEntities.Include(me => me.TimeUnit)
.ToDictionary(me => me.Name);
// Normal case (problematic)
mainEntities["MainEntity1"].TimeUnit = TimeUnit.min;
mainEntities["MainEntity2"].TimeUnit = TimeUnit.s;
actContext.ChangeTracker.DetectChanges(); // After DetectChanges mainEntities["MainEntity2"].TimeUnit is set to TimeUnit.min
// Alternative case (calling DetectChanges after each assignment solves the problem)
mainEntities["MainEntity1"].TimeUnit = TimeUnit.min;
actContext.ChangeTracker.DetectChanges(); // Works correctly
mainEntities["MainEntity2"].TimeUnit = TimeUnit.s;
actContext.ChangeTracker.DetectChanges(); // Works correctly
More code
The MainEntity (entity) and TimeUnit (enumeration) look like this:
public class MainEntity
{
public int Id { get; set; }
public string Name { get; set; }
public TimeUnit TimeUnit { get; set; }
protected MainEntity() { }
public MainEntity(string name, TimeUnit timeUnit)
{
Name = name;
TimeUnit = timeUnit;
}
}
public class TimeUnit
{
public static TimeUnit s = new(1, "s");
public static TimeUnit min = new(2, "min");
[DatabaseGenerated(DatabaseGeneratedOption.None)]
[Key]
public int Id { get; set; }
public string Name { get; set; }
protected TimeUnit() { }
public TimeUnit(int id, string name)
{
Id = id;
Name = name;
}
}
Here is the AppDbContext:
public class AppDbContext : DbContext
{
public DbSet<MainEntity> MainEntities { get; set; }
public DbSet<TimeUnit> TimeUnits { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlServer(@"Server=.\SQLEXPRESS;Database=ChangeTrackerDb;Trusted_Connection=True;");
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
}
public void AddTrackingEvents()
{
ChangeTracker.Tracked += ChangeTracker_Tracked;
ChangeTracker.StateChanged += ChangeTracker_StateChanged;
}
public void ClearTrackingEvents()
{
ChangeTracker.Tracked -= ChangeTracker_Tracked;
ChangeTracker.StateChanged -= ChangeTracker_StateChanged;
}
private void ChangeTracker_Tracked(object sender, Microsoft.EntityFrameworkCore.ChangeTracking.EntityTrackedEventArgs entityEvent)
{
if (typeof(TimeUnit).IsAssignableFrom(entityEvent.Entry.Entity.GetType()))
entityEvent.Entry.State = EntityState.Detached;
}
private void ChangeTracker_StateChanged(object sender, Microsoft.EntityFrameworkCore.ChangeTracking.EntityStateChangedEventArgs entityEvent)
{
if (typeof(TimeUnit).IsAssignableFrom(entityEvent.Entry.Entity.GetType()))
entityEvent.Entry.State = EntityState.Detached;
}
}
And there is the main (.NET 6.0 scaffolding style):
// See https://aka.ms/new-console-template for more information
using ChangeTrackerIssue;
using ChangeTrackerIssue.Entities;
using Microsoft.EntityFrameworkCore;
using System.Reflection;
Console.WriteLine("Hello, World!");
var arrangeContext = new AppDbContext();
arrangeContext.Database.EnsureDeleted();
arrangeContext.Database.EnsureCreated();
// Seed TimeUnits
arrangeContext.Add(TimeUnit.s);
arrangeContext.Add(TimeUnit.min);
arrangeContext.SaveChanges();
// Disable TimeUnit tracking
arrangeContext.AddTrackingEvents();
// Populate Db
var mainEntity1 = new MainEntity("MainEntity1", TimeUnit.s);
var mainEntity2 = new MainEntity("MainEntity2", TimeUnit.min);
arrangeContext.AddRange(mainEntity1, mainEntity2);
arrangeContext.SaveChanges();
var actContext = new AppDbContext();
// Disable TimeUnit tracking
actContext.AddTrackingEvents();
var mainEntities = actContext.MainEntities.Include(me => me.TimeUnit)
.ToDictionary(me => me.Name);
// Normal case (problematic)
mainEntities["MainEntity1"].TimeUnit = TimeUnit.min;
mainEntities["MainEntity2"].TimeUnit = TimeUnit.s;
actContext.ChangeTracker.DetectChanges(); // After DetectChanges mainEntities["MainEntity2"].TimeUnit is set to TimeUnit.min
// Alternative case (calling DetectChanges after each assignment solves the problem)
mainEntities["MainEntity1"].TimeUnit = TimeUnit.min;
actContext.ChangeTracker.DetectChanges(); // Works correctly
mainEntities["MainEntity2"].TimeUnit = TimeUnit.s;
actContext.ChangeTracker.DetectChanges(); // Works correctly
Provider and version information
EF Core version: 6.0.8 Database provider: Microsoft.EntityFrameworkCore.SqlServer Target framework: .NET 6.0 Operating system: Windows 11 IDE: Visual Studio 2022, 17.3.3
Issue Analytics
- State:
- Created a year ago
- Comments:5 (2 by maintainers)
For the record, I took @ajcvickers suggestion and experimented with a value converter and I now have a solution that goes a long way towards solving my problem.
I created a TimeUnitConverted:
And configured the relevant convention:
Here is FromValue:
Since the name for each unit is also unique we could store that instead of the id so we can see the time unit when looking in the database.
It would still be nice to have all TimeUnit information in the database and also enforce foreign key constraints, but compared to the three options I mentioned above this one seems to have the fewer drawbacks.
Personally, I would steer clear of all that guidance. It does not promote EF Core best practices.
This is not supported. If you must use these patterns, then consider not mapping the enum properties at all.