dotnet / dotnet/efcore

State of join entity entry is "Added" although entity is tracked by setting State="Modified".

Open
#28,005 2 comments 0 reactions 0 assignees View on GitHub
area-change-tracking blocked customer-reported
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.