diff --git a/src/EFCore/ChangeTracking/Internal/ChangeDetector.cs b/src/EFCore/ChangeTracking/Internal/ChangeDetector.cs
index 5d2600518a3..d3f23016795 100644
--- a/src/EFCore/ChangeTracking/Internal/ChangeDetector.cs
+++ b/src/EFCore/ChangeTracking/Internal/ChangeDetector.cs
@@ -346,6 +346,24 @@ public virtual bool DetectComplexPropertyChange(InternalEntryBase entry, IComple
// to ensure the entity is detected as modified and the complex type properties are persisted
if (!UseOldBehavior37890 || currentValue is not null)
{
+ if (!InternalComplexTypeBuilder.UseOldBehavior38119)
+ {
+ // Set the discriminator value for the complex type when transitioning from null to non-null or vice versa.
+ // The discriminator is a shadow property whose value needs to be updated to reflect the new state.
+ var discriminatorProperty = complexProperty.ComplexType.FindDiscriminatorProperty();
+ if (discriminatorProperty != null)
+ {
+ if (currentValue is not null)
+ {
+ entry[discriminatorProperty] = complexProperty.ComplexType.GetDiscriminatorValue();
+ }
+ else if (discriminatorProperty.IsShadowProperty())
+ {
+ entry[discriminatorProperty] = discriminatorProperty.ClrType.GetDefaultValue();
+ }
+ }
+ }
+
foreach (var innerProperty in complexProperty.ComplexType.GetFlattenedProperties())
{
// Only mark properties that are tracked and can be modified
diff --git a/src/EFCore/Metadata/Internal/InternalComplexTypeBuilder.cs b/src/EFCore/Metadata/Internal/InternalComplexTypeBuilder.cs
index aa03bb7c0ff..ff79ac03542 100644
--- a/src/EFCore/Metadata/Internal/InternalComplexTypeBuilder.cs
+++ b/src/EFCore/Metadata/Internal/InternalComplexTypeBuilder.cs
@@ -500,6 +500,30 @@ public virtual bool CanSetServiceOnlyConstructorBinding(
=> configurationSource.Overrides(Metadata.GetServiceOnlyConstructorBindingConfigurationSource())
|| Metadata.ServiceOnlyConstructorBinding == constructorBinding;
+ internal static readonly bool UseOldBehavior38119 =
+ AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue38119", out var enabled) && enabled;
+
+ ///
+ /// 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.
+ ///
+ protected override InternalPropertyBuilder? GetOrCreateDiscriminatorProperty(
+ Type? type,
+ string? name,
+ MemberInfo? memberInfo,
+ ConfigurationSource configurationSource)
+ {
+ var builder = base.GetOrCreateDiscriminatorProperty(type, name, memberInfo, configurationSource);
+ if (!UseOldBehavior38119)
+ {
+ builder?.AfterSave(PropertySaveBehavior.Save, ConfigurationSource.Convention);
+ }
+
+ return builder;
+ }
+
///
/// 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.Specification.Tests/Query/AdHocComplexTypeQueryTestBase.cs b/test/EFCore.Specification.Tests/Query/AdHocComplexTypeQueryTestBase.cs
index b2123250eac..0ce294f63c2 100644
--- a/test/EFCore.Specification.Tests/Query/AdHocComplexTypeQueryTestBase.cs
+++ b/test/EFCore.Specification.Tests/Query/AdHocComplexTypeQueryTestBase.cs
@@ -148,13 +148,23 @@ public virtual async Task Optional_complex_type_with_discriminator()
return context.SaveChangesAsync();
});
- await using var context = contextFactory.CreateContext();
+ await using (var context = contextFactory.CreateContext())
+ {
+ var complexTypeNull = await context.Set()
+ .SingleAsync(b => b.AllOptionalsComplexType == null);
+ Assert.Null(complexTypeNull.AllOptionalsComplexType);
- var complexTypeNull = await context.Set().SingleAsync(b => b.AllOptionalsComplexType == null);
- Assert.Null(complexTypeNull.AllOptionalsComplexType);
+ complexTypeNull.AllOptionalsComplexType =
+ new ContextShadowDiscriminator.AllOptionalsComplexType { OptionalProperty = "New thing" };
+ await context.SaveChangesAsync();
+ }
- complexTypeNull.AllOptionalsComplexType = new ContextShadowDiscriminator.AllOptionalsComplexType { OptionalProperty = "New thing" };
- await context.SaveChangesAsync();
+ await using (var context = contextFactory.CreateContext())
+ {
+ var entities = await context.Set().ToListAsync();
+ Assert.Equal(3, entities.Count);
+ Assert.All(entities, e => Assert.NotNull(e.AllOptionalsComplexType));
+ }
}
private class ContextShadowDiscriminator(DbContextOptions options) : DbContext(options)
@@ -401,6 +411,256 @@ public class OptionalComplexProperty
#endregion Issue37337
+ #region Issue38119
+
+ [ConditionalFact]
+ public virtual async Task Nullable_complex_type_with_discriminator_null_to_non_null_roundtrip()
+ {
+ var contextFactory = await InitializeAsync(
+ seed: context =>
+ {
+ context.Add(new Context38119.EntityType { Id = Guid.NewGuid() });
+ return context.SaveChangesAsync();
+ });
+
+ await using (var context = contextFactory.CreateContext())
+ {
+ var entity = await context.Set().SingleAsync();
+ Assert.Null(entity.Prop);
+
+ entity.Prop = new Context38119.OptionalComplexProperty { OptionalValue = true };
+ await context.SaveChangesAsync();
+ }
+
+ await using (var context = contextFactory.CreateContext())
+ {
+ var entity = await context.Set().SingleAsync();
+ Assert.NotNull(entity.Prop);
+ Assert.True(entity.Prop.OptionalValue);
+ }
+ }
+
+ [ConditionalFact]
+ public virtual async Task Nullable_complex_type_with_discriminator_non_null_to_null_roundtrip()
+ {
+ var contextFactory = await InitializeAsync(
+ seed: context =>
+ {
+ context.Add(
+ new Context38119.EntityType
+ {
+ Id = Guid.NewGuid(),
+ Prop = new Context38119.OptionalComplexProperty { OptionalValue = true }
+ });
+ return context.SaveChangesAsync();
+ });
+
+ await using (var context = contextFactory.CreateContext())
+ {
+ var entity = await context.Set().SingleAsync();
+ Assert.NotNull(entity.Prop);
+
+ entity.Prop = null;
+ await context.SaveChangesAsync();
+ }
+
+ await using (var context = contextFactory.CreateContext())
+ {
+ var entity = await context.Set().SingleAsync();
+ Assert.Null(entity.Prop);
+ }
+ }
+
+ [ConditionalFact]
+ public virtual async Task Nullable_complex_type_with_discriminator_update_non_null_entity_roundtrip()
+ {
+ var contextFactory = await InitializeAsync(
+ seed: context =>
+ {
+ context.Add(
+ new Context38119.EntityType
+ {
+ Id = Guid.NewGuid(),
+ Prop = new Context38119.OptionalComplexProperty { OptionalValue = true }
+ });
+ return context.SaveChangesAsync();
+ });
+
+ await using (var context = contextFactory.CreateContext())
+ {
+ var entity = await context.Set().SingleAsync();
+ Assert.NotNull(entity.Prop);
+ Assert.True(entity.Prop.OptionalValue);
+
+ context.Update(entity);
+ await context.SaveChangesAsync();
+ }
+
+ await using (var context = contextFactory.CreateContext())
+ {
+ var entity = await context.Set().SingleAsync();
+ Assert.NotNull(entity.Prop);
+ Assert.True(entity.Prop.OptionalValue);
+ }
+ }
+
+ [ConditionalFact]
+ public virtual async Task Nullable_complex_type_with_discriminator_set_to_different_value()
+ {
+ var contextFactory = await InitializeAsync();
+
+ Guid entityId;
+ await using (var context = contextFactory.CreateContext())
+ {
+ var entity = new Context38119.EntityType
+ {
+ Id = Guid.NewGuid(),
+ Prop = new Context38119.OptionalComplexProperty { OptionalValue = true }
+ };
+ context.Add(entity);
+ entityId = entity.Id;
+
+ // Override the discriminator value before saving
+ var discriminatorEntry = context.Entry(entity).ComplexProperty(e => e.Prop).Property("Discriminator");
+ Assert.Equal("OptionalComplexProperty", discriminatorEntry.CurrentValue);
+ discriminatorEntry.CurrentValue = "SomeOtherValue";
+ await context.SaveChangesAsync();
+ }
+
+ await using (var context = contextFactory.CreateContext())
+ {
+ // The discriminator is non-null so the complex property is still materialized
+ var entity = await context.Set().SingleAsync(e => e.Id == entityId);
+ Assert.NotNull(entity.Prop);
+ Assert.True(entity.Prop.OptionalValue);
+ }
+ }
+
+ [ConditionalFact]
+ public virtual async Task Nullable_complex_type_with_discriminator_set_to_null()
+ {
+ var contextFactory = await InitializeAsync();
+
+ Guid entityId;
+ await using (var context = contextFactory.CreateContext())
+ {
+ var entity = new Context38119.EntityType
+ {
+ Id = Guid.NewGuid(),
+ Prop = new Context38119.OptionalComplexProperty { OptionalValue = true }
+ };
+ context.Add(entity);
+ entityId = entity.Id;
+
+ // Set discriminator to null before saving, which should cause the complex property to be null on reload
+ var discriminatorEntry = context.Entry(entity).ComplexProperty(e => e.Prop).Property("Discriminator");
+ Assert.Equal("OptionalComplexProperty", discriminatorEntry.CurrentValue);
+ discriminatorEntry.CurrentValue = null;
+ await context.SaveChangesAsync();
+ }
+
+ await using (var context = contextFactory.CreateContext())
+ {
+ // With null discriminator, the complex property should be materialized as null
+ var entity = await context.Set().SingleAsync(e => e.Id == entityId);
+ Assert.Null(entity.Prop);
+ }
+ }
+
+ [ConditionalFact]
+ public virtual async Task Nested_nullable_complex_type_with_discriminator_null_to_non_null_roundtrip()
+ {
+ var contextFactory = await InitializeAsync(
+ seed: context =>
+ {
+ context.Add(
+ new Context38119Nested.EntityType
+ {
+ Id = Guid.NewGuid(),
+ Outer = new Context38119Nested.OuterComplexProperty { Name = "outer" }
+ });
+ return context.SaveChangesAsync();
+ });
+
+ await using (var context = contextFactory.CreateContext())
+ {
+ var entity = await context.Set().SingleAsync();
+ Assert.NotNull(entity.Outer);
+ Assert.Null(entity.Outer.Inner);
+
+ entity.Outer.Inner = new Context38119Nested.InnerComplexProperty { Value = 42 };
+ await context.SaveChangesAsync();
+ }
+
+ await using (var context = contextFactory.CreateContext())
+ {
+ var entity = await context.Set().SingleAsync();
+ Assert.NotNull(entity.Outer);
+ Assert.NotNull(entity.Outer.Inner);
+ Assert.Equal(42, entity.Outer.Inner.Value);
+ }
+ }
+
+ private class Context38119(DbContextOptions options) : DbContext(options)
+ {
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ var entity = modelBuilder.Entity();
+ entity.HasKey(p => p.Id);
+ entity.Property(p => p.Id).ValueGeneratedNever();
+
+ var compl = entity.ComplexProperty(p => p.Prop);
+ compl.HasDiscriminator();
+ }
+
+ public class EntityType
+ {
+ public Guid Id { get; set; }
+ public OptionalComplexProperty? Prop { get; set; }
+ }
+
+ public class OptionalComplexProperty
+ {
+ public bool? OptionalValue { get; set; }
+ }
+ }
+
+ private class Context38119Nested(DbContextOptions options) : DbContext(options)
+ {
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ var entity = modelBuilder.Entity();
+ entity.HasKey(p => p.Id);
+ entity.Property(p => p.Id).ValueGeneratedNever();
+
+ entity.ComplexProperty(
+ p => p.Outer, outer =>
+ {
+ outer.ComplexProperty(
+ p => p.Inner, inner => inner.HasDiscriminator());
+ });
+ }
+
+ public class EntityType
+ {
+ public Guid Id { get; set; }
+ public OuterComplexProperty Outer { get; set; } = null!;
+ }
+
+ public class OuterComplexProperty
+ {
+ public string? Name { get; set; }
+ public InnerComplexProperty? Inner { get; set; }
+ }
+
+ public class InnerComplexProperty
+ {
+ public int? Value { get; set; }
+ }
+ }
+
+ #endregion Issue38119
+
protected override string StoreName
=> "AdHocComplexTypeQueryTest";
}