如何在不触发 ConcurrencyException 的情况下删除相关的中间实体记录?

How can I delete a related middle entity record without triggering a ConcurrencyException?

我正在尝试从 EF Core 中删除生成的 SQL 服务器数据库渲染的 ASP.NET MVC 5 with C# and DataTables。

起初我能够成功删除 3 table 关系中的中间相关 table 记录。但是在重新启动会话后,我在尝试删除时收到此错误。

> An unhandled exception occurred while processing the request.
> DbUpdateConcurrencyException: Database operation expected to affect 1
> row(s) but actually affected 0 row(s). Data may have been modified or
> deleted since entities were loaded. See
> http://go.microsoft.com/fwlink/?LinkId=527962 for information on
> understanding and handling optimistic concurrency exceptions.

这些是我正在使用的 table。我正在尝试删除注册实体中的一条记录。

当我跨过代码时,我已经检查过是否在后端发生了删除,但是当我在方法 UnassignUserRegistration(int RegistrationID) 的这一行上时,结果没有被发送到数据库).

await _context.SaveChangesAsync();

我现在将向您展示我的代码。请让我知道您是否需要我展示任何相关代码来帮助解决问题,或者我是否在与问题无关的地方放了太多,谢谢。

更新:3/8/21 添加了 User 和 Job 以及 TeamContext 的模型。

JobController.cs

    public IActionResult GetAssignedUsers()
    {
         _context.Jobs.OrderByDescending(j => j.ID).FirstOrDefault();       
         var userlist = _context.Users.Where(u => u.Registrations.Any());
         return Json(userlist);
    }
    
    /// <summary>
    /// Opens up the UserAssignments view page, using the
    /// currently selected JobID in memory.
    /// </summary>
    /// <param name="ID"></param>
    /// <returns>The currently selected Job in memory</returns>
    public IActionResult UserAssignments(int? ID)
    {
        if (ID == null)
        {
            return NotFound();
        }
        var job = _context.Jobs.Find(ID);
        return View(job);
    }
    
    //TO DO: Fix method.
    [HttpGet]
    public async Task<IActionResult> UnassignUserRegistration(int RegistrationID)
    {
        Registration registration = new Registration{ID = RegistrationID};
        _context.Registrations.Remove(registration).State = EntityState.Deleted;
        await _context.SaveChangesAsync();
        return RedirectToAction(nameof(UserAssignments), new{ID = RegistrationID});
    }

注册机型

using System;
using System.ComponentModel.DataAnnotations.Schema;
using System.ComponentModel.DataAnnotations;

namespace Pitcher.Models
{
    public class Registration
    {
        public int ID {get;set;}
        public int UserID { get; set; }
        public int JobID { get; set; }
        
        [Required]
        [DataType(DataType.Date)]
        [DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]
        [Display(Name = "User Start Date")]
        [Column("RegistrationDate")]
        public DateTime RegistrationDate {get;set;}        
        public User User {get;set;}
        public Job Job {get;set;}
    }
}

工作模式

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;
using System.ComponentModel.DataAnnotations;

namespace Pitcher.Models
{
    public class Job
    {        
        
        public int ID { get; set; }

        [Required]
        [StringLength(20, MinimumLength = 3, ErrorMessage = "Job Title must be bettween 3 to 20 characters.")]
        [DataType(DataType.Text)]
        [Display(Name = "Job Title")]
        [Column("JobTitle")]
        public string JobTitle { get; set; }

        [StringLength(200, MinimumLength = 3, ErrorMessage = "Job Description must be bettween 200 to 3 characters.")]
        [DataType(DataType.Text)]
        [Display(Name = "Description")]
        [Column("JobDescription")]
        public string JobDescription { get; set; }

        [Required]
        [DataType(DataType.Date)]
        [DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]
        [Display(Name = " Start Date")]
        [Column("JobStartDate")]
        public DateTime JobStartDate {get;set;}

        [DataType(DataType.Date)]
        [DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]
        [Display(Name = "Deadline Date")]
        [Column("JobDeadlineDate")]
        public DateTime JobDeadline {get;set;}

        [Display(Name = "Job Is Complete?")]
        [Column("JobIsComplete")]
        public bool JobIsComplete{get;set;}

        public ICollection<Registration> Registrations {get;set;}

        public ICollection<Result> Results {get;set;}
    }   
}

用户模型

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Pitcher.Models;
namespace Pitcher.Models
{
    public class User
    {      
        public int ID { get; set; }

        [Required]
        [StringLength(20, MinimumLength = 2, ErrorMessage = "* First Name be bettween 2 to 20 characters.")]
        [DataType(DataType.Text)]
        [Display(Name = "First Name")]
        [Column("UserFirstName")]
        public string UserFirstName { get; set; }   

        [Required]
        [StringLength(30, MinimumLength = 2, ErrorMessage = "* Last Name be bettween 2 to 30 characters.")]
        [DataType(DataType.Text)]
        [Display(Name = "Last Name")]
        [Column("UserLastName")]
        public string UserLastName { get; set; }        
                
        [Required]
        [StringLength(30, MinimumLength = 3, ErrorMessage = "Email address must be bettween 3 to 30 characters.")]
        [DataType(DataType.EmailAddress)]
        [Display(Name = "Email")]
        [Column("UserContactEmail")]
        public string UserContactEmail{get;set;}      
        
        // [Required(AllowEmptyStrings = true)]
        [Display(Name = "Phone Number")]
        [Phone()]
        [Column("UserPhoneNumber")]
        public string UserPhoneNumber{get;set;}
        
        [StringLength(37,ErrorMessage = "Address cannot be longer than 37 characters.")]
        [DataType(DataType.Text)]
        [Display(Name = "Address")]
        [Column("UserAddress")]
        public string UserAddress{get;set;}
        
        //This regular expression allows valid postcodes and not just USA Zip codes.        
        [Display(Name = "Post Code")]
        [Column("UserPostCode")][DataType(DataType.PostalCode)]
        public string UserPostCode { get; set; }

        [StringLength(15,ErrorMessage = "Country cannot be longer than 15 characters.")]
        [DataType(DataType.Text)]
        [Display(Name = "Country")]
        [Column("UserCountry")] 
        public string UserCountry {get;set;}
        
        
        [Phone()]
        [Display(Name = "Mobile Number")]
        [Column("UserMobileNumber")]
        public string UserMobileNumber {get;set;}

        [StringLength(3,ErrorMessage = "State cannot be longer than 3 characters.")]
        [DataType(DataType.Text)]
        [Display(Name = "State")]
        [Column("UserState")]
        public string UserState {get;set;}           
        
        public string UserFullname => string.Format("{0} {1}", UserFirstName, UserLastName);

        public ICollection<Registration> Registrations {get;set;}
    }
}

TeamContext

using Pitcher.Models;
using Microsoft.EntityFrameworkCore;
using Pitcher.Models.TeamViewModels;
namespace Pitcher.Data
{
    public class TeamContext : DbContext
    {
        public TeamContext(DbContextOptions<TeamContext> options) : base(options)
        {
        }

        public DbSet<User> Users { get; set; }
        public DbSet<Registration> Registrations {get;set;}
        public DbSet<Job> Jobs {get;set;}     

        
        
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<User>().ToTable("tblUser");
            modelBuilder.Entity<Registration>().ToTable("tblRegistration");
            modelBuilder.Entity<Job>().ToTable("tblJob");
        }        
    }
}

UserAssignments.cshtml 查看 table 代码

<h3>Assigned Users</h3>
<table id="registeredUsersTable" style="display: none">
<thead>
        <tr>
            <th>
               @Html.DisplayNameFor(model => user.UserFirstName)
            </th>        
            <th>
               @Html.DisplayNameFor(model => user.UserLastName)
            </th>
            <th>
               @Html.DisplayNameFor(model => user.UserContactEmail)
            </th>
            <th>                
            </th>
        </tr>
    </thead>
    @if(user == null)
    {
         <script type="text/javascript">
            alert("Model empty");
        </script>        
    }
    else
    {
        <tbody></tbody>
    }
</table>
    document.getElementById('registeredUsersTable').style.display = 'block';
        var id=@Model.ID 
        $('#registeredUsersTable').DataTable({
            "ajax": {
            'type': 'get',
            'data': { ID: id},
            'dataType': "json",                  
            "url": "@Url.Action("GetAssignedUsers")",
            "dataSrc": function (result) {
                return result;
                }
            },            
            "columns": [                
            { "data": "userFirstName"},
            { "data": "userLastName"},
            { "data": "userContactEmail"},
            {
            "data": null,
            "render": function (value) {
                return  '<a href="/Jobs/UnassignUserRegistration?RegistrationID=' + value.id + '"button type="button" class="btn btn-primary btn-block">Unassign</a>';
            }
                }
            ]
        });

该代码不会删除 Registration 实体,它会附加一个只有部分数据的新对象,并尝试将其保持在 Removed 状态,从而将其删除。

Registration registration = new Registration{ID = RegistrationID};
_context.Registrations.Remove(registration).State = EntityState.Deleted;
await _context.SaveChangesAsync();

EF 使用 all 属性 值来检测数据是否被修改,显然,UserIDJobIdRegistrationDate 与存储的数据不匹配,因此操作失败。

不是附加新的空对象,而是加载当前对象并将其删除:

var registration = _context.Registrations.FirstOrDefault(r=>r.ID=RegistrationID);
if (registration!=null)
{
    _context.Registrations.Remove(registration);
    await _context.SaveChangesAsync();
}

如果您不想加载实体,则必须直接执行 DELETE SQL 查询。在 EF 6 中,您可以使用 ExecuteSqlCommand :

var sql="DELETE tblRegistration where ID=@p0";
await _context.Database.ExecuteSqlCommandAsync(sql,RegistrationId);

EF 是一个 ORM,而不是一个通用的数据访问库。它的工作是将对象操作转换为数据库操作。它可以在该特定实体上将 Remove(someEntity) 转换为 DELETE,但并不意味着直接生成 DELETE。这是底层数据访问库 ADO.NET 的工作。 ExecuteSqlCommandAsync 提供了一种使用 ADO.NET 执行任意参数化 SQL 命令的方法,而无需显式构造和配置 DbCommand 对象。

EF 核心

EF Core 没有区别。在使用 ORM 本身删除实体之前,必须加载它。

唯一改变的是用于执行直接 SQL 命令的方法的名称。它们被命名为 ExecuteSqlRawAsync, with its blocking counterpart ExecuteSqlRaw and ExecuteSqlInterpolatedAsync 并带有 ExecuteSqlInterpolated.

区别在于ExecuteSqlRawAsync使用带有参数占位符的原始SQL字符串,而ExecuteSqlInterpolatedAsync可以将内插字符串视为参数化SQL字符串:

var sql="DELETE tblRegistration where ID=@id";
await _context.Database.ExecuteSqlRawAsync(sql,id);

await _context.Database.ExecuteSqlInterpolatedAsync(
      "DELETE tblRegistration where ID={id});

在这种情况下,ExecuteSqlInterpolatedAsync("DELETE tblRegistration where ID={id}) 不会生成可能包含恶意内容的 SQL 字符串,而是创建一个带参数的参数化查询,并将 id 值作为该参数。

页面 Using Raw SQL queries 解释了如何使用参数,尤其是内插字符串,以及为什么内插字符串 只能 Interpolated 方法一起使用避免 SQL 注入。太多人使用字符串插值将值注入查询,引入了 SQL 注入漏洞,因此创建了两个单独的方法来明确每种情况下发生的情况。

基于table结构,User和Job包含多对多关系,所以我创建了以下模型:

[Table("tblJob")]
public class Job
{
    [Key]
    public int ID { get; set; }
    public string JobTitle { get; set; }
    public string JobDescription { get; set; }
    public DateTime JobStartDate { get; set; }
    public DateTime JobDeadlineDate { get; set; }
    public bool JobIsComplete { get; set; }

    public List<Registration> Registrations { get; set; }
}
[Table("tblUser")]
public class User
{
    [Key]
    public int ID { get; set; }
    public String UserFirstName { get; set; }
    public string UserLastName { get; set; }
    public string UserContactEmail { get; set; }
    public string UserPhoneNumber { get; set; }
    public string UserAddress { get; set; }
    public string UserPostCode { get; set; }
    public string UserCountry { get; set; }
    public string UserMobileNumber { get; set; }
    public string UserState { get; set; }
    public List<Registration> Registrations { get; set; }

}
[Table("tblRegistration")]
public class Registration
{ 
    public int ID { get; set; }
    public int UserID { get; set; }
    public int JobID { get; set; }

    [Required]
    [DataType(DataType.Date)]
    [DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]
    [Display(Name = "User Start Date")]
    [Column("RegistrationDate")]
    public DateTime RegistrationDate { get; set; }
    public User User { get; set; }
    public Job Job { get; set; }
}

然后,在ApplicationDBContext.cs中配置多对多关系:

public class ApplicationDbContext : IdentityDbContext
{
    public DbSet<Job> Jobs { get; set; }
    public new DbSet<User> Users { get; set; }
    public DbSet<Registration> Registrations { get; set; }
    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
        : base(options)
    {
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);
        modelBuilder.Entity<Registration>()
            .HasKey(t => t.ID);

        modelBuilder.Entity<Registration>()
            .HasOne(pt => pt.User)
            .WithMany(p => p.Registrations)
            .HasForeignKey(pt => pt.UserID);

        modelBuilder.Entity<Registration>()
            .HasOne(pt => pt.Job)
            .WithMany(t => t.Registrations)
            .HasForeignKey(pt => pt.JobID);
    }
}

然后,添加以下测试数据:

要删除相关实体,您可以使用以下代码:

var user = _context.Users.Include(c => c.Registrations).FirstOrDefault(c => c.ID == 1);

foreach (var item in user.Registrations)
{
    _context.Entry(item).State = EntityState.Deleted; //delete items from the Join table(registrations).
}
_context.Entry(user).State = EntityState.Deleted; //delete the user //
_context.SaveChanges();

那么,结果如下:

如果您不想删除该用户,请尝试评论或删除此行:_context.Entry(user).State = EntityState.Deleted;

好的,UnassignUserRegistration 方法的问题是我将 RegistrationID 误解为传入的实际 ID。

当我将鼠标悬停在包含该方法的取消分配按钮上时,我可以看到 URL 并且该函数需要 UserID 和 JobID 而不是 RegistrationID。

修复涉及我将 UserID 和 JobID 放入方法的参数中,然后使用 .Single 方法 return UserID 和 JobID。

然后我只需要 JobID 就重定向到操作。

然后我在视图中只需要return。

UserID=' + value.id + '&JobID=' + id +'

之后没有发生任何错误。

JobController.cs

[HttpGet]
public async Task<IActionResult> UnassignUserRegistration(int UserID, int JobID)
{
    Registration registration = _context.Registrations.Single(c => c.UserID == UserID && c.JobID == JobID);     
    _context.Entry(registration).State = EntityState.Deleted; 
    await _context.SaveChangesAsync();                             
    return RedirectToAction(nameof(UserAssignments), new{ID = JobID});
}

UserAssignments.cshtml

document.getElementById('registeredUsersTable').style.display = 'block';    
$('#registeredUsersTable').DataTable({
    "ajax": {                 
    "url": "@Url.Action("GetAssignedUsers")",
    "dataSrc": function (result) {
        return result;
        }
    },            
    "columns": [                
    { "data": "userFirstName"},
    { "data": "userLastName"},
    { "data": "userContactEmail"},
    {
    "data": null,
    "render": function (value) {
        return  'Unassign';
    }
        }
    ]