【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 表的主键的。但在数据库上没有配置外键和其他约束,

咱们在数据表中放一些数据,以供测试。

image

image

可以看到,这两个表是一对一的关系,即一名学生只对应一个地址。比如,范统同学的 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;

结果如下

image

 

好了,数据准备好了,下面完成客户端代码。关于实体映射,你嫌麻烦可以用 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;

image

 

如果把删除行为改为 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 了。

好了,今天咱们就水到这里了。

 

posted @ 2026-06-28 19:07  东邪独孤  阅读(81)  评论(0)    收藏  举报