diff --git a/src/EFCore/ChangeTracking/Internal/ChangeDetector.cs b/src/EFCore/ChangeTracking/Internal/ChangeDetector.cs index 042e8744a7d..801b23ef921 100644 --- a/src/EFCore/ChangeTracking/Internal/ChangeDetector.cs +++ b/src/EFCore/ChangeTracking/Internal/ChangeDetector.cs @@ -59,6 +59,19 @@ public virtual void PropertyChanged(IInternalEntry entry, IPropertyBase property DetectKeyChange(entry, property); } + else if (propertyBase is IComplexProperty { IsCollection: false } complexProperty) + { + // TODO: This requires notification change tracking for complex types + // Issue #36175 + if (entry.EntityState is not EntityState.Deleted + && setModified + && entry is InternalEntryBase entryBase + && complexProperty.IsNullable + && complexProperty.GetOriginalValueIndex() >= 0) + { + DetectComplexPropertyChange(entryBase, complexProperty); + } + } else if (propertyBase.GetRelationshipIndex() != -1 && propertyBase is INavigationBase navigation) { @@ -292,11 +305,54 @@ private bool LocalDetectChanges(InternalEntryBase entry) changesFound = true; } } + else if (complexProperty.IsNullable && complexProperty.GetOriginalValueIndex() >= 0) + { + if (DetectComplexPropertyChange(entry, complexProperty)) + { + changesFound = true; + } + } } return changesFound; } + /// + /// 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 virtual bool DetectComplexPropertyChange(InternalEntryBase entry, IComplexProperty complexProperty) + { + Check.DebugAssert(!complexProperty.IsCollection, $"Expected {complexProperty.Name} to not be a collection."); + + var currentValue = entry[complexProperty]; + var originalValue = entry.GetOriginalValue(complexProperty); + + if ((currentValue == null) != (originalValue == null)) + { + // If it changed from null to non-null, mark all inner properties as modified + // to ensure the entity is detected as modified and the complex type properties are persisted + if (currentValue != null) + { + foreach (var innerProperty in complexProperty.ComplexType.GetFlattenedProperties()) + { + // Only mark properties that are tracked and can be modified + if (innerProperty.GetOriginalValueIndex() >= 0 + && innerProperty.GetAfterSaveBehavior() == PropertySaveBehavior.Save) + { + entry.SetPropertyModified(innerProperty); + } + } + } + + return true; + } + + return false; + } + /// /// 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 diff --git a/test/EFCore.Cosmos.FunctionalTests/CosmosComplexTypesTrackingTest.cs b/test/EFCore.Cosmos.FunctionalTests/CosmosComplexTypesTrackingTest.cs index 6362ece9712..90c03fb0366 100644 --- a/test/EFCore.Cosmos.FunctionalTests/CosmosComplexTypesTrackingTest.cs +++ b/test/EFCore.Cosmos.FunctionalTests/CosmosComplexTypesTrackingTest.cs @@ -114,7 +114,12 @@ protected override Task TrackAndSaveTest(EntityState state, bool async, return base.TrackAndSaveTest(state, async, createPub); } - protected override async Task ExecuteWithStrategyInTransactionAsync(Func testOperation, Func? nestedTestOperation1 = null, Func? nestedTestOperation2 = null) + public override Task Can_save_default_values_in_optional_complex_property_with_multiple_properties(bool async) + // Optional complex properties are not supported on Cosmos + // See https://github.com/dotnet/efcore/issues/31253 + => Task.CompletedTask; + + protected override async Task ExecuteWithStrategyInTransactionAsync(Func testOperation, Func? nestedTestOperation1 = null, Func? nestedTestOperation2 = null, Func? nestedTestOperation3 = null) { using var c = CreateContext(); await c.Database.CreateExecutionStrategy().ExecuteAsync( @@ -144,6 +149,16 @@ await c.Database.CreateExecutionStrategy().ExecuteAsync( { await nestedTestOperation2(innerContext2); } + + if (nestedTestOperation3 == null) + { + return; + } + + using (var innerContext3 = CreateContext()) + { + await nestedTestOperation3(innerContext3); + } }); } @@ -164,6 +179,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder, DbContext con modelBuilder.Entity().HasPartitionKey(x => x.Id); modelBuilder.Entity().HasPartitionKey(x => x.Id); modelBuilder.Entity().HasPartitionKey(x => x.Id); + modelBuilder.Entity().HasPartitionKey(x => x.Id); if (!UseProxies) { modelBuilder.Entity().HasPartitionKey(x => x.Id); diff --git a/test/EFCore.InMemory.FunctionalTests/ComplexTypesTrackingInMemoryTest.cs b/test/EFCore.InMemory.FunctionalTests/ComplexTypesTrackingInMemoryTest.cs index bcac60ffa8e..486e4c2f591 100644 --- a/test/EFCore.InMemory.FunctionalTests/ComplexTypesTrackingInMemoryTest.cs +++ b/test/EFCore.InMemory.FunctionalTests/ComplexTypesTrackingInMemoryTest.cs @@ -9,11 +9,12 @@ public class ComplexTypesTrackingInMemoryTest(ComplexTypesTrackingInMemoryTest.I protected override async Task ExecuteWithStrategyInTransactionAsync( Func testOperation, Func nestedTestOperation1 = null, - Func nestedTestOperation2 = null) + Func nestedTestOperation2 = null, + Func nestedTestOperation3 = null) { try { - await base.ExecuteWithStrategyInTransactionAsync(testOperation, nestedTestOperation1, nestedTestOperation2); + await base.ExecuteWithStrategyInTransactionAsync(testOperation, nestedTestOperation1, nestedTestOperation2, nestedTestOperation3); } finally { @@ -21,6 +22,11 @@ protected override async Task ExecuteWithStrategyInTransactionAsync( } } + public override Task Can_save_default_values_in_optional_complex_property_with_multiple_properties(bool async) + // InMemory provider has issues with complex type query compilation + // See https://github.com/dotnet/efcore/issues/31464 + => Task.CompletedTask; + public class InMemoryFixture : FixtureBase { protected override ITestStoreFactory TestStoreFactory diff --git a/test/EFCore.Specification.Tests/ComplexTypesTrackingTestBase.cs b/test/EFCore.Specification.Tests/ComplexTypesTrackingTestBase.cs index 94a3686a2b6..c61b3f36075 100644 --- a/test/EFCore.Specification.Tests/ComplexTypesTrackingTestBase.cs +++ b/test/EFCore.Specification.Tests/ComplexTypesTrackingTestBase.cs @@ -2053,10 +2053,11 @@ protected static EntityEntry TrackFromQuery(DbContext context, protected virtual Task ExecuteWithStrategyInTransactionAsync( Func testOperation, Func? nestedTestOperation1 = null, - Func? nestedTestOperation2 = null) + Func? nestedTestOperation2 = null, + Func? nestedTestOperation3 = null) => TestHelpers.ExecuteWithStrategyInTransactionAsync( CreateContext, UseTransaction, - testOperation, nestedTestOperation1, nestedTestOperation2); + testOperation, nestedTestOperation1, nestedTestOperation2, nestedTestOperation3); protected virtual void UseTransaction(DatabaseFacade facade, IDbContextTransaction transaction) { @@ -2397,6 +2398,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder, DbContext con }); }); }); + + modelBuilder.Entity(b => + { + b.ComplexProperty(e => e.ComplexProp); + }); } } @@ -4373,4 +4379,72 @@ protected static FieldPubWithReadonlyStructCollections CreateFieldCollectionPubW ], FeaturedTeam = new TeamReadonlyStruct("Not In This Lifetime", ["Slash", "Axl"]) }; + + [ConditionalTheory(), InlineData(false), InlineData(true)] + public virtual async Task Can_save_default_values_in_optional_complex_property_with_multiple_properties(bool async) + { + await ExecuteWithStrategyInTransactionAsync( + async context => + { + var entity = Fixture.UseProxies + ? context.CreateProxy() + : new EntityWithOptionalMultiPropComplex(); + + entity.Id = Guid.NewGuid(); + entity.ComplexProp = null; + + _ = async ? await context.AddAsync(entity) : context.Add(entity); + _ = async ? await context.SaveChangesAsync() : context.SaveChanges(); + + Assert.Null(entity.ComplexProp); + }, + async context => + { + var entity = async + ? await context.Set().SingleAsync() + : context.Set().Single(); + + Assert.Null(entity.ComplexProp); + + // Set the complex property with default values + entity.ComplexProp = new MultiPropComplex + { + IntValue = 0, + BoolValue = false, + DateValue = default + }; + + _ = async ? await context.SaveChangesAsync() : context.SaveChanges(); + + Assert.NotNull(entity.ComplexProp); + Assert.Equal(0, entity.ComplexProp.IntValue); + Assert.False(entity.ComplexProp.BoolValue); + Assert.Equal(default, entity.ComplexProp.DateValue); + }, + async context => + { + var entity = async + ? await context.Set().SingleAsync() + : context.Set().Single(); + + // Complex types with more than one property should materialize even with default values + Assert.NotNull(entity.ComplexProp); + Assert.Equal(0, entity.ComplexProp.IntValue); + Assert.False(entity.ComplexProp.BoolValue); + Assert.Equal(default, entity.ComplexProp.DateValue); + }); + } + + public class EntityWithOptionalMultiPropComplex + { + public virtual Guid Id { get; set; } + public virtual MultiPropComplex? ComplexProp { get; set; } + } + + public class MultiPropComplex + { + public int IntValue { get; set; } + public bool BoolValue { get; set; } + public DateTimeOffset DateValue { get; set; } + } } diff --git a/test/EFCore.SqlServer.FunctionalTests/ComplexTypesTrackingSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/ComplexTypesTrackingSqlServerTest.cs index 250d379c5ec..0f0df7ca8e7 100644 --- a/test/EFCore.SqlServer.FunctionalTests/ComplexTypesTrackingSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/ComplexTypesTrackingSqlServerTest.cs @@ -338,6 +338,10 @@ public override void Can_write_original_values_for_properties_of_complex_propert { } + // Issue #36175: Complex types with notification change tracking are not supported + public override Task Can_save_default_values_in_optional_complex_property_with_multiple_properties(bool async) + => Task.CompletedTask; + public class SqlServerFixture : SqlServerFixtureBase { protected override string StoreName