Skip to content

J4 · PostgreSQL 与 TypeORM ​

第 4 天 · 11 月 13 日周五 · 交付物:项目持久化

TypeORM 同时扮演了 JPA/Hibernate 和 Spring Data 的角色:带装饰器的实体、每个实体一个 Repository<T>,其余的 交给 QueryBuilder。有三个 Spring 习惯在这里不再适用:根据方法名派生查询、内置的 Page<T>,以及自动保存修改 的持久化上下文。本章给 TaskFlow 接入 PostgreSQL,把项目的内存存储换成 TypeORM,控制器一行不改。

目标 ​

  • 用 @nestjs/typeorm 接入 TypeORM:forRoot、forRootAsync、forFeature。
  • 编写实体并选择 PostgreSQL 类型:uuid、timestamptz、jsonb、枚举。
  • 使用 Repository<T>(find、findOne、save、softDelete)和 QueryBuilder。
  • 理解为什么 synchronize: true 只能用于开发环境。
  • 在不改控制器的前提下把项目持久化到 PostgreSQL,并用种子数据填充开发数据库。

Spring → NestJS ​

SpringNestJS + TypeORM要点
spring.datasource.* + JPA 自动配置TypeOrmModule.forRoot() / forRootAsync()在根模块里配置一次
interface ProjectRepository extends JpaRepository<Project, UUID>@InjectRepository(Project) repo: Repository<Project> + TypeOrmModule.forFeature([Project])不用声明接口:repository 是泛型的
findByNameContaining(name)(或 …IgnoreCase)find({ where: { name: ILike('%x%') } }) 或 QueryBuilder没有根据方法名派生的查询
Page<T> + PageablefindAndCount({ skip, take }) → [items, total]分页结果要自己拼
@Query("select p from Project p …")、Criteria APIcreateQueryBuilder('project')接近 SQL,使用命名参数
@Id @GeneratedValue(strategy = UUID)@PrimaryGeneratedColumn('uuid')由 PostgreSQL 生成(uuid_generate_v4())
@CreationTimestamp、@UpdateTimestamp@CreateDateColumn、@UpdateDateColumn由 TypeORM 填充
@Enumerated(EnumType.STRING)@Column({ type: 'enum', enum: TaskStatus })TypeORM 存的是值,从不存序号
Hibernate 软删除(@SQLDelete + 限制条件)@DeleteDateColumn + softDelete()find* 会排除已删除的行
spring.jpa.hibernate.ddl-auto=updatesynchronize: true只用于开发;之后改用迁移(J5)
持久化上下文、dirty checking没有对应物修改只有在 save() 时才会写入
findById 返回 Optional<T>findOneBy 返回 T | null编译器(strict 模式)强制检查 null

原理 ​

@nestjs/typeorm:forRoot、forRootAsync、forFeature ​

bash
npm i @nestjs/typeorm typeorm pg

集成模块提供三个入口:

  • TypeOrmModule.forRoot(options) 在启动时用写死的选项创建 DataSource(连接池)。
  • TypeOrmModule.forRootAsync({ inject, useFactory }) 做同样的事,但选项由其他 provider 计算得出,通常是 配置。它是一个动态模块。
  • TypeOrmModule.forFeature([Project, ProjectMember]) 写在领域模块里,为每个实体注册一个 Repository<T> provider。用 @InjectRepository(Project) 注入,它的 token 是字符串 "ProjectRepository"。
ts
// TaskFlow 之外的示例:写死的选项
TypeOrmModule.forRoot({
  type: 'postgres',
  host: 'localhost',
  port: 5432,
  username: 'taskflow',
  password: 'taskflow',
  database: 'taskflow',
  autoLoadEntities: true,
  synchronize: false,
});

TaskFlow 从类型化配置(J7)里读取选项:

ts
@Module({
  imports: [
    TypeOrmModule.forRootAsync({
      inject: [databaseConfig.KEY],
      useFactory: (db: DatabaseConfig) => ({
        ...postgresOptions(db),
        // Picks up every entity registered with TypeOrmModule.forFeature().
        autoLoadEntities: true,
      }),
    }),
  ],
})
export class DatabaseModule {}

公共选项放在一个和 TypeORM CLI 共用的函数里,保证应用和迁移用同样的设置连接同一个数据库:

ts
// Shared by the Nest app (database.module.ts) and the TypeORM CLI
// (data-source.ts), so migrations run against the same settings.
export function postgresOptions(db: DatabaseConfig): DataSourceOptions {
  return {
    type: 'postgres',
    ...db,
    // Never true outside a throwaway database: it alters tables to match the
    // entities and can drop columns with their data. Use migrations instead.
    synchronize: false,
    migrations: [join(__dirname, 'migrations', '*.{ts,js}')],
    migrationsTableName: 'typeorm_migrations',
  };
}
  • autoLoadEntities: true 会把每个通过 forFeature() 注册的实体加入 DataSource。一个只通过关系被引用、 没有 forFeature 的实体不会被加载。在 TaskFlow 里,每个实体都有自己的 forFeature(ProjectsModule 里的 Project 和 ProjectMember,TasksModule 里的 Task,UsersModule 里的 User)。
  • TypeORM CLI 不会启动 Nest:它有自己的 DataSource,通过文件匹配模式找到实体 (J5)。
  • DataSource 本身可以按类注入(private readonly dataSource: DataSource):事务要用到它 (J5)。

如果启动时 PostgreSQL 没有响应,模块会重试:默认 10 次,每次间隔 3 秒,每次都会记录 “Unable to connect to the database”。启动时“卡住”三十秒左右的应用,往往只是忘了 docker compose up -d。

实体与列 ​

实体是用 @Entity('表名') 装饰的类;每个要持久化的属性都带一个列装饰器。

装饰器作用JPA 对应物
@Entity('projects')类 ↔ 表@Entity + @Table(name = "projects")
@PrimaryGeneratedColumn('uuid')由数据库生成的主键@Id @GeneratedValue
@PrimaryColumn()由代码提供的主键;多个即为复合主键@Id、@EmbeddedId
@Column({ type, length, nullable, default, name })列@Column
@CreateDateColumn()、@UpdateDateColumn()插入和更新时自动填写的日期@CreationTimestamp、@UpdateTimestamp
@DeleteDateColumn()软删除标记Hibernate 软删除
类上的 @Index('索引名', ['col1', 'col2'])索引@Table(indexes = …)

和 Spring Boot 相比有两个区别:

  • 列名。 Spring Boot 默认把 createdAt 转成 created_at。TypeORM 保留属性名("createdAt",带大写字母, 所以在 SQL 里要加引号)。TaskFlow 给每个由多个单词组成的列都写了 name: 'created_at' 这样的名字。
  • 列类型要显式写出。 不写 type 时,TypeORM 根据 TypeScript 生成的 design:type 元数据推断类型。但在 strict 模式下,description: string | null 生成的是 Object,启动会失败,报 Data type "Object" in "Project.description" is not supported by "postgres" database. TaskFlow 总是写明 type(类型元数据)。

实体属性没有初始值:之所以可以这样写,是因为 tsconfig.json 关闭了 strictPropertyInitialization。 repository.create({ ... }) 构造一个实例,不发任何查询,相当于 new。

PostgreSQL 类型:uuid、timestamptz、jsonb、枚举 ​

需求PostgreSQL 类型TypeORM 声明JavaScript 里的值
标识符uuid@PrimaryGeneratedColumn('uuid')、@Column({ type: 'uuid' })string
时间点(创建、修改)timestamptz@CreateDateColumn({ type: 'timestamptz' })Date
日历日(截止日期)date@Column({ type: 'date' })'YYYY-MM-DD' 格式的 string
JSON 文档jsonb@Column({ type: 'jsonb' })对象
封闭的取值集合ENUM 类型@Column({ type: 'enum', enum: TaskStatus, enumName: 'task_status' })TypeScript 枚举的值
文本varchar(n)、text@Column({ type: 'varchar', length: 100 })string
  • timestamptz 存储一个绝对时间点,相当于 Instant。不带时区的 timestamp 取决于服务器和会话的时区: 事件发生时间不要用它。
  • date 读出来是 'YYYY-MM-DD' 字符串,而不是 Date:所以 Task.dueDate 的类型是 string | null (J5)。它的语义是 LocalDate。
  • enumName 给 PostgreSQL 类型起一个稳定的名字(task_status、user_role),在迁移里一目了然。给 TypeScript 枚举加一个值,需要一次迁移。
  • jsonb 存储可建索引的文档,适合没有固定结构的数据:
ts
// TaskFlow 之外的示例
@Column({ type: 'jsonb', nullable: true })
settings: Record<string, unknown> | null;

查询文档内部的字段要用 QueryBuilder 和 PostgreSQL 运算符,例如 .where("project.settings ->> 'theme' = :theme", { theme: 'dark' })。

Repository:find、findOne、save、softDelete ​

Repository<T> 汇集了常用操作。where 是按实体定义类型的对象,其他条件用运算符(ILike、In、LessThan、 IsNull、Not、Between 等)表达。

方法作用Spring Data
find(options)、findBy(where)列表;options 支持 where、order、skip、take、relations、withDeletedfindAll(...)、派生查询
findOne(options)、findOneBy(where)第一条结果或 nullfindById() → Optional
findOneByOrFail(where)找不到时抛出 EntityNotFoundErrororElseThrow()
findAndCount(options)[items, total]:按 skip / take 截取的一页,以及匹配的总行数Page<T>
existsBy(where)booleanexistsBy...
create(data)创建实例,不执行 SQLnew
save(entity)INSERT 或 UPDATE,返回带有生成值的实体save()
merge(entity, changes)把修改复制到实体上—
insert(data)、update(where, changes)直接执行一条查询,不加载实体,也不级联@Modifying @Query
softDelete(where)、restore(where)填写或清空 deleted_atHibernate 软删除
delete(where)DELETEdeleteById()

计划里给出的对应关系,用一个例子说明:

java
// Spring Data
Page<Project> findByNameContainingIgnoreCase(String name, Pageable pageable);
ts
// TaskFlow 之外的示例:TypeORM 的等价写法
const [items, total] = await this.projects.findAndCount({
  where: { name: ILike(`%${name}%`) },
  order: { createdAt: 'DESC' },
  skip: (page - 1) * limit,
  take: limit,
});

TaskFlow 用 pagination.dto.ts 里的 Page<T> 接口自己拼出分页结果 (J2),并对搜索中的通配符做转义(TaskFlow 案例)。

和 JPA 类比失效的地方:

  • 没有持久化上下文。 修改一个已加载的实体不会写入任何东西:必须调用 save()。没有 dirty checking,没有 一级缓存,也没有延迟加载代理(所以也不会有 LazyInitializationException)。
  • save() 写之前会先读。 对于有 id 的实体,它会重新读取这一行,以决定用 INSERT 还是 UPDATE,并且只写 有变化的部分。要修改大量行时,update() 只发一条 UPDATE:逾期任务的标记就是这样做的 (J9)。
  • 内置软删除。 有了 @DeleteDateColumn,softDelete() 会填写这一列,所有 find* 都会排除这一行; withDeleted: true 让它重新出现,restore() 撤销删除。
  • where 里不允许出现 undefined 和 null。 TypeORM 1.1 默认会抛出 TypeORMError (“Undefined value encountered in property … of a where condition”),而不像以前的版本那样悄悄忽略这个属性。 构造 where 时只放入实际存在的过滤条件,要查 SQL 的 NULL 就用 IsNull()。

QueryBuilder ​

find 的选项能覆盖简单的过滤。带条件的连接、聚合、子查询或 PostgreSQL 特有的运算符,就用 QueryBuilder。 它相当于 JPQL 的 @Query 或 Criteria API,但更接近 SQL:用 别名.属性 指代属性,值永远通过命名参数 (:search)传入,绝不拼接字符串。

ts
// TaskFlow 之外的示例:某个项目里各状态的任务数
const rows = await this.tasks
  .createQueryBuilder('task')
  .select('task.status', 'status')
  .addSelect('COUNT(*)', 'count')
  .where('task.projectId = :projectId', { projectId })
  .groupBy('task.status')
  .getRawMany<{ status: TaskStatus; count: string }>();
结尾方法返回
getMany()、getOne()实体
getManyAndCount()[实体, 总数],用于分页
getRawMany()、getRawOne()原始行(聚合、计算列)
execute()构建好的 insert()、update() 或 delete() 的执行结果
  • COUNT(*) 返回 bigint,pg 驱动会把它作为字符串传回:所以是 count: string。
  • 有连接时,用 skip() / take() 分页,它们按实体计数,而不是用 offset() / limit(),后者按 SQL 行计数。

TaskFlow 在同一个方法里同时用到了这两种写法(搜索与分页)。

synchronize:只用于开发 ​

开启 synchronize: true 后,TypeORM 每次启动都会把实体和数据库结构做比较,并执行必要的 CREATE、ALTER 和 DROP 让两者一致。这就是 ddl-auto=update,只是更粗暴:TypeORM 识别不了重命名,所以重命名一个属性会删掉 原来的列(连同数据),再新建一个空列。

在一次性的数据库上、J4 这段数据库结构每小时都在变的时间里,这样做可以接受:

ts
// TaskFlow 之外的示例:J4 的临时设置,J5 中删除
synchronize: process.env.NODE_ENV === 'development',

绝不能用于生产环境

在存有真实数据的数据库上开启 synchronize: true,下次重启时可能会删掉列。数据库结构一旦稳定,就改用迁移 (J5)。TaskFlow 的最终版本在所有地方都是 synchronize: false。另见 生产环境的 synchronize。

TaskFlow 案例 ​

Project 实体 ​

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[];
}
  • 计划要求 id(UUID)、name、description、createdAt、updatedAt。TaskFlow 又加了 archived(归档, J5)、deletedAt(软删除)和两个关系,分别在 J5 和 J8 加入 (关系)。
  • description: string | null 和 @Column({ type: 'text', nullable: true }) 说的是同一件事,一个说给编译器听, 一个说给数据库听。显式写出类型可以避开 design:type 的陷阱。
  • 有了 @DeleteDateColumn,softDelete() 和自动过滤就能正常工作。
  • 对应的表(CREATE TABLE "projects" …)可以在 J5 的迁移里看到。

store 背后的 TypeORM ​

TypeOrmProjectsStore 实现的 ProjectsStore 接口和内存版 store 是同一个(J3)。 它的构造函数接收 @InjectRepository(Project)、@InjectRepository(ProjectMember) 和 DataSource。简单的方法 都很直接:

ts
// findOneBy() skips soft-deleted rows (deleted_at IS NOT NULL) automatically.
findById(id: string): Promise<Project | null> {
  return this.projects.findOneBy({ id });
}

async update(id: string, changes: UpdateProjectDto): Promise<Project | null> {
  const project = await this.findById(id);
  if (!project) return null;
  // merge() copies the DTO onto the loaded entity; save() issues the UPDATE.
  return this.projects.save(this.projects.merge(project, changes));
}

async softDelete(id: string): Promise<boolean> {
  // UPDATE ... SET deleted_at = now(): the row and its relations stay.
  const result = await this.projects.softDelete({ id });
  return (result.affected ?? 0) > 0;
}
  • findOneBy({ id }) 会排除被软删除的项目:这时 findById() 返回 null,service 返回 404 (J3)。
  • update() 重新读取项目,merge() 把 DTO 复制到已加载的实体上,save() 发出 UPDATE。
  • softDelete() 填写 deleted_at;result.affected 表示是否有行被修改。

成员表有自己的方法:

ts
isMember(projectId: string, userId: string): Promise<boolean> {
  return this.members.existsBy({ projectId, userId });
}

// INSERT ... ON CONFLICT DO NOTHING: adding an existing member is not an error.
async addMember(projectId: string, userId: string): Promise<void> {
  await this.members
    .createQueryBuilder()
    .insert()
    .values({ projectId, userId })
    .orIgnore()
    .execute();
}
  • existsBy() 直接返回布尔值,不加载这一行。
  • orIgnore() 生成 INSERT … ON CONFLICT DO NOTHING:添加一个已经存在的成员不算错误。
  • 软删除项目时,它在 project_members 里的行仍然保留:对已删除的项目,isMember() 依然返回 true,所以 ProjectsService.findOne() 还要检查项目是否存在。
  • create() 和 archive() 会写多张表:它们走事务(J5)。

选择哪个实现在模块里完成,依据是 PROJECTS_STORE 变量:

ts
@Module({
  imports: [TypeOrmModule.forFeature([Project, ProjectMember])],
  controllers: [ProjectsController],
  providers: [
    ProjectsService,
    InMemoryProjectsStore,
    TypeOrmProjectsStore,
    // A @Bean method chosen by configuration, like @ConditionalOnProperty.
    {
      provide: PROJECTS_STORE,
      inject: [ConfigService, InMemoryProjectsStore, TypeOrmProjectsStore],
      useFactory: (config: ConfigService, memory: InMemoryProjectsStore, typeorm: TypeOrmProjectsStore) =>
        config.get('PROJECTS_STORE') === 'memory' ? memory : typeorm,
    },
  ],
  // TasksModule needs assertAccess(): export the service, not the store.
  exports: [ProjectsService],
})
export class ProjectsModule {}

TypeOrmModule.forFeature([Project, ProjectMember]) 提供两个 repository。PROJECTS_STORE=typeorm(默认值, 见 .env.example)时,API 持久化到 PostgreSQL;设为 memory 时,回到 J2 的存储方式。控制器和 service 一行都 没改:ProjectsService 的单元测试跑在内存版 store 上,端到端测试跑在 PostgreSQL 上,用的是同样的类。

搜索与分页 ​

ts
async findPage(query: ProjectQueryDto, memberId?: string): Promise<Page<Project>> {
  if (!memberId) {
    // Simple filters: find options, the closest thing to a derived query.
    // Build `where` from the filters actually present, never with undefined.
    const where: FindOptionsWhere<Project> = {};
    if (query.search) where.name = ILike(likePattern(query.search));
    if (query.archived !== undefined) where.archived = query.archived;
    const [items, total] = await this.projects.findAndCount({
      where,
      order: { createdAt: 'DESC' },
      skip: query.skip,
      take: query.limit,
    });
    return { items, total, page: query.page, limit: query.limit };
  }

  // A join on the membership table: the QueryBuilder is clearer here.
  const qb = this.projects
    .createQueryBuilder('project')
    .innerJoin('project.members', 'member', 'member.userId = :memberId', { memberId })
    .orderBy('project.createdAt', 'DESC')
    .skip(query.skip)
    .take(query.limit);
  if (query.search) qb.andWhere('project.name ILIKE :search', { search: likePattern(query.search) });
  if (query.archived !== undefined) qb.andWhere('project.archived = :archived', { archived: query.archived });
  const [items, total] = await qb.getManyAndCount();
  return { items, total, page: query.page, limit: query.limit };
}
  • 管理员视图(没有 memberId):简单的 find 选项,最接近派生查询的写法。findAndCount 一次调用就返回 当前页和总数。
  • 成员视图:要连接成员表,并加上针对用户的条件。QueryBuilder 表达得更清楚(在 project.members 关系上 innerJoin),getManyAndCount() 起到 findAndCount 的作用。
  • where 只用实际存在的过滤条件构造:where.name = undefined 在 TypeORM 1.1 里会抛错 (repository)。
  • ILike 生成 PostgreSQL 的 ILIKE。文件开头的 likePattern 函数会转义 %、_ 和 \:搜索 50% 找到的 是文本“50%”,而不是所有以“50”开头的内容。

结果的形状是 J2 里介绍的 Page<T>:{ items, total, page, limit }。

开发用的种子数据 ​

ts
// Development data: npm run seed. Safe to run twice (skips if the admin exists).
async function seed() {
  await dataSource.initialize();
  try {
    await dataSource.transaction(async (manager) => {
      const email = 'admin@taskflow.local';
      if (await manager.existsBy(User, { email })) return;

      const admin = await manager.save(
        manager.create(User, {
          email,
          passwordHash: await argon2.hash(process.env.SEED_ADMIN_PASSWORD ?? 'admin-password-dev'),
          role: Role.Admin,
        }),
      );
      const project = await manager.save(
        manager.create(Project, { name: 'TaskFlow demo', description: 'Seeded for local development' }),
      );
      await manager.insert(ProjectMember, { projectId: project.id, userId: admin.id });
      await manager.insert(Task, [
        { title: 'Read the NestJS overview', status: TaskStatus.Done, projectId: project.id },
        { title: 'Write the first migration', status: TaskStatus.InProgress, projectId: project.id, dueDate: '2026-11-16' },
        { title: 'Add JWT authentication', projectId: project.id, dueDate: '2026-11-19', assigneeId: admin.id },
      ]);
    });
  } finally {
    await dataSource.destroy();
  }
}
  • 脚本在 Nest 之外运行:它复用 CLI 的 DataSource(data-source.ts),读取和应用相同的 .env.development 文件。
  • 它是幂等的:如果管理员已存在,就什么也不做。可以反复运行,不会产生重复数据。
  • 所有内容都在一个事务里写入(J5):只执行了一半的种子数据比没有还糟。
  • finally 块会关闭连接(dataSource.destroy()),种子脚本失败时也一样。
  • 密码来自 SEED_ADMIN_PASSWORD,默认值只用于开发;它用 argon2 做哈希(J8)。 在 J4,只创建项目的种子脚本就够了;用户和任务在 J5 和 J8 再加进去。
bash
docker compose up -d
npm run migration:run   # 创建表(J5);在 J4 由 synchronize 负责
npm run seed            # admin@taskflow.local + 一个演示项目及其任务
npm run seed            # 第二次运行:不会创建任何东西

Spring 里对应的做法是按 profile 启用的 CommandLineRunner,或者 data.sql;这里是一个单独的 npm 脚本,绝不会 在生产环境里被意外执行。

场景速查 ​

  • 要做不区分大小写的“包含”搜索 → 用 ILike 加转义后的模式(搜索)。
  • 要一页结果外加总数 → findAndCount({ skip, take }),再手动拼一个 Page<T>(repository)。
  • 查询带有过滤条件的连接或聚合 → createQueryBuilder()(QueryBuilder)。
  • 删除后要能恢复 → @DeleteDateColumn + softDelete(),用 restore() 撤销(repository); 这时访问控制除了检查成员关系,还要检查这一行是否存在(store 背后的 TypeORM)。
  • 连接参数来自配置 → TypeOrmModule.forRootAsync({ inject, useFactory })(模块)。
  • 本地需要演示数据 → 一个幂等的种子脚本,手动运行(种子数据)。
  • 截止日期读出来是字符串而不是 Date → 这是 date 列的正常行为(类型)。
  • 单元测试要模拟 repository → { provide: getRepositoryToken(Task), useValue: mock } (J10)。

陷阱 ​

在非一次性数据库上开启 synchronize: true

它每次启动都会让数据库结构向实体看齐,哪怕要删除列和其中的数据。只用于开发,之后改用迁移 (synchronize)。

修改实体却没有 save()

project.archived = true; 之后没有 save(),什么都不会发生:这里没有 JPA 事务上下文里那样的 dirty checking。

where 里出现 undefined

findBy({ archived: query.archived }) 在 archived 不存在时,TypeORM 1.1 会抛出 TypeORMError。旧版本会悄悄 忽略这个属性,结果可能返回所有行。只把实际存在的过滤条件放进 where,查 NULL 用 IsNull()。

  • 列没有写 type:string | null 字段生成的 design:type 是 Object,启动失败 (Data type "Object" … is not supported)。始终写明 type。
  • repository 没有注册:忘了 TypeOrmModule.forFeature([Project]) 会得到 “Nest can't resolve dependencies of the TypeOrmProjectsStore”,指向的 token 是 ProjectRepository。
  • date 列不是 Date:比较 'YYYY-MM-DD' 字符串,或者显式转换。
  • COUNT 聚合返回字符串:用 Number() 转换。
  • 直接拿用户输入做 ILike 搜索:输入里的 % 和 _ 会变成通配符。像 likePattern 那样转义。
  • 列名默认是 camelCase:一开始就写好 name: 'snake_case',之后再改名需要迁移。
  • softDelete() 不会动关联的行:已删除项目的成员仍然留在 project_members 里。只查这张表的访问控制会 放行;TaskFlow 还会检查项目是否存在(store 背后的 TypeORM)。
  • 直接返回实体会暴露它的所有字段,包括以后才加的字段(J8 的 passwordHash):见 直接返回实体。

练习 ​

  • [ ] 安装 @nestjs/typeorm、typeorm 和 pg,再用 .env.development 里的连接参数配置 TypeOrmModule.forRootAsync()。
  • [ ] 编写 Project 实体(id UUID、name、description、createdAt、updatedAt),并用 TypeOrmModule.forFeature() 注册。
  • [ ] 编写实现 ProjectsStore 的 TypeOrmProjectsStore,在不改控制器的前提下替换内存实现。用 curl 验证 API 的行为没有变化。
  • [ ] 用 find({ where: { name: ILike(...) } }) 实现按名称搜索,再用 QueryBuilder 实现一遍。
  • [ ] 用 findAndCount + skip / take 实现分页,返回 Page<T>。
  • [ ] 软删除一个项目,在 Adminer 里确认 deleted_at 已填写,并且 API 对这个项目和它的任务都返回 404 (GET /v1/projects/:id/tasks,从 J5 开始)。
  • [ ] 编写开发数据的种子脚本,确认重复运行不会产生重复数据。
  • [ ] 在一个开启 synchronize: true 的一次性数据库上,重命名一个属性,观察这一列会怎样。然后改回 false。
  • [ ] 在 LEARNINGS.md 里记下:JpaRepository ↔ Repository<T>,没有派生查询,没有原生的 Page<T>。

延伸阅读 ​