efcore: Setting a Nullable Foreign Key property to Null triggers cascade delete

Using Entity Framework Core 6.0.1

The following code should create a Module model with the HelpCard Foreign Key set, then set the Help Card Foreign Key to Null and save the model to the database, but instead the Cascade Delete is being triggered and the record is deleted from the database. This worked fine in Entity Framework Core 5;

Removing .OnDelete(DeleteBehavior.Cascade) from either of the Configurations does not affect this behavior; removing it from both of the Configurations makes it behave as expected (but I need the Cascade).

Edit: Nullable Reference Types are set to Disable for the project, according to documentation, this should maintain the existing behavior.

// create a model with the ForeignKey set
var m = new Models.Module() {
        Name = "Test",
        HelpCardId = DatabaseContext.HelpCard.Select(r => r.Id).First()
    };
    DatabaseContext.Add(m);
    DatabaseContext.SaveChanges(true);
    
    // set the ForeignKey to null
    m.HelpCardId = null;
    // the model state is now marked as Deleted
    var state = DatabaseContext.Entry(m).State;
    // after saving, the record is deleted from the database instead of being saved with the ForeignKey set to Null
    DatabaseContext.SaveChanges(true);

Models

public partial class Module : Entity, IEntity<Guid>
{
    private HelpCard _HelpCard;

    public Module()
    {
    }

    private Module(ILazyLoader lazyLoader) : this()
    {
        LazyLoader = lazyLoader;
    }

    private ILazyLoader LazyLoader { get; set; }

    [Key]
    [Required()]
    public virtual Guid Id { get; set; }

    [StringLength(100)]
    [Required()]
    public virtual string Name { get; set; }

    public virtual Guid? HelpCardId { get; set; }        

    public virtual HelpCard HelpCard
    {
        get
        {
            return LazyLoader?.Load(this, ref _HelpCard);
        }
        set
        {
            this._HelpCard = value;
        }
    }                
}
public partial class HelpCard : Entity, IEntity<Guid>, ICloneable 
{
    private IList<Module> _Modules;

    public HelpCard()
    {
        this._Modules = new List<Module>();
    }

    private HelpCard(ILazyLoader lazyLoader) : this()
    {
        LazyLoader = lazyLoader;
    }

    private ILazyLoader LazyLoader { get; set; }

    [Key]
    [Required()]
    public virtual Guid Id { get; set; }

    [StringLength(100)]
    [Required()]
    public virtual string Name { get; set; }

    public virtual IList<Module> Modules
    {
        get
        {
           return LazyLoader?.Load(this, ref _Modules);
        }
        set
        {
            this._Modules = value;
        }
    }
}

Configuration

public partial class ModuleConfiguration : IEntityTypeConfiguration<Module>
{
    public void Configure(EntityTypeBuilder<Module> builder)
    {
        builder.ToTable(@"Module", @"dbo");
        builder.Property(x => x.Id).HasColumnName(@"Id").HasColumnType(@"uniqueidentifier").IsRequired().ValueGeneratedOnAdd().HasDefaultValueSql(@"newid()");
        builder.Property(x => x.Name).HasColumnName(@"Name").HasColumnType(@"varchar(100)").IsRequired().ValueGeneratedNever().HasMaxLength(100);
        builder.Property(x => x.HelpCardId).HasColumnName(@"HelpCardId").HasColumnType(@"uniqueidentifier").ValueGeneratedNever();
        builder.HasKey(@"Id");
        builder.HasOne(x => x.HelpCard).WithMany(op => op.Modules).OnDelete(DeleteBehavior.Cascade).HasForeignKey(@"HelpCardId").IsRequired(false);          

        CustomizeConfiguration(builder);
    }
}
public partial class HelpCardConfiguration : IEntityTypeConfiguration<HelpCard>
{
    public void Configure(EntityTypeBuilder<HelpCard> builder)
    {
        builder.ToTable(@"HelpCard", @"dbo");
        builder.Property(x => x.Id).HasColumnName(@"Id").HasColumnType(@"uniqueidentifier").IsRequired().ValueGeneratedOnAdd().HasDefaultValueSql(@"newid()");
        builder.Property(x => x.Name).HasColumnName(@"Name").HasColumnType(@"varchar(100)").IsRequired().ValueGeneratedNever().HasMaxLength(100);
        builder.HasKey(@"Id");
        builder.HasMany(x => x.Modules).WithOne(op => op.HelpCard).OnDelete(DeleteBehavior.Cascade).HasForeignKey(@"HelpCardId").IsRequired(false);

        CustomizeConfiguration(builder);
    }
}

EF Core version: 6.00 & 6.01 Database provider: Microsoft.EntityFrameworkCore.SqlServer Target framework: .NET 6.0 Operating system: Windows 10 IDE: Visual Studio 2022 17.0.2

About this issue

  • Original URL
  • State: closed
  • Created 2 years ago
  • Comments: 20 (8 by maintainers)

Commits related to this issue

Most upvoted comments

@failwyn @m-azyoksul @powermetal63 I’ve been pondering on this over the weekend, and I think you folks are right. Cascade delete is typically used with required relationships, where a dependent cannot exist in the database without a principal. In this case, delete orphans behavior makes sense. However, if cascade delete is used with optional relationships, then the dependent can still exist without the principal, which means it probably doesn’t make sense to delete orphans automatically.

In addition, setting orphan timing to Never definitely should not throw when attempting to save a null FK for optional relationships.

I will investigate and discuss with the team what we can do here. We can likely revert this particular change in a patch (although that will require Director-level approval, so I can’t promise), but we also need to be conscious of making breaking changes here if other behaviors around orphan deletion are changed.

@ajcvickers Why cascade delete / delete orphans options affect the operation of the dependent entity? This is counterintuitive to everything we know about cascade delete - defines what to do with dependents when deleting the principal. Here we are not deleting the principal. We are updating the dependent. No relational database deletes the record when you update the nullable cascade delete enforced FK to NULL.

Also does not match the behavior explained in Optional relationships

By default for optional relationships, the foreign key value is set to null. This means that the dependent/child is no longer associated with any principal/parent. … Notice that the post is not marked as Deleted. It is marked as Modified so that the FK value in the database will be set to null when SaveChanges is called.

This is exactly what one would expect for optional dependent, regardless of the cascade delete option.

So not sure what was fixed in 6.0, but the current behavior definitely sounds like bug to me.

And the worse is that there is no workaround - the orphan timing option you are suggesting is irrelevant, as we are not trying to reparent the entity, but simply disassociate it from the parent (after all, it is possible to be created without parent and should be able to be updated to have no parent).

I agree with everything @powermetal63 said. This does seem like a regression.

I am using the following workaround.

public class User
{
    public string Id { get; set; }
    public string? DepartmentId { get; set; }
    public Department? Department { get; set; }
}

public class Department
{
    public string Id { get; set; }
    public List<User>? Users { get; set; }
}

and in the OnModelCreating

builder.Entity<User>()
  .HasOne(e => e.Department)
  .WithMany(e => e.Users)
  .IsRequired(false) // This line has no effect
  .OnDelete(DeleteBehavior.Cascade);

This correctly sets up an optional FK relationship with cascade delete enabled.

However, when I set myUser.Department = null, the user state becomes Deleted. What I do is just to set the state of the entity back to Modified manually.

user.Department = null;
_db.Entry(user).State = EntityState.Modified;
await _db.SaveChangesAsync();

At the end of the day, I feel like this behavior is confusing. When an optional foreign key with cascade delete enabled is set to null, the dependant entity state is set to Deleted, when I think it should be set to Modified.

@failwyn

That’s not a feasible workaround for large applications

Why so?

Were you able to find anything on why setting DeleteOrphansTiming to Never is throwing an exception for optional relationships?

I filed #27218.

@failwyn

Setting ‘context.ChangeTracker.DeleteOrphansTiming = CascadeTiming.Never’ throws an exception.

I will investigate this.