Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Deletion can cause error "The association between entity types A and B has been severed..." #33365

Closed
edwiles opened this issue Mar 20, 2024 · 5 comments
Labels
area-change-tracking closed-no-further-action The issue is closed and no further action is planned. customer-reported

Comments

@edwiles
Copy link

edwiles commented Mar 20, 2024

When multiple cascade paths have been avoided by setting one path to DeleteBehavior.Restrict, deletion can fail with the error:

Unhandled exception. System.InvalidOperationException: The association between entity types 'A' and 'B' has been severed, but the relationship is either marked as required or is implicitly required because the foreign key is not nullable. If the dependent/child entity should be deleted when a required relationship is severed, configure the relationship to use cascade deletes. Consider using 'DbContextOptionsBuilder.EnableSensitiveDataLogging' to see the key values.

The code below runs successfully when DeleteBehavior is Cascade, but fails when Restrict, even though the deleted entity should not be affected by this setting.

This is related to the fixed issue #30122, but is not the same.

Steps to reproduce

  1. Create a C# console application, with the following .csproj file.
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>disable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.3" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.3" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.3" />
  </ItemGroup>

</Project>
  1. Create the following code in a file Model.cs:
using Microsoft.EntityFrameworkCore;

namespace ConsoleApp.SQLite
{
    public class MyContext : DbContext
    {
        public DbSet<Root> Roots { get; set; }
        public DbSet<Inputs> Inputs { get; set; }
        public DbSet<Outputs> Outputs { get; set; }
        public DbSet<ThingOutputs> ThingOutputs { get; set; }
        public DbSet<Thing> Thing { get; set; }

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            optionsBuilder.UseSqlite("Data Source=mydb");
        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Root>().HasData(new Root { Id = 1 });
            modelBuilder.Entity<Inputs>().HasData(new Inputs { Id = 1, RootId = 1 });
            modelBuilder.Entity<Thing>().HasData(new Thing { Id = 1, InputsId = 1 });
            modelBuilder.Entity<Outputs>().HasData(new Outputs { Id = 1, RootId = 1 });
            modelBuilder.Entity<ThingOutputs>().HasData(new ThingOutputs { Id = 1, OutputsId = 1, ThingId = 1 });

            // Avoid multiple cascade paths (our "real" code uses SQL Server)
            // ON DELETE CASCADE path 1: Root -> Inputs -> Thing
            // ON DELETE CASCADE path 2: Root -> Outputs -> ThingOutputs -> Thing
            // Broken by removing Outputs -> ThingOutputs
            SetForeignKeyDeleteBehaviour<Outputs, ThingOutputs>(modelBuilder, DeleteBehavior.Restrict);
        }

        private static void SetForeignKeyDeleteBehaviour<TParent, TChild>(ModelBuilder modelBuilder, DeleteBehavior deleteBehavior)
        {
            modelBuilder.Model.GetEntityTypes().First(t => t.ClrType == typeof(TChild))
                .GetForeignKeys().First(k => k.PrincipalEntityType.ClrType == typeof(TParent))
                .DeleteBehavior = deleteBehavior;
        }
    }

    public class Root
    {
        public int Id { get; set; }
        public Inputs Inputs { get; set; }
        public Outputs Outputs { get; set; }
    }

    public class Inputs
    {
        public int Id { get; set; }
        public int RootId { get; set; }
        public Root Root { get; set; }
        public IList<Thing> Things { get; set; }
    }

    public class Thing
    {
        public int Id { get; set; }
        public int InputsId { get; set; }
        public Inputs Inputs { get; set; }
    }

    public class Outputs
    {
        public int Id { get; set; }
        public int RootId { get; set; }
        public Root Root { get; set; }
        public IList<ThingOutputs> ThingOutputs { get; set; }
    }

    public class ThingOutputs
    {
        public int Id { get; set; }
        public int OutputsId { get; set; }
        public Outputs Outputs { get; set; }
        public int ThingId { get; set; }
        public Thing Thing { get; set; }
    }
}
  1. Create the following code in a file Program.cs:
using Microsoft.EntityFrameworkCore;

namespace ConsoleApp.SQLite
{
    public class Program
    {
        public static void Main()
        {
            using var db = new MyContext();

            // Drop and create db
            db.Database.EnsureDeleted();
            db.Database.EnsureCreated();

            // Get data
            var root = db.Roots
                .Include(x => x.Inputs)
                    .ThenInclude(x => x.Things)
                .Include(x => x.Outputs)
                    .ThenInclude(x => x.ThingOutputs)
                .SingleOrDefault(x => x.Id == 1);

            // Delete all rows from ThingOutputs, displaying row counts before and after
            Console.WriteLine("=== BEFORE ===");
            DumpCounts(root);

            root.Outputs.ThingOutputs.RemoveAt(0);

            Console.WriteLine("=== AFTER ===");
            DumpCounts(root);

            // Save
            db.SaveChanges();
        }

        public static void DumpCounts(Root root)
        {
            // Dump counts of Root and its children
            Console.WriteLine(root.Inputs == null ? "Inputs is null" : ("Inputs is not null, with Things: " + root.Inputs.Things.Count));
            Console.WriteLine(root.Outputs == null ? "Outputs is null" : ("Outputs is not null, with ThingOutputs: " + root.Outputs.ThingOutputs.Count));
        }
    }
}
  1. Run the program:
dotnet run

The program shows that the ThingOutputs entity has been deleted, but then fails with an exception claiming that the association between Outputs and ThingOutputs has been severed.

I believe this error should not appear, because we deleted the child not the parent (?).

=== BEFORE ===
Inputs is not null, with Things: 1
Outputs is not null, with ThingOutputs: 1
=== AFTER ===
Inputs is not null, with Things: 1
Outputs is not null, with ThingOutputs: 0
Unhandled exception. System.InvalidOperationException: The association between entity types 'Outputs' and 'ThingOutputs' has been severed, but the relationship is either marked as required or is implicitly required because the foreign key is not nullable. If the dependent/child entity should be deleted when a required relationship is severed, configure the relationship to use cascade deletes. Consider using 'DbContextOptionsBuilder.EnableSensitiveDataLogging' to see the key values.
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.InternalEntityEntry.HandleConceptualNulls(Boolean sensitiveLoggingEnabled, Boolean force, Boolean isCascadeDelete)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.InternalEntityEntry.HandleNullForeignKey(IProperty property, Boolean setModified, Boolean isCascadeDelete)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.InternalEntityEntry.SetProperty(IPropertyBase propertyBase, Object value, Boolean isMaterialization, Boolean setModified, Boolean isCascadeDelete, CurrentValueType valueType)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.InternalEntityEntry.SetProperty(IPropertyBase propertyBase, Object value, Boolean isMaterialization, Boolean setModified, Boolean isCascadeDelete)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.InternalEntityEntry.set_Item(IPropertyBase propertyBase, Object value)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.NavigationFixer.ConditionallyNullForeignKeyProperties(InternalEntityEntry dependentEntry, InternalEntityEntry principalEntry, IForeignKey foreignKey)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.NavigationFixer.NavigationCollectionChanged(InternalEntityEntry entry, INavigationBase navigationBase, IEnumerable`1 added, IEnumerable`1 removed)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.InternalEntityEntryNotifier.NavigationCollectionChanged(InternalEntityEntry entry, INavigationBase navigationBase, IEnumerable`1 added, IEnumerable`1 removed)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.ChangeDetector.DetectNavigationChange(InternalEntityEntry entry, INavigationBase navigationBase)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.ChangeDetector.LocalDetectChanges(InternalEntityEntry entry)
   at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.ChangeDetector.DetectChanges(IStateManager stateManager)
   at Microsoft.EntityFrameworkCore.ChangeTracking.ChangeTracker.DetectChanges()
   at Microsoft.EntityFrameworkCore.DbContext.TryDetectChanges()
   at Microsoft.EntityFrameworkCore.DbContext.SaveChanges(Boolean acceptAllChangesOnSuccess)
   at Microsoft.EntityFrameworkCore.DbContext.SaveChanges()
   at ConsoleApp.SQLite.Program.Main() in C:\Users\wilee0\source\repos\EFCoreIssue30122\EFCoreIssue30122\Program.cs:line 33
  1. Comment out Model.cs line 30 (i.e. the call to SetForeignKeyDeleteBehaviour), so that the delete behaviour remains Cascade.

  2. Run the program again:

dotnet run

The same counts are shown, i.e. no cascade deletion was required, and there is no error.

=== BEFORE ===
Inputs is not null, with Things: 1
Outputs is not null, with ThingOutputs: 1
=== AFTER ===
Inputs is not null, with Things: 1
Outputs is not null, with ThingOutputs: 0

Further technical details

EF Core version: 8.0.3
.NET SDK: 8.0.200
Database Provider: Microsoft.EntityFrameworkCore.Sqlite (though in our "real" code we use SqlServer)
Operating system: Windows 11 Enterprise
IDE: Visual Studio 17.9.2

@ajcvickers
Copy link
Member

@edwiles This line, root.Outputs.ThingOutputs.Clear(); does not mark any ThingOutput instances as Deleted. Instead it just severs the relationship between the two types, setting the FK to null. But this is not valid, since the FK cannot be set to null.

In the cascade case, severing the relationship causes the orphans to be marked as Deleted.

@edwiles
Copy link
Author

edwiles commented Mar 22, 2024

@ajcvickers Thanks. I see that replacing that line with root.Outputs.ThingOutputs.RemoveAt(0) fixes the code here.

But our "real" code uses AutoMapper (with the Collections extension) to map, for example, a list of 0 ThingOutputs elements on to a list of 1 element. The deletion there results in this error, and I believe it is using Remove (see this file line 40).

. I'll try to change this toy example to more accurately reflect the real code - please can we keep the issue open for the time being - thanks.

@edwiles
Copy link
Author

edwiles commented Mar 22, 2024

@ajcvickers Actually, I spoke too soon - replacing that line with root.Outputs.ThingOutputs.RemoveAt(0) does result in the same error. This simulates what AutoMapper is doing. I've made the change to the code above.

I can understand why that line in itself doesn't mark the deleted entity as Deleted, but are we saying DetectChanges() doesn't do that either? Does deleting a child try to set the deleted child's foreign key to null - if so why would that be? In the end we only need one DELETE statement to be generated by SaveChanges(). Thanks for your help!

@ajcvickers
Copy link
Member

@edwiles The Removing from a collection navigation docs might be useful here.

@edwiles
Copy link
Author

edwiles commented Mar 26, 2024

@ajcvickers thanks a lot for this. As a workaround, I'll make the FK optional. Case closed!

@edwiles edwiles closed this as not planned Won't fix, can't repro, duplicate, stale Mar 26, 2024
@ajcvickers ajcvickers added the closed-no-further-action The issue is closed and no further action is planned. label Mar 26, 2024
@ajcvickers ajcvickers removed their assignment Mar 26, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-change-tracking closed-no-further-action The issue is closed and no further action is planned. customer-reported
Projects
None yet
Development

No branches or pull requests

3 participants