Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions src/EFCore/ChangeTracking/Internal/ArrayPropertyValues.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ public class ArrayPropertyValues : PropertyValues
private readonly object?[] _values;
private readonly List<ArrayPropertyValues?>?[] _complexCollectionValues;

// 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;

/// <summary>
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
Expand All @@ -30,6 +35,19 @@ public ArrayPropertyValues(InternalEntryBase internalEntry, object?[] values)
_complexCollectionValues = new List<ArrayPropertyValues?>?[ComplexCollectionProperties.Count];
}

/// <summary>
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
/// any release. You should only use it directly in your code with extreme caution and knowing that
/// doing so can result in application failures when updating to a new Entity Framework Core release.
/// </summary>
[EntityFrameworkInternal]
internal void MarkComplexPropertyAsNull(IComplexProperty complexProperty)
{
_nullComplexProperties ??= [];
_nullComplexProperties.Add(complexProperty);
}

/// <summary>
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
Expand All @@ -41,6 +59,19 @@ public override object ToObject()
var structuralObject = StructuralType.GetOrCreateMaterializer(MaterializerSource)(
new MaterializationContext(new ValueBuffer(_values), InternalEntry.Context));

// Set null for nullable complex properties that were explicitly marked as null.
// Shadow properties don't have CLR members and aren't part of the materialized object.
if (_nullComplexProperties != null)
{
foreach (var complexProperty in _nullComplexProperties)
{
if (!complexProperty.IsShadowProperty())
{
structuralObject = ((IRuntimeComplexProperty)complexProperty).GetSetter().SetClrValue(structuralObject, null);
}
}
}

for (var i = 0; i < _complexCollectionValues.Length; i++)
{
var propertyValuesList = _complexCollectionValues[i];
Expand Down Expand Up @@ -133,6 +164,16 @@ public override PropertyValues Clone()
Array.Copy(_values, copies, _values.Length);

var clone = new ArrayPropertyValues(InternalEntry, copies);

// Copy null complex property tracking
if (_nullComplexProperties != null)
{
foreach (var complexProperty in _nullComplexProperties)
{
clone.MarkComplexPropertyAsNull(complexProperty);
}
}

for (var i = 0; i < _complexCollectionValues.Length; i++)
{
var list = _complexCollectionValues[i];
Expand Down
14 changes: 14 additions & 0 deletions src/EFCore/ChangeTracking/Internal/EntryPropertyValues.cs
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,20 @@ public override PropertyValues Clone()
}

var cloned = new ArrayPropertyValues(InternalEntry, values);

// Track null nullable complex properties (non-collection)
foreach (var complexProperty in StructuralType.GetFlattenedComplexProperties())
{
if (!complexProperty.IsCollection && complexProperty.IsNullable)
{
var complexValue = GetValueInternal(InternalEntry, complexProperty);
if (complexValue == null)
{
cloned.MarkComplexPropertyAsNull(complexProperty);
}
}
}

foreach (var complexProperty in ComplexCollectionProperties)
{
var collection = (IList?)GetValueInternal(InternalEntry, complexProperty);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ namespace Microsoft.EntityFrameworkCore;
public class PropertyValuesInMemoryTest(PropertyValuesInMemoryTest.PropertyValuesInMemoryFixture fixture)
: PropertyValuesTestBase<PropertyValuesInMemoryTest.PropertyValuesInMemoryFixture>(fixture)
{
// Nullable complex property test - InMemory provider doesn't support complex types in queries
public override void Nullable_complex_property_with_null_value_returns_null_when_using_ToObject()
{
}

public override Task Complex_current_values_can_be_accessed_as_a_property_dictionary_using_IProperty()
=> Assert.ThrowsAsync<NullReferenceException>( // In-memory database cannot query complex types
() => base.Complex_current_values_can_be_accessed_as_a_property_dictionary_using_IProperty());
Expand Down Expand Up @@ -117,6 +122,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder, DbContext con

// In-memory database doesn't support complex collections
modelBuilder.Ignore<School>();

// In-memory database doesn't fully support complex types
modelBuilder.Ignore<ProductWithNullableComplexType>();
}
}
}
45 changes: 45 additions & 0 deletions test/EFCore.Specification.Tests/PropertyValuesTestBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3167,6 +3167,35 @@ public virtual void SetValues_throws_for_complex_property_with_non_dictionary_va
Assert.Equal(CoreStrings.ComplexPropertyValueNotDictionary("Culture", "string"), exception.Message);
}

[ConditionalFact]
public virtual void Nullable_complex_property_with_null_value_returns_null_when_using_ToObject()
{
using var context = CreateContext();

// Create a product without setting Price (it's null)
var product = new ProductWithNullableComplexType { Name = "Test Product" };
context.Add(product);
context.SaveChanges();

context.ChangeTracker.Clear();

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

// Verify the loaded entity has null Price
Assert.Null(loadedProduct.Price);

// Get OriginalValues and create object
var originalProduct = (ProductWithNullableComplexType)context.Entry(loadedProduct).OriginalValues.ToObject();

// The original value should also have null Price
Assert.Null(originalProduct.Price);

// Also test CurrentValues
var currentProduct = (ProductWithNullableComplexType)context.Entry(loadedProduct).CurrentValues.ToObject();
Assert.Null(currentProduct.Price);
}

[ConditionalFact]
public virtual void Current_values_can_be_copied_to_object_using_ToObject()
{
Expand Down Expand Up @@ -3802,6 +3831,20 @@ protected class Course
public int Credits { get; set; }
}

protected class ProductWithNullableComplexType : PropertyValuesBase
{
public int Id { get; set; }
public required string Name { get; set; }
public ProductPrice? Price { get; set; }
}

[ComplexType]
protected class ProductPrice
{
public decimal Amount { get; init; }
public int CurrencyId { get; init; }
}

protected DbContext CreateContext()
{
var context = Fixture.CreateContext();
Expand Down Expand Up @@ -3910,6 +3953,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder, DbContext con
modelBuilder.Entity<Customer33307>();

modelBuilder.Entity<School>(b => b.ComplexCollection(e => e.Departments));

modelBuilder.Entity<ProductWithNullableComplexType>(b => b.ComplexProperty(e => e.Price));
}

protected override Task SeedAsync(PoolableDbContext context)
Expand Down
Loading