-
Notifications
You must be signed in to change notification settings - Fork 3.3k
Open
Description
Bug description
When using a computed property (CurrentOperator) in an entity (Line) that derives its value from a navigation collection (OperatorChangedEvents), EF Core's ChangeTracker incorrectly marks the property as modified with an original value of null upon materialization, even though the actual value is non-null. This leads to incorrect change tracking and prevents updates from being detected when the navigation collection changes.
Reproduction
Given the following model:
Lineentity with a navigation collectionOperatorChangedEventsCurrentOperatoris a computed property returning the latestOperatorNamefrom the collection.
This computed property is persisted to the database using the proposed workaround in issue 13316.AssignOperatorandClearOperatormethods add events to the collection
Steps:
- Add a
Lineand assign an operator, saving to the database. - Query the
Lineback from the database. - Observe that
CurrentOperatoris'alex', but ChangeTracker marks its original value asnull. - Call
ClearOperator()and detect changes. - ChangeTracker does not mark the entity as modified, even though
CurrentOperatorchanged.
Your code
using Microsoft.EntityFrameworkCore;
await using (var context = new AppDbContext())
{
await context.Database.EnsureDeletedAsync();
await context.Database.EnsureCreatedAsync();
var line1 = new Line();
var line2 = new Line();
line1.AssignOperator("alex");
await context.Lines.AddRangeAsync(line1, line2);
// Debugging the ChangeTracker:
// ┌────────────────────────────────────────────────────────────────────────────────┐
// │ Line {Id: -2147482647} Added │
// │ Line {Id: -2147482646} Added │
// │ OperatorChangedEvent {Id: -9223372036854774807} Added FK {LineId: -2147482647} │
// └────────────────────────────────────────────────────────────────────────────────┘
await context.SaveChangesAsync();
// Database result:
// ┌────┬─────────────────┐
// │ Id │ CurrentOperator │
// ├────┼─────────────────┤
// │ 1 │ alex │
// │ 2 │ <null> │
// └────┴─────────────────┘
}
await using (var context = new AppDbContext())
{
var line1 = await context.Lines.SingleAsync(line => line.Id == 1);
var line2 = await context.Lines.SingleAsync(line => line.Id == 2);
// Debugging the ChangeTracker:
// ┌───────────────────────────────────────────────┐
// │ Line {Id: 1} Unchanged │
// │ Id: 1 PK │
// │ CurrentOperator: 'alex' Originally <null> │ <-- For some reason, this property is marked as modified with an original value of "null" during construction
// │ OperatorChangedEvents: [{Id: 1}] │
// │ Line {Id: 2} Unchanged │
// │ Id: 2 PK │
// │ CurrentOperator: <null> │
// │ OperatorChangedEvents: [] │
// │ OperatorChangedEvent {Id: 1} Unchanged │
// │ Id: 1 PK │
// │ LineId: 1 FK │
// │ OperatorName: 'alex' │
// │ TriggeredOn: '06/01/2026 10:00:43' │
// └───────────────────────────────────────────────┘
line1.ClearOperator();
line2.AssignOperator("alex");
// Debugging the ChangeTracker (after detecting changes):
// ┌────────────────────────────────────────────────────────────────┐
// │ Line {Id: 1} Unchanged │ <-- The value of CurrentOperator has changed to "null", but the entity is still marked as unchanged because EF thinks the original value was already "null"
// │ Id: 1 PK │
// │ CurrentOperator: <null> │
// │ OperatorChangedEvents: [{Id: 1}, {Id: -9223372036854774806}] │
// │ Line {Id: 2} Modified │
// │ Id: 2 PK │
// │ CurrentOperator: 'alex' Modified Originally <null> │
// │ OperatorChangedEvents: [{Id: -9223372036854774805}] │
// │ OperatorChangedEvent {Id: -9223372036854774806} Added │
// │ Id: -9223372036854774806 PK Temporary │
// │ LineId: 1 FK │
// │ OperatorName: <null> │
// │ TriggeredOn: '06/01/2026 10:05:11' │
// │ OperatorChangedEvent {Id: -9223372036854774805} Added │
// │ Id: -9223372036854774805 PK Temporary │
// │ LineId: 2 FK │
// │ OperatorName: 'alex' │
// │ TriggeredOn: '06/01/2026 10:05:11' │
// │ OperatorChangedEvent {Id: 1} Unchanged │
// │ Id: 1 PK │
// │ LineId: 1 FK │
// │ OperatorName: 'alex' │
// │ TriggeredOn: '06/01/2026 10:05:10' │
// └────────────────────────────────────────────────────────────────┘
await context.SaveChangesAsync();
// Database result:
// ┌────┬─────────────────┐
// │ Id │ CurrentOperator │
// ├────┼─────────────────┤
// │ 1 │ alex │
// │ 2 │ alex │
// └────┴─────────────────┘
}
public class AppDbContext : DbContext
{
public DbSet<Line> Lines { get; set; }
private const string ConnectionString = "Server=localhost,1433;Database=Test;";
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) =>
optionsBuilder.UseSqlServer(ConnectionString);
protected override void OnModelCreating(ModelBuilder modelBuilder) =>
modelBuilder.Entity<Line>().Navigation(line => line.OperatorChangedEvents).AutoInclude();
}
public class Line
{
public int Id { get; set; }
public List<OperatorChangedEvent> OperatorChangedEvents { get; set; } = [];
public string? CurrentOperator
{
get => OperatorChangedEvents.OrderByDescending(@event => @event.TriggeredOn).FirstOrDefault()?.OperatorName ?? null;
private set { } // https://github.com/dotnet/efcore/issues/13316#issuecomment-421052406
}
public void AssignOperator(string operatorName)
{
var operatorEvent = new OperatorChangedEvent(operatorName);
OperatorChangedEvents.Add(operatorEvent);
}
public void ClearOperator()
{
var operatorEvent = new OperatorChangedEvent(null);
OperatorChangedEvents.Add(operatorEvent);
}
}
public class OperatorChangedEvent
{
public long Id { get; set; }
public DateTime TriggeredOn { get; set; }
public string? OperatorName { get; set; }
public OperatorChangedEvent(string? operatorName)
{
TriggeredOn = DateTime.UtcNow;
OperatorName = operatorName;
}
}Stack traces
Verbose output
EF Core version
10.0.1
Database provider
Microsoft.EntityFrameworkCore.SqlServer
Target framework
.NET 10
Operating system
Windows 11
IDE
Rider
Wannesrebry