Skip to content

Commit

Permalink
Delete optional orphans when foreign key value is set to null (#25957)
Browse files Browse the repository at this point in the history
Fixes #25360

This is a case where an optional relationship is configured with delete orphans behavior. Since the relationship is optional it means that the FK value can be explicitly set to null. (This is not possible for required relationships, which is the usual case for delete orphans.) In this case the navigation fixer was not triggering behavior in the state manager to process deleting orphans.
  • Loading branch information
ajcvickers authored Sep 10, 2021
1 parent fc57581 commit 50daaa1
Show file tree
Hide file tree
Showing 3 changed files with 109 additions and 51 deletions.
57 changes: 35 additions & 22 deletions src/EFCore/ChangeTracking/Internal/InternalEntityEntry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1257,28 +1257,7 @@ private void SetProperty(
{
if (value == null)
{
if (EntityState != EntityState.Deleted
&& EntityState != EntityState.Detached)
{
_stateData.FlagProperty(propertyIndex, PropertyFlag.Null, isFlagged: true);

if (setModified)
{
SetPropertyModified(
asProperty, changeState: true, isModified: true,
isConceptualNull: true);
}

if (!isCascadeDelete
&& StateManager.DeleteOrphansTiming == CascadeTiming.Immediate)
{
HandleConceptualNulls(
StateManager.SensitiveLoggingEnabled,
force: false,
isCascadeDelete: false);
}
}

HandleNullForeignKey(asProperty, setModified, isCascadeDelete);
writeValue = false;
}
else
Expand Down Expand Up @@ -1377,6 +1356,40 @@ private void SetProperty(
}
}

/// <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 void HandleNullForeignKey(
IProperty property,
bool setModified = false,
bool isCascadeDelete = false)
{
if (EntityState != EntityState.Deleted
&& EntityState != EntityState.Detached)
{
_stateData.FlagProperty(property.GetIndex(), PropertyFlag.Null, isFlagged: true);

if (setModified)
{
SetPropertyModified(
property, changeState: true, isModified: true,
isConceptualNull: true);
}

if (!isCascadeDelete
&& StateManager.DeleteOrphansTiming == CascadeTiming.Immediate)
{
HandleConceptualNulls(
StateManager.SensitiveLoggingEnabled,
force: false,
isCascadeDelete: false);
}
}
}

private static Func<object?, object?, bool> ValuesEqualFunc(IProperty property)
{
var comparer = property.GetValueComparer();
Expand Down
7 changes: 7 additions & 0 deletions src/EFCore/ChangeTracking/Internal/NavigationFixer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,13 @@ var targetDependentEntry
}
}

if (newValue == null
&& (foreignKey.DeleteBehavior == DeleteBehavior.Cascade
|| foreignKey.DeleteBehavior == DeleteBehavior.ClientCascade))
{
entry.HandleNullForeignKey(property);
}

stateManager.UpdateDependentMap(entry, foreignKey);
}

Expand Down
96 changes: 67 additions & 29 deletions test/EFCore.Tests/ChangeTracking/ChangeTrackerTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1923,15 +1923,34 @@ public void Dependent_FKs_are_not_nulled_when_principal_is_detached(bool delayCa
}
}

[ConditionalTheory] // Issue #16546
[InlineData(false)]
[InlineData(true)]
public void Optional_relationship_with_cascade_still_cascades(bool delayCascade)
[ConditionalTheory] // Issues #16546 #25360
[InlineData(false, false, false, true, false)]
[InlineData(true, false, false, true, false)]
[InlineData(false, true, false, true, false)]
[InlineData(true, true, false, true, false)]
[InlineData(false, false, true, true, false)]
[InlineData(true, false, true, true, false)]
[InlineData(false, true, false, false, true)]
[InlineData(true, true, false, false, true)]
[InlineData(false, false, true, false, true)]
[InlineData(true, false, true, false, true)]
[InlineData(false, true, false, true, true)]
[InlineData(true, true, false, true, true)]
[InlineData(false, false, true, true, true)]
[InlineData(true, false, true, true, true)]
public void Optional_relationship_with_cascade_still_cascades(
bool delayCascade,
bool setProperty,
bool setCurrentValue,
bool useForeignKey,
bool useNavigation)
{
Kontainer detachedContainer;
var databaseName = "K" + delayCascade;
using (var context = new KontainerContext(databaseName))
using (var context = new KontainerContext())
{
context.Database.EnsureDeleted();
context.Database.EnsureCreated();

context.Add(
new Kontainer
{
Expand All @@ -1949,51 +1968,77 @@ public void Optional_relationship_with_cascade_still_cascades(bool delayCascade)
.Single();
}

using (var context = new KontainerContext(databaseName))
using (var context = new KontainerContext())
{
var attachedContainer = context.Set<Kontainer>()
.Include(container => container.Rooms)
.ThenInclude(room => room.Troduct)
.Single();

var attachedRoom = attachedContainer.Rooms.Single();
var attachedTroduct = attachedRoom.Troduct;

Assert.Equal(3, context.ChangeTracker.Entries().Count());
Assert.Equal(EntityState.Unchanged, context.Entry(attachedContainer).State);
Assert.Equal(EntityState.Unchanged, context.Entry(attachedContainer.Rooms.Single()).State);
Assert.Equal(EntityState.Unchanged, context.Entry(attachedContainer.Rooms.Single().Troduct).State);

var detachedRoom = detachedContainer.Rooms.Single();
detachedRoom.Troduct = null;
detachedRoom.TroductId = null;

var attachedRoom = attachedContainer.Rooms.Single();
Assert.Equal(EntityState.Unchanged, context.Entry(attachedRoom).State);
Assert.Equal(EntityState.Unchanged, context.Entry(attachedTroduct).State);

if (delayCascade)
{
context.ChangeTracker.DeleteOrphansTiming = CascadeTiming.OnSaveChanges;
}

context.Entry(attachedRoom).CurrentValues.SetValues(detachedRoom);
if (setProperty)
{
if (useForeignKey)
{
attachedRoom.TroductId = null;
}

if (useNavigation)
{
attachedRoom.Troduct = null;
}
}
else if (setCurrentValue)
{
if (useForeignKey)
{
context.Entry(attachedRoom).Property(e => e.TroductId).CurrentValue = null;
}

if (useNavigation)
{
context.Entry(attachedRoom).Reference(e => e.Troduct).CurrentValue = null;
}
}
else
{
var detachedRoom = detachedContainer.Rooms.Single();
detachedRoom.TroductId = null;
context.Entry(attachedRoom).CurrentValues.SetValues(detachedRoom);
}

Assert.Equal(3, context.ChangeTracker.Entries().Count());
Assert.Equal(EntityState.Unchanged, context.Entry(attachedContainer).State);
Assert.Equal(EntityState.Unchanged, context.Entry(attachedContainer.Rooms.Single().Troduct).State);
Assert.Equal(EntityState.Unchanged, context.Entry(attachedTroduct).State);

if (delayCascade)
{
Assert.Equal(EntityState.Modified, context.Entry(attachedContainer.Rooms.Single()).State);
Assert.Equal(EntityState.Modified, context.Entry(attachedRoom).State);
}
else
{
// Deleted because FK with cascade has been set to null
Assert.Equal(EntityState.Deleted, context.Entry(attachedContainer.Rooms.Single()).State);
Assert.Equal(EntityState.Deleted, context.Entry(attachedRoom).State);
}

context.ChangeTracker.CascadeChanges();

Assert.Equal(3, context.ChangeTracker.Entries().Count());
Assert.Equal(EntityState.Unchanged, context.Entry(attachedContainer).State);
Assert.Equal(EntityState.Unchanged, context.Entry(attachedContainer.Rooms.Single().Troduct).State);
Assert.Equal(EntityState.Deleted, context.Entry(attachedContainer.Rooms.Single()).State);
Assert.Equal(EntityState.Unchanged, context.Entry(attachedTroduct).State);
Assert.Equal(EntityState.Deleted, context.Entry(attachedRoom).State);

context.SaveChanges();
}
Expand Down Expand Up @@ -2025,13 +2070,6 @@ private class Troduct

private class KontainerContext : DbContext
{
private readonly string _databaseName;

public KontainerContext(string databaseName)
{
_databaseName = databaseName;
}

protected internal override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<KontainerRoom>()
Expand All @@ -2045,7 +2083,7 @@ protected internal override void OnModelCreating(ModelBuilder modelBuilder)
protected internal override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder
.UseInternalServiceProvider(InMemoryFixture.DefaultServiceProvider)
.UseInMemoryDatabase(_databaseName);
.UseInMemoryDatabase(nameof(KontainerContext));
}

[ConditionalTheory]
Expand Down

0 comments on commit 50daaa1

Please sign in to comment.