Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/EFCore/ChangeTracking/Internal/ChangeDetector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions src/EFCore/Metadata/Internal/InternalComplexTypeBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// <summary>
/// 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.
/// </summary>
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;
}

/// <summary>
/// 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
Expand Down
270 changes: 265 additions & 5 deletions test/EFCore.Specification.Tests/Query/AdHocComplexTypeQueryTestBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ContextShadowDiscriminator.EntityType>()
.SingleAsync(b => b.AllOptionalsComplexType == null);
Assert.Null(complexTypeNull.AllOptionalsComplexType);

var complexTypeNull = await context.Set<ContextShadowDiscriminator.EntityType>().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<ContextShadowDiscriminator.EntityType>().ToListAsync();
Assert.Equal(3, entities.Count);
Assert.All(entities, e => Assert.NotNull(e.AllOptionalsComplexType));
}
}

private class ContextShadowDiscriminator(DbContextOptions options) : DbContext(options)
Expand Down Expand Up @@ -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<Context38119>(
seed: context =>
{
context.Add(new Context38119.EntityType { Id = Guid.NewGuid() });
return context.SaveChangesAsync();
});

await using (var context = contextFactory.CreateContext())
{
var entity = await context.Set<Context38119.EntityType>().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<Context38119.EntityType>().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<Context38119>(
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<Context38119.EntityType>().SingleAsync();
Assert.NotNull(entity.Prop);

entity.Prop = null;
await context.SaveChangesAsync();
}

await using (var context = contextFactory.CreateContext())
{
var entity = await context.Set<Context38119.EntityType>().SingleAsync();
Assert.Null(entity.Prop);
}
}

Comment thread
AndriySvyryd marked this conversation as resolved.
[ConditionalFact]
public virtual async Task Nullable_complex_type_with_discriminator_update_non_null_entity_roundtrip()
{
var contextFactory = await InitializeAsync<Context38119>(
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<Context38119.EntityType>().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<Context38119.EntityType>().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<Context38119>();

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<Context38119.EntityType>().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<Context38119>();

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<Context38119.EntityType>().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<Context38119Nested>(
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<Context38119Nested.EntityType>().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<Context38119Nested.EntityType>().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<EntityType>();
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<EntityType>();
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";
}
Loading