J4 · PostgreSQL 与 TypeORM
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
| Spring | NestJS + 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> + Pageable | findAndCount({ skip, take }) → [items, total] | 分页结果要自己拼 |
@Query("select p from Project p …")、Criteria API | createQueryBuilder('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=update | synchronize: true | 只用于开发;之后改用迁移(J5) |
| 持久化上下文、dirty checking | 没有对应物 | 修改只有在 save() 时才会写入 |
findById 返回 Optional<T> | findOneBy 返回 T | null | 编译器(strict 模式)强制检查 null |
原理
@nestjs/typeorm:forRoot、forRootAsync、forFeature
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"。
// TaskFlow 之外的示例:写死的选项
TypeOrmModule.forRoot({
type: 'postgres',
host: 'localhost',
port: 5432,
username: 'taskflow',
password: 'taskflow',
database: 'taskflow',
autoLoadEntities: true,
synchronize: false,
});TaskFlow 从类型化配置(J7)里读取选项:
@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 共用的函数里,保证应用和迁移用同样的设置连接同一个数据库:
// 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存储可建索引的文档,适合没有固定结构的数据:
// 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、withDeleted | findAll(...)、派生查询 |
findOne(options)、findOneBy(where) | 第一条结果或 null | findById() → Optional |
findOneByOrFail(where) | 找不到时抛出 EntityNotFoundError | orElseThrow() |
findAndCount(options) | [items, total]:按 skip / take 截取的一页,以及匹配的总行数 | Page<T> |
existsBy(where) | boolean | existsBy... |
create(data) | 创建实例,不执行 SQL | new |
save(entity) | INSERT 或 UPDATE,返回带有生成值的实体 | save() |
merge(entity, changes) | 把修改复制到实体上 | — |
insert(data)、update(where, changes) | 直接执行一条查询,不加载实体,也不级联 | @Modifying @Query |
softDelete(where)、restore(where) | 填写或清空 deleted_at | Hibernate 软删除 |
delete(where) | DELETE | deleteById() |
计划里给出的对应关系,用一个例子说明:
// Spring Data
Page<Project> findByNameContainingIgnoreCase(String name, Pageable pageable);// 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)传入,绝不拼接字符串。
// 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 这段数据库结构每小时都在变的时间里,这样做可以接受:
// TaskFlow 之外的示例:J4 的临时设置,J5 中删除
synchronize: process.env.NODE_ENV === 'development',绝不能用于生产环境
在存有真实数据的数据库上开启 synchronize: true,下次重启时可能会删掉列。数据库结构一旦稳定,就改用迁移 (J5)。TaskFlow 的最终版本在所有地方都是 synchronize: false。另见 生产环境的 synchronize。
TaskFlow 案例
Project 实体
@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。简单的方法 都很直接:
// 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表示是否有行被修改。
成员表有自己的方法:
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 变量:
@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 上,用的是同样的类。
搜索与分页
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 }。
开发用的种子数据
// 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 再加进去。
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>。
延伸阅读
- NestJS:TypeORM
- TypeORM 文档:实体、find options、QueryBuilder
- PostgreSQL 16:数据类型
- node-postgres:类型转换