Skip to content

Conversation

Copy link
Contributor

Copilot AI commented Dec 4, 2025

  • I've read the guidelines for contributing and seen the walkthrough
  • I've posted a comment on an issue with a detailed description of how I am planning to contribute and got approval from a member of the team
  • The code builds and tests pass locally (also verified by our automated build checks)
  • Commit messages follow this format:
        Summary of the changes
        - Detail 1
        - Detail 2

        Fixes #bugnumber
  • Tests for the changes have been added (for bug fixes / features)
  • Code follows the same patterns and style as existing code in this repo

Description

When calling OriginalValues.ToObject() or CurrentValues.ToObject() on an entity with a nullable complex property that is null, the returned object incorrectly contains an instantiated complex type with default values instead of null.

public sealed class Product
{
    public int Id { get; private set; }
    public required string Name { get; set; }
    public Price? Price { get; set; }  // null
}

var product = await context.Products.FirstAsync();
var originalProduct = context.Entry(product).OriginalValues.ToObject();
// Bug: originalProduct.Price is non-null with Amount=0, CurrencyId=0
// Expected: originalProduct.Price is null

Root cause: When cloning property values, value type properties inside nullable complex types (e.g., decimal Amount) store default values rather than null when the complex property is null. The materializer cannot distinguish between "complex property is null" and "complex property has default values."

Changes

  • PropertyValues: Added NullableComplexProperties list to track nullable non-collection complex properties for the structural type.
  • ArrayPropertyValues: Added _nullComplexPropertyFlags as a bool[] with indices matching NullableComplexProperties ordering. ToObject() now sets tracked properties to null after materialization. Clone() copies the flags array.
  • EntryPropertyValues: Clone() now uses the entry indexer to check nullable non-collection complex properties and sets the flags in the cloned instance when null.
  • Added OptionalMilk property to existing Building class for testing nullable complex properties.
  • Added test Nullable_complex_property_with_null_value_returns_null_when_using_ToObject()
Original prompt

This section details on the original issue you should resolve

<issue_title>EF 10: Nullable complex property with PropertyValues.ToObject() creates instance instead of null</issue_title>
<issue_description>### Bug description

When using a nullable complex type configured with ComplexProperty, calling OriginalValues.ToObject() when the original complex property value is null creates a complex type instance instead of returning null (same for CurrentValues.ToObject()).

Your code

#:package Npgsql.EntityFrameworkCore.PostgreSQL@10.0.0
#:property PublishAot=false

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

await using var context = new ProductContext();
await context.Database.EnsureDeletedAsync();
await context.Database.EnsureCreatedAsync();

context.Products.Add(new() { Name = "Product 1" });
await context.SaveChangesAsync();

context.ChangeTracker.Clear();
var product = await context.Products.FirstAsync();
var originalProduct = context.ChangeTracker.Entries().First().OriginalValues.ToObject();
;

public sealed class ProductContext : DbContext
{
    public DbSet<Product> Products { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder
            .UseNpgsql("Host=localhost;Username=postgres;Password=postgres;Database=Test")
            .LogTo(Console.WriteLine, LogLevel.Information)
            .EnableSensitiveDataLogging();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
        => modelBuilder.Entity<Product>().ComplexProperty(x => x.Price);
}

public sealed class Product
{
    public int Id { get; private set; }
    public required string Name { get; set; }
    public Price? Price { get; set; }
}

public sealed class Price
{
    public required decimal Amount { get; init; }
    public required int CurrencyId { get; init; }
}

Stack traces


Verbose output


EF Core version

10.0.0

Database provider

Npgsql.EntityFrameworkCore.PostgreSQL

Target framework

.NET 10.0

Operating system

Windows 11

IDE

VSCode</issue_description>

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

@roji @AndriySvyryd here's a minimal SQL Server repro (very close to the above), in case it helps:
Minimal repro with SQL Server
await using var context = new ProductContext();
await context.Database.EnsureDeletedAsync();
await context.Database.EnsureCreatedAsync();

context.Products.Add(new() { Name = "Product 1" });
await context.SaveChangesAsync();

context.ChangeTracker.Clear();
var product = await context.Products.FirstAsync();
var originalProduct = (Product)context.ChangeTracker.Entries().First().OriginalValues.ToObject();
Console.WriteLine("Original Product: " + originalProduct.Price?.Amount); // 0, should be null

public sealed class ProductContext : DbContext
{
    public DbSet<Product> Products { get; set; }

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

    protected override void OnModelCreating(ModelBuilder modelBuilder)
        => modelBuilder.Entity<Product>().ComplexProperty(x => x.Price);
}

public sealed class Product
{
    public int Id { get; private set; }
    public required string Name { get; set; }
    public Price? Price { get; set; }
}

public sealed class Price
{
    public required decimal Amount { get; init; }
    public required int CurrencyId { get; init; }
}

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

Copilot AI and others added 2 commits December 4, 2025 23:19
…f null

When calling OriginalValues.ToObject() or CurrentValues.ToObject() on an entity
with a nullable complex property that is null, the method was creating an instance
of the complex type with default values instead of returning null.

The fix adds tracking for null nullable complex properties in ArrayPropertyValues
and sets them to null after materialization in ToObject().

Co-authored-by: AndriySvyryd <[email protected]>
Copilot AI changed the title [WIP] Fix nullable complex property issue with ToObject() Fix nullable complex property with PropertyValues.ToObject() creating instance instead of null Dec 4, 2025
Copilot AI requested a review from AndriySvyryd December 4, 2025 23:29
Copilot finished work on behalf of AndriySvyryd December 4, 2025 23:29
// Tracks nullable non-collection complex properties that should be null when materializing via ToObject().
// This is needed because value type properties inside nullable complex types store default values (not null)
// when the complex property is null, making it impossible to detect nullness from the values array alone.
private HashSet<IComplexProperty>? _nullComplexProperties;
Copy link
Member

Choose a reason for hiding this comment

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

@copilot Make this bool[], with values in the same order as nullable non-collection complex properties for the given type base

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Changed to bool[] with values matching NullableComplexProperties ordering. Added NullableComplexProperties list to PropertyValues base class. Commit d13bfc9.

{
if (!complexProperty.IsCollection && complexProperty.IsNullable)
{
var complexValue = GetValueInternal(InternalEntry, complexProperty);
Copy link
Member

Choose a reason for hiding this comment

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

@copilot GetValueInternal cannot be used for non-collection complex properties

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed to use InternalEntry[nullableComplexProperties[i]] indexer instead of GetValueInternal. Commit d13bfc9.

Comment on lines 3178 to 3183
context.SaveChanges();

context.ChangeTracker.Clear();

// Reload the product
var loadedProduct = context.Set<ProductWithNullableComplexType>().Single(p => p.Name == "Test Product");
Copy link
Member

Choose a reason for hiding this comment

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

@copilot No need to do these actions, just use the newly added product. Also remove comments in this test

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Simplified test to use the newly added entity directly without SaveChanges/reload, and removed all comments. Commit d13bfc9.

Comment on lines 3841 to 3846
[ComplexType]
protected class ProductPrice
{
public decimal Amount { get; init; }
public int CurrencyId { get; init; }
}
Copy link
Member

Choose a reason for hiding this comment

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

@copilot Remove, use an existing class instead

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Removed ProductWithNullableComplexType and ProductPrice. Added OptionalMilk property to the existing Building class instead. Commit d13bfc9.

…ndexer

- Changed _nullComplexProperties from HashSet to bool[] with indices matching
  NullableComplexProperties ordering
- Added NullableComplexProperties property to PropertyValues base class
- Fixed EntryPropertyValues.Clone() to use InternalEntry[] indexer instead of
  GetValueInternal for complex properties
- Simplified test to use newly added product without reload, removed comments
- Removed ProductWithNullableComplexType, added OptionalMilk to Building class

Co-authored-by: AndriySvyryd <[email protected]>
Copilot AI requested a review from AndriySvyryd December 5, 2025 03:25
Copilot finished work on behalf of AndriySvyryd December 5, 2025 03:25
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.

2 participants