6月5日 00:27

TypeORM实体继承:单表、类表和具体表继承的选择与实现

TypeORM 支持三种实体继承模式,名字听着抽象,但对应的是数据库设计里真实的问题:不同类型的数据,是放一张表还是分多张表?放一张表会有大量 NULL 列,分多张表 JOIN 查询变慢。这篇文章把三种模式的实现方式、优缺点、以及什么时候选哪种,都讲清楚。

三种模式对比

模式表结构查询性能NULL 列适用场景
单表继承(STI)一张表,鉴别列区分类型最好(无 JOIN)子类字段差异小
类表继承(CTI)父类一张 + 每个子类一张中(需 JOIN)子类字段差异大,但有关联查询需求
具体表继承(CTI-var)每个子类独立一张表差(UNION 查所有类型)子类完全独立,很少跨类型查询

单表继承(Single Table Inheritance)

所有子类存在同一张表里,用鉴别列(discriminator column)标识类型。子类独有的列在不对应的行里为 NULL。

实现

typescript
import { Entity, PrimaryGeneratedColumn, Column, ChildEntity, DiscriminatorValue } from 'typeorm'; @Entity() @DiscriminatorColumn({ name: 'type' }) export class Content { @PrimaryGeneratedColumn() id: number; @Column() title: string; @Column({ type: 'text' }) body: string; } @ChildEntity() @DiscriminatorValue('article') export class Article extends Content { @Column({ nullable: true }) // 必须设 nullable: true author: string; @Column({ nullable: true }) category: string; } @ChildEntity() @DiscriminatorValue('video') export class Video extends Content { @Column({ nullable: true }) url: string; @Column({ nullable: true }) duration: number; }

生成的表结构:

shell
content ┌────┬───────┬───────────┬────────┬──────┬──────────┬─────┬──────────┐ idtype │ title │ body │ url │ duration │ author │ category │ ├────┼───────┼───────────┼────────┼──────┼──────────┼─────┼──────────┤ 1 │ article│ ... │ ... │ NULL │ NULL │ Alice│ tech │ 2 │ video │ ... │ ... │ a.mp4│ 600 │ NULL │ NULL │ └────┴───────┴───────────┴────────┴──────┴──────────┴─────┴──────────┘

查询方式

typescript
// 查所有内容(不管类型) const all = await dataSource.getRepository(Content).find(); // 只查文章(TypeORM 自动加 WHERE type = 'article') const articles = await dataSource.getRepository(Article).find(); // 按 type 过滤 const videos = await dataSource.getRepository(Content) .find({ where: { type: 'video' } });

NULL 列问题

单表继承最大的问题:子类独有字段在不对应的行里全是 NULL。3 个子类各 5 个独有字段,表里就有 15 个可能为 NULL 的列。如果子类差异大,一张表 20+ 列一半是 NULL,可读性和索引效率都差。

适合场景:子类字段少、差异小。比如 UserAdmin/Member,独有字段各 2-3 个。

类表继承(Class Table Inheritance)

父类一张表,每个子类各一张表,子类表通过外键关联父类表。没有 NULL 列问题,但查询需要 JOIN。

实现

typescript
@Entity() @TableInheritance({ column: { type: 'varchar', name: 'type' } }) export class Person { @PrimaryGeneratedColumn() id: number; @Column() name: string; @Column() email: string; } @ChildEntity() export class Employee extends Person { @Column() position: string; @Column({ type: 'decimal', precision: 10, scale: 2 }) salary: number; } @ChildEntity() export class Customer extends Person { @Column() companyName: string; @Column({ default: 0 }) loyaltyPoints: number; }

生成的表结构:

shell
person employee customer ┌────┬───────┬──────┬───────────┐ ┌────┬──────────┬────────┐ ┌────┬─────────────┬───────────────┐ idtype │ name │ email │ │ id │ position │ salary │ │ id │ companyName │ loyaltyPoints │ ├────┼───────┼──────┼───────────┤ ├────┼──────────┼────────┤ ├────┼─────────────┼───────────────┤ 1 │ employee│ Alice│ a@t.com│ │ 1 │ Engineer │ 80000 │ │ │ │ │ 2 │ customer│ Bob │ b@t.com│ │ │ │ │ │ 2 │ Acme Inc │ 100└────┴───────┴──────┴───────────┘ └────┴──────────┴────────┘ └────┴─────────────┴───────────────┘

person.idemployee.id/customer.id 是同一个值——子类表的主键同时也是父类表的外键。

查询方式

typescript
// 查所有人(TypeORM 自动 JOIN) const all = await dataSource.getRepository(Person).find(); // 只查员工(自动 JOIN person + employee) const employees = await dataSource.getRepository(Employee).find(); // 保存 const emp = new Employee(); emp.name = 'Alice'; emp.email = 'alice@test.com'; emp.position = 'Engineer'; emp.salary = 80000; await dataSource.getRepository(Employee).save(emp); // 自动向 person 和 employee 两张表插入数据

JOIN 的性能代价

每次查子类都要 JOIN 父类表。数据量大时 JOIN 比单表查询慢。但如果你的查询经常只查父类字段(如按 name 搜索),不需要 JOIN,直接查 person 表即可。

适合场景:子类字段差异大,且经常需要跨类型查询公共字段。

具体表继承(Concrete Table Inheritance)

每个子类一张独立的表,父类不建表。公共字段在每个子类表里重复。TypeORM 用 @Column() 在抽象类里定义,子类继承后各自建表。

实现

typescript
export abstract class Payment { @PrimaryGeneratedColumn() id: number; @Column() amount: number; @Column() currency: string; @Column() createdAt: Date; } @Entity() export class CreditCardPayment extends Payment { @Column() cardNumber: string; @Column() expiryDate: string; } @Entity() export class BankTransferPayment extends Payment { @Column() bankName: string; @Column() accountNumber: string; }

生成的表结构:

shell
credit_card_payment bank_transfer_payment ┌────┬────────┬──────────┬──────┬───────────┬────────────┐ ┌────┬────────┬──────────┬──────┬──────────┬───────────────┐ id │ amount │ currency │ date │ cardNumber│ expiryDate│ │ id │ amount │ currency │ date │ bankName │ accountNumber │ └────┴────────┴──────────┴──────┴───────────┴────────────┘ └────┴────────┴──────────┴──────┴──────────┴───────────────┘

公共字段 amountcurrencycreatedAt 在每张表里都有。没有父类表,没有 JOIN,也没有 NULL 列。

查询所有支付类型

具体表继承最大的问题:查"所有类型的支付"需要 UNION:

typescript
// TypeORM 不直接支持跨子类的 UNION 查询 // 需要手动查每张表再合并 const [creditCards, bankTransfers] = await Promise.all([ dataSource.getRepository(CreditCardPayment).find(), dataSource.getRepository(BankTransferPayment).find(), ]); const allPayments = [...creditCards, ...bankTransfers] .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());

没有统一的 Repository 可以查所有子类——因为根本没有父类表。

适合场景:子类完全独立,几乎不需要跨类型查询。比如支付方式、通知渠道。

选择决策

shell
子类独有字段多吗? ├─ 少(每个子类 2-3 个独有字段)→ 单表继承(STI) └─ 多(每个子类 5+ 个独有字段)→ 经常跨类型查询吗? ├─ 是 → 类表继承(CTI) └─ 否 → 具体表继承

迁移时的注意事项

  • 单表继承加新子类:加列就行,ALTER TABLE ADD COLUMN,不用建新表
  • 类表继承加新子类:新建一张子类表,父类表不变
  • 具体表继承加新子类:新建一张独立表

单表继承加子类最灵活——只加列,不动已有数据。类表和具体表继承加子类需要新表,但不会影响已有表结构。

如果未来子类数量不确定或可能频繁增加,优先选单表继承。

标签:TypeORM