【EF Core】继承策略——TPC

在开始主题之前,老周分享另一个知识,碰巧这知识点也是 EF Core 的,是前些天一位新手程序猿问的,他那是一个小项目,因为小,所以采用 Code First 的方案。不过程序有两个版本,一个是用 SQLite 数据库,一个用 SQL Server。然后有些实体他设定了 CHECK 约束。众所周知,配置 CHECK 约束是直接用 SQL 表达式的。这位同仁比较负责,他觉得哪怕用 EF Core 生成数据库也要规范一点,字段名也应该用边界字符,比如,在 SQLite 中,边界是双引号,表达式应写成 "age" > 15,在 SQL Server 中写成 age > 15。

同仁的意思是,他不想硬编码,EF Core 有没有相关的 API 可以根据不同数据库,自动产生边界字符。于是,作为"老一辈",老周教了他两招。

1、比较笨的方法,其实也是硬编码。

复制代码
/*--------------------------------- 实体类 ------------------------------*/
public class Person
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public int Age { get; set; }
}

/*-------------------------------- 数据库上下文 ----------------------------*/
public class TestContext:DbContext
{
    public TestContext(DbContextOptions<TestContext> options)
        : base(options)
    { }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        var entity = modelBuilder.Entity<Person>();
        // 配置主键
        entity.HasKey(b => b.Id).HasName("PK_People");
        // 配置表映射
        entity.ToTable("tb_people", tb =>
        {
            // 列映射
            tb.Property(a => a.Id).HasColumnName("ps_id");
            tb.Property(a => a.Name).HasColumnName("ps_name");
            tb.Property(a => a.Age).HasColumnName("ps_age");
            // 配置CHECK约束
            string delimiteLeft = "", delimiteRight = "";
            if(this.Database.IsSqlite())
            {
                delimiteLeft = delimiteRight = "\"";
            }
            if(this.Database.IsSqlServer())
            {
                delimiteLeft = "[";
                delimiteRight = "]";
            }
            tb.HasCheckConstraint("CK_Age", $"{delimiteLeft}ps_age{delimiteRight} > 20");
        });
    }
}

这套方案是使用了 IsSqlServer 方法来判断当前配置的是否为 SQL Server 数据库,IsSqlite 方法判断当前配置的是否为 SQLite 数据库。

实例化上下文时,通过构造函数来传递选项,以使用不同的数据库。

复制代码
// 用 SQL Server
DbContextOptionsBuilder<TestContext> opbuilder1 = new();
opbuilder1.UseSqlServer("Server=...");
using(var ctx = new TestContext(opbuilder1.Options))
{
    // 这里咱们不是真的建库,仅获取生成的 SQL
    Console.WriteLine("使用 SQL Server 数据:");
    Console.WriteLine(ctx.Database.GenerateCreateScript());
    Console.Write("\n");
}

// 使用 SQLite 数据库
DbContextOptionsBuilder<TestContext> opbuilder2 = new();
opbuilder2.UseSqlite("data source=...");
using (var ctx = new TestContext(opbuilder2.Options))
{
    Console.WriteLine("使用 SQLite 数据:");
    Console.WriteLine(ctx.Database.GenerateCreateScript());
    Console.Write("\n");
}

结果如下:

复制代码
使用 SQL Server 数据:
CREATE TABLE [tb_people] (
    [ps_id] int NOT NULL IDENTITY,
    [ps_name] nvarchar(max) NOT NULL,
    [ps_age] int NOT NULL,
    CONSTRAINT [PK_People] PRIMARY KEY ([ps_id]),
    CONSTRAINT [CK_Age] CHECK ([ps_age] > 20)
);
GO

使用 SQLite 数据:
CREATE TABLE "tb_people" (
    "ps_id" INTEGER NOT NULL CONSTRAINT "PK_People" PRIMARY KEY AUTOINCREMENT,
    "ps_name" TEXT NOT NULL,
    "ps_age" INTEGER NOT NULL,
    CONSTRAINT "CK_Age" CHECK ("ps_age" > 20)
);

但这种做法还是不够"老辣",咱们看下一个方案。

2、巧用 ISqlGenerationHelper 服务。

这个最好用,不用去判断数据库是什么,能够自动生成带边界字符的名称。

复制代码
entity.ToTable("tb_people", tb =>
{
    // 列映射
    ......

    // 获取服务
    ISqlGenerationHelper sqlHelper = this.GetService<ISqlGenerationHelper>();
    // 生成带边界字符的列名
    string ageColName = sqlHelper.DelimitIdentifier("ps_age");
    // 配置CHECK约束
    tb.HasCheckConstraint("CK_Age", $"{ageColName} > 20");
});

咱们增加一个 PostgreSQL 的 provider 来测试一下。

复制代码
 // 用 SQL Server
 DbContextOptionsBuilder<TestContext> opbuilder1 = new();
 opbuilder1.UseSqlServer("Server=...");
 using(var ctx = new TestContext(opbuilder1.Options))
 {
     // 这里咱们不是真的建库,仅获取生成的 SQL
     Console.WriteLine("使用 SQL Server 数据:");
     Console.WriteLine(ctx.Database.GenerateCreateScript());
     Console.Write("\n");
 }

 // 使用 PostgreSQL 数据库
 DbContextOptionsBuilder<TestContext> opbuilder2 = new();
 opbuilder2.UseNpgsql("Host=...");
 using (var ctx = new TestContext(opbuilder2.Options))
 {
     Console.WriteLine("使用 PostgreSQL 数据:");
     Console.WriteLine(ctx.Database.GenerateCreateScript());
     Console.Write("\n");
 }

 // 使用 SQLite 数据库
 DbContextOptionsBuilder<TestContext> opbuilder3 = new();
 opbuilder3.UseSqlite("data source=...");
 using (var ctx = new TestContext(opbuilder3.Options))
 {
     Console.WriteLine("使用 SQLite 数据:");
     Console.WriteLine(ctx.Database.GenerateCreateScript());
     Console.Write("\n");
 }

得到结果如下:

复制代码
使用 SQL Server 数据:
CREATE TABLE [tb_people] (
    [ps_id] int NOT NULL IDENTITY,
    [ps_name] nvarchar(max) NOT NULL,
    [ps_age] int NOT NULL,
    CONSTRAINT [PK_People] PRIMARY KEY ([ps_id]),
    CONSTRAINT [CK_Age] CHECK ([ps_age] > 20)
);
GO

使用 PostgreSQL 数据:
CREATE TABLE tb_people (
    ps_id integer GENERATED BY DEFAULT AS IDENTITY,
    ps_name text NOT NULL,
    ps_age integer NOT NULL,
    CONSTRAINT "PK_People" PRIMARY KEY (ps_id),
    CONSTRAINT "CK_Age" CHECK (ps_age > 20)
);

使用 SQLite 数据:
CREATE TABLE "tb_people" (
    "ps_id" INTEGER NOT NULL CONSTRAINT "PK_People" PRIMARY KEY AUTOINCREMENT,
    "ps_name" TEXT NOT NULL,
    "ps_age" INTEGER NOT NULL,
    CONSTRAINT "CK_Age" CHECK ("ps_age" > 20)
);

好了,正片开始。今天咱们聊实体继承中的第三种映射策略------TPC。TPC 是地球和平联合组织......我呸,是 Table per Concrete Class 的缩写。它与 TPT 挺像,共同点是"每个类都有对应的表",但不同点在于"具体类型",啥意思呢?至少包含两个意思:

1、可实例化的类,抽象类就不映射了哟;

2、类中的属性(字段)成员,不管是本类中定义的还是从基类继承过来的,都会做列映射。

这么一说,TPC 的独立性更强。咱们上一次所聊的 TPT 策略,由于不映射从基类继承的成员,所以需要通过外键与基类所映射的表建立一对一关系,查询时需要表联合,带来了亿些性能上的问题。而 TPC 是包含了基类成员的,它不需要与基类的表建立相对关系,不设立外键,使用时直接单表查询即可。使查询过程变简单了。

TPC 策略很适合那种"开枝散叶"式继承的实体。典型场景是某个抽象作为公共基类,然后派生出同级别的 N 多个实现类。

比如,下面这个继承关系很是经典,高考每年必考。

复制代码
/// <summary>
/// 公共基类,很抽象的
/// </summary>
public abstract class Animal
{
    /// <summary>
    /// 只是主键,无其他含义
    /// </summary>
    public int Id { get; set; }
    /// <summary>
    /// 这头野兽叫什么
    /// </summary>
    public abstract string Name { get; set; }
    /// <summary>
    /// 这头野兽多大了
    /// </summary>
    public abstract int Age { get; set; }
}

public class Cat : Animal
{
    public override string Name { get; set; } = null!;
    public override int Age { get; set; }

    /// <summary>
    /// 新增成员,毛发纹理
    /// </summary>
    public string? Texture { get; set; }
}

public class Dog : Animal
{
    public override string Name { get; set; } = "John";
    public override int Age { get; set; } = 1;

    /// <summary>
    /// 新增成员,喜欢的食物
    /// </summary>
    public string? FavFood { get; set; }
}

按照上述代码,基类是 Animal,其他两个是它的子类。且按照咱们对前两种映射策略的说明,映射策略、主键是必须在基类上配置的。正是如此,Id 属性只能定义在基类。也就是说,在模型配置时,Animal 类是要添加到实体模型中的,映射不映射由 EF Core 自己处理。

以 SQL Server 数据库为例,实现数据库上下文。

复制代码
public class TestDbContext : DbContext
{
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseSqlServer("Server=(localdb)\\MY;Database=畜生档案馆;MultipleActiveResultSets=True");
        // 配置一下日志,好查看SQL
        optionsBuilder.LogTo((evtid, lv) => evtid == RelationalEventId.CommandExecuted, evtdata =>
        {
            if(evtdata is CommandEventData cmddata)
            {
                // 改变文本颜色
                Console.ForegroundColor = ConsoleColor.Blue;
                // 记录SQL
                Console.WriteLine($"""
                    [SQL]
                    {cmddata.Command.CommandText}
                    """);
                // 记录完日志后,恢复颜色为默认
                Console.ResetColor();
            }
        });
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        var entAnim = modelBuilder.Entity<Animal>();
        // 映射策略
        entAnim.UseTpcMappingStrategy();
        // 主键
        entAnim.HasKey(x => x.Id);
        // 名称的最大字符数
        entAnim.Property(x => x.Name).HasMaxLength(15);

        var entDog = modelBuilder.Entity<Dog>();
        // 表映射
        entDog.ToTable("tb_dogs", tb =>
        {
            tb.Property(g => g.Id).HasColumnName("dog_id");
            tb.Property(g => g.Name).HasColumnName("dog_name");
            tb.Property(g => g.Age).HasColumnName("dog_age");
            tb.Property(g => g.FavFood).HasColumnName("fav_food");
        });

        var entCat = modelBuilder.Entity<Cat>();
        // 表映射
        entCat.ToTable("tb_cats", tb =>
        {
            tb.Property(y => y.Id).HasColumnName("cat_id");
            tb.Property(y => y.Name).HasColumnName("cat_name");
            tb.Property(y => y.Age).HasColumnName("cat_age");
            tb.Property(y => y.Texture).HasColumnName("cat_texture");
        });
    }
}

比较重要的几点,老周逐个说明一下。

1、数据库的连接字符串,要加上 MultipleActiveResultSets=True,批量插入数据时会返回多个结果,不加这个会报错。和 TPH、TPT 一样,使用 TPC 策略也是在配置基类实体时调用 UseTpcMappingStrategy 方法。

2、由于 TPC 策略下每个表是独立的,因此,每个表的名称,以及列的名称都可以自定义。注意要调用 ToTable 方法,再通过 TableBuilder 对象来配置列名,不要在 PropertyBuilder 上配置。在上一篇水文中,老周给大伙伴演示过,EF Core 在建立数据库 Model 的时候,若实体间存在继承关系,那么属性元数据是共享的。比如,Name 属性,从 Animal 到 Cat、Dog 实体都是共享元数据的。如果使用 PropertyBuilder.HasColumnName 来配置列名,那么,只有最后设置的名称生效,就无法做到每个派生类的列名称独立了。因此,一定要用 ToTable 方法,让表映射变成 Override 版本,EF Core 内部会自动保存每个覆盖的属性配置。

3、也正因为存在继承关系的成员是共享元数据的,所以,像 Name 属性那样要配置最大字符数为 15,也只能在 Animal 类上配置,而且所以派生类所映射的表中,各个继承的成员所对应的列,其类型和参数也必须相同的。即 cat_name 列和 dog_name 列的类型和所占空间大小是相同的,cat_age 与 dog_age 列也是如此。

下面咱们来测试一下。由 EF Core 负责创建数据库。然后向数据库插入四条记录。

复制代码
static async Task Main(string[] args)
{
    // 由运行时自动创建数据库
    using(var c = new TestDbContext())
    {
        _ = awaitc.Database.EnsureCreatedAsync();
    }

    // 插入一些记录试试
    using(var c = new TestDbContext())
    {
        Animal[] chuShengs = [
                new Cat() { Name = "Jack", Age = 2, Texture = "虎斑" },
                new Dog() { Name = "Mike", Age = 3, FavFood = "鸡屁股" },
                new Dog() { Name = "Peter", Age = 2, FavFood = "狗粮" },
                new Cat() { Name = "Lily", Age = 2, Texture = "三花" }
            ];
        awaitc.AddRangeAsync(chuShengs);
        // 保存数据
        _ = awaitc.SaveChangesAsync();
    }
}

在上述代码中,老周用的是异步等待版本。在 ASP.NET Core 项目中推荐这样,其他项目就随意吧。

咱们看看 EF Core 在创建数据库时生成的 SQL 语句。

复制代码
CREATE DATABASE [畜生档案馆];

CREATE SEQUENCE [AnimalSequence] START WITH 1 INCREMENT BY 1NO CYCLE;

CREATE TABLE [tb_cats] (
    [cat_id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [cat_name] nvarchar(15) NOT NULL,
    [cat_age] int NOT NULL,
    [cat_texture] nvarchar(max) NULL,
    CONSTRAINT [PK_tb_cats] PRIMARY KEY ([cat_id])
);

CREATE TABLE [tb_dogs] (
    [dog_id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [dog_name] nvarchar(15) NOT NULL,
    [dog_age] int NOT NULL,
    [fav_food] nvarchar(max) NULL,
    CONSTRAINT [PK_tb_dogs] PRIMARY KEY ([dog_id])
);

TPC 的情况特殊,咱们看到,生成的SQL中包含在 SQL Server 中创建递增序列 AnimalSequence。数据表创建了两个:tb_cats 和 tb_dogs。而它们的主键不再是 IDENTITY,而是由序列来产生下一个值。

为什么会这样呢?这是为了 EF Core 的实体追踪(跟踪)。从面向对象的角度看,Animal 是公共基类,那么,一个 Animal 对象的集合,它既可以包含 Cat 实例,也可以包含 Dog 实例。

你猜猜下面代码中,数据集合中会有几个实例?

复制代码
using(var ctx = new TestDbContext())
{
    // 获取数据集合
    var animals = ctx.Set<Animal>();
    foreach(Animal anm in animals)
    {
        Console.WriteLine($"{anm.Name}\t{anm.Age}");
    }
}

答案是:

复制代码
Jack    2
Lily    2
Mike    3
Peter   2

现在咱们假设一下,如果主键由 IDENTITY 生成,而不是序列,那么,就会有一条 Cat 记录的 ID 是 1,一条 Dog 记录的 ID 也是1。结果是,Animal 类型的集合里,有两个实例的主键是 1。同理,如果继续插入数据,就会出现 ID 同时为 2 的 Cat 和 Dog 实例。EF Core 是通过主键的值来跟踪实体状态的,现在出现主键相同的实例,就不好搞了。所以,才要使用序列,保证所有派生类所在的表中,主键的值在【全局】层面不会重复。就像这样

你看,tb_cats 表中的主键值依次为 1、2,而 tb_dogs 表中的主键值则依次为 3、4。这样一来,在 Animal 集合中,这四条记录的 ID 值就不重复了,EF Core 就能进行跟踪了。

EF Core 在数据集合的查询中是遵守面向对象规则的。比如,咱们上面的集合------ Set<Animal>,它可以包含 Cat 和 Dog 实例,这是本着类型兼容性原则,Cat 和 Dog 都是派生类,可以赋值给声明为 Animal 的对象。如果把代码这样改呢。

复制代码
// 获取数据集合
var animals = ctx.Set<Dog>();
foreach(Animal anm in animals)
{
    Console.WriteLine($"{anm.Name}\t{anm.Age}");
}

现在你猜猜数据集合有几个实例?答案是:

复制代码
Mike    3
Peter   2

这时候,Dog 集合只能兼容 Dog 类,除非有 Dog 的派生类。

虽然 TPC 策略中我们不需要配置类型鉴别器,但在查询时,生成的SQL语句,EF Core 也会插入鉴别标识的。比如前面查询 Animal 集合的,生成的 SQL 如下:

复制代码
SELECT [t].[cat_id], [t].[cat_age], [t].[cat_name], [t].[cat_texture], NULL AS [fav_food], N'Cat' AS [Discriminator]
FROM [tb_cats] AS [t]
UNION ALL
SELECT [t0].[dog_id] AS [cat_id], [t0].[dog_age] AS [cat_age], [t0].[dog_name] AS [cat_name], NULL AS [cat_texture], [t0].[fav_food], N'Dog' AS [Discriminator]
FROM [tb_dogs] AS [t0]

咱们看到,EF Core 加了一个名为 Discriminator 的字段,字段的值就是类名。

咱们还有一个问题没解决:像 SQLite 这样不能用序列的数据库,在 TPC 映射策略下如何处理主键呢。最简单粗暴的方法,就是插入新记录时直接给它分配一个------我们手动赋值。

当然,咱们还有简单不粗暴的方法,那就是使用客户端生成器,即由 EF Core 来生成。就是用 ValueGenerator,这货在很多场合还是很有用的。

先看本示例的主角------实体类。

复制代码
/// <summary>
/// 抽象类,卡牌游戏
/// </summary>
public abstract class CardGame
{
    /// <summary>
    /// 主键
    /// </summary>
    public string CardId { get; set; } = null!;
    /// <summary>
    /// 名称
    /// </summary>
    public abstract string Name { get; set; }
    /// <summary>
    /// 是否为主牌
    /// </summary>
    public abstract bool IsMajor { get; set; }
}

/// <summary>
/// 扑克牌
/// </summary>
public class Poker : CardGame
{
    public required override string Name { get; set; }
    public override bool IsMajor { get; set; }
    /// <summary>
    /// 牌上数字,新增
    /// </summary>
    public int Number { get; set; }
}

/// <summary>
/// 库洛牌
/// </summary>
public class ClowCard : CardGame
{
    public required override string Name { get; set; }
    /// <summary>
    /// 是否为四大元素牌
    /// </summary>
    public override bool IsMajor { get; set; }
}

公共基类表示卡牌游戏的共同特征。然后就是扑克牌和库洛牌,其实二者还有些像的,扑克牌有四大主牌,库洛牌有四大元素牌。

用当天的日期 + GUID。这个我相信就算你一天要插入 10 的 99 次方条记录,应该也不会遇上有重复值的。

复制代码
public class MyIDValueGenerator : ValueGenerator<string>
{
    public override string Next(EntityEntry entry)
    {
        // 当前日期
        DateTime currdt = DateTime.Now;
        string firstPart = currdt.ToString("yyMMdd");
        // GUID
        string secondPart = Guid.NewGuid().ToString("N");
        // 组成新值返回
        return firstPart + "_" + secondPart;
    }

    // 此时,生成的值可不是临时值,而是要存入数据库的,所以返回 false
    public override bool GeneratesTemporaryValues => false;
}

ValueGenerator 是派生自 ValueGenerator 的泛型抽象类。带类型参数的基类继承起来更舒服。我们要实现两个成员:

1、GeneratesTemporaryValues 属性:只读属性,表示此生成器生成的值是不是临时的。啥意思呢?就是生成的值只在 EF Core 跟踪实体过程用,不会存入数据库。比如自增长列,每次生成新值都是数据库完成的,但是,新的实体实例在保存到数据库前,是没有生成的值的,这时候,可以给它临时分配一个值。咱们这里生成的值是要存入数据库的,所以,要返回 false,表示非临时值。

2、Next 方法。返回生成的新值。本例中,老周用日期和 GUID 组成新值,用"_"字符连接。

下面,写一下 DbContext 的派生类,配置数据库模型。

复制代码
public class TestContext : DbContext
{
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        // 日志配置
        ILoggerFactory logfac = LoggerFactory.Create(logbuilder =>
        {
            // 添加控制台日志
            logbuilder.AddConsole();
            // 过滤
            logbuilder.AddFilter((cate, lv) =>
            {
                return cate == "Microsoft.EntityFrameworkCore.Database.Command" && lv == LogLevel.Information;
            });
        });
        // 配置数据库
        optionsBuilder.UseSqlite("data source=cards.db")
            .EnableSensitiveDataLogging(true)
            .UseLoggerFactory(logfac);
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<CardGame>(cgm =>
        {
            // 主键
            cgm.HasKey(c => c.CardId);
            // 映射策略
            cgm.UseTpcMappingStrategy();
            // 配置值生成器
            cgm.Property(x => x.CardId).HasValueGenerator<MyIDValueGenerator>();
        });

        modelBuilder.Entity<Poker>(pkt =>
        {
            pkt.ToTable("tb_poker", tb =>
            {
                tb.Property(w => w.Name).HasColumnName("pk_name");
                tb.Property(w => w.CardId).HasColumnName("pk_id");
                tb.Property(w => w.IsMajor).HasColumnName("pk_major");
                tb.Property(k => k.Number).HasColumnName("pk_num");
            });
        });

        modelBuilder.Entity<ClowCard>(cwt =>
        {
            cwt.ToTable("tb_clowcard", tb =>
            {
                tb.Property(t => t.CardId).HasColumnName("cc_id");
                tb.Property(t => t.Name).HasColumnName("cc_name");
                tb.Property(g => g.IsMajor).HasColumnName("cc_major");
            });
        });
    }
}

在配置模型时,调用 HasValueGenerator 方法应用我们自己写的值生成器。注意,值生成器是针对列的,所以你得在属性成员上配置。

这一次的日志记录,老周玩了点新花样,用到了 .NET 的 Logging 功能,相信大伙伴在 ASP.NET Core 上都玩得很 6 的了。如果是控制台项目,记得引用这个 Nuget 库:Microsoft.Extensions.Logging.Console。

这里咱们比较关心执行过的 SQL 语句,所以,在 Logging 的配置中,老周做了过滤。

复制代码
// 添加控制台日志
logbuilder.AddConsole();
// 过滤
logbuilder.AddFilter((cate, lv) =>
{
    return cate == "Microsoft.EntityFrameworkCore.Database.Command" && lv == LogLevel.Information;
});

.NET Logging 是按日志类别(Category)来输出的,而不是 EF Core 内部使用的 Event ID,输出 SQL 语句的类别是 Microsoft.EntityFrameworkCore.Database.Command。配置之后,控制台只打印这个类别,且属于"信息"级别的日志(错误,调试等级别就不打印)。EnableSensitiveDataLogging 方法表示在打印日志显示查询参数的值,为了安全,一般我们不开启它,如果你想看到参数的具体的值,那就开启,投入生产环境后注释掉就好了。

运行程序。下面是创建表的 SQL 语句。

复制代码
CREATE TABLE "tb_clowcard" (
          "cc_id" TEXT NOT NULL CONSTRAINT "PK_tb_clowcard" PRIMARY KEY,
          "cc_name" TEXT NOT NULL,
          "cc_major" INTEGER NOT NULL
);

CREATE TABLE "tb_poker" (
          "pk_id" TEXT NOT NULL CONSTRAINT "PK_tb_poker" PRIMARY KEY,
          "pk_name" TEXT NOT NULL,
          "pk_major" INTEGER NOT NULL,
          "pk_num" INTEGER NOT NULL
);

我们插入一些数据。

复制代码
using(TestContext ctx =new())
{
    // 获取集合
    DbSet<CardGame> cardset = ctx.Set<CardGame>();
    cardset.AddRange([
        new ClowCard{ Name = "Watery", IsMajor = true },          // 水牌
        new ClowCard{ Name = "Move", IsMajor = false },             // 移牌
        new ClowCard{ Name = "Firey", IsMajor = true },              // 火牌
        new Poker{ Name = "Hearts", IsMajor = true, Number = 3 },   // 红桃3
        new Poker{ Name = "Clubs", IsMajor = true, Number = 1 }     // 梅花A
        ]);
    // 提交
    ctx.SaveChanges();
}

产生的 INSERT SQL 语句如下:

复制代码
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (4ms) [Parameters=[@p0='260613_2bd2b99c9f604a3b850cda8fab96c2ea' (Nullable = false) (Size = 39), @p1='False', @p2='Move' (Nullable = false) (Size = 4)], CommandType='Text', CommandTimeout='30']
      INSERT INTO "tb_clowcard" ("cc_id", "cc_major", "cc_name")
      VALUES (@p0, @p1, @p2);
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[@p0='260613_c59f0714bbbc4dcfa08d8c268f4756c9' (Nullable = false) (Size = 39), @p1='True', @p2='Watery' (Nullable = false) (Size = 6)], CommandType='Text', CommandTimeout='30']
      INSERT INTO "tb_clowcard" ("cc_id", "cc_major", "cc_name")
      VALUES (@p0, @p1, @p2);
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[@p0='260613_d1ba8b41a9bb4d3a86b10ea4da8e5d05' (Nullable = false) (Size = 39), @p1='True', @p2='Firey' (Nullable = false) (Size = 5)], CommandType='Text', CommandTimeout='30']
      INSERT INTO "tb_clowcard" ("cc_id", "cc_major", "cc_name")
      VALUES (@p0, @p1, @p2);
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[@p0='260613_1d8ea807b3244147ade7e66e7c32863e' (Nullable = false) (Size = 39), @p1='True', @p2='Hearts' (Nullable = false) (Size = 6), @p3='3'], CommandType='Text', CommandTimeout='30']
      INSERT INTO "tb_poker" ("pk_id", "pk_major", "pk_name", "pk_num")
      VALUES (@p0, @p1, @p2, @p3);
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[@p0='260613_5fbd80b854b14ddc9933259876d436af' (Nullable = false) (Size = 39), @p1='True', @p2='Clubs' (Nullable = false) (Size = 5), @p3='1'], CommandType='Text', CommandTimeout='30']
      INSERT INTO "tb_poker" ("pk_id", "pk_major", "pk_name", "pk_num")
      VALUES (@p0, @p1, @p2, @p3);

正是因为开启了 EnableSensitiveDataLogging,所以在日志咱们能看到 p0、p1、p2 等查询参数的值。

最后,把刚刚插入的记录全查询出来,并打印到控制台。

复制代码
using (var c = new TestContext())
{
    var cards = c.Set<CardGame>();
    foreach(CardGame cg in cards)
    {
        Console.Write("{0,-13}", cg.GetType().Name);
        Console.Write(cg.CardId + "\t");
        Console.Write(cg.Name + "\n");
    }
}

结果为

复制代码
ClowCard     260613_2bd2b99c9f604a3b850cda8fab96c2ea    Move
ClowCard     260613_c59f0714bbbc4dcfa08d8c268f4756c9    Watery
ClowCard     260613_d1ba8b41a9bb4d3a86b10ea4da8e5d05    Firey
Poker        260613_1d8ea807b3244147ade7e66e7c32863e    Hearts
Poker        260613_5fbd80b854b14ddc9933259876d436af    Clubs

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