diff --git a/src/EFCore/ChangeTracking/Internal/ArrayPropertyValues.cs b/src/EFCore/ChangeTracking/Internal/ArrayPropertyValues.cs index e4fe149a005..2bb62665ac6 100644 --- a/src/EFCore/ChangeTracking/Internal/ArrayPropertyValues.cs +++ b/src/EFCore/ChangeTracking/Internal/ArrayPropertyValues.cs @@ -17,17 +17,24 @@ public class ArrayPropertyValues : PropertyValues private readonly object?[] _values; private readonly List?[] _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. + // The array indices correspond to NullableComplexProperties ordering. + private readonly bool[]? _nullComplexPropertyFlags; + /// /// 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. /// - public ArrayPropertyValues(InternalEntryBase internalEntry, object?[] values) + public ArrayPropertyValues(InternalEntryBase internalEntry, object?[] values, bool[]? nullComplexPropertyFlags = null) : base(internalEntry) { _values = values; _complexCollectionValues = new List?[ComplexCollectionProperties.Count]; + _nullComplexPropertyFlags = nullComplexPropertyFlags; } /// @@ -41,6 +48,23 @@ 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 (_nullComplexPropertyFlags != null && NullableComplexProperties != null) + { + for (var i = 0; i < _nullComplexPropertyFlags.Length; i++) + { + if (_nullComplexPropertyFlags[i]) + { + var complexProperty = NullableComplexProperties[i]; + if (!complexProperty.IsShadowProperty()) + { + structuralObject = ((IRuntimeComplexProperty)complexProperty).GetSetter().SetClrValue(structuralObject, null); + } + } + } + } + for (var i = 0; i < _complexCollectionValues.Length; i++) { var propertyValuesList = _complexCollectionValues[i]; @@ -132,7 +156,16 @@ public override PropertyValues Clone() var copies = new object[_values.Length]; Array.Copy(_values, copies, _values.Length); - var clone = new ArrayPropertyValues(InternalEntry, copies); + // Copy null complex property tracking + bool[]? flagsCopy = null; + if (_nullComplexPropertyFlags != null) + { + flagsCopy = new bool[_nullComplexPropertyFlags.Length]; + Array.Copy(_nullComplexPropertyFlags, flagsCopy, _nullComplexPropertyFlags.Length); + } + + var clone = new ArrayPropertyValues(InternalEntry, copies, flagsCopy); + for (var i = 0; i < _complexCollectionValues.Length; i++) { var list = _complexCollectionValues[i]; diff --git a/src/EFCore/ChangeTracking/Internal/EntryPropertyValues.cs b/src/EFCore/ChangeTracking/Internal/EntryPropertyValues.cs index bae89bf32a6..2174ed156cc 100644 --- a/src/EFCore/ChangeTracking/Internal/EntryPropertyValues.cs +++ b/src/EFCore/ChangeTracking/Internal/EntryPropertyValues.cs @@ -282,7 +282,21 @@ public override PropertyValues Clone() values[i] = GetValueInternal(InternalEntry, Properties[i]); } - var cloned = new ArrayPropertyValues(InternalEntry, values); + // Track null nullable complex properties (non-collection) using bool array + bool[]? flags = null; + var nullableComplexProperties = NullableComplexProperties; + if (nullableComplexProperties != null && nullableComplexProperties.Count > 0) + { + flags = new bool[nullableComplexProperties.Count]; + for (var i = 0; i < nullableComplexProperties.Count; i++) + { + // Use the entry's indexer to get the current complex property value + flags[i] = InternalEntry[nullableComplexProperties[i]] == null; + } + } + + var cloned = new ArrayPropertyValues(InternalEntry, values, flags); + foreach (var complexProperty in ComplexCollectionProperties) { var collection = (IList?)GetValueInternal(InternalEntry, complexProperty); diff --git a/src/EFCore/ChangeTracking/PropertyValues.cs b/src/EFCore/ChangeTracking/PropertyValues.cs index 95777844184..76f30fd440e 100644 --- a/src/EFCore/ChangeTracking/PropertyValues.cs +++ b/src/EFCore/ChangeTracking/PropertyValues.cs @@ -27,6 +27,7 @@ namespace Microsoft.EntityFrameworkCore.ChangeTracking; public abstract class PropertyValues { private readonly IReadOnlyList _complexCollectionProperties; + private readonly IReadOnlyList? _nullableComplexProperties; /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to @@ -42,6 +43,8 @@ protected PropertyValues(InternalEntryBase internalEntry) Check.DebugAssert( _complexCollectionProperties.Select((p, i) => p.GetIndex() == i).All(e => e), "Complex collection properties indices are not sequential."); + var nullableComplexProperties = internalEntry.StructuralType.GetFlattenedComplexProperties().Where(p => !p.IsCollection && p.IsNullable).ToList(); + _nullableComplexProperties = nullableComplexProperties.Count > 0 ? nullableComplexProperties : null; } /// @@ -154,6 +157,19 @@ public virtual IReadOnlyList ComplexCollectionProperties get => _complexCollectionProperties; } + /// + /// 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. + /// + [EntityFrameworkInternal] + protected IReadOnlyList? NullableComplexProperties + { + [DebuggerStepThrough] + get => _nullableComplexProperties; + } + /// /// Gets the underlying structural type for which this object is storing values. /// diff --git a/test/EFCore.InMemory.FunctionalTests/PropertyValuesInMemoryTest.cs b/test/EFCore.InMemory.FunctionalTests/PropertyValuesInMemoryTest.cs index 67ed30104d3..2fb90b67dac 100644 --- a/test/EFCore.InMemory.FunctionalTests/PropertyValuesInMemoryTest.cs +++ b/test/EFCore.InMemory.FunctionalTests/PropertyValuesInMemoryTest.cs @@ -8,6 +8,11 @@ namespace Microsoft.EntityFrameworkCore; public class PropertyValuesInMemoryTest(PropertyValuesInMemoryTest.PropertyValuesInMemoryFixture fixture) : PropertyValuesTestBase(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( // In-memory database cannot query complex types () => base.Complex_current_values_can_be_accessed_as_a_property_dictionary_using_IProperty()); @@ -113,6 +118,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder, DbContext con { b.Ignore(e => e.Culture); b.Ignore(e => e.Milk); + b.Ignore(e => e.OptionalMilk); }); // In-memory database doesn't support complex collections diff --git a/test/EFCore.Specification.Tests/PropertyValuesTestBase.cs b/test/EFCore.Specification.Tests/PropertyValuesTestBase.cs index d62eb9c59fc..32b5f6507e9 100644 --- a/test/EFCore.Specification.Tests/PropertyValuesTestBase.cs +++ b/test/EFCore.Specification.Tests/PropertyValuesTestBase.cs @@ -2055,6 +2055,18 @@ private async Task TestProperties(Func, Task().Single(b => b.Name == "Building One"); + building.OptionalMilk = null; + + var originalBuilding = (Building)context.Entry(building).CurrentValues.ToObject(); + Assert.Null(originalBuilding.OptionalMilk); + } + [ConditionalFact] public virtual void Current_values_can_be_copied_to_object_using_ToObject() { @@ -3558,6 +3581,26 @@ public static Building Create(Guid buildingId, string name, decimal value, int? Rating = 8 + (tag ?? 0), Species = "S1" + tag, Validation = false + }, + OptionalMilk = new Milk + { + License = new License + { + Charge = 2.0m + (tag ?? 0), + Tag = new Tag { Text = "Ta3" + tag }, + Title = "Ti2" + tag, + Tog = new Tog { Text = "To3" + tag } + }, + Manufacturer = new Manufacturer + { + Name = "M2" + tag, + Rating = 9 + (tag ?? 0), + Tag = new Tag { Text = "Ta4" + tag }, + Tog = new Tog { Text = "To4" + tag } + }, + Rating = 10 + (tag ?? 0), + Species = "S2" + tag, + Validation = true } }; @@ -3587,6 +3630,7 @@ public string NoSetter public Culture Culture { get; set; } public required Milk Milk { get; set; } + public Milk? OptionalMilk { get; set; } } [ComplexType] @@ -3903,6 +3947,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder, DbContext con b.ComplexProperty(e => e.Culture); b.ComplexProperty(e => e.Milk); + b.ComplexProperty(e => e.OptionalMilk); }); modelBuilder.Entity();