上一篇: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.prisma 后 prisma 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. 常见坑
-
生产打开
synchronize: true实体删字段,库里的列可能被丢掉。
-
Controller 里写
repository.find和第一篇「Controller 薄、Service 厚」对着干。
-
字符串拼接 SQL
query('... WHERE id = ' + id)有注入风险,用参数绑定。 -
实体随手
exports给所有模块乱注入 Repository优先导出 Service,表访问收口。
-
把业务规则写成一段 80 行裸 SQL
先 QueryBuilder;实在表达不清再局部裸 SQL,并加注释说明为什么。
10. 小结
- 连库:
TypeOrmModule.forRoot(或forRootAsync+DATABASE_URL) - 表:
@Entity();模块内forFeature([User])才能注入Repository<User> - 日常 SQL 写在 Service :
find/save;复杂用 QueryBuilder;方言/报表才裸 SQL - 改表结构用 migration ,不要生产开
synchronize - Controller 不写 SQL,也不直接碰 Repository
- Prisma 只是换了一套 API,分层不变
对照本系列:
- 请求谁接、业务谁做、模块怎么导出?
- 启动时谁连库?(TypeORM 根模块 / Prisma 的
onModuleInit) - 这句 SELECT/INSERT 落在哪一层?有没有跑到 Controller 里?