From cde80926a4dde5b994f1d8900be90a79276704ce Mon Sep 17 00:00:00 2001 From: Tanzim Hossain Romel Date: Sun, 25 Jan 2026 01:31:27 +0600 Subject: [PATCH 1/3] Fix reload when nullable complex property is null --- src/EFCore/ChangeTracking/EntityEntry.cs | 62 ++++++++++++++++++- src/EFCore/Internal/EntityFinder.cs | 45 +++++++++++--- .../PropertyValuesTestBase.cs | 15 +++++ 3 files changed, 113 insertions(+), 9 deletions(-) diff --git a/src/EFCore/ChangeTracking/EntityEntry.cs b/src/EFCore/ChangeTracking/EntityEntry.cs index f628c229c13..8ef60f6d08a 100644 --- a/src/EFCore/ChangeTracking/EntityEntry.cs +++ b/src/EFCore/ChangeTracking/EntityEntry.cs @@ -602,8 +602,9 @@ public virtual PropertyValues OriginalValues public virtual PropertyValues? GetDatabaseValues() { var values = Finder.GetDatabaseValues(InternalEntry); + var nullComplexPropertyFlags = values == null ? null : CreateNullComplexPropertyFlags(InternalEntry, values); - return values == null ? null : new ArrayPropertyValues(InternalEntry, values, null); + return values == null ? null : new ArrayPropertyValues(InternalEntry, values, nullComplexPropertyFlags); } /// @@ -633,8 +634,65 @@ public virtual PropertyValues OriginalValues public virtual async Task GetDatabaseValuesAsync(CancellationToken cancellationToken = default) { var values = await Finder.GetDatabaseValuesAsync(InternalEntry, cancellationToken).ConfigureAwait(false); + var nullComplexPropertyFlags = values == null ? null : CreateNullComplexPropertyFlags(InternalEntry, values); - return values == null ? null : new ArrayPropertyValues(InternalEntry, values, null); + return values == null ? null : new ArrayPropertyValues(InternalEntry, values, nullComplexPropertyFlags); + } + + private static bool[]? CreateNullComplexPropertyFlags(InternalEntityEntry entry, object?[] values) + { + List? nullableComplexProperties = null; + foreach (var complexProperty in entry.StructuralType.GetFlattenedComplexProperties()) + { + if (complexProperty.IsCollection || !complexProperty.IsNullable || complexProperty.IsShadowProperty()) + { + continue; + } + + (nullableComplexProperties ??= []).Add(complexProperty); + } + + if (nullableComplexProperties == null) + { + return null; + } + + var flags = new bool[nullableComplexProperties.Count]; + for (var i = 0; i < nullableComplexProperties.Count; i++) + { + var complexProperty = nullableComplexProperties[i]; + var scalarProperties = complexProperty.ComplexType.GetFlattenedProperties(); + + IProperty? requiredProperty = null; + foreach (var property in scalarProperties) + { + if (!property.IsNullable) + { + requiredProperty = property; + break; + } + } + + if (requiredProperty != null) + { + flags[i] = values[requiredProperty.GetIndex()] == null; + continue; + } + + var allNull = true; + foreach (var property in scalarProperties) + { + if (values[property.GetIndex()] != null) + { + allNull = false; + break; + } + } + + flags[i] = allNull; + } + + return flags; } /// diff --git a/src/EFCore/Internal/EntityFinder.cs b/src/EFCore/Internal/EntityFinder.cs index 904bf22ddf4..72692961208 100644 --- a/src/EFCore/Internal/EntityFinder.cs +++ b/src/EFCore/Internal/EntityFinder.cs @@ -863,11 +863,14 @@ private static Expression> BuildProjection(IEntityType en path.Add(complexType.ComplexProperty); } + var readType = GetReadType(property); + Expression instanceExpression = entityParameter; for (var i = path.Count - 1; i >= 0; i--) { + var currentType = i == 0 ? readType : path[i].ClrType; instanceExpression = Expression.Call( - EF.PropertyMethod.MakeGenericMethod(path[i].ClrType), + EF.PropertyMethod.MakeGenericMethod(currentType), instanceExpression, Expression.Constant(path[i].Name, typeof(string))); @@ -877,12 +880,40 @@ private static Expression> BuildProjection(IEntityType en } } - projections.Add( - Expression.Convert( - Expression.Convert( - instanceExpression, - property.ClrType), - typeof(object))); + var projection = instanceExpression.Type == property.ClrType + ? Expression.Convert(Expression.Convert(instanceExpression, property.ClrType), typeof(object)) + : Expression.Convert(instanceExpression, typeof(object)); + + projections.Add(projection); + } + + static Type GetReadType(IProperty property) + { + if (property.ClrType.IsValueType + && !property.ClrType.IsNullableType() + && IsInNullableComplexProperty(property)) + { + return typeof(Nullable<>).MakeGenericType(property.ClrType); + } + + return property.ClrType; + } + + static bool IsInNullableComplexProperty(IProperty property) + { + var declaringType = property.DeclaringType; + while (declaringType is IComplexType complexType) + { + var complexProperty = complexType.ComplexProperty; + if (!complexProperty.IsCollection && complexProperty.IsNullable) + { + return true; + } + + declaringType = complexProperty.DeclaringType; + } + + return false; } return Expression.Lambda>( diff --git a/test/EFCore.Specification.Tests/PropertyValuesTestBase.cs b/test/EFCore.Specification.Tests/PropertyValuesTestBase.cs index db0be471c8a..412cf137007 100644 --- a/test/EFCore.Specification.Tests/PropertyValuesTestBase.cs +++ b/test/EFCore.Specification.Tests/PropertyValuesTestBase.cs @@ -3195,6 +3195,21 @@ public virtual void Nullable_complex_property_with_null_value_returns_null_when_ Assert.NotNull(originalBuilding.OptionalMilk); } + [ConditionalFact] + public virtual async Task Reloading_optional_complex_property_with_null_does_not_throw() + { + using var context = CreateContext(); + var building = Building.Create(Guid.NewGuid(), "Building Null", 123m); + building.OptionalMilk = null; + context.Add(building); + await context.SaveChangesAsync(); + + await context.Entry(building).ReloadAsync(); + + Assert.Null(building.OptionalMilk); + Assert.Equal(EntityState.Unchanged, context.Entry(building).State); + } + [ConditionalFact] public virtual void Setting_current_values_from_cloned_values_sets_nullable_complex_property_to_null() { From cb7fb177d3d2564f703e520968318c5eeadcf2a1 Mon Sep 17 00:00:00 2001 From: Tanzim Hossain Romel Date: Thu, 5 Feb 2026 10:17:22 +0600 Subject: [PATCH 2/3] Address review feedback for nullable complex reload --- src/EFCore/ChangeTracking/EntityEntry.cs | 64 ++--------------- .../Internal/ArrayPropertyValues.cs | 70 ++++++++++++++++++- src/EFCore/Internal/EntityFinder.cs | 46 ++++-------- .../PropertyValuesTestBase.cs | 11 +-- 4 files changed, 94 insertions(+), 97 deletions(-) diff --git a/src/EFCore/ChangeTracking/EntityEntry.cs b/src/EFCore/ChangeTracking/EntityEntry.cs index 8ef60f6d08a..c719da3ec06 100644 --- a/src/EFCore/ChangeTracking/EntityEntry.cs +++ b/src/EFCore/ChangeTracking/EntityEntry.cs @@ -602,9 +602,9 @@ public virtual PropertyValues OriginalValues public virtual PropertyValues? GetDatabaseValues() { var values = Finder.GetDatabaseValues(InternalEntry); - var nullComplexPropertyFlags = values == null ? null : CreateNullComplexPropertyFlags(InternalEntry, values); - return values == null ? null : new ArrayPropertyValues(InternalEntry, values, nullComplexPropertyFlags); + // ArrayPropertyValues will compute null-complex-property flags from the returned value buffer. + return values == null ? null : new ArrayPropertyValues(InternalEntry, values); } /// @@ -634,65 +634,9 @@ public virtual PropertyValues OriginalValues public virtual async Task GetDatabaseValuesAsync(CancellationToken cancellationToken = default) { var values = await Finder.GetDatabaseValuesAsync(InternalEntry, cancellationToken).ConfigureAwait(false); - var nullComplexPropertyFlags = values == null ? null : CreateNullComplexPropertyFlags(InternalEntry, values); - return values == null ? null : new ArrayPropertyValues(InternalEntry, values, nullComplexPropertyFlags); - } - - private static bool[]? CreateNullComplexPropertyFlags(InternalEntityEntry entry, object?[] values) - { - List? nullableComplexProperties = null; - foreach (var complexProperty in entry.StructuralType.GetFlattenedComplexProperties()) - { - if (complexProperty.IsCollection || !complexProperty.IsNullable || complexProperty.IsShadowProperty()) - { - continue; - } - - (nullableComplexProperties ??= []).Add(complexProperty); - } - - if (nullableComplexProperties == null) - { - return null; - } - - var flags = new bool[nullableComplexProperties.Count]; - for (var i = 0; i < nullableComplexProperties.Count; i++) - { - var complexProperty = nullableComplexProperties[i]; - var scalarProperties = complexProperty.ComplexType.GetFlattenedProperties(); - - IProperty? requiredProperty = null; - foreach (var property in scalarProperties) - { - if (!property.IsNullable) - { - requiredProperty = property; - break; - } - } - - if (requiredProperty != null) - { - flags[i] = values[requiredProperty.GetIndex()] == null; - continue; - } - - var allNull = true; - foreach (var property in scalarProperties) - { - if (values[property.GetIndex()] != null) - { - allNull = false; - break; - } - } - - flags[i] = allNull; - } - - return flags; + // ArrayPropertyValues will compute null-complex-property flags from the returned value buffer. + return values == null ? null : new ArrayPropertyValues(InternalEntry, values); } /// diff --git a/src/EFCore/ChangeTracking/Internal/ArrayPropertyValues.cs b/src/EFCore/ChangeTracking/Internal/ArrayPropertyValues.cs index 5b7a3e651f8..d3d0c18d328 100644 --- a/src/EFCore/ChangeTracking/Internal/ArrayPropertyValues.cs +++ b/src/EFCore/ChangeTracking/Internal/ArrayPropertyValues.cs @@ -18,6 +18,17 @@ public class ArrayPropertyValues : PropertyValues private readonly List?[] _complexCollectionValues; 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) + : this(internalEntry, values, nullComplexPropertyFlags: null, computeNullComplexPropertyFlags: true) + { + } + /// /// 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 @@ -25,11 +36,68 @@ public class ArrayPropertyValues : PropertyValues /// doing so can result in application failures when updating to a new Entity Framework Core release. /// public ArrayPropertyValues(InternalEntryBase internalEntry, object?[] values, bool[]? nullComplexPropertyFlags) + : this(internalEntry, values, nullComplexPropertyFlags, computeNullComplexPropertyFlags: false) + { + } + + private ArrayPropertyValues( + InternalEntryBase internalEntry, + object?[] values, + bool[]? nullComplexPropertyFlags, + bool computeNullComplexPropertyFlags) : base(internalEntry) { _values = values; _complexCollectionValues = new List?[ComplexCollectionProperties.Count]; - _nullComplexPropertyFlags = nullComplexPropertyFlags; + _nullComplexPropertyFlags = computeNullComplexPropertyFlags + ? CreateNullComplexPropertyFlags(values) + : nullComplexPropertyFlags; + } + + private bool[]? CreateNullComplexPropertyFlags(object?[] values) + { + var nullableComplexProperties = NullableComplexProperties; + if (nullableComplexProperties == null) + { + return null; + } + + var flags = new bool[nullableComplexProperties.Count]; + for (var i = 0; i < nullableComplexProperties.Count; i++) + { + var complexProperty = nullableComplexProperties[i]; + var scalarProperties = complexProperty.ComplexType.GetFlattenedProperties(); + + IProperty? requiredProperty = null; + foreach (var property in scalarProperties) + { + if (!property.IsNullable) + { + requiredProperty = property; + break; + } + } + + if (requiredProperty != null) + { + flags[i] = values[requiredProperty.GetIndex()] == null; + continue; + } + + var allNull = true; + foreach (var property in scalarProperties) + { + if (values[property.GetIndex()] != null) + { + allNull = false; + break; + } + } + + flags[i] = allNull; + } + + return flags; } /// diff --git a/src/EFCore/Internal/EntityFinder.cs b/src/EFCore/Internal/EntityFinder.cs index 72692961208..2e199fb6d1b 100644 --- a/src/EFCore/Internal/EntityFinder.cs +++ b/src/EFCore/Internal/EntityFinder.cs @@ -858,12 +858,25 @@ private static Expression> BuildProjection(IEntityType en foreach (var property in entityType.GetFlattenedProperties()) { var path = new List { property }; + var isInNullableComplexProperty = false; while (path[^1].DeclaringType is IComplexType complexType) { - path.Add(complexType.ComplexProperty); + var complexProperty = complexType.ComplexProperty; + path.Add(complexProperty); + + if (!complexProperty.IsCollection && complexProperty.IsNullable) + { + isInNullableComplexProperty = true; + } } - var readType = GetReadType(property); + var readType = property.ClrType; + if (readType.IsValueType + && !readType.IsNullableType() + && isInNullableComplexProperty) + { + readType = typeof(Nullable<>).MakeGenericType(readType); + } Expression instanceExpression = entityParameter; for (var i = path.Count - 1; i >= 0; i--) @@ -887,35 +900,6 @@ private static Expression> BuildProjection(IEntityType en projections.Add(projection); } - static Type GetReadType(IProperty property) - { - if (property.ClrType.IsValueType - && !property.ClrType.IsNullableType() - && IsInNullableComplexProperty(property)) - { - return typeof(Nullable<>).MakeGenericType(property.ClrType); - } - - return property.ClrType; - } - - static bool IsInNullableComplexProperty(IProperty property) - { - var declaringType = property.DeclaringType; - while (declaringType is IComplexType complexType) - { - var complexProperty = complexType.ComplexProperty; - if (!complexProperty.IsCollection && complexProperty.IsNullable) - { - return true; - } - - declaringType = complexProperty.DeclaringType; - } - - return false; - } - return Expression.Lambda>( Expression.NewArrayInit(typeof(object), projections), entityParameter); diff --git a/test/EFCore.Specification.Tests/PropertyValuesTestBase.cs b/test/EFCore.Specification.Tests/PropertyValuesTestBase.cs index 412cf137007..af7f7ff7194 100644 --- a/test/EFCore.Specification.Tests/PropertyValuesTestBase.cs +++ b/test/EFCore.Specification.Tests/PropertyValuesTestBase.cs @@ -3199,10 +3199,8 @@ public virtual void Nullable_complex_property_with_null_value_returns_null_when_ public virtual async Task Reloading_optional_complex_property_with_null_does_not_throw() { using var context = CreateContext(); - var building = Building.Create(Guid.NewGuid(), "Building Null", 123m); - building.OptionalMilk = null; - context.Add(building); - await context.SaveChangesAsync(); + var building = context.Set().Single(b => b.Name == "Building Two"); + Assert.Null(building.OptionalMilk); await context.Entry(building).ReloadAsync(); @@ -4005,10 +4003,13 @@ protected override void OnModelCreating(ModelBuilder modelBuilder, DbContext con protected override Task SeedAsync(PoolableDbContext context) { + var buildingTwo = Building.Create(Guid.NewGuid(), "Building Two", 1000000m); + buildingTwo.OptionalMilk = null; + var buildings = new List { Building.Create(new Guid("21EC2020-3AEA-1069-A2DD-08002B30309D"), "Building One", 1500000), - Building.Create(Guid.NewGuid(), "Building Two", 1000000m) + buildingTwo }; foreach (var building in buildings) From baf40b72f02870300c0e765c1c0b054c98788ccc Mon Sep 17 00:00:00 2001 From: Tanzim Hossain Romel Date: Thu, 5 Feb 2026 17:37:26 +0600 Subject: [PATCH 3/3] Trigger CI rerun