seansie's blog

TypeScript 現代化 ORM 實戰指南:Prisma 全面解析與高效操作手冊

Prisma 是一套現代化且具備完整 TypeScript 型別支援的 ORM(Object-Relational Mapping),透過自動產生 Type 定義檔,讓資料庫操作具備自動補全與型別檢查優勢。


1. 環境安裝與初始化

使用 pnpm 建立專案並安裝 Prisma CLI 與 Client:

# 初始化 TypeScript 專案
pnpm init
pnpm add typescript @types/node tsx -D
pnpm tsc --init

# 安裝 Prisma 開發依賴與 Runtime 套件
pnpm add prisma -D
pnpm add @prisma/client

# 初始化 Prisma(以 PostgreSQL 為例,預設產生 prisma/schema.prisma 與 .env)
pnpm prisma init --datasource-provider postgresql

.env 設定資料庫連線字串:

DATABASE_URL="postgresql://user:password@localhost:5432/mydb?schema=public"

2. 定義 Schema 與資料庫關聯

編輯 prisma/schema.prisma,定義一對多(User 與 Post)以及多對多(Post 與 Tag)關聯:

generator client {
  provider = "prisma-client-js"
}

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

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String?
  posts     Post[]
  createdAt DateTime @default(now())

  @@index([email])
}

model Post {
  id        Int       @id @default(autoincrement())
  title     String
  content   String?
  published Boolean   @default(false)
  viewCount Int       @default(0)
  authorId  Int
  author    User      @relation(fields: [authorId], references: [id], onDelete: Cascade)
  tags      Tag[]
  createdAt DateTime  @default(now())

  @@index([authorId])
}

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

執行資料庫遷移(Migration)並生成 Client:

pnpm prisma migrate dev --name init

3. 基本 CRUD 操作

建立單一 Prisma Client 實例(建議在獨立檔案如 db.ts 中管理):

import { PrismaClient } from '@prisma/client';
export const prisma = new PrismaClient();

Create(新增)

支援單筆新增、巢狀新增(Nested Writes)與批次新增:

// 巢狀新增:建立 User 的同時建立關聯的 Post
const userWithPosts = await prisma.user.create({
  data: {
    email: 'alice@example.com',
    name: 'Alice',
    posts: {
      create: [
        { title: 'Prisma 入門指南', published: true },
        { title: 'TypeScript 最佳實踐', published: false },
      ],
    },
  },
  include: { posts: true },
});

// 批次新增
await prisma.tag.createMany({
  data: [{ name: 'TypeScript' }, { name: 'ORM' }, { name: 'Node.js' }],
  skipDuplicates: true,
});

Read(查詢)

// 查詢單筆(支援 Unique 欄位)
const user = await prisma.user.findUnique({
  where: { email: 'alice@example.com' },
});

// 複合條件查詢
const posts = await prisma.post.findMany({
  where: {
    published: true,
    viewCount: { gte: 100 },
    title: { contains: 'Prisma', mode: 'insensitive' },
  },
  select: {
    id: true,
    title: true,
    author: { select: { name: true } },
  },
});

Update(更新)與 Upsert

// 更新單筆
const updatedPost = await prisma.post.update({
  where: { id: 1 },
  data: { viewCount: { increment: 1 } },
});

// Upsert:存在則更新,不存在則新增
const profile = await prisma.user.upsert({
  where: { email: 'bob@example.com' },
  update: { name: 'Bob Updated' },
  create: { email: 'bob@example.com', name: 'Bob' },
});

Delete(刪除)

// 刪除單筆
await prisma.post.delete({ where: { id: 1 } });

// 批次刪除
await prisma.post.deleteMany({ where: { published: false } });

4. 聚合與分組查詢(Aggregate & Group By)

Prisma 提供 aggregategroupBy 計算統計數值:

// 聚合:計算總貼文數、最大/平均觀看次數
const stats = await prisma.post.aggregate({
  _count: { id: true },
  _avg: { viewCount: true },
  _max: { viewCount: true },
  where: { published: true },
});

// Group By:依作者統計發文數與總觀看數,並使用 having 過濾
const authorStats = await prisma.post.groupBy({
  by: ['authorId'],
  _count: { id: true },
  _sum: { viewCount: true },
  having: {
    viewCount: {
      _avg: { gt: 50 },
    },
  },
  orderBy: {
    _sum: { viewCount: 'desc' },
  },
});

5. 分頁策略(Pagination)

Offset-based 分頁(skip / take

適合資料量小、需要跳頁導航的場景,但在資料量龐大時效能較差。

const page = 3;
const pageSize = 10;

const posts = await prisma.post.findMany({
  skip: (page - 1) * pageSize,
  take: pageSize,
  orderBy: { createdAt: 'desc' },
});

Cursor-based 分頁(cursor / take

適合無限滾動(Infinite Scroll)或大資料量場景,利用索引欄位定位,查詢效率恆定。

const lastCursor = 42; // 上一頁最後一筆資料的 ID

const nextPosts = await prisma.post.findMany({
  take: 10,
  skip: 1, // 跳過游標本身
  cursor: { id: lastCursor },
  orderBy: { id: 'asc' },
});

6. Join 優化與 N+1 查詢問題解析

何謂 N+1 問題?

當要查詢 10 筆文章並取得其作者時,若先查出 10 筆 Post,再於迴圈內對每個 Post 發送 1 次 User 查詢,資料庫總共執行了 $1 + 10 = 11$ 次查詢。

Prisma 的處理機制

  • include / select(批次化查詢): Prisma 在預設關聯載入時,底層通常不會直接使用 SQL LEFT JOIN,而是將查詢拆為兩條並以 IN 聚合:
  1. SELECT * FROM "Post";
  2. SELECT * FROM "User" WHERE id IN (1, 2, 3...); 這在應用層自動拼裝,將 $N+1$ 次查詢收斂至 $2$ 次。
  • relationMode = "prisma" vs "foreignKeys" 若資料庫不支援外鍵(如 PlanetScale / Vitess),可在 schema 設定 relationMode = "prisma",由 Prisma 在應用層模擬外鍵約束與 Cascade 行為。
  • Join 優化原則:
  • 避免在 findMany 裡過度深層巢狀 include(如 User -> Posts -> Comments -> Likes)。
  • 僅取所需欄位,使用 select 代替全欄位 include,降低記憶體佔用與網路傳輸。
  • 確保外鍵欄位(如 authorId)建立了索引(Index)。

7. 進階 SQL 查詢(Raw SQL)

當 ORM API 無法表達複雜查詢(如 CTE、Window Functions、全文檢索)時,可使用型別安全的 Raw SQL:

import { Prisma } from '@prisma/client';

// 1. 查詢資料:$queryRaw(使用 tagged template 自動防止 SQL Injection)
const minViews = 50;
const results = await prisma.$queryRaw<Array<{ id: number; title: string; rank: number }>>`
  WITH RankedPosts AS (
    SELECT id, title, view_count,
           DENSE_RANK() OVER (ORDER BY view_count DESC) as rank
    FROM "Post"
    WHERE view_count >= ${minViews}
  )
  SELECT * FROM RankedPosts WHERE rank <= 5;
`;

// 2. 執行指令:$executeRaw(用於 UPDATE / DELETE 等無回傳資料操作)
const affectedRows = await prisma.$executeRaw`
  UPDATE "Post" SET "viewCount" = "viewCount" + 10 WHERE "published" = false;
`;

8. 實用進階技巧(Misc)

交易處理(Transactions)

Prisma 提供 Sequential 與 Interactive 兩種交易模式:

// 批次交易(平行或順序執行,全成功或全失敗)
await prisma.$transaction([
  prisma.user.create({ data: { email: 'user1@example.com' } }),
  prisma.post.deleteMany({ where: { published: false } }),
]);

// 互動式交易(Interactive Transaction,依賴前一步驟運算結果)
await prisma.$transaction(async (tx) => {
  const sender = await tx.user.update({
    where: { id: 1 },
    data: { balance: { decrement: 100 } },
  });

  if (sender.balance < 0) {
    throw new Error('餘額不足');
  }

  await tx.user.update({
    where: { id: 2 },
    data: { balance: { increment: 100 } },
  });
});

Client Extensions(取代 Middleware)

Prisma 推薦使用 Extensions 來擴展功能(如軟刪除、自動 Audit Logs、欄位計算):

const xprisma = prisma.$extends({
  name: 'auditExtension',
  query: {
    post: {
      async create({ args, query }) {
        console.log(`即將建立文章: ${args.data.title}`);
        return query(args);
      },
    },
  },
});

常用 Prisma CLI 指令

指令 說明
pnpm prisma db push 直接將 Schema 同步到資料庫(適合原型開發,不留 Migration 紀錄)
pnpm prisma migrate dev 建立並套用 Migration 腳本(適合正式開發)
pnpm prisma studio 開啟視覺化 Web 介面瀏覽與編輯資料庫資料
pnpm prisma generate 根據最新 Schema 重新產生 @prisma/client 型別