TypeORM 核心概念
一、Entity 定义
Entity 是数据库表的映射,用装饰器描述表结构:
typescript
import {
Entity, PrimaryGeneratedColumn, Column, CreateDateColumn,
UpdateDateColumn, DeleteDateColumn, OneToMany, ManyToOne,
ManyToMany, JoinTable, JoinColumn, Index,
} from 'typeorm';
@Entity('users')
@Index(['email']) // 单列索引
@Index(['email', 'role']) // 复合索引
export class User {
@PrimaryGeneratedColumn()
id: number;
@PrimaryGeneratedColumn('uuid') // UUID 主键
id: string;
@Column({ unique: true, length: 255 })
email: string;
@Column({ nullable: true, default: null })
name: string;
// 枚举:PostgreSQL 用 type:'enum',SQLite 用 type:'simple-enum'
@Column({ type: 'simple-enum', enum: UserRole, default: UserRole.USER })
role: UserRole;
@Column({ select: false }) // 查询时默认不返回(密码等敏感字段)
password: string;
@Column({ type: 'jsonb', nullable: true }) // JSON 字段(PostgreSQL)
metadata: Record<string, any>;
@CreateDateColumn() // 自动设置创建时间
createdAt: Date;
@UpdateDateColumn() // 自动更新修改时间
updatedAt: Date;
@DeleteDateColumn() // 软删除时间(配合 softDelete/softRemove)
deletedAt: Date;
@OneToMany(() => Post, post => post.author)
posts: Post[];
}关系映射
typescript
// 一对多 / 多对一(最常见)
@Entity('posts')
export class Post {
@ManyToOne(() => User, user => user.posts, {
onDelete: 'CASCADE', // 用户删除时,文章级联删除
onUpdate: 'CASCADE',
nullable: false,
eager: false, // false = 不自动加载(默认),true = 总是加载(慎用)
})
@JoinColumn({ name: 'author_id' }) // 自定义外键列名
author: User;
@Column({ name: 'author_id' })
authorId: number;
}
// 多对多(自动创建中间表)
@Entity('tags')
export class Tag {
@ManyToMany(() => Post, post => post.tags)
posts: Post[];
}
@Entity('posts')
export class Post {
@ManyToMany(() => Tag, tag => tag.posts)
@JoinTable({
name: 'post_tags', // 中间表名
joinColumn: { name: 'post_id' },
inverseJoinColumn: { name: 'tag_id' },
})
tags: Tag[];
}
// 一对一
@Entity('profiles')
export class Profile {
@OneToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn()
user: User;
}生命周期钩子
typescript
@Entity()
export class Post {
@Column()
title: string;
@Column()
slug: string;
@BeforeInsert()
generateSlug() {
this.slug = this.title.toLowerCase().replace(/\s+/g, '-');
}
@BeforeUpdate()
updateSlug() {
if (this.title) {
this.slug = this.title.toLowerCase().replace(/\s+/g, '-');
}
}
@AfterLoad()
computeExtraFields() {
// 每次从数据库加载后执行
this.readingTime = Math.ceil(this.content?.split(' ').length / 200);
}
}二、Repository API
Repository<T> 是操作 Entity 的核心接口:
typescript
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private userRepo: Repository<User>,
) {}
// ── 查询 ──────────────────────────────────────
findAll(): Promise<User[]> {
return this.userRepo.find({
where: { deletedAt: IsNull() }, // 过滤软删除记录
relations: { posts: true }, // 加载关联(对象语法,TypeORM 0.3+)
order: { createdAt: 'DESC' },
select: { id: true, email: true, name: true }, // 只查需要的字段
});
}
findOne(id: number): Promise<User | null> {
return this.userRepo.findOne({
where: { id },
relations: { posts: { comments: true } }, // 嵌套关联
});
}
findByEmail(email: string): Promise<User | null> {
// select: false 的字段需要显式 addSelect
return this.userRepo
.createQueryBuilder('user')
.where('user.email = :email', { email })
.addSelect('user.password') // 临时包含 password
.getOne();
}
// ── 创建 / 更新 ───────────────────────────────
async create(dto: CreateUserDto): Promise<User> {
const existing = await this.userRepo.findOne({ where: { email: dto.email } });
if (existing) throw new ConflictException('邮箱已存在');
const user = this.userRepo.create(dto); // 创建实体实例(不写数据库)
user.password = await bcrypt.hash(dto.password, 10);
return this.userRepo.save(user); // 写入数据库
}
async update(id: number, dto: UpdateUserDto): Promise<User> {
// update:直接 SQL UPDATE,不会触发 @BeforeUpdate 钩子
await this.userRepo.update(id, dto);
return this.findOne(id);
// save:先 SELECT,再 UPDATE,会触发钩子,但性能差一点
// const user = await this.findOne(id);
// Object.assign(user, dto);
// return this.userRepo.save(user);
}
// ── 删除 ──────────────────────────────────────
async remove(id: number): Promise<void> {
const user = await this.findOne(id);
if (!user) throw new NotFoundException(`用户 #${id} 不存在`);
await this.userRepo.delete(id); // 硬删除
// await this.userRepo.softDelete(id); // 软删除(设置 deletedAt)
}
// ── 统计 / 检查 ──────────────────────────────
count(role?: UserRole): Promise<number> {
return this.userRepo.count({
where: role ? { role } : undefined,
});
}
exists(email: string): Promise<boolean> {
return this.userRepo.existsBy({ email });
}
}三、QueryBuilder(复杂查询)
当 find() 选项无法满足需求时,使用 QueryBuilder:
typescript
// 分页 + 关键词过滤 + 多条件排序
async findPaginated(options: {
page: number;
limit: number;
keyword?: string;
role?: UserRole;
sortBy?: 'createdAt' | 'name';
sortOrder?: 'ASC' | 'DESC';
}) {
const { page, limit, keyword, role, sortBy = 'createdAt', sortOrder = 'DESC' } = options;
const qb = this.postRepo
.createQueryBuilder('post')
.leftJoinAndSelect('post.author', 'author')
.leftJoinAndSelect('post.tags', 'tag')
.where('post.deletedAt IS NULL');
if (keyword) {
qb.andWhere(
'(post.title ILIKE :keyword OR post.content ILIKE :keyword)',
{ keyword: `%${keyword}%` },
);
}
if (role) {
qb.andWhere('author.role = :role', { role });
}
const [items, total] = await qb
.orderBy(`post.${sortBy}`, sortOrder)
.skip((page - 1) * limit)
.take(limit)
.getManyAndCount();
return {
items,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
// 聚合查询
async getStatsByRole(): Promise<{ role: string; count: string }[]> {
return this.userRepo
.createQueryBuilder('user')
.select('user.role', 'role')
.addSelect('COUNT(user.id)', 'count')
.groupBy('user.role')
.getRawMany();
}
// 子查询
async findUsersWithPostCount() {
return this.userRepo
.createQueryBuilder('user')
.loadRelationCountAndMap(
'user.postCount', // 挂载到 user.postCount 属性
'user.posts',
'post',
qb => qb.where('post.published = true'),
)
.getMany();
}四、NestJS 集成配置
typescript
// app.module.ts
TypeOrmModule.forRoot({
type: 'postgres',
url: process.env.DATABASE_URL,
entities: [User, Post, Comment, Tag],
// entities: [__dirname + '/**/*.entity{.ts,.js}'], // 自动扫描
synchronize: process.env.NODE_ENV === 'development', // ⚠️ 生产环境关闭!
logging: process.env.NODE_ENV === 'development' ? ['query', 'error'] : ['error'],
migrations: [__dirname + '/migrations/**/*{.ts,.js}'],
migrationsRun: process.env.NODE_ENV === 'production', // 生产环境自动运行迁移
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false,
// 连接池配置
extra: {
max: 10, // 最大连接数
min: 2, // 最小连接数
idleTimeoutMillis: 30000,
},
})
// 特性模块注册 Repository
TypeOrmModule.forFeature([User, Post])五、常见陷阱
陷阱 1:N+1 查询问题
typescript
// ❌ 触发 N+1:查询 users 后,每个 user 单独查 posts
const users = await userRepo.find();
for (const user of users) {
console.log(user.posts); // 每次访问 posts 都触发一次 SELECT
}
// ✅ 用 relations 一次性 JOIN 加载
const users = await userRepo.find({ relations: { posts: true } });陷阱 2:synchronize 在生产环境
typescript
// ❌ 生产环境开启 synchronize,可能导致数据丢失!
// synchronize: true 会自动 ALTER TABLE,字段改名会丢数据
// ✅ 生产环境关闭,使用迁移文件管理 Schema 变更
synchronize: process.env.NODE_ENV !== 'production'陷阱 3:select: false 字段查询
typescript
// ❌ 普通 find 不会返回 password
const user = await userRepo.findOne({ where: { email } });
user.password; // undefined!
// ✅ 需要 password 时,必须显式 addSelect
const user = await userRepo
.createQueryBuilder('user')
.where('user.email = :email', { email })
.addSelect('user.password')
.getOne();陷阱 4:update() vs save()
typescript
// update():只执行 SQL UPDATE,不加载实体,不触发钩子
await userRepo.update(id, { name: '新名字' });
// save():先 SELECT 加载实体,再 UPDATE,触发 @BeforeUpdate 钩子
// 但如果传入的对象没有 id,会执行 INSERT!
const user = await userRepo.findOne({ where: { id } });
Object.assign(user, dto);
await userRepo.save(user); // 安全的 UPDATE(有 id)常见错误
| 错误 | 原因 | 解决 |
|---|---|---|
| N+1 查询 | @ManyToOne 关系默认懒加载,循环中触发多次查询 | 用 relations: ['author'] 或 QueryBuilder .leftJoinAndSelect 一次性加载 |
EntityNotFoundError | findOneOrFail / findOneByOrFail 找不到记录 | 捕获后抛出 NotFoundException;或改用 findOne 手动判断 null |
QueryFailedError: duplicate key | 唯一键冲突 | 捕获 error.code === '23505'(PostgreSQL)抛出 ConflictException |
| 事务中使用注入的 Repository | 注入的 repo 不在当前事务 session 内 | 在 dataSource.transaction(manager => ...) 回调中用 manager.getRepository(Entity) |
| 迁移生产环境丢数据 | synchronize: true 自动同步删表 | 生产关闭 synchronize,只用 migration;开发可开启 |
Cannot use 'in' operator to search for 'id' | 实体中字段类型与查询条件类型不匹配(string vs number) | 确保 DTO ParseIntPipe 转换后再查询 |
可运行 Demo:
practice/03-crud-app/typeorm-version— Entity 定义、Repository 查询、关联关系实战