J5 · 关系、迁移与事务
TypeORM 的关系写法和 JPA 一样,但不会隐式加载任何东西,也没有代理:每个查询都要自己说明连接哪些关系。本章 给 TaskFlow 加上任务,用纳入 git 管理的迁移取代 synchronize,并写出第一个事务操作:归档一个项目及其所有 任务。从 Spring 过来最让人意外的一点是:没有 @Transactional。事务是一个回调,它的 manager 必须传给每一个 查询。
目标
- 建模
OneToMany、ManyToOne和ManyToMany,知道外键在哪一侧。 - 选择关系的加载方式:
eager、lazy或按需的relations。 - 识别 N+1 问题,并用
leftJoinAndSelect修正。 - 生成、执行和回滚迁移,并关闭
synchronize。 - 用
dataSource.transaction()写事务,再用QueryRunner写一遍。 - 交付
Task实体、它的接口、事务化的归档操作和第一个迁移。
Spring → NestJS
| Spring / JPA | TypeORM | 要点 |
|---|---|---|
@OneToMany(mappedBy = "project") | @OneToMany(() => Task, (task) => task.project) | 反向一侧:没有任何列 |
@ManyToOne + @JoinColumn(name = "project_id") | @ManyToOne(() => Project, …) + @JoinColumn({ name: 'project_id' }) | 拥有方:外键在这一侧 |
@ManyToMany + @JoinTable | @ManyToMany(() => Tag) + @JoinTable() | 关联本身带数据时,就用一个关联实体 |
FetchType.LAZY(代理) | 默认什么都不加载;lazy = Promise<T> 类型的属性 | 没有代理,也没有 LazyInitializationException |
FetchType.EAGER | { eager: true } | QueryBuilder 会忽略它 |
JOIN FETCH、@EntityGraph | relations: { tasks: true }、leftJoinAndSelect | 每个查询各自决定加载什么 |
| Flyway、Liquibase | TypeORM 迁移:migration:generate、run、revert | 根据实体和数据库之间的差异生成 |
ddl-auto=none + Flyway | synchronize: false + 迁移 | 数据库结构只通过迁移变更 |
@Transactional | dataSource.transaction(async (manager) => …) | 没有原生的声明式写法:manager 要手动传递 |
TransactionTemplate.execute(…) | dataSource.transaction(…) | 最贴切的类比 |
PlatformTransactionManager(手动 commit / rollback) | QueryRunner | 手动控制,必须调用 release() |
CascadeType.ALL | cascade: true(ORM)或 onDelete: 'CASCADE'(数据库) | 两种不同的机制 |
原理
关系:OneToMany、ManyToOne、ManyToMany
和 JPA 一样,一个关系有拥有方(持有外键)和反向一侧(纯对象层面)。以项目 ↔ 任务为例:
Task上的@ManyToOne(() => Project, (project) => project.tasks)是拥有方。配合@JoinColumn({ name: 'project_id' }),它定义了project_id列及其约束。Project上的@OneToMany(() => Task, (task) => task.project)是反向一侧:没有任何列。在 TypeORM 里它不能 单独存在,必须有对应的@ManyToOne。反过来则不然:@ManyToOne可以没有@OneToMany,比如Task.assignee。() => Task这样的函数推迟了对类的读取:project.entity.ts和task.entity.ts互相导入,其中一个类被装饰 时,另一个可能还没定义。第二个函数指明反向属性,作用和mappedBy一样。
@Entity('projects')
export class Project {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 100 })
name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'boolean', default: false })
archived: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
// softDelete() fills this column; normal find() calls then skip the row.
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt: Date | null;
@OneToMany(() => Task, (task) => task.project)
tasks: Task[];
@OneToMany(() => ProjectMember, (member) => member.project)
members: ProjectMember[];
}对于多对多,TypeORM 根据放在拥有方的 @JoinTable() 创建连接表:
// TaskFlow 之外的示例:给任务加标签
@Entity('tags')
export class Tag {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 50, unique: true })
name: string;
}
// 在 Task 里(拥有方):
@ManyToMany(() => Tag)
@JoinTable({ name: 'task_tags', joinColumn: { name: 'task_id' }, inverseJoinColumn: { name: 'tag_id' } })
tags: Tag[];一旦关联本身带有数据(加入日期、在项目中的角色),就把它建模成一个实体,带两个 @ManyToOne。TaskFlow 对 “用户属于某个项目”就是这样处理的,这也和 JPA 里的建议一致:
// The join table of a many-to-many relation, modelled as its own entity so it
// can carry data (joinedAt). Composite primary key: (project_id, user_id).
@Entity('project_members')
export class ProjectMember {
@PrimaryColumn({ name: 'project_id', type: 'uuid' })
projectId: string;
@PrimaryColumn({ name: 'user_id', type: 'uuid' })
userId: string;
@ManyToOne(() => Project, (project) => project.members, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'project_id' })
project: Project;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;
@CreateDateColumn({ name: 'joined_at', type: 'timestamptz' })
joinedAt: Date;
}有两种“级联”机制同时存在,不要混淆:
| 选项 | 作用位置 | 效果 |
|---|---|---|
onDelete: 'CASCADE' / 'SET NULL' | 在数据库里,作用于外键 | 对项目执行 DELETE 会删除它的任务,不管 DELETE 来自哪里 |
cascade: true(或 ['insert', 'update'] 等) | 在 TypeORM 里,save() 时 | 同时保存属性中存在的关联对象,类似 CascadeType |
TaskFlow 只用 onDelete。软删除(softDelete)是一条 UPDATE:不会触发上面任何一种级联。
加载方式:eager、lazy、relations
TypeORM 默认不加载任何关系。执行 findOneBy({ id }) 之后,project.tasks 是 undefined:既不是空数组, 也不是代理。这是和 JPA 的最大区别:JPA 里 @ManyToOne 默认 EAGER,集合默认 LAZY 并躲在代理后面。
| 方式 | 声明 | 效果 | 什么时候用 |
|---|---|---|---|
| 按需 | find({ relations: { tasks: true } }) 或 leftJoinAndSelect | 在需要它的查询里做连接 | 默认选择 |
eager | @ManyToOne(() => Project, { eager: true }) | 所有 find* 都会加载;QueryBuilder 会忽略;一个关系只能有一侧设为 eager | 很少用:一个小且总是需要的关系 |
lazy | 类型为 Promise<Task[]> 的属性 | 第一次 await project.tasks 时发一条查询 | 很少用:每次访问都是一条隐藏的查询 |
// TaskFlow 之外的示例:lazy 关系
@OneToMany(() => Task, (task) => task.project)
tasks: Promise<Task[]>;
const tasks = await project.tasks; // 此时才执行 SQL 查询TypeORM 1.1 里的 relations
计划里写的是 relations: [],这是 TypeORM 0.3 的数组形式(relations: ['tasks'])。在 TypeORM 1.1 里,这个 选项的类型是 FindOptionsRelations<Entity>,只接受对象形式:relations: { tasks: true },或嵌套的 relations: { tasks: { assignee: true } }。
默认情况下,请求的关系通过连接加载。relationLoadStrategy: 'query' 改为用单独的查询加载,当多个被连接的集合 让行数成倍增长时很有用。
TaskFlow 既不用 eager 也不用 lazy。它通过外键列(projectId、assigneeId)写入关联,用显式的连接读取 关系,比如项目搜索里对 project.members 的 innerJoin(QueryBuilder)。
N+1 问题
先用一条查询取列表,再为每个元素各发一条:这就是 N+1,在 JPA 里常见于 LAZY 集合。在 TypeORM 里,它来自带 await 的循环,或者在循环里遍历 lazy 关系:
// TaskFlow 之外的示例:1 条查询取项目,再每个项目 1 条
const projects = await this.projects.find();
for (const project of projects) {
project.tasks = await this.tasks.findBy({ projectId: project.id, archived: false });
}用一条带连接的查询来修正:
// TaskFlow 之外的示例:1 条查询
const projects = await this.projects
.createQueryBuilder('project')
.leftJoinAndSelect('project.tasks', 'task', 'task.archived = :archived', { archived: false })
.orderBy('project.createdAt', 'DESC')
.getMany();leftJoinAndSelect既做连接,又填充project.tasks;单独的leftJoin只用于过滤。- 连接条件(第三个参数)会保留没有活动任务的项目,而写在
task上的where会把它们过滤掉。 - 其他修正方式:
find({ relations: { tasks: true } }),或者两条查询,第二条用In(projectIds),再在内存里 分组。 - 要统计查询次数,临时在
DataSource选项里加上logging: true。 - 带连接的查询要分页,就用
skip/take(J4)。
迁移
迁移是一个纳入版本管理、用来演进数据库结构的脚本;一张跟踪表记录哪些迁移已经执行过。原理和 Flyway、 Liquibase 一样,区别在于:TypeORM 通过比较实体和当前数据库来生成迁移,形式是一个带 up() 和 down() 的 TypeScript 类。
TypeORM CLI 不会启动 Nest。它需要自己的 DataSource,与应用共享选项(postgresOptions,见 J4):
// The TypeORM CLI does not start Nest, so it gets its own DataSource.
// It reads the same .env file as the app.
try {
process.loadEnvFile(`.env.${process.env.NODE_ENV ?? 'development'}`);
} catch {
// No env file: rely on variables already set (CI, Docker).
}
export default new DataSource({
...postgresOptions(databaseConfig()),
entities: [join(__dirname, '..', '**', '*.entity.{ts,js}')],
});package.json 把它封装成几个脚本:
| 脚本 | 实际执行的命令 | 作用 |
|---|---|---|
npm run migration:generate src/database/migrations/<名称> | typeorm-ts-node-commonjs -d src/database/data-source.ts migration:generate … | 写出 <timestamp>-<名称>.ts,其中的 SQL 让数据库与实体保持一致 |
npm run migration:run | … migration:run | 按顺序执行待执行的迁移 |
npm run migration:revert | … migration:revert | 执行最后一个已执行迁移的 down() |
npm run migration:run:prod | typeorm migration:run -d dist/database/data-source.js | 执行 dist/ 里编译好的迁移,不需要 ts-node:Docker 镜像里用的就是这条命令 |
CLI 还提供 migration:create(一个空迁移,用于手写数据迁移)和 migration:show(每个迁移的状态)。
想用得不出意外,需要知道这些:
synchronize: false。 迁移就位后,除了迁移,没有任何东西会修改数据库结构。TaskFlow 在postgresOptions里固定了这个设置,同时还有迁移文件的匹配模式和跟踪表的名字typeorm_migrations(相当于flyway_schema_history)。- 一个事务。 默认情况下,
migration:run在一个事务里执行所有待执行的迁移。PostgreSQL 的 DDL 支持事务, 所以一旦失败就全部回滚。 - 在最新的数据库上生成。 生成的迁移是一份差异:如果数据库落后,它会包含其他迁移已经描述过的变更。先执行
migration:run,再执行migration:generate。 - 审读 SQL。 TypeORM 识别不了重命名:重命名的属性会表现为
DROP COLUMN加ADD COLUMN。提交前手动改成RENAME COLUMN。 - 绝不修改已经执行过的迁移。 另写一个新的。
- 在 CI 里检查。
migration:generate的--check选项会在实体需要某个迁移里没有的变更时以错误退出。 TaskFlow 的流水线没有用它。
TaskFlow 不会在应用启动时执行迁移:迁移需要显式执行,本地用 npm run migration:run,端到端测试的准备阶段用 dataSource.runMigrations()(J10)。部署时,这是新版本上线之前的一个单独步骤: Docker 镜像里只有编译后的代码,在一个一次性容器里运行 npm run migration:run:prod,执行 dist/ 里的迁移 (J11)。
事务
// TaskFlow 之外的示例:一般形式
await this.dataSource.transaction(async (manager) => {
await manager.save(entity);
await manager.update(Task, { projectId }, { archived: true });
});dataSource.transaction() 从连接池里取一个连接,发送 BEGIN,用一个绑定到该连接的 EntityManager 调用回调; 如果回调返回的 Promise 被兑现就发送 COMMIT,被拒绝就发送 ROLLBACK(随后重新抛出错误)。隔离级别作为第一个 参数传入:dataSource.transaction('SERIALIZABLE', async (manager) => …)。
事务里的所有查询都必须通过 manager:manager.save()、manager.update()、manager.getRepository(Task), 或者用 manager.withRepository(this.tasks) 复用注入的 repository。直接调用注入的 repository 会从连接池里拿 另一个连接:它的查询在事务之外执行,失败时也不会被回滚。
为什么没有 @Transactional?Spring 把事务绑定在当前线程上(一个 ThreadLocal):方法执行期间调用的任何 repository 都能找到它。Node 在同一个线程上处理所有请求,每遇到一个 await 就交错执行;不存在“线程的事务”。 Node 里有针对异步代码的 ThreadLocal 等价物(AsyncLocalStorage),一些社区库借助它模拟 @Transactional, 但 Nest 和 TypeORM 都没有原生支持。所以基本做法是把 manager 作为参数传递:
// TaskFlow 之外的示例:一个在事务内外都能用的方法
archiveTasks(projectId: string, manager: EntityManager = this.dataSource.manager) {
return manager.update(Task, { projectId }, { archived: true });
}最贴切的 Spring 类比不是 @Transactional,而是 TransactionTemplate.execute(status -> …)。
QueryRunner
QueryRunner 让你手动控制连接和事务,就像手动使用 PlatformTransactionManager。dataSource.transaction() 替你做的正是这些步骤:
// TaskFlow 之外的示例:用 QueryRunner 写的归档
async archiveWithQueryRunner(id: string): Promise<Project | null> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const project = await queryRunner.manager.findOneBy(Project, { id });
if (project) {
project.archived = true;
await queryRunner.manager.save(project);
await queryRunner.manager.update(Task, { projectId: id }, { archived: true });
}
await queryRunner.commitTransaction();
return project;
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release(); // 无论如何都把连接还给连接池
}
}回调不够用时再用它:根据中间结果决定是否提交,在 startTransaction('SERIALIZABLE') 时选择隔离级别,或者在 事务里用 queryRunner.query() 执行原生 SQL。其他情况优先用 dataSource.transaction(),它不会忘记 release()。
TaskFlow 案例
Task 实体
@Entity('tasks')
@Index('idx_tasks_project_status', ['projectId', 'status'])
export class Task {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 200 })
title: string;
@Column({ type: 'enum', enum: TaskStatus, enumName: 'task_status', default: TaskStatus.Todo })
status: TaskStatus;
// A PostgreSQL `date` comes back as a 'YYYY-MM-DD' string, not a Date.
@Column({ name: 'due_date', type: 'date', nullable: true })
dueDate: string | null;
@Column({ type: 'boolean', default: false })
archived: boolean;
// Keeping the foreign key as a plain column avoids loading the relation
// just to read or set an id.
@Column({ name: 'project_id', type: 'uuid' })
projectId: string;
@ManyToOne(() => Project, (project) => project.tasks, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'project_id' })
project: Project;
@Column({ name: 'assignee_id', type: 'uuid', nullable: true })
assigneeId: string | null;
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'assignee_id' })
assignee: User | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}status是一个名为task_status的 PostgreSQL 枚举,默认TODO;四个值(TODO、IN_PROGRESS、DONE、OVERDUE)来自task-status.enum.ts(状态)。dueDate是date列:值读出来是'YYYY-MM-DD'字符串,所以类型是string | null。创建用的 DTO 强制要求 这种格式(@Matches+@IsDateString({ strict: true }))。projectId是一个普通列,同时也是project关系的键:两者共用project_id。创建或过滤任务时用projectId就行,不必加载项目;也正是这一列让数据库结构里的外键不可为空。assignee是可选的,带onDelete: 'SET NULL':删除一个用户后,他的任务变成无人负责。archived用于项目归档;复合索引idx_tasks_project_status服务于“按状态过滤某个项目的任务列表”。
接口:POST /projects/:id/tasks 和 PATCH /tasks/:id/status
@ApiTags('tasks')
@ApiBearerAuth()
@Controller()
export class TasksController {
constructor(private readonly tasks: TasksService) {}
@Post('projects/:projectId/tasks')
create(
@Param('projectId', ParseUUIDPipe) projectId: string,
@Body() dto: CreateTaskDto,
@CurrentUser() user: AuthUser,
) {
return this.tasks.create(projectId, dto, user);
}
// Pipes convert and validate one parameter, like a Spring Converter:
// "?status=foo" is rejected with 400 before the handler runs.
@Get('projects/:projectId/tasks')
list(
@Param('projectId', ParseUUIDPipe) projectId: string,
@Query('status', new ParseEnumPipe(TaskStatus, { optional: true })) status: TaskStatus | undefined,
@CurrentUser() user: AuthUser,
) {
return this.tasks.list(projectId, status, user);
}
@Patch('tasks/:id/status')
updateStatus(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateTaskStatusDto,
@CurrentUser() user: AuthUser,
) {
return this.tasks.updateStatus(id, dto.status, user);
}
}- 不带前缀的
@Controller()允许在同一个控制器里声明两类 URL:/v1/projects/:projectId/tasks和/v1/tasks/:id/status。 ParseUUIDPipe和ParseEnumPipe在进入处理函数之前就拒绝无效的 id 或状态(J6)。
service 这边的创建和列表:
async create(projectId: string, dto: CreateTaskDto, user: AuthUser): Promise<Task> {
// 404 if the project is unknown, deleted, or not one of the caller's.
await this.projects.assertAccess(projectId, user);
// Setting the foreign key column is enough: no need to load the Project.
return this.tasks.save(
this.tasks.create({
title: dto.title,
dueDate: dto.dueDate ?? null,
assigneeId: dto.assigneeId ?? null,
projectId,
}),
);
}
async list(projectId: string, status: TaskStatus | undefined, user: AuthUser): Promise<Task[]> {
await this.projects.assertAccess(projectId, user);
const where: FindOptionsWhere<Task> = { projectId, archived: false };
if (status) where.status = status;
return this.tasks.find({ where, order: { createdAt: 'ASC' } });
}assertAccess()来自ProjectsService,由ProjectsModule导出(J3)。它委托 给findOne():项目不存在、已被软删除或调用者不是成员时,对任务和对项目本身一样都返回 404。this.tasks.create({ … })只构造实体,不写入任何东西;save()执行INSERT。填上projectId就够了,不需要 加载Project。- 如果
assigneeId不对应任何用户,就违反了外键约束:全局过滤器把这个 PostgreSQL 错误转成 422 (J6)。 list()只返回未归档的任务,并且只有在提供了状态时才把它加进where(J4)。
修改状态:
async updateStatus(id: string, status: TaskStatus, user: AuthUser): Promise<Task> {
const task = await this.tasks.findOneBy({ id });
if (!task) throw new NotFoundException(`Task ${id} not found`);
await this.projects.assertAccess(task.projectId, user);
if (task.status === status) return task;
const from = task.status;
task.status = status;
const saved = await this.tasks.save(task);
// Emit only once the change is saved. Listeners run separately: if one
// fails, this request still succeeds (see NotificationsListener).
this.events.emit(
TASK_STATUS_CHANGED,
new TaskStatusChangedEvent(task.id, task.projectId, from, status, user.id, this.clock()),
);
return saved;
}保存之后发出的事件是 J9 的主题。端到端测试验证过的流程(Authorization 请求头从 J8 才开始要求):
POST /v1/projects/5b0c…/tasks
Authorization: Bearer eyJ…
Content-Type: application/json
{ "title": "Write the README", "dueDate": "2026-11-24" }响应是 201,响应体包含 "status": "TODO"(列的默认值)和 "dueDate": "2026-11-24"。然后:
PATCH /v1/tasks/<任务 id>/status
Content-Type: application/json
{ "status": "DONE" }返回 200,GET /v1/projects/<id>/tasks?status=DONE 会返回这个任务。?status=LATE 返回 400。
归档项目及其任务
POST /v1/projects/:id/archive 先经过项目控制器(@HttpCode(HttpStatus.OK)),再到负责检查访问权限的 ProjectsService.archive,最后到 store:
// There is no @Transactional: the transaction is a callback, and every query
// inside it must go through `manager`. An injected repository would run
// outside the transaction.
archive(id: string): Promise<Project | null> {
return this.dataSource.transaction(async (manager) => {
const project = await manager.findOneBy(Project, { id });
if (!project) return null;
project.archived = true;
await manager.save(project);
await manager.update(Task, { projectId: id }, { archived: true });
return project;
});
}- 三条查询(读取、保存项目、
UPDATE任务)都通过manager:如果任务的UPDATE失败,项目也不会被归档。 manager.update(Task, …)用一条查询修改项目的所有任务,不加载它们。TasksService.list只返回archived: false的任务:端到端测试验证归档之后,项目的任务列表为空。TypeOrmProjectsStore直接使用Task实体,不导入TasksModule:模块之间没有循环依赖 (J3)。
创建项目也遵循同样的模式,把项目和创建者的成员关系一起写入:
async create(data: CreateProjectDto, ownerId: string): Promise<Project> {
// Project and membership are written together or not at all.
return this.dataSource.transaction(async (manager) => {
const project = await manager.save(
manager.create(Project, { name: data.name, description: data.description ?? null }),
);
await manager.insert(ProjectMember, { projectId: project.id, userId: ownerId });
return project;
});
}第一个迁移
TaskFlow 走过的流程,前提是开发数据库为空、synchronize: false:
npm run migration:generate src/database/migrations/InitialSchema # 写出 <timestamp>-InitialSchema.ts
npm run migration:run # 执行,并记录到 typeorm_migrations
npm run migration:generate src/database/migrations/Check # "No changes in database schema were found"
npm run migration:revert # 执行 down()
npm run migration:run # 重新执行第三条命令确认实体和数据库已经一致:TypeORM 输出“No changes in database schema were found”,不写任何文件, 并以错误退出。生成的文件:
import { MigrationInterface, QueryRunner } from "typeorm";
export class InitialSchema1793093891613 implements MigrationInterface {
name = 'InitialSchema1793093891613'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TYPE "public"."user_role" AS ENUM('ADMIN', 'MEMBER')`);
await queryRunner.query(`CREATE TABLE "users" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "email" character varying(254) NOT NULL, "password_hash" character varying NOT NULL, "role" "public"."user_role" NOT NULL DEFAULT 'MEMBER', "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_97672ac88f789774dd47f7c8be3" UNIQUE ("email"), CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE TYPE "public"."task_status" AS ENUM('TODO', 'IN_PROGRESS', 'DONE', 'OVERDUE')`);
await queryRunner.query(`CREATE TABLE "tasks" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "title" character varying(200) NOT NULL, "status" "public"."task_status" NOT NULL DEFAULT 'TODO', "due_date" date, "archived" boolean NOT NULL DEFAULT false, "project_id" uuid NOT NULL, "assignee_id" uuid, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_8d12ff38fcc62aaba2cab748772" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE INDEX "idx_tasks_project_status" ON "tasks" ("project_id", "status") `);
await queryRunner.query(`CREATE TABLE "projects" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying(100) NOT NULL, "description" text, "archived" boolean NOT NULL DEFAULT false, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deleted_at" TIMESTAMP WITH TIME ZONE, CONSTRAINT "PK_6271df0a7aed1d6c0691ce6ac50" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE TABLE "project_members" ("project_id" uuid NOT NULL, "user_id" uuid NOT NULL, "joined_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_b3f491d3a3f986106d281d8eb4b" PRIMARY KEY ("project_id", "user_id"))`);
await queryRunner.query(`ALTER TABLE "tasks" ADD CONSTRAINT "FK_9eecdb5b1ed8c7c2a1b392c28d4" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "tasks" ADD CONSTRAINT "FK_855d484825b715c545349212c7f" FOREIGN KEY ("assignee_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "project_members" ADD CONSTRAINT "FK_b5729113570c20c7e214cf3f58d" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "project_members" ADD CONSTRAINT "FK_e89aae80e010c2faa72e6a49ce8" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "project_members" DROP CONSTRAINT "FK_e89aae80e010c2faa72e6a49ce8"`);
await queryRunner.query(`ALTER TABLE "project_members" DROP CONSTRAINT "FK_b5729113570c20c7e214cf3f58d"`);
await queryRunner.query(`ALTER TABLE "tasks" DROP CONSTRAINT "FK_855d484825b715c545349212c7f"`);
await queryRunner.query(`ALTER TABLE "tasks" DROP CONSTRAINT "FK_9eecdb5b1ed8c7c2a1b392c28d4"`);
await queryRunner.query(`DROP TABLE "project_members"`);
await queryRunner.query(`DROP TABLE "projects"`);
await queryRunner.query(`DROP INDEX "public"."idx_tasks_project_status"`);
await queryRunner.query(`DROP TABLE "tasks"`);
await queryRunner.query(`DROP TYPE "public"."task_status"`);
await queryRunner.query(`DROP TABLE "users"`);
await queryRunner.query(`DROP TYPE "public"."user_role"`);
}
}- 两个枚举类型(
user_role、task_status)、四张表、project_members的复合主键、带ON DELETE CASCADE或SET NULL的外键,以及那个命名索引。 - 主键默认值是
uuid_generate_v4()。 down()按相反顺序撤销up():先约束,再表,最后类型。
这个文件和产生它的实体变更一起纳入版本管理:
git add src/database/migrations src/tasks/entities
git commit -m "feat(tasks): Task 实体和第一个迁移"场景速查
- 要返回一个项目及其任务 →
relations: { tasks: true }或leftJoinAndSelect(加载方式)。 - 一个列表每一行都触发一条查询 → 这是 N+1:用连接,或者一条
In(ids)查询(N+1)。 - 数据库结构要变更 → 修改实体,
migration:generate,审读,migration:run,两者一起提交;部署时,在新版本 上线前执行npm run migration:run:prod(迁移、第一个迁移、 J11)。 - 最后一个迁移写错了 →
migration:revert,修正,重新生成;绝不修改已经在别处执行过的迁移 (迁移)。 - 多次写入要么一起成功,要么一起失败 →
dataSource.transaction(),里面只用manager(事务、归档)。 - 另一个 service 的方法要参与事务 → 把
manager作为参数传给它(事务)。 - 需要
SERIALIZABLE隔离级别,或者要手动决定是否提交 →dataSource.transaction('SERIALIZABLE', …)或QueryRunner(QueryRunner)。 - 两个实体之间的关联带有数据 → 用关联实体,而不是
@ManyToMany(关系)。
陷阱
在事务里使用注入的 repository
在 dataSource.transaction(async (manager) => …) 内部调用 this.tasks.update(...),会在另一个连接上、在事务之外 执行:一旦失败,这次写入依然保留。改用 manager,或者 manager.withRepository(this.tasks)。
QueryRunner 从未释放
finally 里没有 release(),每次调用都会占住一个连接。连接池耗尽后,应用最终会无限期地等待空闲连接。
回调里忘了 await
如果某个查询没有被等待,回调会在它完成之前就结束:COMMIT 可能在它执行之前就发出,它的错误也不会回滚任何 东西。no-floating-promises 规则会报告这个问题(J1)。
relations: ['tasks'](TypeORM 0.3 的写法)在 TypeORM 1.1 里无法编译:改写成relations: { tasks: true }。eager: true会在所有find*里加载关系,即使用不到,但在QueryBuilder里不会:同样的代码因为所用的 API 不同而表现不同。- 在循环里遍历 lazy 关系是一种从代码上看不出来的 N+1。
@OneToMany没有对应的@ManyToOne无法工作:反向一侧离不开拥有方。cascade: true不是ON DELETE CASCADE:前者在 TypeORM 的save()时生效,后者在数据库里生效。而softDelete两者都不会触发。- 在落后的数据库上生成迁移,会得到一个重复了之前迁移内容的迁移。
- 用了迁移还保留
synchronize: true:数据库结构在迁移之外被修改,之后生成的迁移就对不上了 (synchronize)。
练习
- [ ] 创建
Task实体(title、status 枚举、dueDate、projectId),通过@ManyToOne/@OneToMany关联到Project。 - [ ] 暴露
POST /projects/:id/tasks和PATCH /tasks/:id/status;用curl验证:201、200,以及未知状态返回 400。 - [ ] 编写事务化的项目归档,连同所有任务一起归档(
POST /v1/projects/:id/archive)。在任务的UPDATE之后故意 抛出一个错误,在 Adminer 里确认什么都没有改变。 - [ ] 关闭
synchronize,生成第一个迁移,审读 SQL,执行它,再次生成(“No changes”),回滚,重新执行,然后把它 纳入 git 版本管理。 - [ ] 开启
logging: true制造一个 N+1(循环调用findBy),统计查询次数,再用leftJoinAndSelect修正。 - [ ] 用
QueryRunner重写归档操作,并与dataSource.transaction()比较。 - [ ] 草拟一个
@ManyToMany(任务上的标签),并与ProjectMember实体比较。 - [ ] 在
LEARNINGS.md里记下:@Transactional↔ 显式事务,manager要传给每个查询。
延伸阅读
- NestJS:TypeORM(关系、事务、迁移)
- TypeORM 文档:关系、迁移、事务、QueryRunner
- PostgreSQL 16:约束与外键