昨天我們用 zod 確保進來 API 的資料是乾淨的;
今天把關卡往下推到 資料庫:
用 Prisma 讓你在寫 SQL(其實是 ORM)時也享受 自動補全 + 型別安全。
select
/include
型別都跟著走在 Day 15~17 的專案裡繼續:
npm install prisma --save-dev
npm install @prisma/client
npx prisma init
這會產生:
prisma/
schema.prisma
.env # 會多一個 DATABASE_URL
.env
裡設定資料庫連線字串(以 SQLite 或 PostgreSQL 其中一種為例):
DATABASE_URL="file:./dev.db"
用 SQLite 方便你本機先跑起來,之後要換 Postgres 也很容易。
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。
npx prisma migrate dev --name init
這步會:
以後每次改 schema.prisma 都要再跑一次 migration(或 prisma generate 重新生成 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;
// 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 都會把回傳 型別自動精準化(你選什麼欄位,就得到那幾個欄位的型別)。
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");
});
const list = await prisma.post.findMany({
where: { published: true },
select: { id: true, title: true, author: { select: { id: true, name: true } } },
orderBy: { createdAt: "desc" },
});
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 結構在編譯期就被檢查,不容易寫歪。
例如:新增一篇文章並更新使用者「文章數」欄位(假設加了 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 可以確保兩個動作同成同敗。
例如違反 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)。
npx prisma migrate dev
:改了 schema 沒跑遷移,Client 型別不同步 → 記得跑include
:容易把不必要欄位全撈回來 → 用 select
精準挑.env
換 DATABASE_URL
+ provider = "postgresql"
,再跑 migrate