Skip to content
Merged
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
89 changes: 73 additions & 16 deletions src/EFCore/ChangeTracking/Internal/ChangeDetector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,24 +46,38 @@ public virtual void PropertyChanged(IInternalEntry entry, IPropertyBase property
return;
}

if (propertyBase is IProperty property)
switch (propertyBase)
{
if (entry.EntityState is not EntityState.Deleted)
{
entry.SetPropertyModified(property, setModified);
}
else
{
ThrowIfKeyChanged(entry, property);
}
case IProperty property:
if (entry.EntityState is not EntityState.Deleted)
{
entry.SetPropertyModified(property, setModified);
}
else
{
ThrowIfKeyChanged(entry, property);
}

DetectKeyChange(entry, property);
}
else if (propertyBase.GetRelationshipIndex() != -1
&& propertyBase is INavigationBase navigation)
{
DetectNavigationChange(
entry as InternalEntityEntry ?? throw new UnreachableException("Complex type entry with a navigation"), navigation);
DetectKeyChange(entry, property);
break;

case 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);
}
break;

case INavigationBase navigation when propertyBase.GetRelationshipIndex() != -1:
DetectNavigationChange(
entry as InternalEntityEntry ?? throw new UnreachableException("Complex type entry with a navigation"), navigation);
break;
}
}

Expand Down Expand Up @@ -292,11 +306,54 @@ private bool LocalDetectChanges(InternalEntryBase entry)
changesFound = true;
}
}
else if (complexProperty.IsNullable && complexProperty.GetOriginalValueIndex() >= 0)
{
if (DetectComplexPropertyChange(entry, complexProperty))
{
changesFound = true;
}
}
}

return changesFound;
}

/// <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>
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 is null) != (originalValue is 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 is not 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;
}

/// <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
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,12 @@ protected override Task TrackAndSaveTest<TEntity>(EntityState state, bool async,
return base.TrackAndSaveTest(state, async, createPub);
}

protected override async Task ExecuteWithStrategyInTransactionAsync(Func<DbContext, Task> testOperation, Func<DbContext, Task>? nestedTestOperation1 = null, Func<DbContext, Task>? 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<DbContext, Task> testOperation, Func<DbContext, Task>? nestedTestOperation1 = null, Func<DbContext, Task>? nestedTestOperation2 = null, Func<DbContext, Task>? nestedTestOperation3 = null)
{
using var c = CreateContext();
await c.Database.CreateExecutionStrategy().ExecuteAsync(
Expand Down Expand Up @@ -141,6 +146,16 @@ await c.Database.CreateExecutionStrategy().ExecuteAsync(
{
await nestedTestOperation2(innerContext2);
}

if (nestedTestOperation3 == null)
{
return;
}

using (var innerContext3 = CreateContext())
{
await nestedTestOperation3(innerContext3);
}
});
}

Expand All @@ -161,6 +176,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder, DbContext con
modelBuilder.Entity<PubWithArrayCollections>().HasPartitionKey(x => x.Id);
modelBuilder.Entity<PubWithRecordArrayCollections>().HasPartitionKey(x => x.Id);
modelBuilder.Entity<PubWithPropertyBagCollections>().HasPartitionKey(x => x.Id);
modelBuilder.Entity<EntityWithOptionalMultiPropComplex>().HasPartitionKey(x => x.Id);
if (!UseProxies)
{
modelBuilder.Entity<FieldPub>().HasPartitionKey(x => x.Id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,24 @@ public class ComplexTypesTrackingInMemoryTest(ComplexTypesTrackingInMemoryTest.I
protected override async Task ExecuteWithStrategyInTransactionAsync(
Func<DbContext, Task> testOperation,
Func<DbContext, Task> nestedTestOperation1 = null,
Func<DbContext, Task> nestedTestOperation2 = null)
Func<DbContext, Task> nestedTestOperation2 = null,
Func<DbContext, Task> nestedTestOperation3 = null)
{
try
{
await base.ExecuteWithStrategyInTransactionAsync(testOperation, nestedTestOperation1, nestedTestOperation2);
await base.ExecuteWithStrategyInTransactionAsync(testOperation, nestedTestOperation1, nestedTestOperation2, nestedTestOperation3);
}
finally
{
await Fixture.ReseedAsync();
}
}

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
Expand Down
78 changes: 76 additions & 2 deletions test/EFCore.Specification.Tests/ComplexTypesTrackingTestBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2053,10 +2053,11 @@ protected static EntityEntry<TEntity> TrackFromQuery<TEntity>(DbContext context,
protected virtual Task ExecuteWithStrategyInTransactionAsync(
Func<DbContext, Task> testOperation,
Func<DbContext, Task>? nestedTestOperation1 = null,
Func<DbContext, Task>? nestedTestOperation2 = null)
Func<DbContext, Task>? nestedTestOperation2 = null,
Func<DbContext, Task>? nestedTestOperation3 = null)
=> TestHelpers.ExecuteWithStrategyInTransactionAsync(
CreateContext, UseTransaction,
testOperation, nestedTestOperation1, nestedTestOperation2);
testOperation, nestedTestOperation1, nestedTestOperation2, nestedTestOperation3);

protected virtual void UseTransaction(DatabaseFacade facade, IDbContextTransaction transaction)
{
Expand Down Expand Up @@ -2397,6 +2398,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder, DbContext con
});
});
});

modelBuilder.Entity<EntityWithOptionalMultiPropComplex>(b =>
{
b.ComplexProperty(e => e.ComplexProp);
});
}
}

Expand Down Expand Up @@ -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<EntityWithOptionalMultiPropComplex>()
: 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<EntityWithOptionalMultiPropComplex>().SingleAsync()
: context.Set<EntityWithOptionalMultiPropComplex>().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<EntityWithOptionalMultiPropComplex>().SingleAsync()
: context.Set<EntityWithOptionalMultiPropComplex>().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; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading