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