Jonathan Mitchell
Jonathan Mitchell

Reputation: 1367

Remove navigation collection entity from EFCore lazy loaded context - navigation collection not updated immediately

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

Answers (1)

Ivan Stoev
Ivan Stoev

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:

  1. Tracked Parent parent: For each tracked Child child in parent.Children, then child.Parent == parent

  2. 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

Related Questions