Skip to content

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 一次性加载
EntityNotFoundErrorfindOneOrFail / 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 查询、关联关系实战

NestJS 深度学习体系