EF & Automapper. Обновление вложенных коллекций
Я пытаюсь обновить вложенную коллекцию (Города) объекта Country.
Просто простые enitities и dto's:
// EF Models
public class Country
{
public int Id { get; set; }
public string Name { get; set; }
public virtual ICollection<City> Cities { get; set; }
}
public class City
{
public int Id { get; set; }
public string Name { get; set; }
public int CountryId { get; set; }
public int? Population { get; set; }
public virtual Country Country { get; set; }
}
// DTo's
public class CountryData : IDTO
{
public int Id { get; set; }
public string Name { get; set; }
public virtual ICollection<CityData> Cities { get; set; }
}
public class CityData : IDTO
{
public int Id { get; set; }
public string Name { get; set; }
public int CountryId { get; set; }
public int? Population { get; set; }
}
И сам код (протестирован в консольном приложении для простоты):
using (var context = new Context())
{
// getting entity from db, reflect it to dto
var countryDTO = context.Countries.FirstOrDefault(x => x.Id == 1).ToDTO<CountryData>();
// add new city to dto
countryDTO.Cities.Add(new CityData
{
CountryId = countryDTO.Id,
Name = "new city",
Population = 100000
});
// change existing city name
countryDTO.Cities.FirstOrDefault(x => x.Id == 4).Name = "another name";
// retrieving original entity from db
var country = context.Countries.FirstOrDefault(x => x.Id == 1);
// mapping
AutoMapper.Mapper.Map(countryDTO, country);
// save and expecting ef to recognize changes
context.SaveChanges();
}
Этот код генерирует исключение:
Операция завершилась неудачно: отношение не может быть изменено, поскольку одно или несколько свойств внешнего ключа не имеют значения NULL. Когда происходит изменение отношения, соответствующее свойство внешнего ключа устанавливается равным нулевому значению. Если внешний ключ не поддерживает нулевые значения, необходимо определить новое отношение, для свойства внешнего ключа должно быть назначено другое ненулевое значение, или не связанный с ним объект должен быть удален.
хотя объект после последнего сопоставления кажется просто прекрасным и отражает все изменения должным образом.
Я потратил много времени на поиск решения, но не получил никакого результата. Пожалуйста, помогите.
Ответы
Ответ 1
Проблема в том, что country
вы извлекаете из базы данных, уже имеет несколько городов. Когда вы используете AutoMapper, как это:
// mapping
AutoMapper.Mapper.Map(countryDTO, country);
AutoMapper делает что-то вроде правильного создания IColletion<City>
(с одним городом в вашем примере) и присваивает эту совершенно новую коллекцию свойству country.Cities
.
Проблема в том, что EntityFramework не знает, что делать со старой коллекцией городов.
- Должен ли он удалить ваши старые города и принять только новую коллекцию?
- Стоит ли просто объединить два списка и сохранить оба в базе данных?
На самом деле, EF не может решить за вас. Если вы хотите продолжать использовать AutoMapper, вы можете настроить отображение следующим образом:
// AutoMapper Profile
public class MyProfile : Profile
{
protected override void Configure()
{
Mapper.CreateMap<CountryData, Country>()
.ForMember(d => d.Cities, opt => opt.Ignore())
.AfterMap(AddOrUpdateCities);
}
private void AddOrUpdateCities(CountryData dto, Country country)
{
foreach (var cityDTO in dto.Cities)
{
if (cityDTO.Id == 0)
{
country.Cities.Add(Mapper.Map<City>(cityDTO));
}
else
{
Mapper.Map(cityDTO, country.Cities.SingleOrDefault(c => c.Id == cityDTO.Id));
}
}
}
}
Конфигурация Ignore()
используемая для Cities
заставляет AutoMapper просто сохранять исходную ссылку на прокси, EntityFramework
.
Затем мы просто используем AfterMap()
чтобы вызвать действие, выполняющее то, что вы думаете:
- Для новых городов мы сопоставляем DTO и Entity (AutoMapper создает новый экземпляр) и добавляем его в коллекцию стран.
- Для существующих городов мы используем перегрузку
Map
которой мы передаем существующую сущность в качестве второго параметра, а прокси города - в качестве первого параметра, поэтому automapper просто обновляет свойства существующей сущности.
Тогда вы можете сохранить свой оригинальный код:
using (var context = new Context())
{
// getting entity from db, reflect it to dto
var countryDTO = context.Countries.FirstOrDefault(x => x.Id == 1).ToDTO<CountryData>();
// add new city to dto
countryDTO.Cities.Add(new CityData
{
CountryId = countryDTO.Id,
Name = "new city",
Population = 100000
});
// change existing city name
countryDTO.Cities.FirstOrDefault(x => x.Id == 4).Name = "another name";
// retrieving original entity from db
var country = context.Countries.FirstOrDefault(x => x.Id == 1);
// mapping
AutoMapper.Mapper.Map(countryDTO, country);
// save and expecting ef to recognize changes
context.SaveChanges();
}
Ответ 2
при сохранении изменений все города считаются добавленными, потому что EF теперь не о них до экономии времени. Таким образом, EF пытается установить нуль в внешний ключ старого города и вставить его вместо обновления.
используя ChangeTracker.Entries()
, вы узнаете, какие изменения CRUD будут сделаны EF.
Если вы хотите просто обновить существующий город вручную, вы можете просто сделать:
foreach (var city in country.cities)
{
context.Cities.Attach(city);
context.Entry(city).State = EntityState.Modified;
}
context.SaveChanges();
Ответ 3
Кажется, я нашел решение:
var countryDTO = context.Countries.FirstOrDefault(x => x.Id == 1).ToDTO<CountryData>();
countryDTO.Cities.Add(new CityData { CountryId = countryDTO.Id, Name = "new city 2", Population = 100000 });
countryDTO.Cities.FirstOrDefault(x => x.Id == 11).Name = "another name";
var country = context.Countries.FirstOrDefault(x => x.Id == 1);
foreach (var cityDTO in countryDTO.Cities)
{
if (cityDTO.Id == 0)
{
country.Cities.Add(cityDTO.ToEntity<City>());
}
else
{
AutoMapper.Mapper.Map(cityDTO, country.Cities.SingleOrDefault(c => c.Id == cityDTO.Id));
}
}
AutoMapper.Mapper.Map(countryDTO, country);
context.SaveChanges();
этот код обновляет отредактированные элементы и добавляет новые. Но, возможно, есть некоторые подводные камни, которые я пока не могу обнаружить?
Ответ 4
Очень хорошее решение Alisson. Вот мое решение...
Поскольку мы знаем, что EF не знает, будет ли запрос обновляться или вставляться, то что бы я сделал, сначала удалите метод RemoveRange() и отправьте сборку, чтобы вставить ее снова. В фоновом режиме это то, как работает база данных, мы можем эмулировать это поведение вручную.
Вот код:
//country object from request for example
var cities = dbcontext.Cities.Where(x= > x.countryId == country.Id);
dbcontext.Cities.RemoveRange(cities);
/* Now make the mappings and send the object this will make bulk insert into the table related */
Ответ 5
Это не само по себе ответ на OP, но любой, кто смотрит на подобную проблему сегодня, должен рассмотреть возможность использования AutoMapper.Collection. Он обеспечивает поддержку этих проблем родительско-дочерних коллекций, которые раньше требовали много кода для обработки.
Я прошу прощения за то, что не включил хорошее решение или более подробную информацию, но сейчас я только ускоряюсь. В README.md есть отличный простой пример, показанный по ссылке выше.
Использование этого требует некоторой переписки, но это существенно сокращает объем кода, который вы должны написать, особенно если вы используете EF и можете использовать AutoMapper.Collection.EntityFramework
.