Skip to content

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

@tpavlackyYunex

Description

@tpavlackyYunex

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:

Output of the provided example

SQL generated by EF Core 8

UPDATE "Commands" SET "Discriminator" = @p0, "Label" = @p1
WHERE "Id" = @p2
RETURNING 1;

SQL generated by EF Core 9

UPDATE "Commands" SET "DayPlanId" = @p0, "Label" = @p1
WHERE "Id" = @p2
RETURNING 1;

The Discriminator column is absent from the EF 9 UPDATE.

Your code

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<Command>())
{
  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<Command> Commands { get; set; } = new List<Command>();
}

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<DayPlan> DayPlans => Set<DayPlan>();
  public DbSet<Command> Commands => Set<Command>();

  protected override void OnModelCreating(ModelBuilder modelBuilder)
  {
    modelBuilder.Entity<Command>()
        .HasDiscriminator<string>("Discriminator")
        .HasValue<CommandA>("CommandA")
        .HasValue<CommandB>("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


Verbose output


EF Core version

9.0.0

Database provider

Microsoft.EntityFrameworkCore.Sqlite

Target framework

No response

Operating system

No response

IDE

No response

Metadata

Metadata

Assignees

Type

No fields configured for Bug.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions