Attaching an entity with a mix of existing and new entities in its graph
See original GitHub issueI have encountered an issue when attaching a root entity to the context, where the object graph contains a mix of new entities (PK = 0) and existing entities (PK > 0).
The issue occurs with EF Core 1.1.0. I haven’t tried it with EF Core 1.0.0. It is working with EF7 / DNX.
The model
The test model consists in Place, Person, and a many-to-many relationship between Place and Person, through a joining entity named PlacePerson.
public abstract class BaseEntity
{
public int Id { get; set; }
public string Name { get; set; }
}
public class Person : BaseEntity
{
public int? StatusId { get; set; }
public Status Status { get; set; }
public List<PersonPlace> PersonPlaceCollection { get; set; } = new List<PersonPlace>();
}
public class Place : BaseEntity
{
public List<PersonPlace> PersonPlaceCollection { get; set; } = new List<PersonPlace>();
}
public class PersonPlace : BaseEntity
{
public int? PersonId { get; set; }
public Person Person { get; set; }
public int? PlaceId { get; set; }
public Place Place { get; set; }
}
The database context
All relationships are explicitely defined (without redundancy).
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
// PersonPlace
builder.Entity<PersonPlace>()
.HasAlternateKey(o => new { o.PersonId, o.PlaceId });
builder.Entity<PersonPlace>()
.HasOne(pl => pl.Person)
.WithMany(p => p.PersonPlaceCollection)
.HasForeignKey(p => p.PersonId);
builder.Entity<PersonPlace>()
.HasOne(p => p.Place)
.WithMany(pl => pl.PersonPlaceCollection)
.HasForeignKey(p => p.PlaceId);
}
All concrete entities are also exposed in this model:
public DbSet<Person> PersonCollection { get; set; }
public DbSet<Place> PlaceCollection { get; set; }
public DbSet<PersonPlace> PersonPlaceCollection { get; set; }
Factoring data access
I am using a Repository-style base class to factor all data-access related code.
public class DbRepository<T> where T : BaseEntity
{
protected readonly MyContext _context;
protected DbRepository(MyContext context) { _context = context; }
// AsNoTracking provides detached entities
public virtual T FindByNameAsNoTracking(string name) =>
_context.Set<T>()
.AsNoTracking()
.FirstOrDefault(e => e.Name == name);
// New entities should be inserted
public void Insert(T entity) => _context.Add(entity);
// Existing (PK > 0) entities should be updated
public void Update(T entity) => _context.Update(entity);
// Commiting
public void SaveChanges() => _context.SaveChanges();
}
Steps to reproduce the exception
Create one person and save it. Create one Place and save it.
// Repo
var context = new MyContext()
var personRepo = new DbRepository<Person>(context);
var placeRepo = new DbRepository<Place>(context);
// Person
var jonSnow = new Person() { Name = "Jon SNOW" };
personRepo.Add(jonSnow);
personRepo.SaveChanges();
// Place
var castleblackPlace = new Place() { Name = "Castleblack" };
placeRepo.Add(castleblackPlace);
placeRepo.SaveChanges();
Both the person and the place are in the database, and thus have a primary key defined. PK are generated as identity columns by SQL Server.
Reload the person and the place, as detached entities (the fact they are detached is used to mock a scenario of http posted entities through a web API, e.g. with angularJS on client side).
// detached entities
var jonSnow = personRepo.FindByNameAsNoTracking("Jon SNOW");
var castleblackPlace = placeRepo.FindByNameAsNoTracking("Castleblack");
Add the person to the place and save the person (well, actually saving all the changes in the context):
castleblackPlace.PersonPlaceCollection.Add(
new PersonPlace() { Person = jonSnow }
);
placeRepo.Update(castleblackPlace);
placeRepo.SaveChanges();
On SaveChanges an exception is thrown, because EF Core 1.1.0 tries to INSERT the existing person instead of doing an UPDATE (though its primary key value is set).
Exception details
Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while updating the entries. See the inner exception for details. ---> System.Data.SqlClient.SqlException: Cannot insert explicit value for identity column in table 'Person' when IDENTITY_INSERT is set to OFF.
Previous versions
This code would work perfectly (though not necessarily optimized) with Entity Framework 7 and the DNX CLI.
Workaround
Iterate over the root entity graph and properly set the Entity states:
_context.ChangeTracker.TrackGraph(entity, node =>
{
var entry = node.Entry;
var childEntity = (BaseEntity)entry.Entity;
entry.State = childEntity.Id <= 0? EntityState.Added : EntityState.Modified;
});
What’s the issue ?
Why do we have to manually track the entity states, whereas EF7 would totally deal with it, even when reattaching detached entities ?
Further technical details
EF Core version: 1.1.0 Database Provider: Microsoft.EntityFrameworkCore.SqlServer Operating system: Win 10 Targeted framework: Full .Net Framework (4.6.1) IDE: Visual Studio 2015
Full reproduction source
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using Microsoft.EntityFrameworkCore;
namespace EF110CoreTest
{
public class Program
{
public static void Main(string[] args)
{
// One scope for initial data
using (var context = new MyContext())
{
// Repo
var personRepo = new DbRepository<Person>(context);
var placeRepo = new DbRepository<Place>(context);
// Database
context.Database.EnsureDeleted();
context.Database.EnsureCreated();
/***********************************************************************/
// Step 1 : Create a person
var jonSnow = new Person() { Name = "Jon SNOW" };
personRepo.InsertOrUpdate(jonSnow);
personRepo.SaveChanges();
/***********************************************************************/
// Step 2 : Create a place
var castleblackPlace = new Place() { Name = "Castleblack" };
placeRepo.InsertOrUpdate(castleblackPlace);
placeRepo.SaveChanges();
/***********************************************************************/
}
// Another scope to put one people in one place
using (var context = new MyContext())
{
// Repo
var personRepo = new DbRepository<Person>(context);
var placeRepo = new DbRepository<Place>(context);
// entities
var jonSnow = personRepo.FindByNameAsNoTracking("Jon SNOW");
var castleblackPlace = placeRepo.FindByNameAsNoTracking("Castleblack");
// Step 3 : add person to this place
castleblackPlace.AddPerson(jonSnow);
placeRepo.InsertOrUpdate(castleblackPlace);
placeRepo.SaveChanges();
}
}
}
public class DbRepository<T> where T : BaseEntity
{
public readonly MyContext _context;
public DbRepository(MyContext context) { _context = context; }
public virtual T FindByNameAsNoTracking(string name) => _context.Set<T>().AsNoTracking().FirstOrDefault(e => e.Name == name);
public void InsertOrUpdate(T entity)
{
if (entity.IsNew) Insert(entity); else Update(entity);
}
public void Insert(T entity)
{
// uncomment to enable workaround
//ApplyStates(entity);
_context.Add(entity);
}
public void Update(T entity)
{
// uncomment to enable workaround
//ApplyStates(entity);
_context.Update(entity);
}
public void Delete(T entity)
{
_context.Remove(entity);
}
private void ApplyStates(T entity)
{
_context.ChangeTracker.TrackGraph(entity, node =>
{
var entry = node.Entry;
var childEntity = (BaseEntity)entry.Entity;
entry.State = childEntity.IsNew ? EntityState.Added : EntityState.Modified;
});
}
public void SaveChanges() => _context.SaveChanges();
}
#region Models
public abstract class BaseEntity
{
public int Id { get; set; }
public string Name { get; set; }
[NotMapped]
public bool IsNew => Id <= 0;
public override string ToString() => $"Id={Id} | Name={Name} | Type={GetType()}";
}
public class Person : BaseEntity
{
public List<PersonPlace> PersonPlaceCollection { get; set; } = new List<PersonPlace>();
public void AddPlace(Place place) => PersonPlaceCollection.Add(new PersonPlace { Place = place });
}
public class Place : BaseEntity
{
public List<PersonPlace> PersonPlaceCollection { get; set; } = new List<PersonPlace>();
public void AddPerson(Person person) => PersonPlaceCollection.Add(new PersonPlace { Person = person, PersonId = person?.Id, PlaceId = 0});
}
public class PersonPlace : BaseEntity
{
public int? PersonId { get; set; }
public Person Person { get; set; }
public int? PlaceId { get; set; }
public Place Place { get; set; }
}
#endregion
#region Context
public class MyContext : DbContext
{
public DbSet<Person> PersonCollection { get; set; }
public DbSet<Place> PlaceCollection { get; set; }
public DbSet<PersonPlace> PersonPlaceCollection { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
// PersonPlace
builder.Entity<PersonPlace>()
.HasAlternateKey(o => new { o.PersonId, o.PlaceId });
builder.Entity<PersonPlace>()
.HasOne(pl => pl.Person)
.WithMany(p => p.PersonPlaceCollection)
.HasForeignKey(p => p.PersonId);
builder.Entity<PersonPlace>()
.HasOne(p => p.Place)
.WithMany(pl => pl.PersonPlaceCollection)
.HasForeignKey(p => p.PlaceId);
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlServer(@"Server=(localdb)\mssqllocaldb;Database=EF110CoreTest;Trusted_Connection=True;");
}
}
#endregion
}
and the corresponding project file:
{
"version": "1.0.0-*",
"buildOptions": {
"emitEntryPoint": true
},
"dependencies": {
"Microsoft.EntityFrameworkCore": "1.1.0",
"Microsoft.EntityFrameworkCore.SqlServer": "1.1.0",
"Microsoft.EntityFrameworkCore.Tools": "1.1.0-preview4-final"
},
"frameworks": {
"net461": {}
},
"tools": {
"Microsoft.EntityFrameworkCore.Tools.DotNet": "1.0.0-preview3-final"
}
}
Working source with EF7 / DNX
using System.Collections.Generic;
using Microsoft.Data.Entity;
using System.Linq;
using System.ComponentModel.DataAnnotations.Schema;
namespace EF7Test
{
public class Program
{
public static void Main(string[] args)
{
// One scope for initial data
using (var context = new MyContext())
{
// Repo
var personRepo = new DbRepository<Person>(context);
var placeRepo = new DbRepository<Place>(context);
// Database
context.Database.EnsureDeleted();
context.Database.EnsureCreated();
/***********************************************************************/
// Step 1 : Create a person
var jonSnow = new Person() { Name = "Jon SNOW" };
personRepo.InsertOrUpdate(jonSnow);
personRepo.SaveChanges();
/***********************************************************************/
// Step 2 : Create a place
var castleblackPlace = new Place() { Name = "Castleblack" };
placeRepo.InsertOrUpdate(castleblackPlace);
placeRepo.SaveChanges();
/***********************************************************************/
}
// Another scope to put one people in one place
using (var context = new MyContext())
{
// Repo
var personRepo = new DbRepository<Person>(context);
var placeRepo = new DbRepository<Place>(context);
// entities
var jonSnow = personRepo.FindByNameAsNoTracking("Jon SNOW");
var castleblackPlace = placeRepo.FindByNameAsNoTracking("Castleblack");
// Step 3 : add person to this place
castleblackPlace.AddPerson(jonSnow);
placeRepo.InsertOrUpdate(castleblackPlace);
placeRepo.SaveChanges();
}
}
}
public class DbRepository<T> where T : BaseEntity
{
public readonly MyContext _context;
public DbRepository(MyContext context) { _context = context; }
public virtual T FindByNameAsNoTracking(string name) => _context.Set<T>().AsNoTracking().FirstOrDefault(e => e.Name == name);
public void InsertOrUpdate(T entity)
{
if (entity.IsNew) Insert(entity); else Update(entity);
}
public void Insert(T entity) => _context.Add(entity);
public void Update(T entity) => _context.Update(entity);
public void SaveChanges() => _context.SaveChanges();
}
#region Models
public abstract class BaseEntity
{
public int Id { get; set; }
public string Name { get; set; }
[NotMapped]
public bool IsNew => Id <= 0;
public override string ToString() => $"Id={Id} | Name={Name} | Type={GetType()}";
}
public class Person : BaseEntity
{
public List<PersonPlace> PersonPlaceCollection { get; set; } = new List<PersonPlace>();
public void AddPlace(Place place) => PersonPlaceCollection.Add(new PersonPlace { Place = place });
}
public class Place : BaseEntity
{
public List<PersonPlace> PersonPlaceCollection { get; set; } = new List<PersonPlace>();
public void AddPerson(Person person) => PersonPlaceCollection.Add(new PersonPlace { Person = person, PersonId = person?.Id, PlaceId = 0 });
}
public class PersonPlace : BaseEntity
{
public int? PersonId { get; set; }
public Person Person { get; set; }
public int? PlaceId { get; set; }
public Place Place { get; set; }
}
#endregion
#region Context
public class MyContext : DbContext
{
public DbSet<Person> PersonCollection { get; set; }
public DbSet<Place> PlaceCollection { get; set; }
public DbSet<PersonPlace> PersonPlaceCollection { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
// PersonPlace
builder.Entity<PersonPlace>()
.HasAlternateKey(o => new { o.PersonId, o.PlaceId });
builder.Entity<PersonPlace>()
.HasOne(pl => pl.Person)
.WithMany(p => p.PersonPlaceCollection)
.HasForeignKey(p => p.PersonId);
builder.Entity<PersonPlace>()
.HasOne(p => p.Place)
.WithMany(pl => pl.PersonPlaceCollection)
.HasForeignKey(p => p.PlaceId);
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlServer(@"Server=(localdb)\mssqllocaldb;Database=EF7Test;Trusted_Connection=True;");
}
}
#endregion
}
And the corresponding project file:
{
"version": "1.0.0-*",
"buildOptions": {
"emitEntryPoint": true
},
"dependencies": {
"EntityFramework.Commands": "7.0.0-rc1-*",
"EntityFramework.Core": "7.0.0-rc1-*",
"EntityFramework.MicrosoftSqlServer": "7.0.0-rc1-*"
},
"frameworks": {
"dnx451": {}
},
"commands": {
"ef": "EntityFramework.Commands"
}
}
Issue Analytics
- State:
- Created 7 years ago
- Reactions:5
- Comments:5 (2 by maintainers)
@kall2sollies Thanks for the very good bug report. The reason for this is that in 1.1 whenever we determine that an entity should be Added because it does not have a key set, then all entities discovered as children of that entity will also be marked as Added. In this case, the entity in the join table is new and marked as Added, and hence the Place entity also ended up being marked as Added. This has been changed as part of #6990 so the code you provided now results in the join table entity being marked as Added while the Person is still marked as Modified. I’m going to close this as a duplicate if that bug. Also, winter is coming. 😉
@kall2sollies big thanks for a really good bug report! That’s where an open source project becomes shining. @ajcvickers thank you for a good and simple explanation and of course for your work. Waiting ef core 2.0 release.