Discriminator not updated when replacing TPH entity via Remove+Add with same ID — is this intentional in EF 9?
- Dominant language
- C#
- Stars
- 14.8k
- Forks
- 3.4k
- PR merge metrics
- PR metrics pending
Description
### Question
In EF Core 8, replacing a TPH entity in a collection by removing the old instance and adding a new instance with the same ID generates an `UPDATE` that includes the `Discriminator` column. In EF Core 9, the `Discriminator` is no longer included in the `UPDATE`, so the type change is silently lost.
I'm not sure whether the EF 8 behaviour was correct (and this is a regression), or whether EF 9 is correctly rejecting a pattern that was never meant to be supported. I found two issues that seem to be related to this issue and may suggest that this is valid use case:
- #36308 shows EF Core 8 issuing an `UPDATE` with a changed discriminator in a similar scenario, implying it was working and accepted behaviour.
- #30611 one of the comments says: _we do allow this with TPH if the discriminator is mapped as a property that can still be changed after it has been saved for the first time._
## Output of the provided example
### SQL generated by EF Core 8
```sql
UPDATE "Commands" SET "Discriminator" = @p0, "Label" = @p1
WHERE "Id" = @p2
RETURNING 1;
```
### SQL generated by EF Core 9
```sql
UPDATE "Commands" SET "DayPlanId" = @p0, "Label" = @p1
WHERE "Id" = @p2
RETURNING 1;
```
The `Discriminator` column is absent from the EF 9 `UPDATE`.
### Your code
```csharp
using Microsoft.EntityFrameworkCore;
using var initCtx = new AppDbContext();
initCtx.Database.EnsureDeleted();
initCtx.Database.EnsureCreated();
// 1. Seed a CommandA
var dayPlan = new DayPlan { Name = "Day 1" };
var original = new CommandA { Label = "Original A" };
dayPlan.Commands.Add(original);
initCtx.DayPlans.Add(dayPlan);
initCtx.SaveChanges();
Console.WriteLine($"Seeded: Id={original.Id}, Type={original.GetType().Name}");
PrintDb(initCtx);
// 2. Replace CommandA with CommandB
using var ctx = new AppDbContext();
var loadedPlan = ctx.DayPlans.Include(d => d.Commands).First();
var oldCmd = loadedPlan.Commands.First();
var newCmd = new CommandB { Label = "replaced B" };
// Replace CommandA with CommandB => preserve ID
loadedPlan.Commands.Remove(oldCmd);
newCmd.Id = oldCmd.Id;
loadedPlan.Commands.Add(newCmd);
Console.WriteLine($"Before SaveChanges:");
foreach (var entry in initCtx.ChangeTracker.Entries())
{
Console.WriteLine($" [{entry.State}] {entry.Entity.GetType().Name} Id={entry.Entity.Id}");
}
Console.WriteLine($"Replacing commands:");
ctx.SaveChanges();
Console.WriteLine($"After SaveChanges:");
PrintDb(ctx);
// Load from DB via new instance of the context
Console.WriteLine($"Fresh context:");
using var freshCtx = new AppDbContext();
PrintDb(freshCtx);
static void PrintDb(AppDbContext ctx)
{
ctx.ChangeTracker.Clear();
var rows = ctx.DayPlans.Include(d => d.Commands).First().Commands;
foreach (var c in rows)
{
Console.WriteLine($" DB row: Id={c.Id}, Type={c.GetType().Name}, Label={c.Label}");
}
}
// -----------------------------------------------------------------------
// Model
// -----------------------------------------------------------------------
public class DayPlan
{
public int Id { get; set; }
public string Name { get; set; } = "";
public ICollection Commands { get; set; } = new List();
}
public abstract class Command
{
public int Id { get; set; }
public string Label { get; set; } = "";
public int DayPlanId { get; set; }
}
public class CommandA : Command { }
public class CommandB : Command { }
// -----------------------------------------------------------------------
// DbContext — SQLite in-memory for zero setup
// -----------------------------------------------------------------------
public class AppDbContext : DbContext
{
public DbSet DayPlans => Set();
public DbSet Commands => Set();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity()
.HasDiscriminator("Discriminator")
.HasValue("CommandA")
.HasValue("CommandB");
}
protected override void OnConfiguring(DbContextOptionsBuilder o) =>
o.UseSqlite("Data Source=repro.db")
.LogTo(msg =>
{
// Only print SQL statements, skip noise
if (msg.Contains("Executed DbCommand"))
{
Console.WriteLine(msg);
}
}, Microsoft.Extensions.Logging.LogLevel.Information);
}
```
### Stack traces
```text
```
### Verbose output
```text
```
### EF Core version
9.0.0
### Database provider
Microsoft.EntityFrameworkCore.Sqlite
### Target framework
_No response_
### Operating system
_No response_
### IDE
_No response_
Contributor guide
Assessment
This issue has not been assessed yet.