State of join entity entry is "Added" although entity is tracked by setting State="Modified".
- Dominant language
- C#
- Stars
- 14.8k
- Forks
- 3.4k
- PR merge metrics
- PR metrics pending
Description
After updating from EF Core 5 to EF Core 6, some of our unit tests failed. I couldn't figure out by which of the known breaking changes this might be caused, but found the changed behavior in a method that uses stubs to remove an entry from a many-to-many relation. In EF Core 5, the state of the join entity entry changes from `Added` to `Deleted`, while in EF Core 6, it changes from `Added` to `Detached`. (The "new" behavior seems to be "more correct".) As I tried to solve our remove-problem, I wondered:
_Why is the state of the join entity entry `Added` at all?_
### odd behavior
The involved stubs are only made tracked in `Unchanged` or `Modified` state. But the state of the join entity entry is `Unchanged` or `Added`. _Particularly strange_: Whether it is `Unchanged` or `Added` depends on the order in which the involved stubs are made tracked or whether a stub is first made `Unchanged` and then `Modified` right after.
In the following code, these three cases are covered (see `foreach` and `switch`). Furthermore, the code contains tables showing the state of the join entity entry and the final result for each of these three cases, both for EF Core 5 and EF Core 6.
_Is there a "good reason" for the way these three cases behave, and that not all behave the same?_
### runnable code
```C#
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.ObjectModel;
using System.Linq;
namespace MyNamespace
{
public class Program
{
private static void Main(string[] args)
{
// Define whether to prepare user entry state assignment by another user entry state assignment
// or whether to defer role entry state assignment after user entry state assignment.
foreach (var defer in new[] { (Boolean?)null, false, true })
{
Console.WriteLine("=== prepare user entry state assignment by another user entry state assignment: {0}", defer == null);
Console.WriteLine("=== defer role entry state assignment after user entry state assignment: {0}", defer == true);
Console.WriteLine();
// Populate database.
using (var context = new MyContext())
{
context.Database.EnsureDeleted();
context.Database.EnsureCreated();
context.Users.Add(new User() { RolesChangedAt = default, Roles = { new Role(), new Role() } });
context.SaveChanges();
}
// Remove relation between user with ID 1 and role with ID 1 via stubs. (Only IDs have to be known.)
using (var context = new MyContext())
{
// Create stubs, and assign their entity entry state.
var role = new Role() { Id = 1 };
var user = new User() { Id = 1, RolesChangedAt = DateTime.UtcNow, Roles = { role } };
switch (defer)
{
case null:
context.Entry(role).State = EntityState.Unchanged;
context.Entry(user).State = EntityState.Unchanged;
context.Entry(user).State = EntityState.Modified;
break;
case false:
context.Entry(role).State = EntityState.Unchanged;
context.Entry(user).State = EntityState.Modified;
break;
case true:
context.Entry(user).State = EntityState.Modified;
context.Entry(role).State = EntityState.Unchanged;
break;
}
// Report entity entries before removing role.
context.ChangeTracker.DetectChanges();
Console.WriteLine("before removing role:");
Console.WriteLine(context.ChangeTracker.DebugView.ShortView);
/* The following table shows the state of the join entity entry:
* | EF Core 5 | EF Core 6 |
* ---------------+------------------------------------------
* defer == null | Unchanged | Unchanged |
* defer == false | Added | Added | <= odd behavior: Why "Added" here?
* defer == true | Unchanged | Unchanged |
*/
// Remove role.
user.Roles.Remove(role);
// Report entity entries after removing role.
context.ChangeTracker.DetectChanges();
Console.WriteLine("after removing role:");
Console.WriteLine(context.ChangeTracker.DebugView.ShortView);
/* The following table shows the state of the join entity entry:
* | EF Core 5 | EF Core 6 |
* ---------------+-----------+-----------+
* defer == null | Deleted | Deleted |
* defer == false | Deleted | None | <= changed behavior (for the better IMHO)
* defer == true | Deleted | Deleted |
* where "None" means that there is no join entity entry any more (i.e., "None" means "Detached").
* (Reason: When an entity is in the Added state then removing it will disconnect it rather than mark it for deletion.)
*/
context.SaveChanges();
}
// Check whether there is a relation between user with ID 1 and role with ID 1.
using (var context = new MyContext())
{
var user = context.Users.Include(x => x.Roles).FirstOrDefault(x => x.Id == 1);
Console.WriteLine("result:");
Console.WriteLine("date changed: {0}", user.RolesChangedAt != default);
Console.WriteLine("role removed: {0}", user.Roles.All(x => x.Id != 1));
Console.WriteLine();
/* The following table shows whether the role is removed from the user's role collection:
* | EF Core 5 | EF Core 6 |
* ---------------+-----------+-----------+
* defer == null | True | True |
* defer == false | True | False | <= consistent result of changed behavior
* defer == true | True | True |
*/
}
}
}
public class MyContext : DbContext
{
public DbSet Users { get; private set; }
public DbSet Roles { get; private set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => optionsBuilder.UseSqlServer(@"Server=(LocalDB)\MSSQLLocalDB;Database=EfCore6-Problem-Db");
}
public class User
{
public Int32 Id { get; set; }
public DateTime RolesChangedAt { get; set; }
public Collection Roles { get; } = new();
}
public class Role
{
public Int32 Id { get; set; }
public Collection Users { get; } = new();
}
}
}
```
### console output
```
=== prepare user entry state assignment by another user entry state assignment: True
=== defer role entry state assignment after user entry state assignment: False
before removing role:
Role {Id: 1} Unchanged
User {Id: 1} Modified
RoleUser (Dictionary) {RolesId: 1, UsersId: 1} Unchanged FK {RolesId: 1} FK {UsersId: 1}
after removing role:
Role {Id: 1} Unchanged
User {Id: 1} Modified
RoleUser (Dictionary) {RolesId: 1, UsersId: 1} Deleted FK {RolesId: 1} FK {UsersId: 1}
result:
date changed: True
role removed: True
=== prepare user entry state assignment by another user entry state assignment: False
=== defer role entry state assignment after user entry state assignment: False
before removing role:
Role {Id: 1} Unchanged
User {Id: 1} Modified
RoleUser (Dictionary) {RolesId: 1, UsersId: 1} Added FK {RolesId: 1} FK {UsersId: 1}
after removing role:
Role {Id: 1} Unchanged
User {Id: 1} Modified
result:
date changed: True
role removed: False
=== prepare user entry state assignment by another user entry state assignment: False
=== defer role entry state assignment after user entry state assignment: True
before removing role:
Role {Id: 1} Unchanged
User {Id: 1} Modified
RoleUser (Dictionary) {RolesId: 1, UsersId: 1} Unchanged FK {RolesId: 1} FK {UsersId: 1}
after removing role:
Role {Id: 1} Unchanged
User {Id: 1} Modified
RoleUser (Dictionary) {RolesId: 1, UsersId: 1} Deleted FK {RolesId: 1} FK {UsersId: 1}
result:
date changed: True
role removed: True
```
### provider and version information
EF Core version: 6.0.5
Database provider: Microsoft.EntityFrameworkCore.SqlServer
Target framework: .NET 6.0
Operating system: Windows 11
IDE: Visual Studio 2022 17.1.6
Contributor guide
Assessment
This issue has not been assessed yet.