PAYATHON 2026

Entity Framework Code First 插入数据时出现 NULL 值

支付老李

结论

问题不在 SaveChanges(),而是继承模型的映射方式。

MyValue 和 MyType 在 PaymentComponent 中被声明为抽象属性,并由各派生类分别重写。Entity Framework 可能会把这些重写后的属性识别为不同派生类型各自的成员。

默认使用 TPH(Table-per-Hierarchy)继承映射时,所有类型都保存在同一张表中,但每个派生类型有各自的列。保存某一种类型时,其他派生类型对应的列自然会是 NULL。

添加下面的属性后:

public DbSet<ClubCardPayment> ClubCardPayments { get; set; }

数据库中出现更多列,是因为 ClubCardPayment 被明确加入了 EF 模型。EF 随后为它识别到的派生属性生成了对应的列。

如果 MyValue 和 MyType 是所有支付组件共有的数据,应把它们定义为基类中的普通可映射属性,不要让每个派生类重复重写。

推荐修改

把公共状态放到 PaymentComponent 中:

public abstract class PaymentComponent
{
    public int PaymentComponentID { get; set; }

    public int MyValue { get; set; }

    public string MyType { get; set; }

    public abstract int GetEffectiveValue();
}

派生类只保留各自的业务行为:

public class GiftCouponPayment : PaymentComponent
{
    public override int GetEffectiveValue()
    {
        return MyValue;
    }
}

public class ClubCardPayment : PaymentComponent
{
    public override int GetEffectiveValue()
    {
        return MyValue;
    }
}

Payment 中的导航属性应使用明确的类型:

public class Payment
{
    public int PaymentID { get; set; }

    public virtual ICollection<PaymentComponent> PaymentComponents { get; set; }

    public DateTime PayedTime { get; set; }

    public Payment()
    {
        PaymentComponents = new HashSet<PaymentComponent>();
    }
}

两个派生类型都应明确注册到 EF 模型中:

public class NerdDinners : DbContext
{
    public NerdDinners(string connString)
        : base(connString)
    {
    }

    public DbSet<Payment> Payments { get; set; }
    public DbSet<PaymentComponent> PaymentComponents { get; set; }
    public DbSet<GiftCouponPayment> GiftCouponPayments { get; set; }
    public DbSet<ClubCardPayment> ClubCardPayments { get; set; }

    protected override void OnModelCreating(DbModelBuilder modelBuilder)
    {
        modelBuilder.Conventions.Remove<PluralizingTableNameConvention>();

        base.OnModelCreating(modelBuilder);
    }
}

同时引入相应的命名空间:

using System.Data.Entity;
using System.Data.Entity.ModelConfiguration.Conventions;

插入代码可以继续采用类似的写法:

using (var db = new NerdDinners(connectionString))
{
    var giftCouponPayment = new GiftCouponPayment
    {
        MyValue = 250,
        MyType = "GiftCouponPayment"
    };

    var clubCardPayment = new ClubCardPayment
    {
        MyValue = 5000,
        MyType = "ClubCardPayment"
    };

    var payment = new Payment
    {
        PayedTime = DateTime.Now,
        PaymentComponents = new List<PaymentComponent>
        {
            giftCouponPayment,
            clubCardPayment
        }
    };

    db.Payments.Add(payment);
    db.SaveChanges();
}

修改后,MyValue 和 MyType 只会映射到一组公共列,不会再为每个派生类型分别生成一组可空列。

更合适的类型标识方式

如果 MyType 只用于区分 GiftCouponPayment 和 ClubCardPayment,通常没有必要手动维护这个字符串。TPH 映射可以通过 discriminator 列区分实体类型:

protected override void OnModelCreating(DbModelBuilder modelBuilder)
{
    modelBuilder.Entity<PaymentComponent>()
        .Map<GiftCouponPayment>(m =>
            m.Requires("PaymentComponentType")
             .HasValue("GiftCoupon"))
        .Map<ClubCardPayment>(m =>
            m.Requires("PaymentComponentType")
             .HasValue("ClubCard"));

    base.OnModelCreating(modelBuilder);
}

这样就可以从领域模型中删除 MyType:

public abstract class PaymentComponent
{
    public int PaymentComponentID { get; set; }

    public int MyValue { get; set; }

    public abstract int GetEffectiveValue();
}

数据库中的 PaymentComponentType 会由 EF 根据实体的实际类型填写,从而避免字符串值与对象类型不一致。

如果派生类确实需要不同属性

如果两个派生类中的值在业务上不是同一个概念,就不应统一命名为 MyValue,而应分别定义含义明确的属性:

public class GiftCouponPayment : PaymentComponent
{
    public int CouponValue { get; set; }

    public override int GetEffectiveValue()
    {
        return CouponValue;
    }
}

public class ClubCardPayment : PaymentComponent
{
    public int CardValue { get; set; }

    public override int GetEffectiveValue()
    {
        return CardValue;
    }
}

使用默认 TPH 映射时,表结构会类似下面这样:

PaymentComponentID
PaymentComponentType
CouponValue
CardValue

保存 GiftCouponPayment 时,CardValue 为 NULL;保存 ClubCardPayment 时,CouponValue 为 NULL。这是 TPH 的正常表现,并不表示数据丢失。

如果不希望表中保留这些派生类型专用的可空列,可以改用 TPT(Table-per-Type),让每个派生类型使用自己的表:

protected override void OnModelCreating(DbModelBuilder modelBuilder)
{
    modelBuilder.Entity<PaymentComponent>()
        .ToTable("PaymentComponent");

    modelBuilder.Entity<GiftCouponPayment>()
        .ToTable("GiftCouponPayment");

    modelBuilder.Entity<ClubCardPayment>()
        .ToTable("ClubCardPayment");

    base.OnModelCreating(modelBuilder);
}

TPT 使用多张表和共享主键,查询派生类型时通常需要连接这些表。是否采用 TPT,应根据数据库结构和查询需求决定,不必只为了消除几个正常的 NULL 而更换映射方式。

注意事项

修改实体继承结构或映射配置后,现有数据库不会自动调整为新的结构。需要通过 EF Migration 更新数据库;如果是无须保留数据的开发环境,也可以重新创建数据库。

还需要注意以下几点:

  • 所有需要持久化的派生实体类型都必须加入 EF 模型,可以通过 DbSet<T> 或 modelBuilder.Entity<T>() 注册。
  • 不建议使用私有字段和重写属性承载所有派生类共有的数据。公共持久化状态应直接定义在基类中。
  • 如果 MyType 只是实体类型名称,应优先使用 EF 的 discriminator,避免手动赋值。
  • 使用 TPH 时,派生类型专用列为 NULL 属于正常情况。只有公共属性对应的列为 NULL,才说明映射或赋值可能存在问题。

备注:内容仅供参考。