• 【EF Core】继承策略——TPT


    先补充一下前一篇中的 TPH 策略的内容——非完整性类型鉴别器。这个东西官方文档写了等于没写,许多大伙伴可能不知道是啥玩意儿。不用慌,老周给你整个示例,你就懂了。

    这种特例多见于先有数据库(DB First)的方案。好,那咱们就先建库,脚本如下,很简单。

    复制代码
    use master;
    go
    
    -- 创建数据库
    create database schoolDB;
    go
    
    use schoolDB;
    go
    
    -- 创建表
    create table [tb_students]
    (
        -- 基类字段
        id int identity not null,
        [name] nvarchar(20) not null,
        [age] int not null,
        -- “转校生”字段
        src_school nvarchar(40) null,
        -- “留级生”字段
        repeat_grade int null,
        -- 鉴别器字段
        _type char(1) not null,
        -- 主键
        constraint [PK_Student] primary key ([id] asc)
    );
    go
    
    -- 添加点数据
    insert into tb_students
        ([name], age, src_school, repeat_grade, _type)
    values
        (N'王番薯', 19, NULL, NULL, 'S'),
        (N'吴正经', 20, N'华中聊汉大学', NULL, 'T'),
        (N'余小琳', 17, NULL, 3, 'R'),
        (N'欧皮革', 20, NULL, NULL, 'Z');
    go
    复制代码

    上述脚本做了三件事:

    1、创建数据库,命名为 schoolDB;

    2、在库中建表,名为 tb_students;

    3、往表中写入新数据,用于示例。

    tb_students 表其实包含了三个实体:

    A、正常学生(id、name、age);

    B、转校生,在正常学生基础上增加了 src_school 列,表示从哪个学校转过来的;

    C、留级生,在正常学生基础上增加 repeat_grade 列,重读的年级。

    用作类型鉴别器的是 _type 列,S 指代正常学生,T 指代转校生,R 指代留级生,Z 无意义。

    好了,数据库搞好了,下面弄 EF Core。

    先定义三个实体类。

    复制代码
    /// 
    /// 正常学生
    /// 
    public class Student
    {
        public int Id { get; set; }
        public required string Name { get; set; }
        public int Age { get; set; }
    }
    
    /// 
    /// 转校生
    /// 
    public class TransferStudent : Student
    {
        public string SourceSchool { get; set; } = null!;
    }
    
    /// 
    /// 留级生
    /// 
    public class RepeatStudent:Student
    {
        public int RepeatGrade { get; set; }
    }
    复制代码

    在数据库上下文的 OnModelCreating 方法中配置模型。

    复制代码
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // 映射策略和主键都要在基类上配置
        modelBuilder.Entity(ent =>
        {
            ent.UseTphMappingStrategy();
            ent.HasKey(x => x.Id);
            // 表映射
            ent.ToTable("tb_students");
            // 列映射
            ent.Property(x => x.Id).HasColumnName("id");
            ent.Property(x => x.Name).HasColumnName("name").HasMaxLength(20);
            ent.Property(x => x.Age).HasColumnName("age");
            // 鉴别器
            ent.HasDiscriminator<string>("StuType")
                .HasValue("S")
                .HasValue("T")
                .HasValue("R");
            ent.Property<string>("StuType").HasColumnName("_type").HasMaxLength(1);
        });
    
        // 派生类的映射
        modelBuilder.Entity(ent =>
        {
            ent.Property(x => x.SourceSchool).HasMaxLength(40).HasColumnName("src_school");
        });
        modelBuilder.Entity(ent =>
        {
            ent.Property(u => u.RepeatGrade).HasColumnName("repeat_grade");
        });
    }
    复制代码

    现在咱们尝试把所有数据查询出来。

    复制代码
    // 配置连接字符串
    DbContextOptionsBuilder opbuilder = new();
    opbuilder.UseSqlServer("Data Source=.\\TEST;Initial Catalog=schoolDB;Integrated Security=True;Persist Security Info=False;Encrypt=True;TrustServerCertificate=True");
    
    using var context = new MyContext(opbuilder.Options);
    // 获取数据集合
    DbSet stus = context.Set();
    // 打印
    foreach(var s in stus)
    {
        Console.WriteLine("id:  {0}", s.Id);
        Console.WriteLine("name: {0}", s.Name);
        Console.WriteLine("age: {0}", s.Age);
        if(s is TransferStudent tfstu)
        {
            Console.WriteLine("source school: {0}", tfstu.SourceSchool);
        }
        if(s is RepeatStudent rpstu)
        {
            Console.WriteLine("repeat grade: {0}", rpstu.RepeatGrade);
        }
        Console.WriteLine();
    }
    复制代码

    这个代码在运行后,你会看到该错误:

    image

    现在回过头看看鉴别器配置。

    ent.HasDiscriminator<string>("StuType")
        .HasValue("S")
        .HasValue("T")
        .HasValue("R");

    再看看数据库中的数据。

    select _type from tb_students

    image

    根据咱们的配置,Student 类由 S 表示,TransferStudent 类由 T 表示,RepeatStudent 类由 R 表示。Z 是没有类型映射的,这个异常的意思就是类型的鉴别值不完整——就是多了个Z出来,EF Core 不知道 Z 跟哪个实体类有关。

    这种情况,我们要明确告诉 EF Core,咱们这个数据库中的鉴别器的值与实际的实体类型没有完全匹配的,我们所配置的类型鉴别的值是不完整的。

    ent.HasDiscriminator<string>("StuType")
        .HasValue("S")
        .HasValue("T")
        .HasValue("R")
        .IsComplete(false);

    true 表示类型列表是完整的,false 是不完整的。这样配置后就不会抛异常了。

    --------------------------------------------------------------------------------------------------------------------------

    现在进入主题,今天咱们聊 TPT 策略。TPT 会为每个实体类型独立映射一个数据表,但表中的列仅限于当前类所定义的成员,不包含从基类继承的成员。

    咱们依旧使用上面那三个【学生】实体,不过,这次配置为 TPT 映射策略。

    复制代码
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // 映射策略和主键都要在基类上配置
        modelBuilder.Entity(ent =>
        {
            ent.UseTptMappingStrategy();
            ent.HasKey(x => x.Id);
            // 表映射
            ent.ToTable("tb_students", tb =>
            {
                tb.Property(u => u.Id).HasColumnName("id");
                tb.Property(u => u.Name).HasColumnName("name");
                tb.Property(u => u.Age).HasColumnName("age");
            });
    
            ent.Property(x => x.Name).HasMaxLength(20);
        });
    
        // 派生类的映射
        modelBuilder.Entity(ent =>
        {
            ent.Property(x => x.SourceSchool).HasMaxLength(40);
            // 表映射
            ent.ToTable("tb_trf_students", tb =>
            {
                tb.Property(i => i.Id).HasColumnName("mid");
                tb.Property(i => i.SourceSchool).HasColumnName("src_school");
            });
        });
        modelBuilder.Entity(ent =>
        {
            // 表映射
            ent.ToTable("tb_rpt_students", tb =>
            {
                tb.Property(w => w.Id).HasColumnName("mid");
                tb.Property(w => w.RepeatGrade).HasColumnName("repeat_grade");
            });
        });
    }
    复制代码

    不管你用哪种映射策略,UseXXXMappingStrategy 方法必须在配置基类实体时调用,不能在派生类的配置中调用,那样会报错。

    由于 TPT 是每个类型一个表,所以你可以用 ToTable 方法为各个表自定义名称。

    这里各位要注意:列映射的自定义名称最好在 ToTable 方法中通过 TableBuilder 对象来配置,不要在实体属性上直接配置(ent.Property(...).HasColumnName(...))。这是因为在 PropertyBuilder 上配置的列名是通过 Annotations 字典(Key = Relational:ColumnName)来存储的,这表明这个列名你能存储一个值。如果这个属性被多次列映射,那么,后面设置的列名会覆盖掉前面设置的列名,而不管你映射的是否为同一个表。

    对 TPT 策略而言,只有主键列会被多次映射,其他属性不会有覆盖的问题(派生类的表不包含基类成员,自然就不会重复映射了)。比如,基类 Student,在 tb_students 表中映射了 Id、Name、Age 属性;到了 TransferStudent 类,它只定义了 SourceSchool 属性,所以表  tb_trf_students 中只映射 SourceSchool 成员。RepeatStudent 实体同理。

    从上面的配置代码看到,只有 Id 属性被做了多次列映射。所以,除了 Id 属性以外,其他属性是可以在 PropertyBuilder 上用 HasColumnName 方法配置列映射的,但为了代码更好看,统一用 TableBuilder 来配置最好。尤其在 TPC 策略下各个属性都会多次映射(本文先不提)。

    那么,为什么 TPT 策略要把基类的主键映射多次呢?看看它生成的 SQL 语句,你或许就明白了。

    复制代码
    CREATE TABLE [tb_students] (
        [id] int NOT NULL IDENTITY,
        [name] nvarchar(20) NOT NULL,
        [age] int NOT NULL,
        CONSTRAINT [PK_tb_students] PRIMARY KEY ([id])
    );
    GO
    
    CREATE TABLE [tb_rpt_students] (
        [mid] int NOT NULL,
        [repeat_grade] int NOT NULL,
        CONSTRAINT [PK_tb_rpt_students] PRIMARY KEY ([mid]),
        CONSTRAINT [FK_tb_rpt_students_tb_students_mid] FOREIGN KEY ([mid]) REFERENCES [tb_students] ([id]) ON DELETE CASCADE
    );
    GO
    
    CREATE TABLE [tb_trf_students] (
        [mid] int NOT NULL,
        [src_school] nvarchar(40) NOT NULL,
        CONSTRAINT [PK_tb_trf_students] PRIMARY KEY ([mid]),
        CONSTRAINT [FK_tb_trf_students_tb_students_mid] FOREIGN KEY ([mid]) REFERENCES [tb_students] ([id]) ON DELETE CASCADE
    );
    GO
    复制代码

    不知道大伙伴们看出啥门道了没有。在 TPT 映射策略中,只有基类的主键列会生成/插入新值,其他派生类表都是通过外键来引用基类表的主键的。正因为这样,所以在查询数据时,就等于做联表查询,这使得 TPT 策略的性能会比其他策略低。

     啥意思呢,咱们试着插入几条记录就知道了。

    复制代码
    using var context = new MyContext(opbuilder.Options);
    
    context.Database.EnsureCreated();       // 运行时创建数据库
    // 获取数据集合
    DbSet students = context.Set();
    // 添加新记录
    students.AddRange([
            new Student{Name = "吴珍珠", Age = 18},
            new TransferStudent{Name = "王大山", Age = 18, SourceSchool = "飓风中学"},
            new RepeatStudent{Name = "陆大锤", Age = 17, RepeatGrade = 2}
        ]);
    // 保存数据
    context.SaveChanges();
    复制代码

    咱们每个类型各添加一条记录,看看数据库怎么存储它们。

    select * from tb_students;
    select * from tb_trf_students;
    select * from tb_rpt_students;

    image

    【吴珍珠】同学的 Id 为2,因为它是 Student 类,作为基类,只用到 tb_students 表;

    【王大山】同学的 Id 为 3,它是 TransferStudent 类。从基类继承的 Name 和 Age 属性存放到 tb_students 表中,而 SourceSchool 属性的值则存放在 tb_trf_students 表的 src_school 列中;

    【陆大锤】同学的 Id 为1,它是 RepeatStudent 类,其中 Name、Age 属性存入 tb_students 列,而它所定义的 RepeatGrade 属性的值就存入 tb_rpt_students 表的 repeat_grade 列。

    最后,咱们把注意力放在主键列上。所有记录的主键值都在基类表中生成(tb_students.id 列),然后

    对于【吴珍珠】同学,它就在基类表中,不需要外键引用;

    对于【王大山】同学,tb_trf_students.mid 列通过外键,引用了主键值 3;

    对于【陆大锤】同学,tb_rpt_students.mid 列通过外键引用了主键值 1;

    目前 EF Core 在配置主键的约束名称是有限制的,所以不要去自定义主键的约束。

    // 不要调用 HasName 方法
    ent.HasKey(x => x.Id).HasName("PK_what_the_fk");

    下面老周解释一下为什么会有这个局限。

    1、派生类中不允许配置主键。看看 EntityType.SetPrimaryKey 方法的源代码。

    复制代码
    public virtual Key? SetPrimaryKey(
        IReadOnlyList? properties,
        ConfigurationSource configurationSource)
    {
        EnsureMutable();
        Check.DebugAssert(IsInModel, "The entity type has been removed from the model");
    
        if (BaseType != null)
        throw new InvalidOperationException(CoreStrings.DerivedEntityTypeKey(DisplayName(), GetRootType().DisplayName()));
        }
    
       ……
    }
    复制代码

    意思就是如果你正在配置的实体存在基类,那就抛出异常。所以,你只能在基类上配置主键。

    2、对于 EF Core 的数据库模型,如果实体存在继承关系,那么,派生类实体所继承的成员,与基类实体所定义的同一个成员,它们之间使用相同的元数据。这后果是,如果你在 Student 类中配置了主键的约束名为 PK_XXX,那么,TransferStudent 类和 RepeatStudent 类的 Id 属性都从 Student 害继承,即它们的元数据相同,导致所有数据表的主键的约束名都变成 PK_XXX。多个表使用相同的约束名,在数据库中会报错。

    所以,你不能改变约束名,一改就全部一起改掉了。但保留 EF Core 的默认配置就没有问题,因为 EF Core 在生成 SQL 语句时,主键默认的名字是 PK_<表名>,外键是 FK_<表名>,这样就不会出现重复约束名了。

    复制代码
     public static string? GetDefaultName(
         this IReadOnlyKey key,
         in StoreObjectIdentifier storeObject,
         IDiagnosticsLogger? logger)
     {
         if (storeObject.StoreObjectType != StoreObjectType.Table
             || key.DeclaringEntityType.IsMappedToJson())
         {
             return null;
         }
    
         if (key.DeclaringEntityType.IsMappedToJson())
         {
             return null;
         }
    
         string? name;
         if (key.IsPrimaryKey())
         {
             var rootKey = key;
             // Limit traversal to avoid getting stuck in a cycle (validation will throw for these later)
             // Using a hashset is detrimental to the perf when there are no cycles
             for (var i = 0; i < RelationalEntityTypeExtensions.MaxEntityTypesSharingTable; i++)
             {
                 var linkingFk = rootKey!.DeclaringEntityType.FindRowInternalForeignKeys(storeObject)
                     .FirstOrDefault();
                 if (linkingFk == null)
                 {
                     break;
                 }
    
                 rootKey = linkingFk.PrincipalEntityType.FindPrimaryKey();
             }
    
             if (rootKey != null
                 && rootKey != key)
             {
                 return rootKey.GetName(storeObject);
             }
    
             name = "PK_" + storeObject.Name;
         }
         else
         {
             var columnNames = key.Properties.GetColumnNames(storeObject);
             if (columnNames == null)
             {
                 if (logger != null)
                 {
                     var table = storeObject;
                     if (key.DeclaringEntityType.GetMappingFragments(StoreObjectType.Table)
                         .Any(t => t.StoreObject != table && key.Properties.GetColumnNames(t.StoreObject) != null))
                     {
                         return null;
                     }
    
                     if (key.DeclaringEntityType.GetMappingStrategy() != RelationalAnnotationNames.TphMappingStrategy
                         && key.DeclaringEntityType.GetDerivedTypes()
                             .Select(e => StoreObjectIdentifier.Create(e, StoreObjectType.Table))
                             .Any(t => t != null && key.Properties.GetColumnNames(t.Value) != null))
                     {
                         return null;
                     }
    
                     logger.KeyPropertiesNotMappedToTable((IKey)key);
                 }
    
                 return null;
             }
    
             var rootKey = key;
    
             // Limit traversal to avoid getting stuck in a cycle (validation will throw for these later)
             // Using a hashset is detrimental to the perf when there are no cycles
             for (var i = 0; i < RelationalEntityTypeExtensions.MaxEntityTypesSharingTable; i++)
             {
                 IReadOnlyKey? linkedKey = null;
                 foreach (var otherKey in rootKey.DeclaringEntityType
                              .FindRowInternalForeignKeys(storeObject)
                              .SelectMany(fk => fk.PrincipalEntityType.GetKeys()))
                 {
                     var otherColumnNames = otherKey.Properties.GetColumnNames(storeObject);
                     if ((otherColumnNames != null)
                         && otherColumnNames.SequenceEqual(columnNames))
                     {
                         linkedKey = otherKey;
                         break;
                     }
                 }
    
                 if (linkedKey == null)
                 {
                     break;
                 }
    
                 rootKey = linkedKey;
             }
    
             if (rootKey != key)
             {
                 return rootKey.GetName(storeObject);
             }
    
             name = new StringBuilder()
                 .Append("AK_")
                 .Append(storeObject.Name)
                 .Append('_')
                 .AppendJoin(columnNames, "_")
                 .ToString();
         }
    
         return Uniquifier.Truncate(name, key.DeclaringEntityType.Model.GetMaxIdentifierLength());
     }
    复制代码

     

    这时候有大伙伴可能想到了使用约定来修改主键的约束名称。

    复制代码
    public class MyConvention : IModelFinalizingConvention
    {
        public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext context)
        {
            var entStudent = modelBuilder.Metadata.FindEntityType(typeof(Student));
            if(entStudent != null)
            {
                var key = entStudent.FindPrimaryKey() as IMutableKey;
                if(key != null)
                {
                    key.SetName("PK_Stu_base");
                }
            }
            var entTrfStudent = modelBuilder.Metadata.FindEntityType(typeof(TransferStudent));
            if(entTrfStudent != null)
            {
                var key = entTrfStudent.FindPrimaryKey() as IMutableKey;
                if(key != null)
                {
                    key.SetName("PK_Transf_stu");
                }
            }
            var entRptStudent = modelBuilder.Metadata.FindEntityType(typeof(RepeatStudent));
            if (entRptStudent != null)
            {
                var key = entRptStudent.FindPrimaryKey() as IMutableKey;
                if (key != null)
                {
                    key.SetName("PK_Rpt_stu");
                }
            }
        }
    }
    复制代码

    数据库模型一旦 Finalized 阶段就变成只读了,无法修改元数据。所以你不能在实例化 DbContext 之后修改,那时候已经改不了。故,咱们要用约定的话,只能在 Finalizing 阶段。这时候模型的配置已经完成,但还未被固化(只读),即实现 IModelFinalizingConvention 接口,这样做可以避免被其他约定干扰。

    约定类写好后,重写 DbContext.ConfigureConventions 方法,将其注册到约定集合中。

    protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
    {
        configurationBuilder.Conventions.Add(_ => new MyConvention());
    }

    然而,结果会让你失望的。

    复制代码
    CREATE TABLE [tb_students] (
        [id] int NOT NULL IDENTITY,
        [name] nvarchar(20) NOT NULL,
        [age] int NOT NULL,
        CONSTRAINT [PK_Rpt_stu] PRIMARY KEY ([id])
    );
    GO
    
    CREATE TABLE [tb_rpt_students] (
        [mid] int NOT NULL,
        [repeat_grade] int NOT NULL,
        CONSTRAINT [PK_Rpt_stu] PRIMARY KEY ([mid]),
        CONSTRAINT [FK_tb_rpt_students_tb_students_mid] FOREIGN KEY ([mid]) REFERENCES [tb_students] ([id]) ON DELETE CASCADE
    );
    GO
    
    CREATE TABLE [tb_trf_students] (
        [mid] int NOT NULL,
        [src_school] nvarchar(40) NOT NULL,
        CONSTRAINT [PK_Rpt_stu] PRIMARY KEY ([mid]),
        CONSTRAINT [FK_tb_trf_students_tb_students_mid] FOREIGN KEY ([mid]) REFERENCES [tb_students] ([id]) ON DELETE CASCADE
    );
    GO
    复制代码

    只要改其中一个,等于全部主键都改了。这时可以初步推断,由于主键是从基类继承的,所以,派生类实体的元数据中,使用的主键对象是同一个实例。

    要证明这个推测也很容易,我们打印出三个实体的 Key 对象的内存地址。

    复制代码
    public class MyConvention : IModelFinalizingConvention
    {
        private void PrintObjectAddress(string tag, object obj)
        {
            GCHandle handle = GCHandle.Alloc(obj, GCHandleType.WeakTrackResurrection);
            IntPtr addr = GCHandle.ToIntPtr(handle);
            handle.Free();
            Console.WriteLine("{0}: 0x{1:X}", tag, addr);
        }
    
        public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext context)
        {
            var entStudent = modelBuilder.Metadata.FindEntityType(typeof(Student));
            if(entStudent != null)
            {
                var key = entStudent.FindPrimaryKey() as IMutableKey;
                if(key != null)
                {
                    key.SetName("PK_Stu_base");
                    PrintObjectAddress("Student Key", key);
                }
            }
            var entTrfStudent = modelBuilder.Metadata.FindEntityType(typeof(TransferStudent));
            if(entTrfStudent != null)
            {
                var key = entTrfStudent.FindPrimaryKey() as IMutableKey;
                if(key != null)
                {
                    key.SetName("PK_Transf_stu");
                    PrintObjectAddress("TransferStudent Key", key);
                }
            }
            var entRptStudent = modelBuilder.Metadata.FindEntityType(typeof(RepeatStudent));
            if (entRptStudent != null)
            {
                var key = entRptStudent.FindPrimaryKey() as IMutableKey;
                if (key != null)
                {
                    key.SetName("PK_Rpt_stu");
                    PrintObjectAddress("RepeatStudent Key", key);
                }
            }
        }
    }
    复制代码

    然后得到以下结果:

    Student Key:              0x245CD862820
    TransferStudent Key:      0x245CD862820
    RepeatStudent Key:        0x245CD862820            

    看吧,它们的地址一样。

    所以,现阶段,在 TPT 策略下你不能自定义主键的约束名称,但微软说以后的版本会支持。

     

  • 相关阅读:
    面试二十一、红黑树
    DeepSpeed教程
    【萌新解题】斐波那契数列
    EasyLazyload系列之实现图片懒加载
    第4周学习:MobileNetV1, V2, V3
    计算机毕业设计Java无人售货机管理系统(源码+系统+mysql数据库+Lw文档)
    PHP md5_file() 函数用于判断文件是否有修改
    Python字典和集合操作指南:创建、获取值、修改和删除键值对,复制和遍历方法全解析
    Controller统一异常处理和yaml配置
    爱尔兰博士后招聘|利默里克大学-广告学
  • 原文地址:https://www.cnblogs.com/tcjiaan/p/20238402