【EF Core】级联删除行为
DeleteBehavior枚举(位于 Microsoft.EntityFrameworkCore 命名空间)所定义的数据删除行为是争对存在相对关系的实体来说的。这个和数据库中表与表之间的关系一致。数据表之间是通过列引用实现的。假设 A 依赖 B,那么 A 中会存在一列或多列去引用 B 中的一列或多列(通常是主键)。在 A 中引用外部记录的列就成了外键。而删除行为描述的就是:当 B 被删除后,A 中的外键如何处理。
上面只是简单提一下数据库方面的知识,后面咱们会有例子。现在,咱们先看看 DeleteBehavior 枚举定义了啥。
public enum DeleteBehavior { ClientNoAction, ClientSetNull, ClientCascade, /*----------- 强力分割线 --------*/ Restrict, SetNull, Cascade, NoAction }
如你所见,DeleteBehavior 枚举的成员可以分为两组。第一组是带 Client 开头的,就是客户端的意思。那客户端是谁?谁连接并操作数据库?是 EF Core,所以,它是客户端。因此,这一组的意思就是由 EF Core 来处理关系实体的级联删除行为。什么情况下会这样呢?先有数据库,然后根据数据库生成(编写)实体类,但数据库并未定义任何关系,由 EF Core 来管理关系。说大白话就是,数据库只建表和存储数据,并不定义其他对象。相信很多伙伴比较喜欢这种方案,老周可喜欢这样做了,数据库的作用只存数据,老周连存储过程都不写的。哪怕 SQL Server 有很多强大功能,老周只拿来建表,连视图都很少用。这样搞的好处是好维护,除非你那里有专人维护数据库,不然像老周这样,多数项目都是自己一手包办的,把很多数据库的处理逻辑搬到程序代码中,省了很多事。正因为经常这样,导致老周在写 SQL 方面比较菜。
现在有些 SQL 查询可以用 AI 生成,但你也别指望它是万能的,只是在赶时间的时候能省些功夫。一般查询的话 AI 是没啥问题,生成的语句也基本对的。除了这个,目前 AI 比较好用的就是在 CSS 上,其他(如 C++ 之属)除非是一些很“流行”或标准算法的代码,不然错误很多。有些代码甚至是全错的(说明没有训练过),毕竟模型再大也不可能应对实际应用。所以这玩意儿用来省省时间是可以的,全信它还不如信我是汉武帝。
曾有某新同事,就是懒得写一些初始化代码,就用 AI 帮他完成的初始化(C语言)。然后他很苦恼,因为他检查来检查去都没发现哪里错了,他经过多次生成代码问题依旧。烧录进单片机后无限 Reset。害得他装逼失败,被同事笑了两个星期(因为同事是女的,所以他要在她面前装逼,也是自作多情的小伙子)。最后他厚着脸皮来找老周。老周就帮他看了下代码,错误就在 memcpy 函数拷贝错了内存。估计这代码是从网上的多个源混合训练模型的,说不定是从谁的博文中偷的代码。不仅拷错了内存,还把别的内存破坏了。你会问:编译不报错?那肯定不报错了,语法上又没错。当然,这个你不能说是C语言的问题,C语言的强项就在这里,内存管理是直接的,而且权限都给了开发者的。好处是在性能的时候,你能做到极致。你有一把很锋利的宝刀,在高手看来那可是宝物,但是你练武的时候把自己的头砍了,那你不能怪这刀太锋利。
第二组就是不带 Client 开头的,自然是在数据库上定义外键约束。这个一般用在 Code First 方案,由 EF Core 生成 SQL 语句并创建数据库。当然,DB First 也可能的,比如你的表关系是数据库上定义的,而且也定义了外键约束。
老周写这一篇水文的目的就跟大伙伴说这个事,如何选择 DeleteBehavior 的值。
好,上面的内容比较无聊,相信大伙都看得天灵盖冒烟了。下面咱们搞些有趣的。这里以 SQL Server 数据库为例。为什么用它?因为方便,其他数据库没那么方便。所以一般老周写测试代码都会用 SQL Server。
建好数据库后,创建两个表。
CREATE TABLE [dbo].[tb_address] ( [Id] INT IDENTITY (1, 1) NOT NULL, [city] NVARCHAR (10) NOT NULL, [town] NVARCHAR (20) NOT NULL, [zip_code] VARCHAR (8) NULL ); CREATE TABLE [dbo].[tb_stu] ( [Id] INT IDENTITY (1, 1) NOT NULL, [name] NVARCHAR (12) NOT NULL, [age] INT NOT NULL, [addr_id] INT NULL );
很简单的两个表,一个表示地址,一个表示学生。字段也是随便写的,你不用在意是啥含义。不过,你要注意 tb_stu 表的 addr_id 字段。你能如老周一样聪明,肯定猜到了,它就是外键。是的,该字段就是引用 tb_address 表的主键的。但在数据库上没有配置外键和其他约束,
咱们在数据表中放一些数据,以供测试。


可以看到,这两个表是一对一的关系,即一名学生只对应一个地址。比如,范统同学的 addr_id 为 3,对应 Id=3 的地址,即他来自乌龟市王八镇。
咱们可以用联表查询来一览两个表的关系。
select s.[name], a.city, a.town from tb_stu s left join tb_address a on s.addr_id = a.Id;
结果如下

好了,数据准备好了,下面完成客户端代码。关于实体映射,你嫌麻烦可以用 EF 工具来生成。因为代码不多,老周直接手写了。
先定义实体类。
public class Student { public int Id { get; set; } public string Name { get; set; } = default!; public int Age { get; set; } public Address? Addr { get; set; } } public class Address { public int Id { get; set; } public string City { get; set; } = default!; public string Town { get; set; } = default!; public string? ZipCode { get; set; } }
很 Easy,不用解释了啊。然后是从 DbContext 类派生出咱们自己的上下文(不派生也行,先初始化 Options 然后 new DbContext)。
protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<Address>(ent => { ent.ToTable("tb_address"); ent.Property(x => x.Id).HasColumnName("Id"); ent.Property(y => y.City).HasColumnName("city").HasMaxLength(10).IsUnicode(); ent.Property(j => j.ZipCode).HasColumnName("zip_code").HasMaxLength(8).IsUnicode(false); ent.Property(x => x.Town).HasColumnName("town").HasMaxLength(20); ent.HasKey(x => x.Id); }); modelBuilder.Entity<Student>(ent => { ent.ToTable("tb_stu"); ent.Property(x => x.Id).HasColumnName("Id"); ent.Property(a => a.Name).HasColumnName("name").HasMaxLength(12); ent.Property(s => s.Age).HasColumnName("age"); // 影子属性 ent.Property<int?>("AddrId").HasColumnName("addr_id"); ent.HasOne(s => s.Addr) .WithOne() .HasForeignKey<Student>("AddrId") .HasPrincipalKey<Address>(a => a.Id) .OnDelete(DeleteBehavior.ClientSetNull) .IsRequired(false); ent.Navigation(s => s.Addr).AutoInclude(); }); }
Student 实体的 Addr 属性是一个导航属性,引用 Address 实体实例。导航的含议就是从 Student 实体就能访问关系实体 Address。毕竟 EF Core 的核心思想是要面向对象的,肯定不能搞个外键来访问关系实体的。
配置的代码很简单,你可以不看,重点关注 HasOne 一段。
HasOne 表示从当前(Student)实体起,到 s.Addr 所导航的另一个实体(Address)的关系。啥关系?一个 Student 对一个 Address。
同理,随后 WithOne 的意思是从反向找关系,从 Address 到 Student 也是一个 Address 对着一个 Student 实体。由于 Address 实体中没有指向 Student 的导航属性,故 WithOne 方法不用传参。
因此,总结起来,Student -> Address 是一对一关系。
HasForeignKey 方法配置外键,一对一的外键方法特殊,它可以指定一个类型参数,代表外键所在的实体。因为一对一比较特别,它既可以A引用B,也可以B引用A。所以,一对一关系必须交代明白谁引用谁,EF Core 无法自动识别。在本例中,是 Student 引用 Address,因此外键应当在 Student 实体上。但我们代码中基本不会去访问外键,所以老周把外键 AddrId 属性定义为影子属性——类中不定义的属性,只在 EF Core 的更改跟踪中维护。
调用 AutoInclude 方法的用途是让 EF Core 在查询 Student 实体时自动加载 Addr 导航属性,即引用相关的 Address 对象。这个会生成联表查询,就不用在LINQ语句中调用 Include 方法了。
现在,咱们做一件事: 把 Id 为 2 的 Address 记录删除。
Address? theaddr = c.Set<Address>().FirstOrDefault(a => a.Id == 2); if(theaddr != null) { // 删除 c.Remove(theaddr); } // 检测更改 c.ChangeTracker.DetectChanges(); // 打印跟踪信息 Console.WriteLine(c.ChangeTracker.ToDebugString()); // 提交更新 c.SaveChanges();
DetectChanges 是扫描实体并检测状态,这个在 SaveChanges 方法时也会调用。此处老周是为了打印实体状更新才这样做,实际使用时,不要调用 DetectChanges 方法。
运行一下,看看发生什么。
Address 是删除了,但 Student 中对应的外键没有设置为 NULL。这表明没达到咱们预期。
这是因为咱们只查询了 tb_address 表,EF Core 根本不知道 tb_stu 表中哪些行引用了 id=2 的地址信息,所以,EF Core 就无法进行级联处理。
修改为ID 为3的地址信息,它由 id=1 的学生记录引用。咱们要先查出 id=1 的 Student 记录,再通过 Addr 导航属性来获取对应的地址信息,然后删除。
Student? student = c.Set<Student>().FirstOrDefault(s => s.Id == 1); if(student != null && student.Addr != null) { // 删除 c.Remove(student.Addr); } // 检测更改 c.ChangeTracker.DetectChanges(); // 打印跟踪信息 Console.WriteLine(c.ChangeTracker.ToDebugString()); // 提交更新 c.SaveChanges();
这一次结果正确。看看打印的实体状态变更。
Address {Id: 3} Deleted
Id: 3 PK
City: '乌龟市'
Town: '王八镇'
ZipCode: '912033'
Student {Id: 1} Modified
Id: 1 PK
AddrId: <null> FK Modified Originally 3
Age: 23
Name: '范统'
Addr: <null>
id=3 的地址信息被删除,而引用了此记录的 Student 对象,AddrId 也从原来的 3 变为 null。生成的 SQL 语句既有 DELETE 语句也有 UPDATE 语句。
UPDATE [tb_stu] SET [addr_id] = @p0 OUTPUT 1 WHERE [Id] = @p1; DELETE FROM [tb_address] OUTPUT 1 WHERE [Id] = @p2;

如果把删除行为改为 ClientCascade,那么,Address 记录删除后,引用该记录的 Student 记录也会被删除。
ent.HasOne(s => s.Addr) .WithOne() .HasForeignKey<Student>("AddrId") .HasPrincipalKey<Address>(a => a.Id) .OnDelete(DeleteBehavior.ClientCascade) .IsRequired(false);
运行代码会看到,除了被删的 Address 实体,引用它的 Student 实体也标记了删除。
Address {Id: 3} Deleted
Id: 3 PK
City: '鸡腿市'
Town: '牛奶镇'
ZipCode: '256213'
Student {Id: 1} Deleted
Id: 1 PK
AddrId: 3 FK
Age: 23
Name: '范统'
Addr: {Id: 3}
生成的SQL语句自然也会包含两条 DELETE FROM 语句。
DELETE FROM [tb_stu] OUTPUT 1 WHERE [Id] = @p0; DELETE FROM [tb_address] OUTPUT 1 WHERE [Id] = @p1;
剩下的 ClientNoAction 行为就是当被引用的记录删除后,引用它的外键不会被删除,也不会设置为 NULL。就是啥也不做。这个就不演示了。
DeleteBehavior 枚举的不带 Client 开头的成员表示级联删除行为会在数据库中定义。
1、Code First 方案下,创建数据库时,EF Core 会自动生成包含外键约束的 SQL 语句;
2 、DB First 方案下,数据库已定义了外键约束。删除时如果违反规范会报错。
总的来说就是数据库一定要定义外键约束且设置了级联删除行为(或保留默认值)。
下面的示例咱们采用 Code First 方案,由数据库执行级联删除。示例用的是 SQLite 数据库,要添加以下 nuget 库。
Microsoft.EntityFrameworkCore.Sqlite
定义两个实体类。
// 暂时关闭 Nullable #nullable disable public class User { public int Uid { get; set; } public string Name { get; set; } /// <summary> /// 导航属性 /// </summary> public IEnumerable<ShareItem> Shares { get; set; } } public class ShareItem { public int ShareID { get; set; } public string Title { get; set; } public string FileName { get; set; } public string? Comment { get; set; } } // 恢复 Nullable #nullable enable
User 实体代表用户信息,ShareItem 代表用户分享的文件信息。User.Shares 是导航属性,即一个用户可以分享多个文件,所以,用户与分享文件信息之间是一对多的关系。
下面是配置模型,这里老周演示一下如何不继承 DbContext 来配置。
1、先创建 ModelBuilder 实例。这里注意,咱们不用直接调用构造函数,那样约定集合是空的,就无法使用 EF Core 内置的约定功能了,很多东西就要手动配置了。所以要用 SqliteConventionSetBuilder.CreateModelBuilder 静态方法。每个数据库都会提供这个方法的,比如 SqlServerConventionSetBuilder。这些类能够创建包含内置约定的 ModelBuilder。
ModelBuilder modelBuilder = SqliteConventionSetBuilder.CreateModelBuilder();
有了 ModelBuilder 实例,剩下的事情你应该全懂了,一样的配方,熟悉的家乡味。
2、配置数据库模型——添加实体。
// 添加实体 var entUser = modelBuilder.Entity<User>(); var entShareItem = modelBuilder.Entity<ShareItem>(); // 配置主键 entUser.HasKey(u => u.Uid).HasName("PK_UserID"); entShareItem.HasKey(s => s.ShareID).HasName("PK_ShareID"); // 外键存在于 ShareItem 中,用影子属性来存储 entShareItem.Property<int?>("userId"); // 表映射 entUser.ToTable("tb_users", tb => { tb.Property(u => u.Uid).HasColumnName("u_id"); tb.Property(u => u.Name).HasColumnName("u_name"); }); entShareItem.ToTable("tb_shares", tb => { tb.Property(s => s.ShareID).HasColumnName("sh_id"); tb.Property(s => s.Title).HasColumnName("sh_title"); tb.Property(s => s.FileName).HasColumnName("sh_file"); tb.Property(s => s.Comment).HasColumnName("sh_cmt"); // 这个是影子属性映射的 tb.Property("userId").HasColumnName("usr_id"); }); // 一对多关系 entUser.HasMany(u => u.Shares) .WithOne() .HasForeignKey("userId") .HasPrincipalKey(u => u.Uid) .OnDelete(DeleteBehavior.Cascade) .HasConstraintName("FK_ShareToUser");
由于是一对多关系,所以外键只能放在 ShareItem 实体上,调用 HasForeignKey 方法不必考虑类型参数了(人家也没声明类型参数)。OnDelete设置为级联删除。也就是:如果某个 User 对象被删了,那么,与它有关的 ShareItem 对象也要一起删除(株连三族)。
3、创建 DbContextOptionsBuilder 实例,用来配置 DbContext,如数据库连接字符串等。
// 准备调料(选项) DbContextOptionsBuilder opbuilder = new DbContextOptionsBuilder(); // 使用啥数据库 opbuilder.UseSqlite("data source=shareMan.db"); // 需要日志 opbuilder.LogTo(msg => { Console.ForegroundColor = ConsoleColor.Green; Console.WriteLine(msg); Console.ResetColor(); }, (eventid, _) => eventid == RelationalEventId.CommandExecuted);
LogTo 调用的重载有两个委托。第一个是输入一个 string 参数,这个参数是日志内容,可以自定义记录方式。比如写到文件中,这里老周只写到控制台。第二个委托有两个输入参数:参数A是日志关联的 EventId,参数B是日志等级(调试、严重、警告、信息),返回值是 bool 类型,这个委托是个筛选器,返回 true 表示记录它;返回 false 表示不记录该条日志。这里只判断事件 RelationalEventId.CommandExecuted,如果是它就记录,其他事件拜拜。CommandExecuted 指的是执行 SQL 之后发生。这个配置的目的就是记录 SQL 语句。
4、很重要一步,新手最容易忘记。因为咱们现在不继承 DbContext 类,就不会重写 OnModelCreating 方法了,所以,你得让 DbContext 知道咱们用的数据库模型。所以要调用 UseModel 方法关联我们前面配置好的模型。
opbuilder.UseModel(modelBuilder.Model.FinalizeModel());
调用 FinalizeModel 方法是让模型固定(只读,不能改了)。
5、实例化DbContext对象,把选项传进构造函数。其他操作不变。
6、先动态创建一下数据库,存入些测试数据。
using (DbContext ctx = new(opbuilder.Options)) { bool done = ctx.Database.EnsureCreated(); if(done) { User u1 = new() { Name = "吴淼水", Shares = [ new ShareItem{ Title = "大宋神探", FileName = "神碳吴四水.docx", Comment = "时代楷模" } ] }; User u2 = new() { Name = "高大上", Shares = new List<ShareItem>{ new ShareItem { Title = "想要飞得更高", FileName = "励志王.pdf", Comment = "笑死人" }, new ShareItem { Title = "一氧化二氢居然有毒", FileName = "2026智慧档案.doc", Comment = "民用化学开山之祖" } } }; // 将两实体标记为 Added 状态 ctx.AddRange(u1, u2); // 保存数据 ctx.SaveChanges(); Console.WriteLine("\n---------- 初始化后 ----------"); PrintUsers(ctx.Set<User>().Include(u => u.Shares).ToArray()); } }
访问 DbContextOptionsBuilder 对象的 Options 属性,你就能拿到已配置的选项了。
PrintUsers 是个自定义方法,用来打印 User 与跟它有关的 ShareItem 信息。
static void PrintUsers(IEnumerable<User> usrs) { foreach(User u in usrs) { Console.WriteLine($"用户:{u.Name} ({u.Uid})"); // 遍历导航属性 foreach(ShareItem share in u.Shares) { Console.WriteLine($"\t{share.Title} | {share.FileName}"); } } Console.WriteLine(); }
创建数据表的 SQL 如下:
CREATE TABLE "tb_users" ( "u_id" INTEGER NOT NULL CONSTRAINT "PK_UserID" PRIMARY KEY AUTOINCREMENT, "u_name" TEXT NULL ); CREATE TABLE "tb_shares" ( "sh_id" INTEGER NOT NULL CONSTRAINT "PK_ShareID" PRIMARY KEY AUTOINCREMENT, "sh_title" TEXT NULL, "sh_file" TEXT NULL, "sh_cmt" TEXT NULL, "usr_id" INTEGER NULL, CONSTRAINT "FK_ShareToUser" FOREIGN KEY ("usr_id") REFERENCES "tb_users" ("u_id") ON DELETE CASCADE );
数据库中在 tb_shares 表中已定义了外键约束,且级联删除。
8、我们现在删除一个用户。
using (DbContext ctx = new(opbuilder.Options)) { // 找出吴四水 User? theuser = ctx.Set<User>() .Include(u => u.Shares) .FirstOrDefault(u => u.Name.Equals("吴淼水")); if(theuser != null) { // 把它删除 ctx.Remove(theuser); // 保存 ctx.SaveChanges(); // 删除后 Console.WriteLine("\n---------- 删除用户后 ----------"); PrintUsers(ctx.Set<User>().Include(u => u.Shares).ToArray()); } }
Include 方法就是连同关系表一起查询。由于数据量小,咱们不用考虑笛卡尔乘积问题,所以就不必分开查询了。
生成的SQL如下:
DELETE FROM "tb_shares" WHERE "sh_id" = @p0 RETURNING 1; DELETE FROM "tb_users" WHERE "u_id" = @p0 RETURNING 1;
对比一下,删除前后打印的数据。
---------- 初始化后 ---------- 用户:吴淼水 (1) 大宋神探 | 神碳吴四水.docx 用户:高大上 (2) 想要飞得更高 | 励志王.pdf 一氧化二氢居然有毒 | 2026智慧档案.doc ---------- 删除用户后 ---------- 用户:高大上 (2) 想要飞得更高 | 励志王.pdf 一氧化二氢居然有毒 | 2026智慧档案.doc
哈哈,这个对比其实没B用,反正 User 已经没了一个,自然查不到 ShareItem 了。
好了,今天咱们就水到这里了。

浙公网安备 33010602011771号