【问题标题】:duplicate values inserted when saving the EF Core context保存 EF Core 上下文时插入的重复值
【发布时间】:2021-05-11 10:01:54
【问题描述】:

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

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

SqlException:违反主键约束“PK_SoilZone”。 无法在对象“dbo.SoilZone”中插入重复键。复制品 键值为 (1, 1)。

景色是这样的

商务舱:

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

【问题讨论】:

标签: c# entity-framework asp.net-core entity-framework-core ef-core-5.0


【解决方案1】:

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

例如,从您的示例中:

//  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 而不是 First。像 First 这样的方法应该只在您期望多行时使用,并且应该始终包含 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&lt;Soil&gt;。如果声明为List&lt;Soil&gt;,则可以使用AddRange/RemoveRange,尽管virtual ICollection&lt;Soil&gt; 通常是推荐的声明,以确保支持更改跟踪和延迟加载。

【讨论】:

  • 如果我理解得很好,在做await _context.SaveChangesAsync();之前不需要_context.Update(zoneDB)(另见我自己的答案)
  • 是的。只要您从 DbContext 加载跟踪的实体并复制值(手动或使用 Mapper.Map),那么您只需调用SaveChanges,EF 就会为这些实体中发生更改的实体和字段编写更新语句。
【解决方案2】:

在您的foreach 中尝试 _context.Soils.Include(s =&gt; s.SoilZones).Where(s =&gt; 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,而不是从 dto 映射然后从上下文中添加其元素...
【解决方案3】:

最后,我没有从 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();

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

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2012-01-17
    • 1970-01-01
    • 2013-08-14
    • 2018-08-28
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2023-03-17
    相关资源
    最近更新 更多