Reputation: 1367
For legacy reason we use an injected lazy loader in EF Core.
In the code below Context.Add(child)
results in the lazy loaded navigation collection being updated but Context.Remove(child)
does not. SaveChanges()
must be called first. Is this by design?
Now I know I can call Parent.Children.Remove(child)
and that is hunky dory but our solution requires invoking Context.Remove(child)
. I don't like just chucking in SaveChanges()
without understanding why it is necessary.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace EFDemo
{
public class Parent
{
private Action<object, string> _lazyLoader;
public Parent() { }
public Parent(Action<object, string> lazyLoader)
{
_lazyLoader = lazyLoader;
}
public int Id { get; set; }
public string Name { get; set; }
private List<Child> _children;
public List<Child> Children {
get
{
_lazyLoader?.Invoke(this, "Children");
_children ??= new List<Child>();
return _children;
}
set => _children = value;
}
}
public class Child
{
public int Id { get; set; }
public string Name { get; set; }
public int ParentId { get; set; }
public Parent Parent { get; set; }
}
public class EFDemoContext : DbContext
{
public DbSet<Parent> Parents { get; set; }
public DbSet<Child> Children { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
var connectionString =
"Server=tcp:localhost,1433;Initial Catalog=EFDemo;Persist Security Info=False;User ID=xxxxx;Password=xxx@xxxx;Encrypt=False;Max Pool Size=500;Pooling=True;";
optionsBuilder.UseSqlServer(connectionString)
.LogTo(Console.WriteLine, LogLevel.Information)
.EnableDetailedErrors()
.EnableSensitiveDataLogging();
base.OnConfiguring(optionsBuilder);
}
}
class Program
{
static void Main(string[] args)
{
var context = new EFDemoContext();
context.Database.EnsureDeleted();
context.Database.Migrate();
var p = new Parent()
{
Name = "Alice",
Children = new List<Child>
{
new() { Name = "Bob" },
new() { Name = "Charlie" },
new() { Name = "Doris" },
}
};
context.Add(p);
context.SaveChanges();
Debug.Assert(p.Children.Count == 3, "Initial children not found");
context.Add(new Child() { Name = "Elizabeth", Parent = p });
Debug.Assert(p.Children.Count == 4, "New child not found");
context.Remove(p.Children.First());
//context.SaveChanges(); // we need to save in order to succeed
Debug.Assert(p.Children.Count == 3, "Child not removed");
}
}
}
--- Followup --
This issue addresses the matter precisely. There is some inconsistency I think in how fix up occurs for deletes but clearly an internal EFCore fix for this broke too many things.
Upvotes: 2
Views: 626
Reputation: 205769
This EF Core behavior is unrelated to lazy loading and is caused by the so called navigation fixup performed by many operations of the context change tracker.
While it might seem inconsistent with Add
, in fact both them (as well as other change tracking related operations) are trying to ensure the following invariants for tracked entities:
Tracked Parent parent
: For each tracked Child child
in parent.Children
, then child.Parent == parent
Tracked Child child
: If child.Parent != null
, then child.Parent.Childen.Contains(child)
When you call Add
(or Attach
, Update
) of Child child
with child.Parent != null
, because of the invariant #2 EF Core adds it to the parent.Children
collection if it is not already there.
However, when you call Remove
with Child child
having child.Parent != null)
, the entity is marked for deletion (State = EntityState.Deleted
), but the Parent
property is not nulled out, hence due to the aforementioned rules it cannot be removed from child.Parent.Children
collection. And not only that, in fact it will be added to that collection if it is not there, which can be seen with code like this
var child = new Child { Id = 1, Parent = new Parent { Id = 1 } };
Debug.Assert(child.Parent.Children.Count == 0);
context.Remove(child);
Debug.Assert(child.Parent != null && child.Parent.Children.Contains(child));
Now the question might be why the Parent
is not nulled out. I guess because Deleted
state is considered temporary (you might decide later to set it to something else, thus "undeleting" the "deleted" entity).
So what they do is wait you to "confirm" the entity state/operation, which happens after SaveChanges
, and more specifically by the ChangeTracker.AcceptAllChanges()
call after successfully applying the modifications in database. This call converts the Deleted
temporary state to Detached
persistent state. Since Detached
means the entity will no more be tracked, they perform the final navigation fixup, which in this case is removing the child from the parent collection and nulling out the parent.
The above explains the rationale behind the Remove
behavior. What if you don't like it and want child to be removed from the parent collection immediately. One way is to replace the EF Core service responsible for that with your own - in this case, INavigationFixer
interface with default implementation in NavigationFixer
class, but both are undocumented and considered part of the "internal API" (even though they are public from C# POV) - this is not a problem for me, but for many people it is. So another option is to hook into context change tracking events and perform the desired action. For instance, add the following to your derived context class
void Initialize()
{
ChangeTracker.Tracked += OnEntityTracked;
ChangeTracker.StateChanged += OnEntityStateChanged;
}
void OnEntityStateChanged(object sender, EntityStateChangedEventArgs e)
{
if (e.NewState == EntityState.Deleted)
OnEntityDeleted(e.Entry);
}
void OnEntityTracked(object sender, EntityTrackedEventArgs e)
{
if (!e.FromQuery && e.Entry.State == EntityState.Deleted)
OnEntityDeleted(e.Entry);
}
void OnEntityDeleted(EntityEntry entry)
{
foreach (var refEntry in entry.References)
{
var refEntity = refEntry.CurrentValue;
if (refEntity != null)
{
var inverseNavigation = refEntry.Metadata.Inverse;
if (inverseNavigation != null && inverseNavigation.IsCollection)
{
var collection = inverseNavigation.GetCollectionAccessor();
collection.Remove(refEntity, entry.Entity);
}
}
}
}
and call Initialize
from constructor(s).
Upvotes: 4