EF 和自动映射器。更新嵌套集合

EF & Automapper. Update nested collections

我正在尝试更新国家/地区实体的嵌套集合(城市)。

只是简单的实体和 dto:

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

此代码抛出异常:

The operation failed: The relationship could not be changed because one or more of the foreign-key properties is non-nullable. When a change is made to a relationship, the related foreign-key property is set to a null value. If the foreign-key does not support null values, a new relationship must be defined, the foreign-key property must be assigned another non-null value, or the unrelated object must be deleted.

尽管上次映射后的实体看起来还不错并且正确反映了所有更改。

我花了很多时间寻找解决方案,但没有结果。请帮忙。

问题是您从数据库中检索的 country 已经有一些城市。当您像这样使用 AutoMapper 时:

// mapping 
AutoMapper.Mapper.Map(countryDTO, country);

AutoMapper 正在做一些事情,比如正确创建 IColletion<City>(在您的示例中有一个城市),并将这个全新的 collection 分配给您的 country.Cities 属性。

问题是 EntityFramework 不知道如何处理旧的 collection 城市。

  • 它是否应该删除您的旧城市并仅假设新的 collection?
  • 是否应该合并两个列表并将它们都保存在数据库中?

事实上,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));
            }
        }
    }
}

用于 CitiesIgnore() 配置使 AutoMapper 仅保留由 EntityFramework 构建的原始代理引用。

然后我们只需使用 AfterMap() 来调用一个完全按照您的想法执行的操作:

  • 对于新城市,我们从 DTO 映射到 Entity(AutoMapper 创建一个新的 实例)并将其添加到国家/地区的 collection.
  • 对于现有城市,我们使用 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();
    }

当保存更改时,所有城市都被视为已添加,因为 EF 在保存时间之前没有关注它们。因此 EF 尝试将 null 设置为旧城市的外键并插入它而不是更新。

使用 ChangeTracker.Entries() 您会发现 EF 将对 CRUD 进行哪些更改。

如果您只想手动更新现有城市,您只需执行以下操作:

foreach (var city in country.cities)
{
    context.Cities.Attach(city); 
    context.Entry(city).State = EntityState.Modified;
}

context.SaveChanges();

看来我找到了解决办法:

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

此代码会更新已编辑的项目并添加新的项目。但也许有一些我现在无法检测到的陷阱?

A​​lisson 的解决方案非常好。这是我的解决方案... 正如我们所知,EF 不知道请求是更新还是插入,所以我要做的是先使用 RemoveRange() 方法删除,然后发送集合以再次插入。在后台,这就是数据库的工作方式,然后我们可以手动模拟这种行为。

代码如下:

// 来自请求的国家对象,例如 </p> <p>var cities = dbcontext.Cities.Where(x=>x.countryId == country.Id);</p> <p>dbcontext.Cities.RemoveRange(城市);</p> <p>/* 现在进行映射并发送对象,这将批量插入到 table 相关 */

这本身并不是对 OP 的回答,但今天看到类似问题的任何人都应该考虑使用 AutoMapper.Collection。它为这些 parent-child 收集问题提供了支持,这些问题过去需要大量代码才能处理。

对于没有提供好的解决方案或更多细节,我深表歉意,但我现在只是在加快速度。上面 link 中显示的 README.md 中有一个很好的简单示例。

使用它需要一些重写,但它极大地 减少了您必须编写的代码量,特别是如果您使用的是 EF 并且可以利用AutoMapper.Collection.EntityFramework.

我花了一些时间为 AutoMapper 11+ 提出更好的解决方案,因为目前没有不使用 AfterMap() 的 EF Core 和映射关系集合的解决方案。这不像它可能的那样有效(需要多次枚举),但它在映射大量子关系时节省了大量模板,并且如果源集合和目标集合的顺序不同,则支持条件:

// AutoMapper Profile
public class MyProfile : Profile
{
  protected override void Configure()
  {
    Mapper.CreateMap<CountryData, Country>()
      .ForMember(d => d.Id, opt => opt.MapFrom(x => x.Id))
      // relationship collections must be ignored, CountryDataMappingAction will take care of it
      .ForMember(d => d.Cities, opt => opt.Ignore())
      .AfterMap<CountryDataMappingAction>();
  }

  public class CountryDataMappingAction : BaseCollectionMapperAction<CountryData, Country>
  {
    public override void Process(CountryData source, Country destination, ResolutionContext context)
    {
      MapCollection(source.Cities, destination.Cities, (x, y) => x.Id == y.Id, context);
    }
  }
}
public class BaseCollectionMapperAction<TSource, TDestination> : IMappingAction<TSource, TDestination>
{
    public void MapCollection<TCollectionSource, TCollectionDestination>(IEnumerable<TCollectionSource> sourceCollection, IEnumerable<TCollectionDestination> destCollection, Func<TCollectionSource, TCollectionDestination, bool> predicate, ResolutionContext context)
    {
        MapCollection(sourceCollection.ToList(), destCollection.ToList(), predicate, context);
    }

    public void MapCollection<TCollectionSource, TCollectionDestination>(IList<TCollectionSource> sourceList, IList<TCollectionDestination> destList, Func<TCollectionSource, TCollectionDestination, bool> predicate, ResolutionContext context)
    {
        for (var sourceIndex = 0; sourceIndex < sourceList.Count; sourceIndex++)
        {
            for (var destIndex = 0; sourceIndex < destList.Count; destIndex++)
            {
                var result = predicate(sourceList[sourceIndex], destList[destIndex]);
                if (result)
                {
                    destList[destIndex] = context.Mapper.Map(sourceList[sourceIndex], destList[destIndex]);
                    break;
                }
            }
        }
    }

    public virtual void Process(TSource source, TDestination destination, ResolutionContext context)
    {
        throw new NotImplementedException("You must provide a mapping implementation!");
    }
}