dotnet / dotnet/efcore

Discriminator not updated when replacing TPH entity via Remove+Add with same ID — is this intentional in EF 9?

Open
#38,300 1 comment 3 reactions 1 assignee Claimed by @AndriySvyryd View on GitHub
area-relational-mapping area-save-changes customer-reported regression
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.