Присоединение сущности с сочетанием существующих и новых сущностей в ее графике (Entity Framework Core 1.1.0)

я столкнулся с проблемой при присоединении сущностей, содержащих ссылочные свойства к существующим сущностям (я называю существующую сущность сущностью, которая уже существует в базе данных и имеет свой ПК правильно установлен).

проблема заключается в использовании Entity Framework Core 1.1.0. Это то, что отлично работает с Entity Framework 7 (начальное имя ядра Entity Framework).

я не пробовал это ни с EF6, ни с EF Core 1.0.0.

интересно, это регрессия или изменение поведения, сделанное специально.

модель

тестовая модель состоит из Place, Person и отношения "многие ко многим" между местом и человеком через присоединяющуюся сущность с именем 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; }
}

контекст базы данных

все отношения явно определены (без избыточность.)

    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);
    }

все конкретные объекты также представлены в этой модели:

public DbSet<Person> PersonCollection { get; set; } 
public DbSet<Place> PlaceCollection { get; set; }
public DbSet<PersonPlace> PersonPlaceCollection { get; set; }

Факторинг доступа к данным

я использую базовый класс в стиле репозитория для учета всего кода, связанного с доступом к данным.

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();
}

шаги для воспроизведения исключения

создайте одного человека и сохраните его. Создайте одно место и сохраните его.

// 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();

и человек и место в база данных, и, таким образом, первичный ключ определен. PK создаются SQL Server в виде столбцов идентификаторов.

перезагрузите человека и место, как отдельно стоящее сущности (тот факт, что они отсоединены, используется для макета сценария http-объектов через веб-API, например, с angularJS на стороне клиента).

// detached entities
var jonSnow = personRepo.FindByNameAsNoTracking("Jon SNOW");
var castleblackPlace = placeRepo.FindByNameAsNoTracking("Castleblack");

добавьте человека на место и сохраните это:

castleblackPlace.PersonPlaceCollection.Add(
    new PersonPlace()  { Person = jonSnow }
);
placeRepo.Update(castleblackPlace);
placeRepo.SaveChanges();

On SaveChanges создается исключение, потому что EF Core 1.1.0 пытается вставить существующий человек вместо того, чтобы делать обновление (хотя его значение первичного ключа установлено).

сведения об исключении

Microsoft.EntityFrameworkCore.DbUpdateException: произошла ошибка при обновлении записей. Подробнее см. Внутреннее исключение. ---> Система.Данные.В sqlclient.SqlException: не удается вставить явное значение столбца identity в таблицу "Person", если IDENTITY_INSERT имеет значение ВЫКЛЮЧЕНО.

предыдущие версии

этот код будет работать отлично (хотя и не обязательно оптимизирован) с альфа-версией EF Core (с именем EF7) и DNX CLI.

решение

выполните итерацию по корневому графу сущности и правильно установите состояния сущности:

_context.ChangeTracker.TrackGraph(entity, node =>
    {
        var entry = node.Entry;
        var childEntity = (BaseEntity)entry.Entity;
        entry.State = childEntity.Id <= 0? EntityState.Added : EntityState.Modified;
    });

какой вопрос наконец ???

почему мы должны вручную отслеживать состояния сущности, в то время как предыдущие версии EF полностью справились бы с этим, даже при повторном присоединении отдельных объектов ?

полный источник воспроизведения (EFCore 1.1.0 - не работает)

полный источник воспроизведения (обходной путь, описанный выше, включен, но его вызов прокомментирован. Удалив этот источник работы).

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
}

.файл json для EFCore1.1.0 проект

{
  "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"
  }
}

рабочий источник в случае 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
}

и соответствующий файл проекта:

{
"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"
}
}

1 ответов


после некоторых исследований, чтения комментариев, сообщений в блоге и, прежде всего, ответа члена команды EF на вопрос, который я представил в репозитории GitHub, кажется, что поведение, которое я заметил в своем вопросе, не является ошибкой, а особенностью EF Core 1.0.0 и 1.1.0.

[...] в 1.1 всякий раз, когда мы определяем, что объект должен быть добавлен поскольку у него нет набора ключей, все объекты, обнаруженные как дети этой сущности также будут помечены как Добавлен.

(Артур Викерс ->https://github.com/aspnet/EntityFramework/issues/7334)

Итак, то, что я назвал "обходным путем", на самом деле является рекомендуемой практикой, как заявил Иван Стоев в своем комментарии.

работа с состояниями сущностей в соответствии с их состоянием первичного ключа

на DbContect.ChangeTracker.TrackGraph(object rootEntity, Action<EntityEntryGraphNodes> callback) метод принимает корневой объект (тот, который разнесен или добавлен, обновлен, прикреплен, что угодно), а затем выполняет итерацию по всем обнаруженным сущностям в графе отношений корня и выполняет действие обратного вызова.

это можно назвать до до _context.Add() или _context.Update() методы.

_context.ChangeTracker.TrackGraph(rootEntity, node => 
{ 
    node.Entry.State = n.Entry.IsKeySet ? 
        EntityState.Modified : 
        EntityState.Added; 
});

но (ничего не сказано До " но " на самом деле имеет значение!) есть что-то, чего мне не хватало слишком долго, и это вызвало у меня головные боли:

если обнаружен объект, который уже отслеживается контекст, этот объект не обрабатывается (и его свойства навигации не являются пройденный.)

(источник: intellisense этого метода !)

поэтому, возможно, было бы безопасно убедиться, что контекст свободен от чего-либо перед публикацией отключенного объекта:

public virtual void DetachAll()
{
    foreach (var entityEntry in _context.ChangeTracker.Entries().ToArray())
    {
        if (entityEntry.Entity != null)
        {
            entityEntry.State = EntityState.Detached;
        }
    }
}

отображение состояния на стороне клиента

другой подход-иметь дело с государством на стороне клиента, post entities (поэтому отключен по дизайну), и установить их состояние в соответствии с состоянием на стороне клиента.

во-первых, определите перечисление, которое сопоставляет состояния клиента с состояниями сущности (только отсоединенное состояние отсутствует, потому что не имеет смысла):

public enum ObjectState
{
    Unchanged = 1,
    Deleted = 2,
    Modified = 3,
    Added = 4
}

затем используйте DbContect.ChangeTracker.TrackGraph(object rootEntity, Action<EntityEntryGraphNodes> callback) метод для установки состояний сущности в соответствии с состоянием клиента:

_context.ChangeTracker.TrackGraph(entity, node =>
{
    var entry = node.Entry;
    // I don't like switch case blocks !
    if (childEntity.ClientState == ObjectState.Deleted) entry.State = EntityState.Deleted;
    else if (childEntity.ClientState == ObjectState.Unchanged) entry.State = EntityState.Unchanged;
    else if (childEntity.ClientState == ObjectState.Modified) entry.State = EntityState.Modified;
    else if (childEntity.ClientState == ObjectState.Added) entry.State = EntityState.Added;
});

при таком подходе я использую BaseEntity абстрактный класс, который разделяет Id (PK) моих сущностей, а также ClientState (типа ObjectState) (и isnew accessor, основанный на значении PK)

public abstract class BaseEntity
{
    public int Id {get;set;}
    [NotMapped]
    public ObjectState ClientState { get;set; } = ObjectState.Unchanged;
    [NotMapped]
    public bool IsNew => Id <= 0;
}

оптимистический / эвристический подход

это то, что я фактически реализовал. У меня есть смесь старого подхода (это означает, что если En entity имеет PK undefined, он должен быть добавлен, и если корень имеет PK, он должен быть обновлен) и подход к состоянию клиента:

_context.ChangeTracker.TrackGraph(entity, node =>
{
    var entry = node.Entry;
    // cast to my own BaseEntity
    var childEntity = (BaseEntity)node.Entry.Entity;
    // If entity is new, it must be added whatever the client state
    if (childEntity.IsNew) entry.State = EntityState.Added;
    // then client state is mapped
    else if (childEntity.ClientState == ObjectState.Deleted) entry.State = EntityState.Deleted;
    else if (childEntity.ClientState == ObjectState.Unchanged) entry.State = EntityState.Unchanged;
    else if (childEntity.ClientState == ObjectState.Modified) entry.State = EntityState.Modified;
    else if (childEntity.ClientState == ObjectState.Added) entry.State = EntityState.Added;
});