开发环境:Nest.js + TypeORM + MySQL
这篇文章仅是由我本人的开发过程中总结出来的一些规律,希望能够给有需要的同学一些帮助。
我们都知道,在传统的关系型数据库当中,如果某个实体和某个实体之间存在一定程度上的相关性,那么必须要在对应的数据库中去配置好这一层关系,否则无法保证数据库的完整性。
现阶段总共的级联关系包括:
- 一对一
- 一对多
- 多对多
# 实验:nest 结合 typeorm 进行级联关系处理
# 前置任务:实体准备
在这里,一般的 typeorm 结合 nest 的配置方法暂且不详细展开。默认大家已经在 nest 中配置好了 typeorm 和 mysql 的连接。
连接好之后,我们可以先准备几个实体以供实验。我在这里准备采用如下的实体列表:
- 用户 user
- 头像 avatar
- 文章 article
- 标签 label
它们之间有如下的几种级联关系:
- 用户和头像:一对一(One-to-One)关系,每个用户有一个唯一的头像。
- 用户和文章:一对多(One-to-Many)关系,一个用户可以有多篇文章。
- 文章和标签:多对多(Many-to-Many)关系,一篇文章可以有多个标签,一个标签可以关联多篇文章。
首先,新建 user 模块和 avatar 模块:
nest g res user | |
nest g res avatar | |
nest g res article | |
nest g res label |
此处都选择 RESTful 模式,并且生成 CRUD。此时你会在目录中看到这四个模块。
我们进入 entities 目录,开始使用 typeorm 对四个实体进行编写。
user.entity.ts
// user.entity.ts | |
import { Column, Entity, OneToOne, PrimaryGeneratedColumn } from "typeorm"; | |
@Entity({ | |
name: "users", | |
}) | |
export class User { | |
@PrimaryGeneratedColumn() | |
id: number; | |
@Column({ | |
type: "varchar", | |
length: 20, | |
}) | |
username: string; | |
} |
avatar.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, OneToOne } from "typeorm"; | |
@Entity({ | |
name: "avatars", | |
}) | |
export class Avatar { | |
@PrimaryGeneratedColumn() | |
id: number; | |
@Column({ | |
type: "varchar", | |
length: 20, | |
}) | |
url: string; | |
} |
article.entity.ts
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; | |
@Entity({ | |
name: "articles", | |
}) | |
export class Article { | |
@PrimaryGeneratedColumn() | |
id: number; | |
@Column({ | |
type: "text", | |
}) | |
content: string; | |
} |
label.entity.ts
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; | |
@Entity({ | |
name: "labels", | |
}) | |
export class Label { | |
@PrimaryGeneratedColumn() | |
id: number; | |
@Column({ | |
type: "varchar", | |
length: 20, | |
}) | |
value: string; | |
} |
可以看到,我们已经分别定义了四个实体,彼此有各自的字段属性。我们启动 nest 应用时,会根据实体生成对应的表。但是此刻它们还没有 联系,我们需要使用一些特殊的装饰器进行处理。
# 前置知识:处理级联关系的装饰器
# 装饰器汇总
在 typeorm 中,用于处理级联关系的装饰器一般有如下几个:
@OneToOne
:定义一对一的关系,当一个实体实例与另一个实体实例有唯一对应关系时使用。@OneToMany
:定义一对多的实体关系,当一个实体实例可以关联多个另一个实体实例时使用。通常和@ManyToOne
装饰器一起使用。@ManyToOne
:定义多对一的实体关系,当多个实体实例可以关联到一个单独的实体实例时使用。通常和@OneToMany
装饰器一起使用。@ManyToMany
:定义多对多的实体关系,当多个实体实例可以关联到多个另一个实体实例时使用。@JoinTable
:定义多对多关系中的连接表,在多对多关系中使用,用于指定关系连接表及其列。使用时,仅需要在多对多联系的任意一方加入即可。@JoinColumn
:定义 外键 和连接列,在@OneToOne
和@ManyToOne
关系中使用,用于指定连接列。一般来说,加在被@ManyToOne
修饰的那个字段。
其中,1~4 装饰器用来从 实体层面 定义级联关系;5~6 装饰器用来处理 数据库层面 的级联关系以维护数据库完整性。
# 参数说明
根据装饰器的不同,它们接受不同的配置参数。
# 1~4
对于 1~4 装饰器,它们通常接受三个参数:
() => TargetEntity
(sourceEntity) => sourceEntity.property
options
第一个参数,是一个返回目标实体类型的函数。这个参数通常是一个返回目标实体类型的箭头函数,TypeORM 会使用这个函数来解析目标实体类型。
第二个参数,是一个返回目标实体中与当前实体相关联的属性的函数。这个函数用于定义目标实体和源实体之间的关系。
第三个参数,是一个可选的配置对象,用于指定关系的额外选项。例如,你可以在这里定义是否自动加载相关实体,是否级联删除等。我们用以配置级联的详细内容,就是在这个配置项中指定的。
对于第三个参数的详细配置选项,有非常非常多。我将按照参数的作用类型进行分类后在下一个模块中作详细说明。
# @JoinTable
接受一个可选的配置对象,该对象包含以下属性:
-
name
类型:
string
描述:指定连接表的名称。如果不指定,TypeORM 会自动生成一个连接表名称。
-
joinColumns
类型:
JoinColumnOptions[]
描述:定义当前实体的连接列。
name
是连接表中的列名,referencedColumnName
是当前实体中的列名(通常是主键)。 -
inverseJoinColumns
类型:
JoinColumnOptions[]
描述:定义关系实体的连接列。
name
是连接表中的列名,referencedColumnName
是关系实体中的列名(通常是主键)。
# @JoinColumn
接受一个或两个配置对象。
接受单个配置对象时:
-
name
类型:
string
描述:指定外键列的名称。如果不指定,TypeORM 会使用默认生成的列名。
-
referencedColumnName
类型:
string
描述:指定目标实体中的列名。通常是目标实体的主键。
接受两个配置对象时,第一个对象用于定义当前实体中的连接列,第二个对象用于定义目标实体中的连接列。比如:
@JoinColumn([ | |
{ name: 'localColumn1', referencedColumnName: 'referencedColumn1' }, | |
{ name: 'localColumn2', referencedColumnName: 'referencedColumn2' } | |
]) |
# 对 options 配置项的特殊说明
一般来说,配置具体的级联操作全是通过这一个对象配置完成的,因此它提供的配置项也有非常非常多。
# 关系的基本配置
-
cascade: 指定是否级联操作。可以是一个布尔值或者一个数组,用于指定级联的操作类型:
true
或者['insert', 'update', 'remove', 'soft-remove', 'recover']
:进行所有类型的级联操作。false
:不进行任何级联操作。- 数组:例如
['insert', 'update']
表示仅对插入和更新操作进行级联。
-
nullable: 指定关系是否可以为空。默认为
true
,即可以为空。 -
onDelete: 指定当关系删除时的操作。有以下几种选择:
'CASCADE'
:删除该实体时,会级联删除相关的实体。'SET NULL'
:删除该实体时,会将外键设为NULL
。'RESTRICT'
:如果有关联存在,则不能删除该实体。'NO ACTION'
:默认行为,通常等同于RESTRICT
。'SET DEFAULT'
:删除该实体时,会将外键设为默认值。
-
onUpdate: 指定当关系更新时的操作,选项同
onDelete
。
在这里,详细说明一下
cascade: ['remove']
和onDelete: 'CASCADE'
的主要区别。
cascade
用于定义在某些操作(如插入、更新、删除等)时,是否对相关的关联实体进行级联操作。
作用范围:
cascade
主要影响在 应用程序层面 的操作,也就是 你实际写代码层面 的操作。例如在用实体对应的Repository
对实体进行保存(save)、删除(remove)等操作时,是否对关联的实体进行同样的操作。可用选项:
cascade
接受一个布尔值或者一个字符串数组,指定对哪些操作进行级联处理。
true
: 等同于['insert', 'update', 'remove', 'soft-remove', 'recover']
,表示对所有支持的操作都进行级联处理。false
: 不进行任何级联操作。- 数组:你可以指定具体的操作类型,例如
['insert', 'update']
。操作影响:
insert
: 当插入主实体时,会插入关联的实体。update
: 当更新主实体时,会更新关联的实体。remove
: 当删除主实体时,会删除关联的实体。soft-remove
: 当软删除主实体时,会软删除关联的实体。recover
: 当恢复主实体时,会恢复关联的实体。使用场景:适用于需要在操作主实体的同时对其关联实体进行同步操作的场景。例如,保存一个包含关联实体的主实体时,希望自动保存这些关联实体。
代码示例:
@ManyToOne(() => User, user => user.posts, { cascade: ['insert', 'update'] }) user: User;在这个例子中,当你插入或更新
Post
实体时,相关的User
实体也会被插入或更新。
onDelete
配置项用于定义在 数据库层面 上,当主实体被删除时,如何处理与其相关的外键约束。
作用范围:
onDelete
主要影响数据库中的外键约束行为,当你在数据库中删除主实体时,如何处理与之关联的外键记录。可用选项:
onDelete
接受字符串值,指定在删除主实体时对关联的外键记录进行的操作。
'CASCADE'
: 当删除主实体时,自动删除所有引用该实体的外键记录(关联实体)。'SET NULL'
: 当删除主实体时,将外键记录的值设置为NULL
。'RESTRICT'
: 当存在引用该实体的外键记录时,禁止删除主实体。'NO ACTION'
: 默认行为,通常等同于RESTRICT
,即禁止删除。'SET DEFAULT'
: 当删除主实体时,将外键记录的值设置为默认值。操作影响:
'CASCADE'
: 级联删除所有引用该实体的关联实体。这是一种硬删除,会从数据库中物理删除记录。'SET NULL'
: 外键被设置为NULL
,这样不会删除关联的实体,只是解除关联。'RESTRICT'
和'NO ACTION'
: 禁止删除有依赖关系的实体,保证数据完整性。使用场景:适用于需要确保数据库外键关系的一致性时使用。例如,在删除一个用户时,需要同时删除所有与之关联的订单记录。
代码示例:
@ManyToOne(() => User, user => user.posts, { onDelete: 'CASCADE' }) user: User;在这个例子中,当你删除
User
实体时,所有关联的Post
实体也会被删除。至此,我们可以总结一下:
- 应用层次不同:
cascade
主要用于应用程序层面的操作控制,如插入、更新和删除操作时的关联实体处理。onDelete
主要用于数据库层面的外键约束控制,定义删除主实体时对外键记录的处理方式。- 操作对象不同:
cascade
操作对象是与主实体相关联的其他实体,应用在 TypeORM 操作时。onDelete
操作对象是数据库表中的外键记录,影响的是数据库存储的行为。- 功能侧重点不同:
cascade
更侧重于对关联实体的同步操作,适用于复杂的对象关系操作场景。onDelete
更侧重于数据完整性和一致性,适用于防止孤立数据的场景。
# 性能优化和缓存
-
eager: 指定是否在查询实体时自动加载关联实体。默认为
false
。true
:会在查询时自动加载关联的实体。false
:不会自动加载,需要手动指定。
-
lazy: 指定是否启用惰性加载。默认为
false
。true
:当访问关联属性时,才会执行查询并加载实体。
-
persistence: 指定是否允许持久化。默认为
true
。
# 加载策略
-
loadRelationIds: 指定是否仅加载关联的外键而不是整个实体。
true
:只加载外键 ID。false
:加载整个关联实体。
-
relationLoadStrategy: 指定加载策略。
'join'
:使用连接查询加载关联实体。'query'
:使用单独的查询来加载关联实体。
# 其他配置
-
deferrable: 指定是否推迟外键约束检查。
'INITIALLY IMMEDIATE'
:立即检查外键约束。'INITIALLY DEFERRED'
:事务提交时检查外键约束。
-
primary: 指定是否为主键。
true
:此关系会成为主键的一部分。false
:不是主键。
-
orphanedRowAction: 指定当移除关系时孤儿行的处理方式。
'nullify'
:将孤儿行的外键设为NULL
。'delete'
:删除孤儿行。
-
index: 指定是否为该列创建索引。
true
:创建索引。false
:不创建索引。
# 实现一对一的关系
根据上述的前置知识,我们了解到了如何在 nest 当中使用 typeorm 编写实体与实体之间的级联关系。
对于一对一,我们可以使用 @OneToOne
和 @JoinColumn
来对 user 和 avatar 实体进行修饰。
编写后的实体如下:
// user.entity.ts | |
@Entity({ | |
name: "users", | |
}) | |
export class User { | |
@PrimaryGeneratedColumn() | |
id: number; | |
@Column({ | |
type: "varchar", | |
length: 20, | |
}) | |
username: string; | |
@OneToOne(() => Avatar, (avatar) => avatar.user, { | |
onDelete: "CASCADE", | |
}) | |
avatar: Avatar; | |
} | |
// avatar.entity.ts | |
@Entity({ | |
name: "avatars", | |
}) | |
export class Avatar { | |
@PrimaryGeneratedColumn() | |
id: number; | |
@Column({ | |
type: "varchar", | |
length: 20, | |
}) | |
small: string; | |
@Column({ | |
type: "varchar", | |
length: 20, | |
}) | |
medium: string; | |
@Column({ | |
type: "varchar", | |
length: 20, | |
}) | |
large: string; | |
@OneToOne(() => User, (user) => user.avatar) | |
@JoinColumn({ name: "user_id" }) | |
user: User; | |
} |
在这里,我们建立了一对一的实体关系,并且将外键列指定在了 avatars 表中,对应的字段名为 user_id
。级联关系指定在了 user 实体中,这意味着在删除这个用户的时候,会把对应的头像也给删除。
实际上,一对一的关系可以直接合成一张表,因此不太常用。
# 实现一对多的关系
一对多的关系实际上和一对一的实现差不多,方式类似,只是换了个装饰器。
在这里,我们所需要实现的关系为:一个用户可以有多篇文章。
使用装饰器,更新实体如下:
// user.entity.ts | |
@Entity({ | |
name: "users", | |
}) | |
export class User { | |
@PrimaryGeneratedColumn() | |
id: number; | |
@Column({ | |
type: "varchar", | |
length: 20, | |
}) | |
username: string; | |
@OneToOne(() => Avatar, (avatar) => avatar.user, { | |
onDelete: "CASCADE", | |
}) | |
avatar: Avatar; | |
@OneToMany(() => Article, (article) => article.user) | |
articles: Article[]; | |
} | |
// article.entity.ts | |
@Entity({ | |
name: "articles", | |
}) | |
export class Article { | |
@PrimaryGeneratedColumn() | |
id: number; | |
@Column({ | |
type: "text", | |
}) | |
content: string; | |
@ManyToOne(() => User, (user) => user.articles, { | |
onDelete: "CASCADE", | |
}) | |
@JoinColumn({ name: "user_id" }) | |
user: User; | |
} |
@OneToMany
一般加在一对多的” 一 “的一方,并且通常无需进行多余的配置。如果有需要,可能需要配置一下 options。
@ManyToOne
一般加载一对多的” 多 “的一方,并且 一般在这个属性上加上外键列 @JoinColumn
,且指定数据库层面的级联关系。我在这里指定了数据库层面的级联删除。
# 实现多对多的关系
多对多的关系和其他两种关系的区别在于,这个关系需要生成一个新的邻接表来保存级联关系。
我们可以使用 @ManyToMany
和 @JoinTable
两个装饰器来实现。
在上述实体中,一篇文章可以有多个标签,一个标签可以关联多篇文章。
我们可以修改实体如下:
// label.entity.ts | |
@Entity({ | |
name: "labels", | |
}) | |
export class Label { | |
@PrimaryGeneratedColumn() | |
id: number; | |
@Column({ | |
type: "varchar", | |
length: 20, | |
}) | |
value: string; | |
@ManyToMany(() => Article, (article) => article.labels) | |
@JoinTable({ name: "article_label" }) | |
articles: Article[]; | |
} | |
// article.entity.ts | |
@Entity({ | |
name: "articles", | |
}) | |
export class Article { | |
@PrimaryGeneratedColumn() | |
id: number; | |
@Column({ | |
type: "text", | |
}) | |
content: string; | |
@ManyToOne(() => User, (user) => user.articles, { | |
onDelete: "CASCADE", | |
}) | |
@JoinColumn({ name: "user_id" }) | |
user: User; | |
@ManyToMany(() => Label, (label) => label.articles) | |
labels: Label[]; | |
} |
使用方式实际上和 @OneToOne
类似,把 @JoinColumn
换成 @JoinTable
以实现邻接表的创建。当然,这个装饰器也是双方都可以进行插入。相对应的级联关系,也是按照类似的方法进行配置即可。
# 启动 nest 服务
当然,实体编写完成后,也不要忘记去 app.module.ts
中对 typeorm 配置进行一下更新。
@Module({ | |
imports: [ | |
TypeOrmModule.forRoot({ | |
type: "mysql", | |
host: "localhost", | |
port: 3306, | |
username: "root", | |
password: "xxx", | |
database: "typeorm-practice", | |
synchronize: true, | |
logging: true, | |
entities: [User, Avatar, Label, Article], // 更新实体的引入 | |
poolSize: 10, | |
connectorPackage: "mysql2", | |
extra: { | |
authPlugin: "sha256_password", | |
}, | |
}), | |
AvatarModule, | |
UserModule, | |
ArticleModule, | |
LabelModule, | |
], | |
controllers: [AppController], | |
providers: [AppService], | |
}) | |
export class AppModule {} |
配置完成后,我们可以尝试启动一下 nest 服务,观察数据库是否按照我们的意愿正常生成。
可以看到,数据库表都已经正常的生成完毕,并且绑定了对应的外键与实体关系。