保存 EF Core 上下文时插入的重复值
duplicate values inserted when saving the EF Core context
我有一个管理区域(区域)土壤的小型应用程序。一个 Zone
有 Soil
个它可以与之关联。
当我创建区域时,我可以 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
而不是 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<Soil>
。如果声明为 List<Soil>
,您可以使用 AddRange
/ RemoveRange
,尽管 virtual ICollection<Soil>
通常是推荐的声明,以确保支持更改跟踪和延迟加载。
我有一个管理区域(区域)土壤的小型应用程序。一个 Zone
有 Soil
个它可以与之关联。
当我创建区域时,我可以 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
而不是 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<Soil>
。如果声明为 List<Soil>
,您可以使用 AddRange
/ RemoveRange
,尽管 virtual ICollection<Soil>
通常是推荐的声明,以确保支持更改跟踪和延迟加载。