Why can't Entity Framework handle cascade deletes on a maybe child

David Thielen 3,141 Reputation points
2023-06-05T23:36:18.4366667+00:00

I don't know if this is a SQL Server issue or an Entity Framework issue. In addition, I am reticent to say "bug" as Microsoft tests so well, but this strikes me as a potential bug (or feature limitation).

This is for a political campaign system. In it we have the campaign model. The campaign can be national (President) or local. A local campaign is owned by the appropriate State. So a Campaign has exactly 1 parent, but it can be a Country or State.

public class Campaign : IOrganization
{
    public int Id { get; private set; }

    public State? State { get; set; }
    public int? StateId { get; set; }

    public Country? Country { get; set; }
    public int? CountryId { get; set; }
}

And State & Country have their collections of the Campaigns they each own.

public class Country : IOrganization
{
    public int Id { get; private set; }

    public ICollection<Campaign>? Campaigns { get; private set; }
}

public class State : IOrganization
{
    public int Id { get; private set; }

    public ICollection<Campaign>? Campaigns { get; private set; }
}

When I run update-database I get:

fail: Microsoft.EntityFrameworkCore.Database.Command[20102]
      Failed executing DbCommand (9ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE [Campaigns] (
          [Id] int NOT NULL IDENTITY,
          [UniqueId] nvarchar(450) NOT NULL,
          [Name] nvarchar(450) NOT NULL,
          [News] nvarchar(max) NULL,
          [Address_Address1] nvarchar(max) NULL,
          [Address_Address2] nvarchar(max) NULL,
          [Address_City] nvarchar(max) NULL,
          [Address_State] nvarchar(max) NULL,
          [Address_Country] nvarchar(max) NULL,
          [Address_PostalCode] nvarchar(max) NULL,
          [Address_Location] geography NULL,
          [StateId] int NULL,
          [CountryId] int NULL,
          [Enabled] bit NOT NULL,
          [Closed] bit NOT NULL,
          [Deleted] bit NOT NULL,
          [Created] datetime2 NOT NULL DEFAULT (getutcdate()),
          [RowVersion] rowversion NOT NULL,
          CONSTRAINT [PK_Campaigns] PRIMARY KEY ([Id]),
          CONSTRAINT [FK_Campaigns_Countries_CountryId] FOREIGN KEY ([CountryId]) REFERENCES [Countries] ([Id]) ON DELETE CASCADE,
          CONSTRAINT [FK_Campaigns_States_StateId] FOREIGN KEY ([StateId]) REFERENCES [States] ([Id]) ON DELETE CASCADE
      );
Failed executing DbCommand (9ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
CREATE TABLE [Campaigns] (
    [Id] int NOT NULL IDENTITY,
    [UniqueId] nvarchar(450) NOT NULL,
    [Name] nvarchar(450) NOT NULL,
    [News] nvarchar(max) NULL,
    [Address_Address1] nvarchar(max) NULL,
    [Address_Address2] nvarchar(max) NULL,
    [Address_City] nvarchar(max) NULL,
    [Address_State] nvarchar(max) NULL,
    [Address_Country] nvarchar(max) NULL,
    [Address_PostalCode] nvarchar(max) NULL,
    [Address_Location] geography NULL,
    [StateId] int NULL,
    [CountryId] int NULL,
    [Enabled] bit NOT NULL,
    [Closed] bit NOT NULL,
    [Deleted] bit NOT NULL,
    [Created] datetime2 NOT NULL DEFAULT (getutcdate()),
    [RowVersion] rowversion NOT NULL,
    CONSTRAINT [PK_Campaigns] PRIMARY KEY ([Id]),
    CONSTRAINT [FK_Campaigns_Countries_CountryId] FOREIGN KEY ([CountryId]) REFERENCES [Countries] ([Id]) ON DELETE CASCADE,
    CONSTRAINT [FK_Campaigns_States_StateId] FOREIGN KEY ([StateId]) REFERENCES [States] ([Id]) ON DELETE CASCADE
);
Microsoft.Data.SqlClient.SqlException (0x80131904): Introducing FOREIGN KEY constraint 'FK_Campaigns_States_StateId' on table 'Campaigns' may cause cycles or multiple cascade paths. Specify ON DELETE NO ACTION or ON UPDATE NO ACTION, or modify other FOREIGN KEY constraints.
Could not create constraint or index. See previous errors.
   at Microsoft.Data.SqlClient.SqlConnection.OnError(SqlException exception, Boolean breakConnection, Action`1 wrapCloseInAction)
   at Microsoft.Data.SqlClient.SqlInternalConnection.OnError(SqlException exception, Boolean breakConnection, Action`1 wrapCloseInAction)
   at Microsoft.Data.SqlClient.TdsParser.ThrowExceptionAndWarning(TdsParserStateObject stateObj, Boolean callerHasConnectionLock, Boolean asyncClose)
   at Microsoft.Data.SqlClient.TdsParser.TryRun(RunBehavior runBehavior, SqlCommand cmdHandler, SqlDataReader dataStream, BulkCopySimpleResultSet bulkCopyHandler, TdsParserStateObject stateObj, Boolean& dataReady)
   at Microsoft.Data.SqlClient.SqlCommand.RunExecuteNonQueryTds(String methodName, Boolean isAsync, Int32 timeout, Boolean asyncWrite)
   at Microsoft.Data.SqlClient.SqlCommand.InternalExecuteNonQuery(TaskCompletionSource`1 completion, Boolean sendToPipe, Int32 timeout, Boolean& usedCache, Boolean asyncWrite, Boolean inRetry, String methodName)
   at Microsoft.Data.SqlClient.SqlCommand.ExecuteNonQuery()
   at Microsoft.EntityFrameworkCore.Storage.RelationalCommand.ExecuteNonQuery(RelationalCommandParameterObject parameterObject)
   at Microsoft.EntityFrameworkCore.Migrations.MigrationCommand.ExecuteNonQuery(IRelationalConnection connection, IReadOnlyDictionary`2 parameterValues)
   at Microsoft.EntityFrameworkCore.Migrations.Internal.MigrationCommandExecutor.ExecuteNonQuery(IEnumerable`1 migrationCommands, IRelationalConnection connection)
   at Microsoft.EntityFrameworkCore.Migrations.Internal.Migrator.Migrate(String targetMigration)
   at Microsoft.EntityFrameworkCore.Design.Internal.MigrationsOperations.UpdateDatabase(String targetMigration, String connectionString, String contextType)
   at Microsoft.EntityFrameworkCore.Design.OperationExecutor.UpdateDatabaseImpl(String targetMigration, String connectionString, String contextType)
   at Microsoft.EntityFrameworkCore.Design.OperationExecutor.UpdateDatabase.<>c__DisplayClass0_0.<.ctor>b__0()
   at Microsoft.EntityFrameworkCore.Design.OperationExecutor.OperationBase.Execute(Action action)
ClientConnectionId:e68c158d-e2e8-4149-9d65-7f6fa23bf360
Error Number:1785,State:0,Class:16
Introducing FOREIGN KEY constraint 'FK_Campaigns_States_StateId' on table 'Campaigns' may cause cycles or multiple cascade paths. Specify ON DELETE NO ACTION or ON UPDATE NO ACTION, or modify other FOREIGN KEY constraints.
Could not create constraint or index. See previous errors.

I can avoid this with the following fluent:

// one for Country too
public void Configure(EntityTypeBuilder<State> builder)
{
    builder.HasMany(x => x.Campaigns)
            .WithOne(x => x.State).OnDelete(DeleteBehavior.ClientSetNull);
}

And I then in my DbContext.SaveChangesAsync() override figure out what campaigns need to be deleted and delete them there.

Is there some way to tell EF to not worry, that it can cascade delete and there will not be any cases where a Campaign has both a State and a Country parent? Because I figure it's much better to have the DB enforce a cascading delete.

So here's my question: Why won't EF/SQL Server handle this as a plain old cascading delete? What's the issue here? And is there a way to get SQL Server to handle it?

thanks - dave

Entity Framework Core
Entity Framework Core
A lightweight, extensible, open-source, and cross-platform version of the Entity Framework data access technology.
761 questions
SQL Server
SQL Server
A family of Microsoft relational database management and analysis systems for e-commerce, line-of-business, and data warehousing solutions.
14,125 questions
{count} votes

Accepted answer
  1. Erland Sommarskog 113.3K Reputation points MVP
    2023-06-06T12:03:34.67+00:00

    This is a limitation with cascading constraints. I guess that the States table has a foreign-key column to the Countries table, and now SQL Server gets nervous that this could lead to trouble.

    I think it is quite clear that SQL Server is overly conservative when it comes to cascading constraints and possible cycles, but it has been this way since cascading constraints were introduced in SQL 2000, and I doubt that it will ever going to change.

    So for this situation, there is nothing but to raise the white flag and implement the cascading manually. And, when you think of it, if someone says

    DELETE country WHERE Name = 'Mexico'

    Do you really want all states and campaigns tied to Mexico to go out the window? Maybe you would rather prefer an error to prevent the accident?


1 additional answer

Sort by: Most helpful
  1. David Thielen 3,141 Reputation points
    2023-06-06T12:11:30.3933333+00:00

    I'm accepting @Erland Sommarskog answer above but want to add a couple of links on this I found from his answer:

    I think my present approach where I tell EF to use DeleteBehavior.ClientSetNull and then in my DbContext.SaveChangesAsync() I delete the children objects is best. Any restructuring of the DB schema to handle this makes the schema more complicated to handle a rare event.

    0 comments No comments

Your answer

Answers can be marked as Accepted Answers by the question author, which helps users to know the answer solved the author's problem.