Skip to content

J5 · 关系、迁移与事务 ​

第 5 天 · 11 月 16 日周一 · 交付物:关联到项目的任务、迁移

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 / JPATypeORM要点
@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、@EntityGraphrelations: { tasks: true }、leftJoinAndSelect每个查询各自决定加载什么
Flyway、LiquibaseTypeORM 迁移:migration:generate、run、revert根据实体和数据库之间的差异生成
ddl-auto=none + Flywaysynchronize: false + 迁移数据库结构只通过迁移变更
@TransactionaldataSource.transaction(async (manager) => …)没有原生的声明式写法:manager 要手动传递
TransactionTemplate.execute(…)dataSource.transaction(…)最贴切的类比
PlatformTransactionManager(手动 commit / rollback)QueryRunner手动控制,必须调用 release()
CascadeType.ALLcascade: 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 一样。
ts
@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() 创建连接表:

ts
// 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 里的建议一致:

ts
// 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 时发一条查询很少用:每次访问都是一条隐藏的查询
ts
// 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 关系:

ts
// 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 });
}

用一条带连接的查询来修正:

ts
// 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):

ts
// 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:prodtypeorm 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)。

事务 ​

ts
// 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 作为参数传递:

ts
// 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() 替你做的正是这些步骤:

ts
// 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 实体 ​

ts
@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 ​

ts
@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 这边的创建和列表:

ts
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)。

修改状态:

ts
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 才开始要求):

http
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"。然后:

http
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:

ts
// 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)。

创建项目也遵循同样的模式,把项目和创建者的成员关系一起写入:

ts
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:

bash
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”,不写任何文件, 并以错误退出。生成的文件:

ts
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():先约束,再表,最后类型。

这个文件和产生它的实体变更一起纳入版本管理:

bash
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 要传给每个查询。

延伸阅读 ​