Skip to content

Fix owned entities with default values not saved in TPH with shared columns#37751

Merged
AndriySvyryd merged 1 commit into
mainfrom
copilot/fix-owned-entities-update
Feb 27, 2026
Merged

Fix owned entities with default values not saved in TPH with shared columns#37751
AndriySvyryd merged 1 commit into
mainfrom
copilot/fix-owned-entities-update

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Feb 20, 2026

Fixes #37525

Description
When using Table-Per-Hierarchy (TPH) inheritance with owned entities (OwnsOne) where different entity types share column names via .HasColumnName(), setting an owned entity from null to a new instance with all CLR default values (e.g., EnumValue.Unknown = 0, IsBool = false) silently dropped the UPDATE columns — the owned entity remained null in the database.

ColumnValuePropagator.TryPropagate treats sentinel values on Added entries as "nothing to write." This is correct when another entry recorded a different value for the shared column, but wrong when the Added entry is the sole writer — the sentinel is the intended value.

Customer impact
Customers using TPH with owned entities sharing column names cannot save owned entities with default values. The data silently remains null in the database, leading to data corruption. No workaround other than avoiding default values or not sharing column names.

How found
Customer reported.

Regression
Yes, from EF Core 7.

Testing
Test added.

Risk
Low. The fix only affects saving changes in TPH with shared columns. Quirk mode added

Original prompt

This section details on the original issue you should resolve

<issue_title>OwnOne entities with default values not saved in TPH scenario</issue_title>
<issue_description>### Bug description

When using Table-Per-Hierarchy (TPH) inheritance with owned entities (OwnsOne), EF Core 8 generates an UPDATE statement but omits the owned entity columns when:

  • An owned entity property starts as null in the database
  • It's then set to an instance where all properties have their CLR default values (e.g., EnumValue.Unknown = 0, IsBool = false)
  • Nullable reference types are disabled (disable in the project file)

This leads to data corruption, preventing us from effectively upgrading from EF core 7 to EF core 8/9/10.

Please find a sample repro project attached.
EfCoreEnumNullTest.zip

EF Core version

8/9/10

Database provider

SqlServer

Target framework

.NET 10

IDE

rider</issue_description>

<agent_instructions>Start by creating a test that fails in the described way based on the comment #37525 (comment)</agent_instructions>

Comments on the Issue (you are @copilot in this section)

@roji # AI answer

Reproduction Confirmed

I've successfully reproduced this issue. This is a regression affecting EF Core 8 and 9, which has been fixed in EF Core 10.

Symptoms

When using TPH (Table-Per-Hierarchy) inheritance with owned entities (OwnsOne) where <Nullable>disable</Nullable> is set in the project, and setting an owned entity with CLR default values (e.g., EnumValue.Unknown = 0, IsBool = false), EF Core 8/9 fails to generate an UPDATE statement. This results in the database retaining NULL values instead of the expected default values.

Version Testing Results

EF Core 7: ✅ Works correctly - generates UPDATE statement with owned entity columns
EF Core 8: ❌ Broken - no UPDATE statement generated, values remain NULL
EF Core 9: ❌ Broken - no UPDATE statement generated, values remain NULL
EF Core 10: ✅ Fixed - generates UPDATE statement with owned entity columns

Minimal repro
await using var context = new BlogContext();
await context.Database.EnsureDeletedAsync();
await context.Database.EnsureCreatedAsync();

var childA = new ChildA();
context.Parents.Add(childA);
await context.SaveChangesAsync();
var childId = childA.Id;

context.ChangeTracker.Clear();

var loaded = await context.Parents.OfType<ChildA>().SingleAsync(c => c.Id == childId);
loaded.SetShared(SharedType.Create(EnumValue.Unknown, false));

await context.SaveChangesAsync();

context.ChangeTracker.Clear();

var verified = await context.Parents.OfType<ChildA>().SingleAsync(c => c.Id == childId);
Console.WriteLine($"EnumValue: {verified.Shared?.EnumValue} (expected: Unknown)");
Console.WriteLine($"IsBool: {verified.Shared?.IsBool} (expected: False)");

public abstract class Parent
{
    public int Id { get; set; }
}

public class ChildA : Parent
{
    public SharedType Shared { get; private set; }

    public void SetShared(SharedType shared)
    {
        Shared = shared ?? throw new ArgumentNullException(nameof(shared));
    }
}

public class ChildB : Parent
{
    public SharedType Shared { get; private set; }

    public void SetShared(SharedType shared)
    {
        Shared = shared ?? throw new ArgumentNullException(nameof(shared));
    }
}

public class SharedType
{
    protected SharedType() { }

    private SharedType(EnumValue enumValue, bool isBool)
    {
        EnumValue = enumValue;
        IsBool = isBool;
    }

    public EnumValue EnumValue { get; private set; }
    public bool IsBool { get; private set; }

    public static SharedType Create(EnumValue enumValue, bool isBool)
        => new(enumValue, isBool);
}

public enum EnumValue
{
    Unknown,
    Pass,
    Fail
}

public class BlogContext : DbContext
{
    public DbSet<Parent> Parents => Set<Parent>();

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder
            .UseSqlServer(Environment.GetEnvironmentVariable("Test__SqlServer__DefaultConnection"))
            .LogTo(Console.WriteLine, Microsoft.Extensions.Logging.LogLevel.Information)
            .EnableSensitiveDataLogging();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Parent>()
            .HasDiscriminator<string>("ChildType")
            .HasValue<ChildA>("ChildA")
            .HasValue<ChildB>("ChildB");

        modelBuilder.Entity<ChildA>().OwnsOne(c => c.Shared, sb =>
        {
            sb.Property(s => s.EnumValue).HasConversion<string>();
        });

        modelBuilder.Entity<ChildB>().OwnsOne(c => c.Shared, sb =>
        {
            sb.Property(s => s.EnumValue).HasConversion<string>();
        });
    }
}

</...


💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

Copilot AI changed the title [WIP] Fix missed columns in TPH for owned entities Fix owned entities with default values not saved in TPH with shared columns Feb 20, 2026
Copilot AI requested a review from AndriySvyryd February 20, 2026 01:11
Copy link
Copy Markdown
Member

@AndriySvyryd AndriySvyryd left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot Update progress by rewriting the PR summary using this template:

Fixes #{number}

**Description**
{Issue description}

**Customer impact**
{How does the reported issue affects customer's app? Are there workarounds?}

**How found**
{Was it customer reported or found during verification? How many customers are affected?}

**Regression**
{Is it a regression from a released version? Which one?}

**Testing**
{How the changes were tested}

**Risk**
{Low/Medium/High, argument why}

Copy link
Copy Markdown
Contributor Author

Copilot AI commented Feb 20, 2026

@copilot Update progress by rewriting the PR summary using this template:

Updated the PR description using the requested template in commit fb4099b.

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • github-production-user-asset-6210df.s3.amazonaws.com
    • Triggering command: /home/REDACTED/work/_temp/ghcca-node/node/bin/node /home/REDACTED/work/_temp/ghcca-node/node/bin/node --enable-source-maps /home/REDACTED/work/_temp/copilot-developer-action-main/dist/index.js (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

Copilot AI requested a review from AndriySvyryd February 20, 2026 03:54
@AndriySvyryd AndriySvyryd changed the title Fix owned entities with default values not saved in TPH with shared columns [release/10.0] Fix owned entities with default values not saved in TPH with shared columns Feb 20, 2026
@AndriySvyryd AndriySvyryd marked this pull request as ready for review February 23, 2026 20:53
@AndriySvyryd AndriySvyryd requested a review from a team as a code owner February 23, 2026 20:53
Copilot AI review requested due to automatic review settings February 23, 2026 20:53
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a regression in EF Core 8/9/10 where owned entities with default values were not saved when using Table-Per-Hierarchy (TPH) inheritance with shared column names. The issue occurred because the ColumnValuePropagator.TryPropagate method incorrectly suppressed writes for sentinel values even when the Added entry was the sole writer for a shared column.

Changes:

  • Added a guard condition to TryPropagate that only suppresses sentinel value writes when another entry has recorded a different value for the shared column
  • Added comprehensive test coverage with a TPH model using owned entities, shared column names, and value converters
  • Added test model classes (Nougat, CrunchyNougat, SoftNougat, NougatFilling, NougatFillingKind) to the Updates test infrastructure

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated no comments.

Show a summary per file
File Description
src/EFCore.Relational/Update/ModificationCommand.cs Fixed ColumnValuePropagator.TryPropagate to add an additional guard that compares _currentValue with the entry's provider value before suppressing sentinel writes
test/EFCore.Specification.Tests/Update/UpdatesTestBase.cs Added model configuration for Nougat entities with owned Filling and string value converter on enum property
test/EFCore.Specification.Tests/TestModels/UpdatesModel/UpdatesContext.cs Added DbSet properties for CrunchyNougat and SoftNougat
test/EFCore.Specification.Tests/TestModels/UpdatesModel/Nougat.cs Added TPH test model with owned entities containing default values (enum = 0, bool = false)
test/EFCore.Relational.Specification.Tests/Update/UpdatesRelationalTestBase.cs Added test method and model configuration with shared column names (FillingKind, FillingIsFresh) for the TPH scenario

@AndriySvyryd AndriySvyryd assigned roji and unassigned AndriySvyryd and Copilot Feb 23, 2026
@AndriySvyryd AndriySvyryd requested a review from artl93 February 23, 2026 21:01
@roji roji assigned AndriySvyryd and unassigned roji Feb 24, 2026
Copy link
Copy Markdown
Member

@artl93 artl93 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the comment about the EF7 origin meaning that the issue has been in the product since .NET 8, or that this feature has been around since EF7?

@AndriySvyryd
Copy link
Copy Markdown
Member

@artl93 the issue has been in the product since .NET 8, but since it is only triggered on default values most customers probably haven't realized that they are affected or have added workarounds converting NULL to the default value

@artl93
Copy link
Copy Markdown
Member

artl93 commented Feb 26, 2026

So changes in this version exposted this one?

@AndriySvyryd
Copy link
Copy Markdown
Member

No, its level of exposure hasn't changed in the recent versions.

@artl93
Copy link
Copy Markdown
Member

artl93 commented Feb 26, 2026

@AndriySvyryd - I'd like for us to just ship this fix in 11 - it's an old bug at this point that hasn't blocked upgrade and it sounds like there's a workaround.

@AndriySvyryd
Copy link
Copy Markdown
Member

@artl93 Sounds good

@AndriySvyryd AndriySvyryd changed the base branch from release/10.0 to main February 26, 2026 19:43
@AndriySvyryd AndriySvyryd changed the title [release/10.0] Fix owned entities with default values not saved in TPH with shared columns Fix owned entities with default values not saved in TPH with shared columns Feb 26, 2026
@AndriySvyryd AndriySvyryd force-pushed the copilot/fix-owned-entities-update branch from ae3f4d2 to ea319f3 Compare February 26, 2026 22:34
@AndriySvyryd AndriySvyryd requested a review from Copilot February 26, 2026 22:44
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Comment thread src/EFCore.Relational/Update/ModificationCommand.cs Outdated
Comment thread src/EFCore.Relational/Update/ModificationCommand.cs Outdated
@AndriySvyryd AndriySvyryd force-pushed the copilot/fix-owned-entities-update branch from ea319f3 to eeea3ae Compare February 26, 2026 22:49
@AndriySvyryd AndriySvyryd enabled auto-merge (squash) February 26, 2026 22:50
@AndriySvyryd AndriySvyryd merged commit 1c16df9 into main Feb 27, 2026
11 checks passed
@AndriySvyryd AndriySvyryd deleted the copilot/fix-owned-entities-update branch February 27, 2026 00:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OwnOne entities with default values not saved in TPH scenario

5 participants