Take Cascade Delete into account when ordering update commands
- Dominant language
- C#
- Stars
- 14.8k
- Forks
- 3.4k
- PR merge metrics
- PR metrics pending
Description
### Description
In a detached parent/child hierarchy scenario when replacing a parent node with a child node, a DbUpdateConcurrencyException is thrown when DeleteBehavior.Cascade is used.
Before:
Root
└── Child
''''''''└── Grandchild
After (desired state):
Root
└── Grandchild
## Code Example
### Models for failing test case
```C#
public class Root
{
[Key]
public int Id { get; set; }
public List Children { get; set; }
}
public class Child
{
[Key]
public int Id { get; set; }
public List Children { get; set; }
public int? ParentId { get; set; }
public Child? Parent { get; set; }
public int? RootId { get; set; }
public Root? Root { get; set; }
}
```
### DbContext for failing test case
```C#
public class RelationshipTestsDbContext : DbContextBase
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity()
.HasMany(c => c.Children)
.WithOne(c => c.Parent)
.IsRequired(false)
.OnDelete(DeleteBehavior.Cascade);
}
}
```
### Failing test case
```C#
[Test]
public async Task Test()
{
// Root
var root = new Root()
{
Children = new()
{
// Child -> ID:1
new Child()
{
Children = new()
{
// Grandchild -> ID:2
new Child()
}
}
}
};
await using (var dbContext = new RelationshipTestsDbContext())
{
dbContext.Add(root);
await dbContext.SaveChangesAsync();
}
// Notice in the update below, the grandchild takes the place of the child.
// The child gets deleted.
// Root Node
var rootUpdate = new Root()
{
Id = root.Id,
Children = new()
{
// Grandchild
new Child()
{
Id = root.Children.Single().Children.Single().Id,
Children = new()
}
}
};
await using (var dbContext = new RelationshipTestsDbContext())
{
// Track the whole tree as modified
dbContext.Update(rootUpdate);
// Child is not present in the tree -> load from db and mark as deleted
var child = await dbContext.Set()
.Where(c => c.Id == root.Children.Single().Id)
.SingleAsync();
dbContext.Remove(child);
// DbUpdateConcurrency exception is thrown because:
// 1. In the DbContext Child<->Parent relationship delete behavior is set to cascade
// 2. The DELETE statement for Child is executed before the UPDATE statement for Grandchild
// 3. In the DB, Child gets deleted -> Grandchild is also deleted because of cascade
// -> UPDATE does not affect any rows
await dbContext.SaveChangesAsync();
}
}
```
### Change Tracker before SaveChanges call:
Child {Id: 1} Deleted
Id: 1 PK
ParentId: \ FK
RootId: 1 FK
Children: \
Parent: \
Root: {Id: 1}
Child {Id: 2} Modified
Id: 2 PK
ParentId: \ FK Modified
RootId: 1 FK Modified Originally \
Children: []
Parent: \
Root: {Id: 1}
Root {Id: 1} Modified
Id: 1 PK
Children: [{Id: 2}, {Id: 1}]
### Resulting SQL
Executed DbCommand (27ms) [Parameters=[@p0='1', @p3='2', @p1=NULL (DbType = Int32), @p2='1' (Nullable = true)], CommandType='Text', CommandTimeout='0']
DELETE FROM "Child"
WHERE "Id" = @p0; // ID 1
UPDATE "Child" SET "ParentId" = @p1, "RootId" = @p2
WHERE "Id" = @p3; // ID 2
The problem would not occur if the UPDATE was run before the DELETE. Because of the CASCADE delete behavior Child with ID 2 gets deleted when DELETE of Child with ID 1 is done.
The UPDATE then does not affect any rows because the entry with the ID 2 is deleted.
I'm not quite sure if this is a bug or a design limitation or what would be an improvement.
A possible workaround would be to set the delete behavior SetNull and delete the orphans "by hand" after changes are saved.
### StackTrace of failing test
```
Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException : The database operation was expected to affect 1 row(s), but actually affected 0 row(s); data may have been modified or deleted since entities were loaded. See http://go.microsoft.com/fwlink/?LinkId=527962 for information on understanding and handling optimistic concurrency exceptions.
at Npgsql.EntityFrameworkCore.PostgreSQL.Update.Internal.NpgsqlModificationCommandBatch.ThrowAggregateUpdateConcurrencyExceptionAsync(RelationalDataReader reader, Int32 commandIndex, Int32 expectedRowsAffected, Int32 rowsAffected, CancellationToken cancellationToken)
at Npgsql.EntityFrameworkCore.PostgreSQL.Update.Internal.NpgsqlModificationCommandBatch.Consume(RelationalDataReader reader, Boolean async, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.ReaderModificationCommandBatch.ExecuteAsync(IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.Update.Internal.BatchExecutor.ExecuteAsync(IEnumerable`1 commandBatches, IRelationalConnection connection, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(IList`1 entriesToSave, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.StateManager.SaveChangesAsync(StateManager stateManager, Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
at Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.NpgsqlExecutionStrategy.ExecuteAsync[TState,TResult](TState state, Func`4 operation, Func`4 verifySucceeded, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
```
### Include provider and version information
EF Core version: 8.0.7
Database provider: Npgsql.EntityFrameworkCore.PostgreSQL
Target framework: .NET 8.0
Operating system: macOS 14.4.1 (23E224)
Contributor guide
Assessment
This issue has not been assessed yet.