Chapter 06

数据库集成 Prisma ORM

用 Prisma 优雅地管理数据库 Schema、迁移与类型安全的 CRUD 操作

6.1 什么是 Prisma ORM

Prisma 是 Node.js 和 TypeScript 生态中最流行的 ORM(对象关系映射)工具。它通过一套声明式的 Schema 语言定义数据模型,自动生成类型安全的查询客户端,让数据库操作如同操作 TypeScript 对象一样直观、安全。

Prisma 的核心优势在于类型安全:查询结果的类型会根据你的 Schema 自动推导,IDE 可以对所有数据库操作进行类型检查,彻底消除了因字段名拼写错误或类型不匹配导致的运行时错误。

6.2 安装与初始化

# 安装 Prisma 开发依赖和客户端
pnpm add -D prisma
pnpm add @prisma/client

# 初始化 Prisma(选择数据库)
pnpm prisma init --datasource-provider postgresql
# 或 sqlite(本地开发更简单)
pnpm prisma init --datasource-provider sqlite
SHELL

初始化后会生成 prisma/schema.prisma 文件和 .env 文件。在 .env 中配置数据库连接:

# .env
# PostgreSQL(使用 Vercel Postgres 或 Supabase)
DATABASE_URL="postgresql://user:password@host:5432/mydb?sslmode=require"

# SQLite(本地开发)
# DATABASE_URL="file:./dev.db"
ENV

6.3 Schema 定义

Prisma Schema 使用简洁的 DSL(领域特定语言)定义数据模型。每个 model 对应数据库中的一张表,字段类型会自动映射到对应数据库的列类型,并生成相应的 TypeScript 类型。

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        String   @id @default(cuid())
  email     String   @unique
  name      String?
  image     String?
  role      Role     @default(USER)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // 关系:一个用户有多篇文章
  posts     Post[]
  comments  Comment[]
}

model Post {
  id          String    @id @default(cuid())
  title       String
  slug        String    @unique
  content     String
  published   Boolean   @default(false)
  viewCount   Int       @default(0)
  createdAt   DateTime  @default(now())
  updatedAt   DateTime  @updatedAt

  // 关系:文章属于某个作者(外键)
  authorId    String
  author      User      @relation(fields: [authorId], references: [id])

  // 关系:文章有多条评论
  comments    Comment[]

  // 关系:文章属于多个标签(多对多)
  tags        Tag[]

  @@index([authorId])  // 添加索引
  @@map("posts")       // 映射到数据库表名
}

model Comment {
  id        String   @id @default(cuid())
  content   String
  createdAt DateTime @default(now())

  authorId  String
  author    User     @relation(fields: [authorId], references: [id])
  postId    String
  post      Post     @relation(fields: [postId], references: [id], onDelete: Cascade)
}

model Tag {
  id    String @id @default(cuid())
  name  String @unique
  posts Post[]
}

enum Role {
  USER
  ADMIN
}
PRISMA

6.4 Prisma Migrate 数据库迁移

# 创建并应用迁移(开发环境)
pnpm prisma migrate dev --name init

# 生成 Prisma Client(每次修改 schema 后需要运行)
pnpm prisma generate

# 将迁移应用到生产数据库(不创建新迁移)
pnpm prisma migrate deploy

# 重置数据库(删除所有数据并重新迁移,仅用于开发!)
pnpm prisma migrate reset

# 打开可视化 Studio
pnpm prisma studio
SHELL
🚨

生产环境注意永远不要在生产环境运行 prisma migrate devprisma migrate reset,这两个命令可能删除数据。CI/CD 流程中应使用 prisma migrate deploy,它只会应用未执行的迁移,不会修改已有数据。

6.5 配置单例 Prisma Client

在 Next.js 开发模式下,每次热更新都会重新执行模块,如果不做特殊处理,会创建多个 PrismaClient 实例,导致"Too many connections"错误。标准做法是使用全局变量缓存实例。

// lib/db.ts — 单例模式
import { PrismaClient } from '@prisma/client'

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined
}

export const db = globalForPrisma.prisma ?? new PrismaClient({
  log: process.env.NODE_ENV === 'development'
    ? ['query', 'error', 'warn']
    : ['error'],
})

if (process.env.NODE_ENV !== 'production') {
  globalForPrisma.prisma = db
}
TS

6.6 CRUD 操作与关系查询

Prisma Client 提供了直观的 API 进行数据操作。以下是常用 CRUD 操作的示例:

import { db } from '@/lib/db'

// ── CREATE ──
const post = await db.post.create({
  data: {
    title:    'Hello Next.js',
    slug:     'hello-nextjs',
    content:  '...',
    author:   { connect: { id: userId } }, // 关联已有用户
    tags: {
      connectOrCreate: [              // 连接或创建标签
        { where: { name: 'Next.js' }, create: { name: 'Next.js' } }
      ]
    }
  },
  include: { author: true, tags: true } // 同时返回关联数据
})

// ── READ(带过滤、排序、分页)──
const posts = await db.post.findMany({
  where: {
    published: true,
    author:    { role: 'ADMIN' },       // 关系过滤
    title:     { contains: 'Next' },     // 模糊搜索
  },
  orderBy: { createdAt: 'desc' },
  skip:  0,   // 分页:跳过 N 条
  take:  10,  // 分页:取 N 条
  select: {                              // 只选择需要的字段
    id: true, title: true, slug: true,
    author: { select: { name: true, image: true } }
  }
})

// ── UPDATE ──
await db.post.update({
  where: { id: postId },
  data:  { viewCount: { increment: 1 } } // 原子递增
})

// ── DELETE(级联删除)──
await db.post.delete({ where: { id: postId } })
// Comment 因设置了 onDelete: Cascade 会自动删除

// ── 事务 ──
await db.$transaction([
  db.post.update({ where: { id }, data: { published: true } }),
  db.user.update({ where: { id: authorId }, data: { postCount: { increment: 1 } } }),
])
TS
💡

select vs includeselect 只返回指定字段,include 在默认字段基础上加载关联数据。优先使用 select 仅获取页面需要的字段,避免过度获取(Over-fetching),尤其是不要在列表查询中加载大型文本字段(如 content)。