NestJS 入门(9):连上数据库,SQL 写在哪?

上一篇:NestJS 入门(8):环境变量与配置 讲了 DATABASE_URL、密钥不要写死。

前面几篇把请求链路讲完了:Module、注入、Guard、信封、Pipe、启动钩子、环境变量。

还缺一块很多人学 Nest 时最先碰到的:数据怎么进出 PostgreSQL / MySQL。

中文教程里最常见的是 TypeORM

另一条路上是 Prisma (用 schema.prisma + 生成客户端,Service 里写 prisma.user.findMany())。两者在 Nest 里的位置一样:

连库是基础设施模块;查询写在 Service(或 Repository),不要写在 Controller。

本篇以 TypeORM 为主把「连上」和「SQL 写哪」讲清楚,文末用一张表对照 Prisma。


1. 先分清三层:连接、映射、查询

干什么 TypeORM 里是谁
连接 DATABASE_URL 连上数据库 TypeOrmModule.forRoot()
映射 一张表对应一个类 @Entity() 实体
查询 增删改查 Repository / QueryBuilder / 少量裸 SQL

Controller 仍然只负责接请求:

typescript 复制代码
@Get()
findAll() {
  return this.usersService.findAll();
}

SELECT * FROM users 不出现在这里。


2. 安装与根模块:连上数据库

bash 复制代码
npm i @nestjs/typeorm typeorm pg
# MySQL 则换成 mysql2

根模块注册一次连接(全局数据源):

typescript 复制代码
import { TypeOrmModule } from '@nestjs/typeorm';

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'postgres',
      url: process.env.DATABASE_URL,
      autoLoadEntities: true, // 各业务模块 forFeature 登记的实体自动加载
      synchronize: false,     // 生产务必 false;改表用 migration
    }),
    UsersModule,
  ],
})
export class AppModule {}

和第八篇同一条纪律:DATABASE_URL 来自环境变量,不要把账号密码写进仓库。

更稳的写法是 forRootAsync,等 ConfigService 就绪再连:

typescript 复制代码
TypeOrmModule.forRootAsync({
  inject: [ConfigService],
  useFactory: (config: ConfigService) => ({
    type: 'postgres',
    url: config.getOrThrow<string>('DATABASE_URL'),
    autoLoadEntities: true,
    synchronize: false,
  }),
});

synchronize: true 会按实体自动改表,本地玩玩可以,生产会删列、丢数据。正经项目用 migration(见第 7 节)。

连库发生在 Nest 创建 DataSource 时(接近第七篇的「启动期 I/O」)。连接失败时应用起不来,这是好事。


3. 实体:表结构写在类上

typescript 复制代码
import { Column, Entity, PrimaryColumn } from 'typeorm';

@Entity({ name: 'users' })
export class User {
  @PrimaryColumn()
  id!: string;

  @Column({ unique: true })
  email!: string;

  @Column()
  password!: string;

  @Column()
  name!: string;

  @Column({ name: 'created_at', type: 'timestamptz' })
  createdAt!: Date;
}

这不是 SQL 文件,但等价于在描述:

sql 复制代码
CREATE TABLE users (
  id TEXT PRIMARY KEY,
  email TEXT UNIQUE NOT NULL,
  password TEXT NOT NULL,
  name TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL
);

实体 = 表的 TypeScript 形态。

真正建表、改表通常交给 migration,而不是每次启动 synchronize


4. 业务模块:把「这张表」登记进盒子

第六篇的边界在这里又出现一次:根模块 forRoot 只负责连接;哪张表给哪个模块用 ,用 forFeature

typescript 复制代码
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './user.entity';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';

@Module({
  imports: [TypeOrmModule.forFeature([User])],
  controllers: [UsersController],
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}

forFeature([User]) 的含义:本模块可以注入 Repository<User>

别的模块要用用户表,要么 imports: [UsersModule] 用导出的 Service,要么自己 forFeature------入门优先走 Service,避免到处直接操同一张表。


5. SQL 写在哪?三种由浅到深

5.1 Repository:日常 CRUD(推荐默认)

typescript 复制代码
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './user.entity';

@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User)
    private readonly users: Repository<User>
  ) {}

  findAll() {
    return this.users.find();
    // 大致对应 SELECT * FROM users
  }

  findByEmail(email: string) {
    return this.users.findOne({ where: { email } });
    // SELECT * FROM users WHERE email = $1 LIMIT 1
  }

  create(data: { email: string; password: string; name: string }) {
    const row = this.users.create(data);
    return this.users.save(row);
    // INSERT INTO users (...)
  }
}

你几乎看不到 SQL 字符串,但每条 API 都对应一句 SQL。

这就是入门阶段「SQL 写在哪」的答案:写在 Service 里,用 Repository API 表达。

登录校验仍在 Service,查库也在同一层:

typescript 复制代码
async login(email: string, password: string) {
  const user = await this.users.findByEmail(email);
  if (!user) {
    throw new UnauthorizedException('Invalid credentials');
  }
  // bcrypt.compare ...
}

5.2 QueryBuilder:条件一复杂就用它

多表 join、动态筛选、分页,find({ where }) 会别扭。改用 QueryBuilder:

typescript 复制代码
findEditorsOfProject(projectId: string) {
  return this.users
    .createQueryBuilder('u')
    .innerJoin('project_members', 'm', 'm.user_id = u.id')
    .where('m.project_id = :projectId', { projectId })
    .andWhere('m.role = :role', { role: 'editor' })
    .getMany();
}

生成的 SQL 接近:

sql 复制代码
SELECT u.*
FROM users u
INNER JOIN project_members m ON m.user_id = u.id
WHERE m.project_id = $1 AND m.role = $2

仍然写在 Service(或单独的 Repository 类)里。

参数用 :projectId 绑定,不要把用户输入拼进字符串(防 SQL 注入)。

5.3 裸 SQL:报表、数据库方言、ORM 搞不定时

typescript 复制代码
async countActiveSessions(): Promise<number> {
  const rows = await this.users.query(
    `SELECT COUNT(*)::int AS cnt FROM sessions WHERE expires_at > NOW()`
  );
  return rows[0].cnt;
}

或注入 DataSource:

typescript 复制代码
constructor(private readonly dataSource: DataSource) {}

await this.dataSource.query('SELECT 1');

适用:

  • 窗口函数、CTE、特定 PG 语法
  • 一次性数据修复
  • ORM 生成的 SQL 明显更差

不要把整站 CRUD 都写成字符串。能 Repository 就 Repository,能 QueryBuilder 就不要上裸 SQL。


6. 一张图:请求怎么打到数据库

text 复制代码
GET /api/users
  → UsersController.findAll()
    → UsersService.findAll()
      → repository.find()
        → TypeORM 生成 SQL
          → 驱动发给 PostgreSQL

对照本系列:

职责
Controller HTTP
Service 业务 + 调用 Repository
TypeOrmModule.forFeature 提供 Repository<Entity>
TypeOrmModule.forRoot 连接、连接池

不要 在 Controller 里 @InjectRepository 直接查库------测试和复用都会变差。

复杂项目会再拆 UsersRepository 类,把 QueryBuilder 从 Service 拿走;入门一个 Service 足够。


7. Migration 是啥?和日常 SQL 不是一回事

前面说的 find() / save()运行时查询 :请求进来了,读一行、插一行。

Migration(迁移) 是另一类 SQL:改表结构,而且要可重复、可版本化。

可以把它想成数据库的 Git:

text 复制代码
代码有 git log:谁在哪天改了什么
数据库有 migrations 文件夹:谁在哪天加了哪张表、哪一列

为什么不能靠 synchronize

synchronize: true 的意思是:启动时看实体和库不一样,就自动 ALTER。

问题是:

  • 你本地删了实体上的一个字段,生产库对应列可能被直接丢掉
  • 同事 A、B 的库会被改成不同样子,很难对齐
  • 没有「这一步改了什么」的记录,出问题不好回滚

所以生产关 synchronize,改结构走 migration。

一次 migration 长什么样

就是一份带版本号的 SQL 文件,例如「给 users 表加一列」:

sql 复制代码
-- 20260810120000_add_user_prefs
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "generation_preferences_json" JSONB;

第一次建库则是 CREATE TABLE users (...)

这些文件提交进 Git。新同事或新服务器执行「跑 migration」,库就会一步步追上代码。

和 Service 里 SQL 的分工

日常查询(Service) Migration
何时跑 每个 HTTP 请求都可能 部署时跑一次(或几份未执行的文件)
典型语句 SELECT / INSERT / UPDATE 一行业务数据 CREATE TABLE / ALTER TABLE / CREATE INDEX
写在哪 UsersService 的 Repository / QueryBuilder migrations/*.sql(或 TypeORM 生成的 ts)
例子 登录时按 email 查用户 给 users 增加 generation_preferences_json

一句话:

Service 改的是表里的数据;migration 改的是表长什么样。

TypeORM 常用 typeorm migration:generate 根据实体差异生成文件,再 migration:run 打到库上。

Prisma 则是改 schema.prismaprisma migrate,效果相同:多一份带时间戳的 SQL。

入门先记住「有这么一层」,不必把命令背熟。


8. 和 Prisma 差在哪?(同一套 Nest 分层)

Prisma 没有 @Entity(),表写在 schema.prisma

prisma 复制代码
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id    String @id
  email String @unique
  name  String
  @@map("users")
}

Nest 里常包一层客户端,启动时 $connect()

typescript 复制代码
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
  async onModuleInit() {
    await this.$connect();
  }
}

Service 里查询变成:

typescript 复制代码
this.prisma.user.findMany();
this.prisma.user.findUnique({ where: { email } });
TypeORM Prisma
表结构 @Entity() schema.prisma
连库 TypeOrmModule.forRoot PrismaClient.$connect(多放 onModuleInit
日常查询 Repository / QueryBuilder prisma.user.findMany()
裸 SQL repository.query() / dataSource.query() prisma.$queryRaw
改表结构 TypeORM migration prisma migrate
SQL 写在哪 都在 Service(或 Repository),不在 Controller 同左

换 ORM,不换 Nest 分层。

选 TypeORM 往往因为装饰器实体、和 Nest 官方模块集成熟;选 Prisma 往往因为 schema 集中、类型生成舒服。入门先把「查询待在 Service」养成即可。


9. 常见坑

  1. 生产打开 synchronize: true

    实体删字段,库里的列可能被丢掉。

  2. Controller 里写 repository.find

    和第一篇「Controller 薄、Service 厚」对着干。

  3. 字符串拼接 SQL

    query('... WHERE id = ' + id) 有注入风险,用参数绑定。

  4. 实体随手 exports 给所有模块乱注入 Repository

    优先导出 Service,表访问收口。

  5. 把业务规则写成一段 80 行裸 SQL

    先 QueryBuilder;实在表达不清再局部裸 SQL,并加注释说明为什么。


10. 小结

  • 连库:TypeOrmModule.forRoot(或 forRootAsync + DATABASE_URL
  • 表:@Entity();模块内 forFeature([User]) 才能注入 Repository<User>
  • 日常 SQL 写在 Servicefind / save;复杂用 QueryBuilder;方言/报表才裸 SQL
  • 改表结构用 migration ,不要生产开 synchronize
  • Controller 不写 SQL,也不直接碰 Repository
  • Prisma 只是换了一套 API,分层不变

对照本系列:

  1. 请求谁接、业务谁做、模块怎么导出?
  2. 启动时谁连库?(TypeORM 根模块 / Prisma 的 onModuleInit
  3. 这句 SELECT/INSERT 落在哪一层?有没有跑到 Controller 里?

系列导航

相关推荐
举手1 小时前
Epoll模型
linux·c++·学习
qq_22589174661 小时前
基于Flask的城市地铁客流量数据预测系统设计与实现
后端·python·flask
IT_陈寒1 小时前
搞不定JavaScript的数组去重?你可能漏了这两个坑
前端·人工智能·后端
MartinYeung51 小时前
[论文学习]JBShield:通过激活概念分析与操纵防御大语言模型越狱攻击
人工智能·学习·语言模型
math_hongfan1 小时前
一对多的数据库姻缘:ArkTS 为鸿蒙订单设计外键与明细表
数据库·华为·harmonyos
城管不管1 小时前
MySQL 慢查询完整排查
java·服务器·jvm·数据库·mysql·spring·面试
XLYcmy1 小时前
PDF论文处理器 - 功能总结
数据库·python·pdf·csv·pymupdf·dify·文本分割
MartinYeung51 小时前
[论文学习]SMSR:带平滑检索的签名记忆——针对持久化LLM智能体系统运行时内存投毒的认证防御
人工智能·学习
树码小子1 小时前
MySQL 的事务隔离级别(重点面试题)
数据库·mysql