iT邦幫忙

2025 iThome 鐵人賽

DAY 18
0

昨天我們用 zod 確保進來 API 的資料是乾淨的;

今天把關卡往下推到 資料庫

Prisma 讓你在寫 SQL(其實是 ORM)時也享受 自動補全 + 型別安全


1. 為什麼選 Prisma?

  • 端到端型別安全:模型 → 生成 Prisma Client → 你的 TS 程式,全程有型別護體
  • 好維護:schema 為中心,migration 版本化
  • DX 佳:自動補全、關聯跳轉、select/include 型別都跟著走

2. 安裝與初始化

在 Day 15~17 的專案裡繼續:

npm install prisma --save-dev
npm install @prisma/client
npx prisma init

這會產生:

prisma/
  schema.prisma
.env             # 會多一個 DATABASE_URL

.env 裡設定資料庫連線字串(以 SQLite 或 PostgreSQL 其中一種為例):

先用 SQLite(入門超快)

DATABASE_URL="file:./dev.db"

用 SQLite 方便你本機先跑起來,之後要換 Postgres 也很容易。


3. 定義資料模型

prisma/schema.prisma(SQLite 版本):

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

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

model User {
  id        String   @id @default(cuid())
  email     String   @unique
  name      String
  posts     Post[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Post {
  id        String   @id @default(cuid())
  title     String
  content   String?
  published Boolean  @default(false)
  authorId  String
  author    User     @relation(fields: [authorId], references: [id])
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

這裡我們做一個最常見的 一對多:一個 User 有多篇 Post。


4. 建資料庫 + 生成 Client

npx prisma migrate dev --name init

這步會:

  1. 建立資料庫與資料表
  2. 產生 Prisma Client(對應型別自動生成)

以後每次改 schema.prisma 都要再跑一次 migration(或 prisma generate 重新生成 Client)。


5. 建立 Prisma Client

src/lib/prisma.ts 建一個單例(避免開太多連線):

// src/lib/prisma.ts
import { PrismaClient } from "@prisma/client";

const globalForPrisma = global as unknown as { prisma: PrismaClient };

export const prisma =
  globalForPrisma.prisma ||
  new PrismaClient({
    log: ["query", "error", "warn"], // 開發時可觀察
  });

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;


6. 第一次 CRUD:User + Post

建立使用者

// src/services/user.service.ts
import { prisma } from "../lib/prisma";

export async function createUser(email: string, name: string) {
  return prisma.user.create({
    data: { email, name },
    select: { id: true, email: true, name: true, createdAt: true },
  });
}

查詢使用者(含文章)

export async function getUserWithPosts(userId: string) {
  return prisma.user.findUnique({
    where: { id: userId },
    include: {
      posts: {
        select: { id: true, title: true, published: true },
        orderBy: { createdAt: "desc" },
      },
    },
  });
}

建立文章

// src/services/post.service.ts
import { prisma } from "../lib/prisma";

export async function createPost(authorId: string, title: string, content?: string) {
  return prisma.post.create({
    data: { authorId, title, content },
    select: { id: true, title: true, published: true },
  });
}

發布文章(更新)

export async function publishPost(postId: string) {
  return prisma.post.update({
    where: { id: postId },
    data: { published: true },
    select: { id: true, published: true },
  });
}

刪除文章

export async function deletePost(postId: string) {
  return prisma.post.delete({
    where: { id: postId },
    select: { id: true },
  });
}

每個 .select / .include 都會把回傳 型別自動精準化(你選什麼欄位,就得到那幾個欄位的型別)。


7. 串到 Express 路由(搭配 zod)

src/routes/user.ts(延伸 Day 17):

import express from "express";
import { z } from "zod";
import { validate } from "../middleware/validate";
import { createUser, getUserWithPosts } from "../services/user.service";

const router = express.Router();

const createUserSchema = z.object({
  body: z.object({
    email: z.string().email(),
    name: z.string().min(1),
  }),
  params: z.object({}),
  query: z.object({}),
});

router.post("/", validate(createUserSchema), async (req, res) => {
  const { email, name } = req.body as { email: string; name: string };
  const user = await createUser(email, name);
  res.status(201).json(user);
});

const getUserSchema = z.object({
  params: z.object({ id: z.string().min(1) }),
  query: z.object({}),
  body: z.object({}),
});

router.get("/:id", validate(getUserSchema), async (req, res) => {
  const user = await getUserWithPosts(req.params.id);
  if (!user) return res.status(404).json({ message: "User not found" });
  res.json(user);
});

export default router;

src/routes/post.ts

import express from "express";
import { z } from "zod";
import { validate } from "../middleware/validate";
import { createPost, publishPost, deletePost } from "../services/post.service";

const router = express.Router();

const createPostSchema = z.object({
  body: z.object({
    authorId: z.string().min(1),
    title: z.string().min(1),
    content: z.string().optional(),
  }),
  params: z.object({}),
  query: z.object({}),
});

router.post("/", validate(createPostSchema), async (req, res) => {
  const { authorId, title, content } = req.body as {
    authorId: string; title: string; content?: string;
  };
  const post = await createPost(authorId, title, content);
  res.status(201).json(post);
});

const publishSchema = z.object({
  params: z.object({ id: z.string().min(1) }),
  query: z.object({}),
  body: z.object({}),
});

router.patch("/:id/publish", validate(publishSchema), async (req, res) => {
  const post = await publishPost(req.params.id);
  res.json(post);
});

const deleteSchema = z.object({
  params: z.object({ id: z.string().min(1) }),
  query: z.object({}),
  body: z.object({}),
});

router.delete("/:id", validate(deleteSchema), async (req, res) => {
  const post = await deletePost(req.params.id);
  res.json(post);
});

export default router;

src/index.ts(掛載路由):

import express from "express";
import userRoutes from "./routes/user";
import postRoutes from "./routes/post";

const app = express();
app.use(express.json());

app.use("/users", userRoutes);
app.use("/posts", postRoutes);

app.listen(3000, () => {
  console.log("🚀 Server running on http://localhost:3000");
});


8. 關聯查詢與選欄位最佳實踐

只拿需要的欄位

const list = await prisma.post.findMany({
  where: { published: true },
  select: { id: true, title: true, author: { select: { id: true, name: true } } },
  orderBy: { createdAt: "desc" },
});

條件組合(Type 安全)

type Filters = { published?: boolean; keyword?: string };

export async function searchPosts({ published, keyword }: Filters) {
  return prisma.post.findMany({
    where: {
      published,
      title: keyword ? { contains: keyword, mode: "insensitive" } : undefined,
    },
  });
}

Prisma 會用「可選欄位 + 明確型別」讓你的 where 結構在編譯期就被檢查,不容易寫歪。


9. 交易(Transaction)與批次操作

例如:新增一篇文章並更新使用者「文章數」欄位(假設加了 postCount 欄位):

export async function createPostAndInc(authorId: string, title: string) {
  return prisma.$transaction(async (tx) => {
    const post = await tx.post.create({
      data: { authorId, title },
      select: { id: true, title: true },
    });
    await tx.user.update({
      where: { id: authorId },
      data: { posts: { /* 也可用 relation aggregate 改別的欄位 */ } },
    });
    return post;
  });
}

使用 $transaction 可以確保兩個動作同成同敗。


10. 錯誤處理(KnownRequestError)

例如違反 unique constraint:

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

export async function safeCreateUser(email: string, name: string) {
  try {
    return await prisma.user.create({ data: { email, name } });
  } catch (err) {
    if (err instanceof Prisma.PrismaClientKnownRequestError) {
      if (err.code === "P2002") {
        throw new Error("Email already exists");
      }
    }
    throw err;
  }
}

Prisma 會給你 錯誤代碼,可以針對性回應(例如 409 Conflict)。


11. 常見坑位 & 小技巧

  • 忘了 npx prisma migrate dev:改了 schema 沒跑遷移,Client 型別不同步 → 記得跑
  • 大量 include:容易把不必要欄位全撈回來 → 用 select 精準挑
  • 連線數管理:在開發熱重載環境(如 ts-node-dev)要做 PrismaClient 單例(上面已示範)
  • 環境切換:本機 SQLite、線上 Postgres → .envDATABASE_URL + provider = "postgresql",再跑 migrate
  • 資料檢核:外部輸入用 zod、資料庫層靠 Prisma schema(unique、relation、default)雙保險

上一篇
Day 17|型別安全的路由參數與查詢參數驗證:express + zod
下一篇
Day 19|React + TypeScript:Props / State / 事件 / Hook / API 串接一次搞懂
系列文
我與型別的 30 天約定:TypeScript 入坑實錄19
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言