Skip to content

Commit

Permalink
Allow cascade delete timing to be configured
Browse files Browse the repository at this point in the history
Fixes #10114

Allows timing cascade delete (i.e. delete of dependent on principal deletion) and delete orphans (i.e. delete of dependent on severing its relationship to the principal) to be configured as:
* Immediate: Dependents changed to Deleted immediately, or at least at the next DetectChanges
* OnSaveChanges: Dependents are not changed to Deleted until SaveChanges is called
* Never: Cascades don't happen automatically. They can be triggered by a new method: `context.ChangeTracker.CascadeChanges()`

The default for both has been changed to `Immediate` which is a breaking change, but likely a better default because:
* Auditing in overridden SaveChanges will now report anything that will be deleted
* Data binding will reflect the changes immediately

However, deleting orphans can be more difficult. This doesn't appear to be a major problem in EF Core because we don't aggressively graph shred, we don't typically have entities that handle their own fixup, and conceptual nulls still exist to allow transitionary states.

Note that EF6 does not support deleting orphans, which means the behavior here with both set to Immediate is not quite the same as the default behavior of EF6.
  • Loading branch information
ajcvickers committed Jan 20, 2019
1 parent 40230a1 commit e90bd07
Show file tree
Hide file tree
Showing 27 changed files with 5,380 additions and 1,488 deletions.
29 changes: 29 additions & 0 deletions src/EFCore/ChangeTracking/CascadeTiming.cs
@@ -0,0 +1,29 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

namespace Microsoft.EntityFrameworkCore.ChangeTracking
{
/// <summary>
/// Defines different strategies for when cascading actions will be performed.
/// See <see cref="ChangeTracker.CascadeDeleteTiming" /> and <see cref="ChangeTracker.DeleteOrphansTiming" />.
/// </summary>
public enum CascadeTiming
{
/// <summary>
/// Cascading actions are made to dependent/child entities as soon as the principal/parent
/// entity changes.
/// </summary>
Immediate,

/// <summary>
/// Cascading actions are made to dependent/child entities as part of <see cref="DbContext.SaveChanges()" />.
/// </summary>
OnSaveChanges,

/// <summary>
/// Cascading actions are never made automatically to dependent/child entities, but must instead
/// be triggered by an explicit call.
/// </summary>
Never
}
}
65 changes: 59 additions & 6 deletions src/EFCore/ChangeTracking/ChangeTracker.cs
Expand Up @@ -109,6 +109,35 @@ public virtual QueryTrackingBehavior QueryTrackingBehavior
set => _queryTrackingBehavior = value;
}

/// <summary>
/// <para>
/// Gets or sets a value indicating when a dependent/child entity will have its state
/// set to <see cref="EntityState.Deleted" /> once severed from a parent/principal entity
/// through either a navigation or foreign key property being set to null. The default
/// value is <see cref="CascadeTiming.Immediate" />.
/// </para>
/// <para>
/// Dependent/child entities are only deleted automatically when the relationship
/// is configured with <see cref="DeleteBehavior.Cascade" />. This is set by default
/// for required relationships.
/// </para>
/// </summary>
public virtual CascadeTiming DeleteOrphansTiming { get; set; } = CascadeTiming.Immediate;

/// <summary>
/// <para>
/// Gets or sets a value indicating when a dependent/child entity will have its state
/// set to <see cref="EntityState.Deleted" /> once its parent/principal entity has been marked
/// as <see cref="EntityState.Deleted" />. The default value is<see cref="CascadeTiming.Immediate" />.
/// </para>
/// <para>
/// Dependent/child entities are only deleted automatically when the relationship
/// is configured with <see cref="DeleteBehavior.Cascade" />. This is set by default
/// for required relationships.
/// </para>
/// </summary>
public virtual CascadeTiming CascadeDeleteTiming { get; set; } = CascadeTiming.Immediate;

/// <summary>
/// Gets an <see cref="EntityEntry" /> for each entity being tracked by the context.
/// The entries provide access to change tracking information and operations for each entity.
Expand Down Expand Up @@ -313,22 +342,46 @@ public virtual void DetectChanges()
remove => StateManager.StateChanged -= value;
}

#region Hidden System.Object members

/// <summary>
/// Returns a string that represents the current object.
/// <para>
/// Forces immediate cascading deletion of child/dependent entities when they are either
/// severed from a required parent/principal entity, or the required parent/principal entity
/// is itself deleted. See <see cref="DeleteBehavior" />.
/// </para>
/// <para>
/// This method is usually used when <see cref="CascadeDeleteTiming" /> and/or
/// <see cref="DeleteOrphansTiming" /> have been set to <see cref="CascadeTiming.Never" />
/// to manually force the deletes to have at a time controlled by the application.
/// </para>
/// </summary>
/// <returns> A string that represents the current object. </returns>
[EditorBrowsable(EditorBrowsableState.Never)]
public override string ToString() => base.ToString();
public virtual void CascadeChanges()
{
if (AutoDetectChangesEnabled)
{
DetectChanges();
}

StateManager.CascadeChanges(force: true);
}

void IResettableService.ResetState()
{
_queryTrackingBehavior = _defaultQueryTrackingBehavior;
AutoDetectChangesEnabled = true;
LazyLoadingEnabled = true;
CascadeDeleteTiming = CascadeTiming.Immediate;
DeleteOrphansTiming = CascadeTiming.Immediate;
}

#region Hidden System.Object members

/// <summary>
/// Returns a string that represents the current object.
/// </summary>
/// <returns> A string that represents the current object. </returns>
[EditorBrowsable(EditorBrowsableState.Never)]
public override string ToString() => base.ToString();

/// <summary>
/// Determines whether the specified object is equal to the current object.
/// </summary>
Expand Down
12 changes: 12 additions & 0 deletions src/EFCore/ChangeTracking/Internal/IStateManager.cs
Expand Up @@ -267,6 +267,18 @@ public interface IStateManager : IResettableService
/// </summary>
bool SensitiveLoggingEnabled { get; }

/// <summary>
/// This API supports the Entity Framework Core infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
void CascadeChanges(bool force);

/// <summary>
/// This API supports the Entity Framework Core infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
void CascadeDelete([NotNull] InternalEntityEntry entry, bool force);

/// <summary>
/// This API supports the Entity Framework Core infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
Expand Down
42 changes: 32 additions & 10 deletions src/EFCore/ChangeTracking/Internal/InternalEntityEntry.cs
Expand Up @@ -273,6 +273,12 @@ private void SetEntityState(EntityState oldState, EntityState newState, bool acc
}

FireStateChanged(oldState);

if (newState == EntityState.Deleted
&& StateManager.Context.ChangeTracker.CascadeDeleteTiming == CascadeTiming.Immediate)
{
StateManager.CascadeDelete(this, force: false);
}
}

private void FireStateChanged(EntityState oldState)
Expand Down Expand Up @@ -519,7 +525,7 @@ public virtual void SetTemporaryValue([NotNull] IProperty property, object value
CoreStrings.TempValue(property.Name, EntityType.DisplayName()));
}

SetProperty(property, value, setModified, CurrentValueType.Temporary);
SetProperty(property, value, setModified, isCascadeDelete: false, CurrentValueType.Temporary);
}

/// <summary>
Expand All @@ -534,7 +540,7 @@ public virtual void SetStoreGeneratedValue(IProperty property, object value)
CoreStrings.StoreGenValue(property.Name, EntityType.DisplayName()));
}

SetProperty(property, value, setModified: true, CurrentValueType.StoreGenerated);
SetProperty(property, value, setModified: true, isCascadeDelete: false, CurrentValueType.StoreGenerated);
}

/// <summary>
Expand Down Expand Up @@ -909,13 +915,15 @@ public virtual void AddToCollectionSnapshot([NotNull] IPropertyBase propertyBase
public virtual void SetProperty(
[NotNull] IPropertyBase propertyBase,
[CanBeNull] object value,
bool setModified = true)
=> SetProperty(propertyBase, value, setModified, CurrentValueType.Normal);
bool setModified = true,
bool isCascadeDelete = false)
=> SetProperty(propertyBase, value, setModified, isCascadeDelete, CurrentValueType.Normal);

private void SetProperty(
[NotNull] IPropertyBase propertyBase,
[CanBeNull] object value,
bool setModified,
bool isCascadeDelete,
CurrentValueType valueType)
{
var currentValue = this[propertyBase];
Expand Down Expand Up @@ -968,6 +976,15 @@ public virtual void AddToCollectionSnapshot([NotNull] IPropertyBase propertyBase
asProperty, changeState: true, isModified: true,
isConceptualNull: true);
}

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

writeValue = false;
Expand Down Expand Up @@ -1134,7 +1151,7 @@ public virtual InternalEntityEntry PrepareToSave()
/// This API supports the Entity Framework Core infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public virtual void HandleConceptualNulls(bool sensitiveLoggingEnabled)
public virtual void HandleConceptualNulls(bool sensitiveLoggingEnabled, bool force, bool isCascadeDelete)
{
var fks = new List<IForeignKey>();
foreach (var foreignKey in EntityType.GetForeignKeys())
Expand Down Expand Up @@ -1172,7 +1189,10 @@ public virtual void HandleConceptualNulls(bool sensitiveLoggingEnabled)
}

var cascadeFk = fks.FirstOrDefault(fk => fk.DeleteBehavior == DeleteBehavior.Cascade);
if (cascadeFk != null)
if (cascadeFk != null
&& (force
|| (!isCascadeDelete
&& StateManager.Context.ChangeTracker.DeleteOrphansTiming != CascadeTiming.Never)))
{
var cascadeState = EntityState == EntityState.Added
? EntityState.Detached
Expand All @@ -1193,18 +1213,20 @@ public virtual void HandleConceptualNulls(bool sensitiveLoggingEnabled)
}
else if (fks.Count > 0)
{
var foreignKey = fks.First();

if (sensitiveLoggingEnabled)
{
throw new InvalidOperationException(
CoreStrings.RelationshipConceptualNullSensitive(
fks.First().PrincipalEntityType.DisplayName(),
foreignKey.PrincipalEntityType.DisplayName(),
EntityType.DisplayName(),
this.BuildOriginalValuesString(EntityType.FindPrimaryKey().Properties)));
this.BuildOriginalValuesString(foreignKey.Properties)));
}

throw new InvalidOperationException(
CoreStrings.RelationshipConceptualNull(
fks.First().PrincipalEntityType.DisplayName(),
foreignKey.PrincipalEntityType.DisplayName(),
EntityType.DisplayName()));
}
else
Expand All @@ -1222,7 +1244,7 @@ public virtual void HandleConceptualNulls(bool sensitiveLoggingEnabled)
CoreStrings.PropertyConceptualNullSensitive(
property.Name,
EntityType.DisplayName(),
this.BuildOriginalValuesString(EntityType.FindPrimaryKey().Properties)));
this.BuildOriginalValuesString(new[] { property })));
}

throw new InvalidOperationException(
Expand Down
47 changes: 31 additions & 16 deletions src/EFCore/ChangeTracking/Internal/StateManager.cs
Expand Up @@ -811,31 +811,46 @@ public virtual IReadOnlyList<IUpdateEntry> GetEntriesToSave()
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public virtual IReadOnlyList<InternalEntityEntry> GetInternalEntriesToSave()
{
CascadeChanges(force: false);

return Entries
.Where(
e => e.EntityState == EntityState.Added
|| e.EntityState == EntityState.Modified
|| e.EntityState == EntityState.Deleted)
.Select(e => e.PrepareToSave())
.ToList();
}

/// <summary>
/// This API supports the Entity Framework Core infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public virtual void CascadeChanges(bool force)
{
foreach (var entry in Entries.Where(
e => (e.EntityState == EntityState.Modified
|| e.EntityState == EntityState.Added)
&& e.HasConceptualNull).ToList())
{
entry.HandleConceptualNulls(SensitiveLoggingEnabled);
entry.HandleConceptualNulls(SensitiveLoggingEnabled, force, isCascadeDelete: false);
}

foreach (var entry in Entries.Where(e => e.EntityState == EntityState.Deleted).ToList())
{
CascadeDelete(entry);
CascadeDelete(entry, force);
}

return Entries
.Where(
e => e.EntityState == EntityState.Added
|| e.EntityState == EntityState.Modified
|| e.EntityState == EntityState.Deleted)
.Select(e => e.PrepareToSave())
.ToList();
}

private void CascadeDelete(InternalEntityEntry entry)
/// <summary>
/// This API supports the Entity Framework Core infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public virtual void CascadeDelete(InternalEntityEntry entry, bool force)
{
var doCascadeDelete = force || Context.ChangeTracker.CascadeDeleteTiming != CascadeTiming.Never;

foreach (var fk in entry.EntityType.GetReferencingForeignKeys())
{
foreach (var dependent in (GetDependentsFromNavigation(entry, fk)
Expand All @@ -847,7 +862,8 @@ private void CascadeDelete(InternalEntityEntry entry)
&& (dependent.EntityState == EntityState.Added
|| KeysEqual(entry, fk, dependent)))
{
if (fk.DeleteBehavior == DeleteBehavior.Cascade)
if (fk.DeleteBehavior == DeleteBehavior.Cascade
&& doCascadeDelete)
{
var cascadeState = dependent.EntityState == EntityState.Added
? EntityState.Detached
Expand All @@ -864,18 +880,18 @@ private void CascadeDelete(InternalEntityEntry entry)

dependent.SetEntityState(cascadeState);

CascadeDelete(dependent);
CascadeDelete(dependent, force);
}
else
{
foreach (var dependentProperty in fk.Properties)
{
dependent[dependentProperty] = null;
dependent.SetProperty(dependentProperty, null, setModified: true, isCascadeDelete: true);
}

if (dependent.HasConceptualNull)
{
dependent.HandleConceptualNulls(SensitiveLoggingEnabled);
dependent.HandleConceptualNulls(SensitiveLoggingEnabled, force, isCascadeDelete: true);
}
}
}
Expand All @@ -895,7 +911,6 @@ private static bool KeysEqual(InternalEntityEntry entry, IForeignKey fk, Interna
entry[principalProperty],
dependent[dependentProperty]))
{
//dependent[dependentProperty] = null;
return false;
}
}
Expand Down
8 changes: 7 additions & 1 deletion src/EFCore/DbContext.cs
Expand Up @@ -575,7 +575,9 @@ DbContextPoolConfigurationSnapshot IDbContextPoolable.SnapshotConfiguration()
_changeTracker?.AutoDetectChangesEnabled,
_changeTracker?.QueryTrackingBehavior,
_database?.AutoTransactionsEnabled,
_changeTracker?.LazyLoadingEnabled);
_changeTracker?.LazyLoadingEnabled,
_changeTracker?.CascadeDeleteTiming,
_changeTracker?.DeleteOrphansTiming);

void IDbContextPoolable.Resurrect(DbContextPoolConfigurationSnapshot configurationSnapshot)
{
Expand All @@ -585,10 +587,14 @@ void IDbContextPoolable.Resurrect(DbContextPoolConfigurationSnapshot configurati
{
Debug.Assert(configurationSnapshot.QueryTrackingBehavior.HasValue);
Debug.Assert(configurationSnapshot.LazyLoadingEnabled.HasValue);
Debug.Assert(configurationSnapshot.CascadeDeleteTiming.HasValue);
Debug.Assert(configurationSnapshot.DeleteOrphansTiming.HasValue);

ChangeTracker.AutoDetectChangesEnabled = configurationSnapshot.AutoDetectChangesEnabled.Value;
ChangeTracker.QueryTrackingBehavior = configurationSnapshot.QueryTrackingBehavior.Value;
ChangeTracker.LazyLoadingEnabled = configurationSnapshot.LazyLoadingEnabled.Value;
ChangeTracker.CascadeDeleteTiming = configurationSnapshot.CascadeDeleteTiming.Value;
ChangeTracker.DeleteOrphansTiming = configurationSnapshot.DeleteOrphansTiming.Value;
}
else
{
Expand Down
3 changes: 1 addition & 2 deletions src/EFCore/DeleteBehavior.cs
Expand Up @@ -50,8 +50,7 @@ public enum DeleteBehavior
/// For entities being tracked by the <see cref="DbContext" />, the values of foreign key properties in
/// dependent entities are not changed. This can result in an inconsistent graph of entities
/// where the values of foreign key properties do not match the relationships in the
/// graph. If a property remains in this state when <see cref="DbContext.SaveChanges()" />
/// is called, then an exception will be thrown.
/// graph.
/// </para>
/// <para>
/// If the database has been created from the model using Entity Framework Migrations or the
Expand Down

0 comments on commit e90bd07

Please sign in to comment.