EF Core 6 automatically add navigation property

Viewed 437

Imagine I had the following code.

var myEntity = Context.MyEntities.GetById(id);
myEntity.SomeNavigationProperty = new MyNavigationProperty(...);
await Context.SaveChangesAsync();

I have the following mapping for MyEntity

builder.HasOne(o => o.SomeNavigationProperty).WithOne().HasForeignKey<MyEntity>(o => o.SomeNavigationPropertyId);

SaveChangesAsync fail with:

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.

If I look at the change tracker after save, MyNavigationProperty has been marked as modified and not added.

Versions that work

var myEntity = new MyEntity(...);
myEntity.SomeNavigationProperty = new MyNavigationProperty(...);
await Context.AddAsync(myEntity);
await Context.SaveChangesAsync();

and

var myEntity = Context.MyEntities.GetById(id);
myEntity.SomeNavigationProperty = new MyNavigationProperty(...);
await Context.AddAsync(myEntity.SomeNavigationProperty);
await Context.SaveChangesAsync();

This behavior makes sense to a degree if I read up on https://docs.microsoft.com/en-us/dotnet/api/microsoft.entityframeworkcore.dbcontext.addasync?view=efcore-6.0. I do not completely understand why ef core decides to mark the untracked entity as modified instead of added though.

Either way... my problem is that my domain and repository are separated. In other words when the property gets set I do not have access to the context.

Is there a way I can tell the EF Core context to re-add all if its not already added without it complaining about duplicates?

Something like

await Context.RefreshAddAsync(myEntity);

where RefreshAddAsync will skip MyEntity (if it's already added) and mark MyNavigationProperty as added.

Any other suggestions appreciated. Worse case scenario I'll write a version of RefreshAddAsync using reflection, but would like a cleaner solution if there is one.

3 Answers

If I'm not mistaken MyEntities is item and SomeNavigationProperty is parent. You have an item and you work to create a parent for your current item. Your this code seems like the most appropriate method for this situation:

var myEntity = Context.MyEntities.GetById(id);
myEntity.SomeNavigationProperty = new MyNavigationProperty(...);
await Context.AddAsync(myEntity.SomeNavigationProperty);
await Context.SaveChangesAsync();

But if you have parent data. You can do like this:

var someNavigationProperty = Context.SomeNavigationProperty.GetById(id);
someNavigationProperty.MyEntities = anyMyEntitiesData;
await Context.SaveChangesAsync();

Due to your constraints I would try the following:

The default constructor of your navigation property sets its Id property to some special value, defined as ´public static readonly` field on the entity (e.g. -1 or Guid.NewGuid()).

At next add an interceptor to your context that's being executed before SaveChanges() and within that method ask the ChangeTracker to give you all entities, filtered by the navigation property type, the special Id value and the state being Modified. Now change on all these items the entity state to be Added and exit your interceptor.

Unfortunately I can't create a working example right now, but IMHO this should solve your problem. I also ran into this problem when upgrading to EF core 6, but on all places I had access to the context class itself and could add the navigation property to the corresponding context collection directly to avoid this problem, but I think the above described way should also work.

I ended up doing this which works pretty well.

public static async Task AddOrUpdateEntityAsync<T>(this ArtBankContext context, T entity)
        where T : DomainEntity
{
    context.ThrowIfNull();
    entity.ThrowIfNull();

    var toProcessQueue = new Queue<DomainEntity>();
    var processed = new List<DomainEntity>();

    var tracked = RunWithAutoDetectChangesDisabled(() => context.ChangeTracker
        .Entries<DomainEntity>()
        .Select(o => o.Entity));

    toProcessQueue.Enqueue(entity);

    while (toProcessQueue.Any())
    {
        var toProcess = toProcessQueue.Dequeue();

        if (!tracked.Any(o => o == toProcess))
        {
            await context
                .AddEntityAsync(toProcess)
                    .ContinueOnAnyContext();
        }

        foreach (var toProcessPropertyInfo in toProcess
            .GetType()
            .GetProperties()
            .Where(o => o.PropertyType.IsAssignableTo(typeof(DomainEntity))))
        {
            var toProcessProperty = (DomainEntity?)toProcessPropertyInfo.GetValue(toProcess);

            if (toProcessProperty == null || processed.Any(o => o == toProcessProperty))
                continue;

            toProcessQueue.Enqueue(toProcessProperty);
        }

        processed.Add(toProcess);
    }

    TResult RunWithAutoDetectChangesDisabled<TResult>(Func<TResult> func)
    {
        //Ef auto detects changes when you call methods like context.ChangeTracker.Entries
        //In this specific use case the entities that we want to add dynamically gets detected and incorreectly added to the contexts.
        //Hence we disable the flag before executing
        var originalAutoDetectChangesEnabledValue = context.ChangeTracker.AutoDetectChangesEnabled;

        context.ChangeTracker.AutoDetectChangesEnabled = false;

        var result = func();

        context.ChangeTracker.AutoDetectChangesEnabled = originalAutoDetectChangesEnabledValue;

        return result;
    }
}

it is the used like

var myEntity = Context.MyEntities.GetById(id);
myEntity.SomeNavigationProperty = new MyNavigationProperty(...);
await Context.AddOrUpdateEntityAsync(myEntity);
await Context.SaveChangesAsync();
Related