保存 EF Core 上下文时插入的重复值

duplicate values inserted when saving the EF Core context

我有一个管理区域(区域)土壤的小型应用程序。一个 ZoneSoil 个它可以与之关联。

当我创建区域时,我可以 select 土壤然后保存,但是一旦创建,当我在修改中打开它并点击“保存”时,没有任何其他,它抛出我:

SqlException: Violation of PRIMARY KEY constraint 'PK_SoilZone'. Cannot insert duplicate key in object 'dbo.SoilZone'. The duplicate key value is (1, 1).

景色是这样的

企业Class:

public class Zone
{
    public string Name { get; set; }
    
    public int CountryId { get; set; }
    public Country Country { get; set; }
    
    #region navigation props
    public ICollection<Soil> Soils { get; set; } = new List<Soil>();        
    public List<SoilZone> SoilZones { get; set; } = new List<SoilZone>();
    #endregion
}

DTO 对象(ViewModel):

public class ZoneDTO
{
    public string Name { get; set; }
    
    public int CountryId { get; set; }
    public string CountryName { get; set; }

    public int[] AvailableSoilIds { get; set; } = new int[] { };
    public int[] SoilIds { get; set; } = new int[] { };
    public string[] SoilNames { get; set; } = new string[] { };
}

自动映射:

CreateMap<Zone, ZoneDTO>()
    .ForMember(p => p.CountryName, o => o.MapFrom(p => p.Country.Name))
    .ForMember(p => p.SoilIds, o => o.MapFrom(p => p.Soils.Select(s => s.Id).ToArray()))
    .ForMember(p => p.SoilNames, o => o.MapFrom(p => p.Soils.Select(s => s.Name).ToArray()))
    .ReverseMap();

查看:

@model MyApp.Web.DTOs.ZoneDTO

<form asp-action="Edit">
    <div>
        <label asp-for="Name"></label>
        <input asp-for="Name" />
    </div>

    <div>
        <label asp-for="CountryId" class="control-label"></label>
        <select asp-for="CountryId" class="form-control" asp-items="ViewBag.CountryId"></select>

        <label asp-for="SoilIds"></label>               
        <select asp-for="AvailableSoilIds" asp-items="ViewBag.AvailableSoils" multiple="multiple"></select>
        <a href="#" id="addSoil">Add</a>

        <select asp-for="SoilIds" asp-items="ViewBag.Soils" multiple="multiple"></select>
        <a href="#" id="removeSoil">Remove</a>
    </div>

    <input type="hidden" asp-for="Id" />
    <div>
        <input type="submit" value="Save" />
        <a asp-action="Index" >Cancel</a>
    </div>
</form>

管理员编辑:

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, ZoneDTO zoneDto)
{
    if (ModelState.IsValid)
    {
        try
        {
            var zone = _mapper.Map<Zone>(zoneDto);
            
            //  from here I am not sure if it's OK <<<<<<<<<<<<
            zone.Soils.Clear();
            foreach (var soilId in zoneDto.SoilIds)
            {
                var soil = _context.Soils.Where(s => s.Id == soilId).FirstOrDefault();
                zone.Soils.Add(soil);
            }
            zone.Country = _context.Country.Find(zoneDto.CountryId);

            _context.Update(zone);
            await _context.SaveChangesAsync();
            // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
        }
        catch (DbUpdateConcurrencyException)
        {
            if (!ZoneExists(zoneDto.Id)) {
                return NotFound();
            }
            else {
                throw;
            }
        }
        return RedirectToAction(nameof(Index));
    }
    var dto = _mapper.Map<ZoneDTO>(zoneDto);
    ViewData["CountryId"] = new SelectList(_context.Country, "Id", "Code", zoneDto.CountryId);
    ViewData["AvailableSoils"] = new MultiSelectList(_context.Soils, "Id", "Name", dto.SoilIds);
    return View(dto);
}

和 EF 配置:

public override void Configure(EntityTypeBuilder<Zone> builder)
{
    base.Configure(builder);
    builder.HasIndex(p => p.Nom);
    builder
        .HasOne(p => p.Country)
        .WithMany(p => p.Zones)
        .HasForeignKey(p => p.CountryId)
        .IsRequired();

    builder
        .HasMany(p => p.Soils)
        .WithMany(p => p.Zones)
        .UsingEntity<SoilZone>(
            p => p
                .HasOne(p => p.Soil)
                .WithMany(p => p.SoilZones)
                .HasForeignKey(p => p.SoilId),
            p => p
                .HasOne(p => p.Zone)
                .WithMany(p => p.SoilZones)
                .HasForeignKey(p => p.ZoneId),
            p =>
            {
                p.HasKey(k => new { k.ZoneId, k.SoilId });
            });
}

在你的 foreach.

中尝试 _context.Soils.Include(s => s.SoilZones).Where(s => s.Id == soilId)

这里也不需要foreach,试试这样的:

var zone = _mapper.Map<Zone>(zoneDto);
zone.Soils = _context.Soils
    .Include(s => s.SoilZones)
    .Where(s => zoneDto.SoilIds.Contains(s.Id))
    .ToList();
zone.Country = _context.Country.Find(zoneDto.CountryId);
_context.Update(zone);

最后,我没有从 DTO 获取对象,而是从 dbContext 获取对象并从 DTO 手动更新它,如下所示:

var zoneDB = _context.Zones.Include(z => z.Soils).Where(z => z.Id == zoneDto.Id).First();

zoneDB.Soils.Clear();
foreach (var soilId in zoneDto.SoilIds)
{
    var soil = _context.Sols.Find(soilId);
    if (sol != null)
        zoneDB.Soils.Add(sol);
}
zoneDB.Name = zoneDto.Name;

_context.Update(zoneDB);
await _context.SaveChangesAsync();

也许它不是那么漂亮,但它确实有效....

在处理引用时,您应该预先加载您的子集合,然后根据更改的关系确定性地修改它。

例如你的例子:

//  from here I am not sure if it's OK <<<<<<<<<<<<
zone.Soils.Clear(); 
foreach (var soilId in zoneDto.SoilIds)
{
    var soil = _context.Soils.Where(s => s.Id == soilId).FirstOrDefault();
    zone.Soils.Add(soil);
}

看起来您正在尝试做的是清除任何现有的土壤关联并重新关联它们。

我强烈建议不要这样做:

var zone = _mapper.Map<Zone>(zoneDto);
// ...
_context.Update(zone);

这种方法的问题是您信任 zoneDto 中的数据来创建区域,并将使用该数据覆盖您的数据记录。 DTO 应仅包含足以识别记录的数据,并且仅包含操作可以可以修改的数据。在您的情况下,Zone 中的几乎所有内容都可能存在,但在其他情况下,如果您有其他 FK 关系等,客户端无法作为此操作的一部分进行更改,您 不想 在 DTO 中公开它们。 (还没有组成一个对象来更新数据库,Mapper 调用将需要它们)从数据库操作的角度来看,它也是低效的。当利用更改跟踪和 SaveChanges 时,EF 将仅为确认已更改的值编写更新语句。使用 Update 或将实体状态设置为 Modified 会导致更新语句更新 所有 列,无论它们是否更改。

相反,正如 Guru 指出的那样,您应该仅使用 DTO 中的 ID 从要更新的 DbContext 中获取对象。但是,如果您希望有 1 条记录,请使用 Single 而不是 FirstFirst 之类的方法仅应在您期望多行时使用,并且应始终包含 OrderBy* 子句以使选择可预测。

由于我们将关联土壤,因此我们也希望预先加载它们:

var zoneDB = _context.Zones
    .Include(z => z.Soils)
    .Single(z => z.Id == zoneDto.Id);

要更新土壤,请确定需要添加和移除哪些土壤。对于需要添加的土壤,我们可以一次性全部取回。

var existingSoilIds = zoneDB.Soils.Select(x => x.Id).ToList();
var soilIdsToRemove = existingSoilIds.Except(zoneDto.SoilIds).ToList();
var soilIdsToAdd = zoneDto.SoilIds.Except(existingSoilIds).ToList();

foreach(var soilId in soilIdsToRemove)
    zoneDb.Soils.Remove(zoneDb.Soils.Single(x => x.Id == soilId));

var soilsToAdd = _context.Soils.Where(x => soilIdsToAdd.Contains(x.Id)).ToList();
foreach(var soil in soilsToAdd)
    zoneDb.Sois.Add(soil);

_context.SaveChanges();

这将决定添加和移除哪些土壤。对于添加的土壤,我们可以一次从 DbContext 中获取它们。删除了我们刚刚在急切加载的集合中找到的 ID 并将其删除。更改跟踪将处理插入和删除。

这假设 Zone.Soils 声明为 ICollection<Soil>。如果声明为 List<Soil>,您可以使用 AddRange / RemoveRange,尽管 virtual ICollection<Soil> 通常是推荐的声明,以确保支持更改跟踪和延迟加载。