รู้จักกับ Prisma ORM

/ 25 min read

Share on social media

next-prisma สามารถดู video ของหัวข้อนี้ก่อนได้ ดู video

Prisma คืออะไร

Prisma ORM (Object Relational Mapping) คือเครื่องมือจัดการฐานข้อมูล open source สำหรับ application TypeScript และ JavaScript โดย Prisma ORM จะมีชุดเครื่องมือในการสร้างและจัดการโครงสร้างของฐานข้อมูล ส่งคำสั่งค้นหา (queries) รวมถึงสามารถโยกย้ายฐานข้อมูล (migrations) ได้อย่างมีประสิทธิภาพและใช้งานง่าย

คุณสมบัติหลักของ Prisma ORM:

  • Prisma Client ระบบที่สร้างคำสั่ง query อัตโนมัติพร้อมตรวจสอบความถูกต้องของชนิดข้อมูล (type-safe query builder) ช่วยให้นักพัฒนาสามารถทำงานกับฐานข้อมูลได้ง่ายขึ้น โดยระบบจะแปลง model ที่กำหนดไว้ใน Prisma Schema ให้เป็นการดำเนินการ CRUD ที่พร้อมใช้กับฐานข้อมูล โดย Prisma Client จะรับรู้ถึงโครงสร้างฐานข้อมูล รวมถึงมีชุดคำสั่งที่หลากหลาย ทำให้มั่นใจในความถูกต้องของข้อมูลและลดโอกาสเกิดข้อผิดพลาดขณะใช้งาน
  • Prisma Migrate เครื่องมือจัดการการเปลี่ยนแปลงโครงสร้างฐานข้อมูล ช่วยให้สามารถกำหนดเวอร์ชันและติดตามการเปลี่ยนแปลงต่างๆได้อย่างเป็นระบบ นักพัฒนาสามารถกำหนดสิ่งที่ต้องการปรับปรุงในโครงสร้างฐานข้อมูลได้ด้วยรูปแบบที่เข้าใจง่าย จากนั้น Prisma Migrate จะสร้างไฟล์ migration ให้โดยอัตโนมัติเพื่อนำไป update กับฐานข้อมูลจริงได้
  • Prisma Schema เป็นส่วนสำคัญของ Prisma โดย schema file นี้ช่วยให้นักพัฒนาสามารถกำหนด Model และ Relation ต่างๆ ภายใน application ได้ โดย Schema นี้เปรียบเสมือนแหล่งข้อมูลเดียว (single source of truth) ของโครงสร้างฐานข้อมูลที่เครื่องมือต่างๆ ใน Prisma จะนำไปใช้สร้าง code และไฟล์ migration ได้
  • Prisma ใช้งานร่วมกับฐานข้อมูลได้หลากหลายชนิด ไม่ว่าจะเป็น PostgreSQL, MySQL, SQLite, SQL Server หรือแม้แต่ MongoDB

Prisma ได้รับความนิยมอย่างมากกับการพัฒนาคู่กับ Node.js และ TypeScript แต่อย่างไรก็ตามก็สามารถใช้กับ JavaScript ได้เช่นกัน จุดเด่นของ Prisma คือการที่มันช่วยลดงานจุกจิกที่เกี่ยวกับฐานข้อมูลและใช้ API ที่เข้าใจง่ายยิ่งขึ้นในการโต้ตอบกับฐานข้อมูลได้ดียิ่งขึ้นด้วยเช่นกัน

Setup Prisma

เพื่อให้เข้าใจ Prisma มากขึ้นในหัวข้อนี้เราจะพาเล่น Prisma ผ่าน Next.js กันเพื่อให้เห็นภาพการใช้งานทั้งจากฝั่ง Backend (ผ่าน Server Component ของ Next.js ที่มี Node.js เป็น back อยู่) และ Frontend (ผ่าน Client Component ของ React)

โดย project ที่เราจะทำคือ เราจะทำเว็บสำหรับจัดการ Blog Content โดยเบื้องต้น ต้องสามารถ

  • สร้าง / แก้ไข / ลบ Post ได้
  • สามารถดึง Post ทั้งหมดออกมาได้

เราจะแยกทำออกเป็นทั้งหมด 3 ส่วนคือ

  1. Setup Schema และ Prisma Client
  2. ส่วนของ API (Backend) จะทำการต่อเข้ากับ PostgreSQL ผ่าน Prisma เป็น CRUD API ของ post
  3. ส่วนของ React (Frontend) จะทำการดึงข้อมูลมาใช้เพื่อแสดงผลออกผ่านหน้าเว็บโดยดึงจาก API ที่ต่อเข้ากับ PostgreSQL

นี่คือตัวอย่างของผลลัพธ์แรกที่เราจะทำกันออกมา

blog-01.gif

เราจะมาไล่ทำทีละ step กัน

1. Setup Project และ Prisma Client

ขั้นแรกสุดเราต้อง setup project ขึ้นมาก่อน โดยเราจะ setup ของทั้งหมด 3 อย่างขึ้นมาคือ

  1. project Next.js สำหรับส่วนของ Application
  2. PostgreSQL ซึ่งในทีนี้เราจะสร้างผ่าน docker-compose.yml ขึ้นมา (พร้อมกับ pgadmin ในหน้าจัดการ)
  3. prisma client ที่จะทำการสร้างพร้อมกับ schema ของ database และ migration ขึ้นมา

โดย เริ่มต้น step แรกสุด ในทีนี้เราใช้ Next.js ในการทำ ดังนั้นเราจะทำการ init project next.js ขึ้นมาตาม document https://nextjs.org/docs/getting-started/installation โดยใช้คำสั่ง

Terminal window
npx create-next-app@latest

โดยทำการเลือกใช้เป็น TypeScript และ Tailwind CSS เพื่อให้สามารถจัดการ style ได้ง่ายขึ้น ที่เหลือสามารถเลือกเป็นค่า default ได้เลย

prisma-01.webp

หลังจาก project run ขึ้นมาได้เรียบร้อย เราจะทำการวางไว้ก่อน เป็นอันจบ step แรกของการ setup

step ที่ 2 ทำการลง postgreSQL ผ่าน docker-compose โดยใช้ docker-compose.yml ที่มีหน้าตาแบบนี้ เป็นการ start postgreSQL และ pgAdmin (หน้าสำหรับจัดการ database ของ postgreSQL) ขึ้นมา

version: '3.8'
services:
postgres:
image: postgres:latest
environment:
POSTGRES_USER: myuser
POSTGRES_PASSWORD: mypassword
POSTGRES_DB: mydb
ports:
- "5432:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
pgadmin:
image: dpage/pgadmin4
environment:
PGADMIN_DEFAULT_EMAIL: [email protected]
PGADMIN_DEFAULT_PASSWORD: root
ports:
- "5050:80"
depends_on:
- postgres
volumes:
postgres-data:

หลังจากนั้นให้ทำการ start docker ขึ้นมาด้วยคำสั่ง

Terminal window
docker-compose up -d

และเมื่อลองตรวจสอบด้วย docker ps ดู และเจอว่ามี service 2 ตัวขึ้นแบบตามภาพนี้เรียบร้อย เท่ากับว่า postgreSQL และ pgAdmin ได้ run ขึ้นมาแล้วเรียบร้อย

prisma-02.webp

หลังจากนั้น ให้ลองทดสอบเปิด pgAdmin โดยเปิดผ่าน localhost:5050 ดูทำการต่อผ่าน config ของ docker-compose.yml และหากสามารถเปิด database connection มาได้ถือว่า เรา setup ทุกอย่างมาถูกต้องแล้ว

blog-02.gif

ตอนนี้เราก็มีทั้ง project Next.js และ database (PostgreSQL) พร้อมแล้วเรียบร้อย มาเข้าสู่ step สุดท้ายคือการสร้าง Schema และการ migration database ขึ้นมากัน

ที่ project Next.js ให้ลง package ของ prisma (สำหรับจัดการ prisma schema ผ่าน CLI) และ @prisma/client สำหรับจัดการ prisma ผ่าน node.js (ใน Next.js)

Terminal window
npm install prisma --save-dev
npm install @prisma/client

หลังจากนั้น ให้ทำการพิมพ์คำสั่งนี้เพื่อทำการสร้าง file เริ่มต้นของ schema ขึ้นมา

Terminal window
npx prisma init
prisma-03.webp

เมื่อเข้าไปในไฟล์ก็จะเจอค่าเริ่มต้นตามนี้

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}

โดยจะมีการแบ่งออกเป็นทั้งหมด 2 block ไว้คือ Generator Block (ตรง generator) และ Datasource Block (ตรง datasource)

  • Generator Block จะกำหนดวิธีที่ Prisma จะสร้าง Client ขึ้นมา โดย Client นี้ถือเป็นช่องทางหลักที่คุณจะใช้ติดต่อสื่อสารกับฐานข้อมูลของคุณผ่าน Prisma (โดยในทีนี้เราระบุเป็น “prisma-client-js” หมายความว่า Client ที่ถูกสร้างขึ้นจะเป็นภาษา JavaScript ซึ่งเหมาะสำหรับการใช้งาน application Node.js นั่นเอง)
  • Datasource Block เป็นส่วนที่ทำการตั้งค่าการเชื่อมต่อกับฐานข้อมูล โดยที่ db คือชื่อที่เราตั้งให้กับ datasource และ provider ระบุประเภทของฐานข้อมูลที่ใช้ ในที่นี้ postgresql เป็นการบอกว่าฐานข้อมูลที่เราเชื่อมต่อนั้นเป็น PostgreSQL และ url แทนค่าเป็น url ของฐานข้อมูลซึ่งสามารถกำหนดได้ผ่าน environment variable อย่าง .env ได้เลยเช่นกัน (เคสใน config นี้ เราจะทำการดึง DATABASE_URL ผ่าน .env ออกมาได้)

เพราะฉะนั้น เอาจริงทั้ง 2 block นี้ถือว่า init มาพร้อมสำหรับการใช้งานแล้วเรียบร้อย ต่อมา เราจะทำการเพิ่มอีก 1 block เพิ่มขึ้นมานั่นคือ Model Block หน้าตาแบบนี้เพิ่มต่อเข้าไป

model Post {
id Int @id @default(autoincrement())
title String
content String?
createdAt DateTime @default(now())
}

model Post ที่ถูกกำหนดไว้ใน Prisma schema นี้เปรียบเสมือนโครงสร้างของตารางสำหรับเก็บข้อมูล Post ในฐานข้อมูล ซึ่งประกอบด้วย

  • id เป็นเลขจำนวนเต็มที่เพิ่มขึ้นอัตโนมัติ ทำหน้าที่เป็นตัวระบุเฉพาะสำหรับแต่ละ Post
  • title เป็นช่องสำหรับใส่ชื่อเรื่องของ Post และเป็นส่วนที่จำเป็นต้องมี
  • content เป็นช่องที่ใส่เนื้อหาของ Post
  • createdAt เป็นช่องเก็บวันที่และเวลาโดยสร้างขึ้นอัตโนมัติ ทุกครั้งที่มีการสร้าง Post ใหม่

ซึ่งสิ่งนี้สร้างตาม spec ของ Prisma Schema ตาม document นี้ https://www.prisma.io/docs/orm/prisma-schema/overview (สามารถอ่านเพิ่มเติมได้ที่ document นี้เช่นกัน) โดยสิ่งนี้จะกลายเป็นตัวแทนของฐานข้อมูล ที่จะใช้สำหรับเป็นต้นแบบสำหรับการสร้าง table ใน database ต่อไปด้วยเช่นกัน

เมื่อกำหนด schema เรียบร้อย ให้ทำการพิมพ์คำสั่งนี้เพิ่มเข้าไป เพื่อทำการเริ่มต้น prisma client ออกมา

npx prisma generate

โดยใน Prisma คำสั่ง prisma generate จะทำการ update Prisma Client ให้สอดคล้องกับการเปลี่ยนแปลงในไฟล์ Prisma schema โดยหน้าที่หลักของคำสั่งนี้คือ ****อ่านไฟล์ schema.prisma แล้วสร้าง client (Prisma Client) ขึ้นมาโดยอัตโนมัติ ซึ่งมีความสามารถในการตรวจสอบชนิดข้อมูล เพื่อใช้งานร่วมกับฐานข้อมูลของเรา (ที่ประกาศผ่าน model) ได้

โดยจุดประสงค์ใหญ่ๆของการใช้ Prisma Client (ที่ถือว่าเป็นส่วนหลักที่ application จะทำการเชื่อมต่อไปยังฐานข้อมูล) คือ เป็นชุดคำสั่ง API สำหรับใช้ทำงานกับฐานข้อมูล เช่น สร้าง, อ่าน, อัปเดต และลบข้อมูล ได้ผ่าน Prisma Client นี้เอง

เมื่อใช้คำสั่งเรียบร้อย มันก็จะระบุว่าได้ทำการ update Prisma Client ไปที่ node_modules แล้วเป็นที่เรียบร้อย หลังจากนี้ เราก็สามารถใช้งาน Model ผ่านคำสั่งบน @prisma/client ได้แล้ว (โดยทุกครั้งที่มีการแก้ไข schema จะต้องทำเสมอ เพื่อให้มี code สำหรับใช้งานที่ฝั่ง Prisma Client ได้)

prisma-04.webp

step สุดท้ายก่อนที่เราจะลองต่อฐานข้อมูล แน่นอน ตอนนี้เราประกาศ schema มาแล้ว และทำการ setup config เรียบร้อย แต่เรายังไม่ได้สร้างฐานข้อมูลตาม schema ที่มีการกำหนดผ่าน model Post เอาไว้ และแน่นอน Prisma เองก็ได้อำนวยความสะดวกเรื่องนี้ไว้แล้วเช่นกัน โดยสามารถใช้ได้ผ่านคำสั่ง

Terminal window
npx prisma migrate dev --name <ชื่อ migrate>

โดยคำสั่งนี้

  • จะสร้างไฟล์ migration ใหม่ขึ้นมาภายใน folder migrations ของ Prisma โดยไฟล์เหล่านี้จะมีคำสั่ง SQL ที่จำเป็นต่อการปรับเปลี่ยนโครงสร้างฐานข้อมูลให้ตรงกับ schema.prisma ณ เวลานั้น
  • ส่วนคำสั่ง --name <ชื่อ migration> จะช่วยให้เราตั้งชื่อให้กับ migration นั้นๆได้ ซึ่งจะช่วยระบุจุดประสงค์ของ migration เมื่อถูกเก็บอยู่ใน version control system (เพื่อทำให้กลับมาตรวจสอบได้ง่ายว่า database มีการ update อะไรไปบ้างในแต่ละรอบที่เกิด imgration)
  • หลังจากสร้างไฟล์ migration แล้ว คำสั่งนี้จะปรับใช้ migration ไปยังฐานข้อมูลโดยอัตโนมัติ ซึ่งจะทำการ updatee โครงสร้างฐานข้อมูลให้ตรงกับที่ระบุไว้ในไฟล์ schema.prisma เช่น การเพิ่มตารางใหม่, ปรับเปลี่ยนตารางเดิม หรือลบตาราง (migration จะวิเคราห์ออกมาผ่าน SQL ใน migration ให้เรียบร้อยแบบอัตโนมัติ)
  • หลังจากปรับใช้ migration ไปยังฐานข้อมูลเรียบร้อยแล้ว คำสั่งนี้จะ update Prisma Client ใหม่ เพื่อรับรองว่า Prisma Client จะทำงานร่วมกับโครงสร้างใหม่ของฐานข้อมูลได้อย่างสมบูรณ์

เราจะลอง run โดยตั้งชื่อ migration ว่า npx prisma migrate dev --name init

prisma-05.webp

เมื่อทำการ run migration เรียบร้อย ให้ไปดูผลลัพธ์ที่ database ของ PostgreSQL ผ่าน pgAdmin ดู

prisma-06.webp

ก็จะเจอตารางชื่อเดียวกันกับ model (Post) ออกมาได้ พร้อมกับตาราง _prisma*_*migrations ที่เป็นการเก็บ version ของ database ที่มีการ migrate เข้าไป เพื่อทำให้เวลาที่ Prisma run migration ใหม่อีกรอบ จะได้ทราบได้ว่า มีการ run migration นี้ไปแล้วหรือไม่ออกมาได้ (เพื่อป้องกันการ run migration ซ้ำออกมาได้)

และนี่ก็คือ step การ setup โดยประมาณของการใช้ Prisma ไอเดียหลักๆคือ

  1. setup project พร้อมลง Prisma และ Prisma Client
  2. setup database ที่จะใช้
  3. สร้าง schema Prisma ขึ้นมา
  4. run migration เพื่อทำการสร้าง table ใน database และ Prisma Client API สำหรับให้ฝั่ง Client เข้ามาเชื่อมต่อกับ database ได้

Step ต่อไปเราจะเริ่มทดลองต่อเข้ากับ database กัน

2. ส่วนต่อ Backend

Step แรกสุดเราจะลองสร้าง API ทั้งหมด 4 ตัวออกมากัน คือ CRUD ของ posts นั่นเองประกอบด้วย

  • GET /api/posts สำหรับดึง Post ทั้งหมดออก
  • GET /api/posts/:id สำหรับดึง Post ออกมาตาม id
  • POST /api/posts สำหรับสร้าง Post
  • PUT /api/posts/:id สำหรับแก้ไข Post ตาม id
  • DELETE /api/posts/:id สำหรับลบ Post ตาม id

เนื่องจากเราใช้ Next.js ดังนั้น เราจะใช้หลักการของ Route Handler ในการสร้าง API ออกมา https://nextjs.org/docs/app/building-your-application/routing/route-handlers ดังนั้น เราจะสร้าง structure project ตามนี้ขึ้นมาเพื่อทำ API ตามโจทย์นี้

Terminal window
├── app
├── api
└── posts
├── [id]
└── route.ts
└── route.ts

โดย

  • api/posts/route.ts จะทำการเก็บ API สำหรับการ Get Post ทั้งหมด และการสร้าง Post เอาไว้ โดย path ก็จะอ้างอิงตาม folder นั่นคือ /api/posts
  • api/posts/[id]/route.ts จะทำการเก็บ API สำหรับการ Get Post ราย id, แก้ไข Post และ ลบ Post ตาม id ออกมา โดย path ก็จะอ้างอิงตาม folder นั่นคือ /api/posts/:id

ดังนั้นเริ่มต้นที่ api/posts/route.ts ก่อน ทำการสร้าง API 2 path ขึ้นมา

import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export async function GET() {
return Response.json(await prisma.post.findMany())
}
export async function POST(req: Request) {
try {
const { title, content } = await req.json()
const newPost = await prisma.post.create({
data: {
title,
content,
},
})
return Response.json(newPost)
} catch (error) {
return new Response(error as BodyInit, {
status: 500,
})
}
}

จาก code

  • function GET ที่มีหน้าที่ในการดึงข้อมูลทั้งหมดจากตาราง post ในฐานข้อมูล และส่งกลับข้อมูลออกมาในรูปแบบ JSON โดยจะใช้ method ชื่อว่า findMany ของ prisma.post ซึ่งเป็น method ที่สร้างขึ้นมาโดยอัตโนมัติโดยอิงจาก model Post ที่เราได้กำหนดไว้ใน Prisma schema โดย function นี้จะถูกใช้เมื่อมีHTTP request แบบ GET เข้ามา
  • function POST ออกแบบมาเพื่อรับ HTTP request แบบ POST โดยเริ่มต้นด้วยการดึงข้อมูล title และ content ออกมาจากส่วน body ของ request ที่ส่งมาในรูปแบบ JSON จากนั้นจะใช้ method ชื่อ create ของ prisma.post เพื่อเพิ่มข้อมูลใหม่เข้าไปในตาราง post ด้วย title และ content ที่ได้รับมา

ผลลัพธ์ของ code ก็จะเป็นตามนี้ ก็จะสามารถ ยิง GET / POST ตาม path ที่กำหนดออกมาได้

blog-03.gif

ต่อมาที่ api/posts/[id]/route.ts ก่อน ทำการสร้าง API 3 path ขึ้นมา

import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export async function GET(
req: Request,
{ params }: { params: { id: string } },
) {
return Response.json(await prisma.post.findUnique({
where: { id: Number(params.id) },
}))
}
export async function PUT(
req: Request,
{ params }: { params: { id: string } },
) {
try {
const { title, content } = await req.json()
return Response.json(await prisma.post.update({
where: { id: Number(params.id) },
data: { title, content },
}))
} catch (error) {
return new Response(error as BodyInit, {
status: 500,
})
}
}
export async function DELETE(
req: Request,
{ params }: { params: { id: string } },
) {
try {
return Response.json(await prisma.post.delete({
where: { id: Number(params.id) },
}))
} catch (error) {
return new Response(error as BodyInit, {
status: 500,
})
}
}

จาก code

  • function GET ทำหน้าที่ในการดึงข้อมูล post เดี่ยวๆ โดยระบุ id ของ post นั้น ซึ่งจะดึงค่า id มาจาก parameter ใน request จากนั้นแปลงเป็นตัวเลข และใช้ prisma.post.findUnique ไปค้นหา post ถ้าหาเจอก็จะส่งข้อมูลของ post นั้นกลับมาในรูปแบบ JSON
  • function PUT ทำหน้าที่อัปเดต title และ content ของ post อ้างอิงจาก id โดยจะดึง title และ content ใหม่ออกมาจากส่วน body ของ request ที่ส่งมาในรูปแบบ JSON แล้วเรียกใช้ prisma.post.update พร้อมกับระบุ id และข้อมูลใหม่เข้าไปด้วย ซึ่งถ้าอัปเดตสำเร็จ Prisma จะส่งข้อมูล post ที่อัปเดตแล้วกลับมาในรูปแบบ JSON
  • function DELETE ทำการลบ post ออกจากฐานข้อมูลโดยใช้อ้างอิงจาก id ของ post นั้น ระบบจะเรียกใช้ prisma.post.delete พร้อมกับระบุ id ของ post ที่ต้องการลบ

ผลลัพธ์ของ code ก็จะเป็นตามนี้ ก็จะสามารถ ยิง GET / PUT / DELETE ตาม path ที่กำหนดออกมาได้

blog-04.gif

และนี่ก็คือพื้นฐานการทำ CRUD API ผ่าน Next.js ผ่านคำสั่งของ Prisma ออกมา step ต่อมาเราจะเริ่มนำ API ไปต่อฝั่ง Frontend เพื่อเรียกใช้จริงตามตัวอย่างกัน

3. ส่วนต่อ Frontend

สำหรับ Frontend นั้น เพื่อให้เห็นภาพการใช้งานแบบแยกเคสกัน เราจะขอแยกทั้ง 3 หน้าออกจากกันนั่นคือ

  1. หน้าสำหรับแสดง Post ทั้งหมดออกมา (และสามารถนำไปสู่หน้า สร้าง / แก้ไข และ ลบ Post ได้)

  2. หน้าสำหรับการสร้าง Post

  3. หน้าสำหรับการแก้ไข Post

ดังนั้นสำหรับส่วนของหน้า Frontend เราจะสร้างตาม structure นี้ โดยทุกหน้าที่เราสร้างขึ้นมาจะทำเป็น Client component เพื่อให้สามารถจัดการ state จากหน้าเว็บออกมาได้

Terminal window
├── app
├── api --> API จากส่วนก่อนหน้านี้
└── posts
├── [id]
└── route.ts
└── route.ts
├── create
└── page.tsx
├── edit
└── [id]
└── page.tsx
└── page.tsx

โดย

  • app/page.tsx จะเป็นหน้าหลักของเว็บ โดยจะแสดงเป็นหน้าแสดง Post ทั้งหมดออกมา
  • app/create/page.tsx จะเป็นหน้าสำหรับการสร้าง Post
  • app/edit/[id]/page.tsx จะเป็นหน้าสำหรับแก้ไข Post (โดยหยิบ id จาก param มาใช้)

โดยเพื่อให้ง่ายต่อการใช้งาน api เราจะขอลง library axios เพิ่มเพือใช้สำหรับเรียกใช้งาน API

Terminal window
npm install axios

เริ่มต้นจาก app/page.tsx

'use client'
import React, { useEffect, useState } from 'react'
import axios from 'axios'
import Link from 'next/link'
const List = () => {
const [posts, setPosts] = useState([])
useEffect(() => {
fetchPosts()
}, [])
const fetchPosts = async () => {
try {
const res = await axios.get('/api/posts')
setPosts(res.data)
} catch (error) {
console.error(error)
}
}
const deletePost = async (id: Number) => {
try {
await axios.delete(`/api/posts/${id}`)
fetchPosts()
} catch (error) {
console.error('Failed to delete the post', error)
}
}
return (
<div className="max-w-6xl mx-auto px-4 py-8">
<h1 className="text-2xl font-semibold mb-6">Blog Posts</h1>
<div className="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Title
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{posts.map((post: any) => (
<tr key={post.id}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{post.title}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<Link
className="text-indigo-600 hover:text-indigo-900 mr-4"
href={`/edit/${post.id}`}
>
Edit
</Link>
<button
onClick={() => deletePost(post.id)}
className="text-red-600 hover:text-red-900"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Link
className="mt-4 inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
href="/create"
>
Create a New Post
</Link>
</div>
)
}
export default List

สังเกตว่า

  • หลักการไม่ต่างกับการเรียกใช้งาน API ปกติเลย โดยเป็นการเรียกใช้ API GET /api/posts เพื่อทำการดึง posts ทั้งหมดมาแสดงที่หน้าเว็บ
  • และเรียกใช้ DELETE /api/posts/:id เมื่อมีการกดลบ Post จาก list ที่แสดงออกมา (โดยอ้างอิงตาม id ที่ส่งเข้ามา)

และก็เช่นเดียวกันกับหน้าสร้าง Post app/create/page.tsx

pages/create.tsx
'use client'
import React, { useState } from 'react'
import axios from 'axios'
import { useRouter } from 'next/navigation'
const Create = () => {
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const router = useRouter()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
try {
await axios.post('/api/posts', { title, content })
router.push('/')
} catch (error) {
console.error(error)
}
}
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<h1 className="text-2xl font-semibold mb-6">Create a New Post</h1>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label
htmlFor="title"
className="block text-sm font-medium text-gray-700"
>
Title
</label>
<input
type="text"
name="title"
id="title"
required
value={title}
onChange={(e) => setTitle(e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
/>
</div>
<div>
<label
htmlFor="content"
className="block text-sm font-medium text-gray-700"
>
Content
</label>
<textarea
name="content"
id="content"
required
rows={4}
value={content}
onChange={(e) => setContent(e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
></textarea>
</div>
<div>
<button
type="submit"
className="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Submit
</button>
</div>
</form>
</div>
)
}
export default Create

และหน้าแก้ไข app/edit/[id]/page.tsx

'use client'
import React, { useEffect, useState } from 'react'
import axios from 'axios'
import { useRouter } from 'next/navigation'
const Edit = ({ params }: { params: { id: string } }) => {
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const router = useRouter()
const { id } = params
const fetchPost = async (id: Number) => {
try {
const res = await axios.get(`/api/posts/${id}`)
setTitle(res.data.title)
setContent(res.data.content)
} catch (error) {
console.error(error)
}
}
useEffect(() => {
if (id) {
fetchPost(parseInt(id))
}
}, [id])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
try {
await axios.put(`/api/posts/${id}`, {
title,
content,
})
router.push('/')
} catch (error) {
console.error(error)
}
}
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<h1 className="text-2xl font-semibold mb-6">Edit Post {id}</h1>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label
htmlFor="title"
className="block text-sm font-medium text-gray-700"
>
Title
</label>
<input
type="text"
name="title"
id="title"
required
value={title}
onChange={(e) => setTitle(e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
/>
</div>
<div>
<label
htmlFor="content"
className="block text-sm font-medium text-gray-700"
>
Content
</label>
<textarea
name="content"
id="content"
required
rows={4}
value={content}
onChange={(e) => setContent(e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
></textarea>
</div>
<div>
<button
type="submit"
className="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Update
</button>
</div>
</form>
</div>
)
}
export default Edit

สังเกตว่า ทั้ง 3 หน้านั้นหลักการใช้งานก็คือการเรียกใช้งานผ่าน API ทั่วๆไปเหมือนกับการ call API ตามปกติ และถ้าทำทุกอย่างมาถูกต้องผลลัพธ์ก็จะเหมือนกับภาพแรกสุดที่เราเกริ่นไว้ตอนต้นออกมาได้นั่นเอง

Query กับ Prisma

เรามาเพิ่มเติม feature ของ Prisma กัน โดยนอกเหนือจากการดึงข้อมูล แก้ไขข้อมูลและลบข้อมูลตามปกติแล้ว Prisma เองก็มี feature Query สำหรับการทำ search, filter, sort เหมือนกับ ORM ทั่วไปเช่นกัน

ในตัวอย่างนี้เราจะโชว์ตัวอย่างเพิ่มเติมที่ทำให้หน้าเว็บสามารถ ค้นหา Post / filter Post (ตาม category) และ sort Post ตามเวลาที่สร้างออกมาได้

blog-05.gif

ดังนั้น สิ่งที่เราจะทำเพิ่มคือ

  1. เราจะเพิ่ม category ใน model ของ Post เข้ามา (เพื่อใช้งานร่วมกับ Filter)
  2. update CRUD API ของ post ให้ ใช้งานร่วมกับ category รวมถึง UI ให้ใช้งานร่วมกับ category ออกมา (โดย UI จะทำการ fix UI)

เราจะลองมาเพิ่มไปทีละจุดกัน

ปรับ Schema Post

ที่ไฟล์ prisma/schema.prisma ให้ทำการเพิ่ม category เข้ามาใน Post

datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// Define the Prisma Client generator
generator client {
provider = "prisma-client-js"
}
model Post {
id Int @id @default(autoincrement())
title String
content String?
category String? // เพิ่ม field ตรงนี้มา โดยใส่ ? เป็น optional มา
createdAt DateTime @default(now())
}

หลังจากที่เพิ่ม field เข้ามาให้ทำการ run migrate เพิ่มอีก 1 version ขึ้นมา

npx prisma migrate dev --name add_post_category
prisma-08.webp

เมื่อ run เรียบร้อย ให้ลองมาดู database ที่ pgadmin ก็จะเจอ column ใหม่ category ขึ้นมาได้

prisma-07.webp

และอย่างที่อธิบายไปตอนแรก คำสั่งของ Prisma Client เองก็จะทำการสร้าง field นี้มาให้เองอัตโนมัติเช่นเดียวกัน ดังนั้นเราจึงสามารถใช้งาน field category ออกมาได้แล้วเช่นเดียวกัน

เพิ่มส่วน API (Backend)

สำหรับ API 2 files app/api/post/[id]/route.ts และ app/api/post/route.ts เองก็ต้องทำการเพิ่ม field category ทั้งการส่งออกและการนำเข้ามา

โดยเริ่มจาก app/api/post/[id]/route.ts

import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
/* เปลี่ยนเฉพาะ PUT */
export async function PUT(
req: Request,
{ params }: { params: { id: string } }
) {
try {
// เพิ่ม category รับผ่าน body เข้ามาเพิ่ม
const { title, content, category } = await req.json()
const post = await prisma.post.update({
where: { id: Number(params.id) },
data: { title, content, category },
})
return Response.json(post)
} catch (error) {
return new Response(error as BodyInit, {
status: 500,
})
}
}

สังเกตจาก code

  • ฝั่ง GET code เหมือนเดิม เนื่องจาก Prisma Client ทำการ handle field category เพิ่มมาแล้วเป็นที่เรียบร้อย
  • ฝั่ง DELETE ก็ไม่ต้องแก้อะไรเช่นกัน เนื่องจากไม่ได้ทำอะไรกับ field category เลย
  • สำหรับ PUT นั้น เพิ่มเพียง field category เข้ามาเท่านั้น เพื่อให้ support กับ category ที่ส่งแก้ไขมาเพิ่มได้

มาที่ app/api/post/[id]/route.ts

import { type NextRequest } from 'next/server'
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export async function GET(req: NextRequest) {
const searchParams = req.nextUrl.searchParams
const category = searchParams.get('category')
const search = searchParams.get('search') || ''
const sort = searchParams.get('sort') || 'desc'
const whereCondition = category
? {
category,
title: {
contains: search,
mode: 'insensitive',
},
}
: {
title: {
contains: search,
mode: 'insensitive',
},
}
try {
const posts = await prisma.post.findMany({
where: whereCondition,
orderBy: {
createdAt: sort,
},
})
return Response.json(posts)
} catch (error) {
return new Response(error, {
status: 500,
})
}
}
export async function POST(req: Request) {
try {
const { title, content, category } = await req.json()
const newPost = await prisma.post.create({
data: {
title,
content,
category,
},
})
return Response.json(newPost)
} catch (error) {
return new Response(error as BodyInit, {
status: 500,
})
}
}

จาก code

  • ฝั่ง POST ไม่ได้มีการปรับอะไรมากนอกจากเพิ่ม category เข้ามา และนำ category ไปสร้าง Post
  • ฝั่ง GET เพิ่มเติมตัวกรองข้อมูลเสริมเข้าไป ไม่ว่าจะเป็น category, search query และลำดับการแสดงผล (sort order) โดย function นี้จะอ่าน parameter ที่มาจากใน URL ของ request เพื่อเอามาใช้เป็นตัวกรอง ดังนี้
    • category กรอง Post ตามหมวดหมู่
    • search ค้นหาชื่อหัวข้อของ Post โดยจะค้นหาแบบ insensitive case (ไม่สนใจตัวเล็ก-ใหญ่)
    • sort กำหนดลำดับการแสดงผลตามข้อมูล timestamp ใน createdAt โดยค่าตั้งต้นจะเป็นเรียงแบบใหม่สุดไปเก่าสุด (desc)
  • โดยตัว function จะสร้าง object whereCondition ตามค่า category ที่ระบุมา จากนั้น function ก็จะไปค้นหา Post ในฐานข้อมูลด้วยคำสั่ง prisma.post.findMany โดยอาศัย where condition และข้อมูลในการเรียงลำดับ (orderBy) ที่สร้างขึ้นมา

และด้วยการเพิ่มสิ่งนี้เข้าไป ก็ทำให้ API Get /api/posts ก็รองรับการ search, sort และ filter (จาก category) ออกมาได้นั่นเอง step ต่อไปเราจะนำ API เหล่านี้ไปใช้กับฝั่ง UI กัน

เพิ่มส่วน Frontend

เนื่องจากมีการเพิ่ม category เข้ามา ดังนั้นทั้ง 3 หน้าเองก็ต้องเพิ่มการใช้งาน category เข้ามา โดยไอเดียคือ

  • ทั้ง 3 หน้าจะเพิ่ม dropdown category แบบ fixed (แบบ hard code ใส่เอาไว้)
  • ใน หน้าแสดง Post ทั้งหมด
  • ใน หน้าสร้าง / แก้ไข Post ให้ทำการดึง category และ map กับ dropdown เพื่อให้สามารถ สร้าง / แก้ไข category Post ได้

เราจะเริ่มจากหน้าสร้างและแก้ไขกันก่อน โดยทั้ง 2 files เราจะแก้ไขตามนี้

เริ่มที่ app/create/page.tsx

pages/create.tsx
'use client'
import React, { useState } from 'react'
import axios from 'axios'
import { useRouter } from 'next/navigation'
const Create = () => {
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [category, setCategory] = useState('')
const router = useRouter()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
try {
// ทำการส่ง category
await axios.post('/api/posts', { title, content, category })
router.push('/')
} catch (error) {
console.error(error)
}
}
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<h1 className="text-2xl font-semibold mb-6">Create a New Post</h1>
<form onSubmit={handleSubmit} className="space-y-6">
<!-- code เหมือนเดิม เพิ่ม category เข้ามา -->
<div>
<label>Category</label>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
>
<option value="">Select a category</option>
{/* Example static categories, replace or populate dynamically */}
<option value="Tech">Tech</option>
<option value="Lifestyle">Lifestyle</option>
</select>
</div>
<div>
<button
type="submit"
className="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Submit
</button>
</div>
</form>
</div>
)
}
export default Create

และที่ app/edit/[id]/page.tsx

'use client'
import React, { useEffect, useState } from 'react'
import axios from 'axios'
import { useRouter } from 'next/navigation'
const Edit = ({ params }: { params: { id: string } }) => {
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [category, setCategory] = useState('')
const router = useRouter()
const { id } = params
const fetchPost = async (id: Number) => {
try {
const res = await axios.get(`/api/posts/${id}`)
setTitle(res.data.title)
setContent(res.data.content)
setCategory(res.data.category || '')
} catch (error) {
console.error(error)
}
}
useEffect(() => {
if (id) {
fetchPost(parseInt(id))
}
}, [id])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
try {
await axios.put(`/api/posts/${id}`, {
title,
content,
category,
})
router.push('/')
} catch (error) {
console.error(error)
}
}
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<h1 className="text-2xl font-semibold mb-6">Edit Post {id}</h1>
<form onSubmit={handleSubmit} className="space-y-6">
<!-- code เหมือนเดิม -->
<div>
<label>Category</label>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
>
<option value="">Select a category</option>
{/* Populate categories as needed */}
<option value="Tech">Tech</option>
<option value="Lifestyle">Lifestyle</option>
</select>
</div>
<div>
<button
type="submit"
className="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Update
</button>
</div>
</form>
</div>
)
}
export default Edit

โดยทั้ง Create และ Edit นั้นทำการเพิ่ม Dropdown ของ Category เข้ามาและทำการเพิ่ม category field เข้าไปในเส้นของการ POST / PUT /api/posts เหมือนกัน

ต่อมาที่หน้าแสดง Post ทั้งหมด เราจะทำการเพิ่ม search, filter และ sort เข้าไปในหน้า Post

'use client'
import React, { useEffect, useState } from 'react'
import axios from 'axios'
import Link from 'next/link'
const List = () => {
const [posts, setPosts] = useState([])
const [search, setSearch] = useState('')
const [category, setCategory] = useState('')
const [sort, setSort] = useState('desc')
useEffect(() => {
fetchPosts()
}, [])
const fetchPosts = async () => {
try {
const query = new URLSearchParams({ category, search, sort }).toString()
const res = await axios.get(`/api/posts?${query}`)
setPosts(res.data)
} catch (error) {
console.error(error)
}
}
// Event handler for the Apply button
const handleApplyFilters = () => {
fetchPosts()
}
const deletePost = async (id: Number) => {
try {
await axios.delete(`/api/posts/${id}`)
fetchPosts()
} catch (error) {
console.error('Failed to delete the post', error)
}
}
return (
<div className="max-w-6xl mx-auto px-4 py-8">
<h1 className="text-2xl font-semibold mb-6">Blog Posts</h1>
<div className="flex justify-between items-center mb-6">
<div className="flex gap-4">
<input
type="text"
placeholder="Search by title..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="">Select Category</option>
<option value="Tech">Tech</option>
<option value="Lifestyle">Lifestyle</option>
</select>
<select
value={sort}
onChange={(e) => setSort(e.target.value)}
className="px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="desc">Latest</option>
<option value="asc">Oldest</option>
</select>
<button
onClick={handleApplyFilters}
className="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 transition-colors"
>
Apply
</button>
</div>
</div>
<div className="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
<!-- ส่วนตารางแสดงผลยังเหมือนเดิม -->
</div>
<Link
className="mt-4 inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
href="/create"
>
Create a New Post
</Link>
</div>
)
}
export default List

จาก code ได้มีการเพิ่มเติม 2 ส่วนเข้ามา

  • เพิ่มส่วน useEffect Hook โดยทันทีที่ component ถูกแสดงผล component จะดึงข้อมูล posts โดยเรียกใช้ function fetchPosts ซึ่ง function นี้จะสร้าง query string โดยใช้ค่าปัจจุบันของตัวแปร category, search และ sort แล้วส่ง GET request ไปยัง /api/posts พร้อมค่า parameter เหล่านี้เข้าไปด้วย จากนั้นข้อมูล posts ที่ได้รับ ก็จะถูกเก็บไว้ใน state ที่ชื่อว่า posts ออกมาได้
  • โดยส่วนของการ search และ sort นั้น ที่ component นี้จะมีช่องกรอกข้อมูลและตัวเลือก เพื่อให้ผู้ใช้สามารถใส่คำค้นหา เลือกหมวดหมู่ และเลือกวิธีการเรียงลำดับได้ โดย function handleApplyFilters ซึ่งจะทำงานเมื่อผู้ใช้กดปุ่ม “Apply” จะทำการดึงข้อมูล posts มาใหม่อีกครั้งตามค่า filters และ sort ที่ระบุไว้

และเมื่อลอง run หน้าเว็บออกมา ผลลัพธ์ก็จะออกมาเหมือนกันกับตอนแรกออกมาได้

ทำ Relation Query

นอกเหนือจาก query ทั่วไปแล้ว การเล่นกับ relational database เองยังสามารถเล่นกับ relation ระหว่าง table ออกมาได้เช่นเดียวกัน โดยตัวอย่างนี้ เราจะลองเพิ่มเติมโดยการเปลี่ยน category จากแต่เดิมเป็นการ fix ค่าเอาไว้ ให้ดึงผ่าน database มาแทน โดย

  • เราจะทำการปรับ category จากแต่เดิมที่เป็น string ใน Post ปรับเป็น categoryId ที่อ้างอิงจากตารางใหม่ที่จะสร้างขึ้นมาแทน
  • ตารางใหม่ที่สร้างขึ้นมาคือตาราง category

ปรับ schema ใน prisma/schema.prisma เป็น

datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// Define the Prisma Client generator
generator client {
provider = "prisma-client-js"
}
model Post {
id Int @id @default(autoincrement())
title String
content String?
categoryId Int
category Category @relation(fields: [categoryId], references: [id])
createdAt DateTime @default(now())
}
model Category {
id Int @id @default(autoincrement())
name String @unique
posts Post[]
}

ทีนี้เพื่อไม่ให้มี field category ซ้ำซ้อน เราจะทำการเคลียร์ field category และสร้าง field ใหม่เป็น categoryId แทน (เป็นการเปลี่ยน category ให้เก็บเป็น relation แทน) ส่งผลให้เมื่อ run migrate ด้วยคำสั่งเดิม

npx prisma migrate dev

ก็จะเจอคำถามว่า ขอเคลียร์ data แทนเนื่องจาก schema ของ data เปลี่ยนไปแล้วเรียบร้อย

prisma-09.webp

เมื่อตอบ yes ไป ก็จะทำการ run migrate ใหม่โดยการเคลียร์ data ใหม่ทั้งหมดแทน เพียงเท่านี้ก็จะเป็นการเคลียร์ข้อมูลเก่าและ

prisma-10.webp

กรณี นี้จะเกิดขึ้นเมื่อ table ที่มี column เดิมที่เคยสร้างอยู่มีการเปลี่ยนแปลงบางอย่างเกิดขึ้น (จริงๆมีหลายเคสาก) ดังนั้น หากมีการแก้ไข column เดิมใน table เดิม ขอให้ศึกษาเพิ่มเติมก่อนที่จะตัดสินใจลบ data ออกนะครับ

และหากทุกอย่างทำอย่างถูกต้อง ผลลัพธ์ก็จะออกมาเป็นตามนี้ได้ มี table เพิ่มมาเป็น 2 ตารางและ column ก็ของ Post ก็จะโดนแก้ไขไปเป็นแบบนี้แทน

prisma-12.webp

เพิ่ม Category CRUD API

เราจะมาเพิ่ม API ส่วน Category กัน โดยรอบนี้ เราจะให้จัดการ Category ได้ผ่าน API เท่านั้น เราเลยจะเพิ่ม API มาแค่ 4 เส้นคือ

  • GET /api/categories get category ทั้งหมด
  • POST /api/categories สร้าง category
  • PUT /api/categories/:id แก้ไข category
  • DELETE /api/categories/:id ลบ category

ดังนั้น เคสก็จะคล้ายๆเดิม สร้าง 2 file ที่ categories เพิ่มเข้ามาสำหรับทำ API 2 เส้นเพิ่มเติม

├── app
├── api
├── categories --> ส่วนที่เพิ่มเข้ามา
│ ├── [id]
│ │ └── route.ts
│ └── route.ts
└── posts
├── [id]
│ └── route.ts
└── route.ts

ที่ api/categories/route.ts ทำการเพิ่ม API สำหรับ GET ทั้งหมด และ สร้าง category ออกมา

import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export async function GET() {
try {
const categories = await prisma.category.findMany()
return Response.json(categories)
} catch (error) {
return new Response(error as BodyInit, {
status: 500,
})
}
}
export async function POST(req: Request) {
try {
const { name } = await req.json()
const newCategory = await prisma.category.create({
data: {
name
},
})
return Response.json(newCategory)
} catch (error) {
return new Response(error as BodyInit, {
status: 500,
})
}
}

ที่ api/categories/[id]/route.ts ทำการเพิ่ม API สำหรับแก้ไขและลบ Category (จะเพิ่มเคส get รายตัวเหมือน Post ก็ได้ แต่เนื่องจาก Category เป็นข้อมูลที่มีไม่เยอะมาก และไม่ได้มีเคสใช้งานจริงที่ฝั่ง Frontend ดังนั้นเราจึงไม่ได้มีการเพิ่มเข้าไป)

import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export async function PUT(
req: Request,
{ params }: { params: { id: string } }
) {
try {
const { name } = await req.json()
const category = await prisma.category.update({
where: { id: Number(params.id) },
data: { name },
})
return Response.json(category)
} catch (error) {
return new Response(error as BodyInit, {
status: 500,
})
}
}
export async function DELETE(
req: Request,
{ params }: { params: { id: string } }
) {
try {
return Response.json(
await prisma.category.delete({
where: { id: Number(params.id) },
})
)
} catch (error) {
return new Response(error as BodyInit, {
status: 500,
})
}
}

หลังจากนั้นให้ลองยิง API ทั้ง 4 เส้นดู ถ้าสามารถสร้างได้และ get category ออกมาได้ (เหมือนกับ post) = การเพิ่มของเราเสร็จสิ้นเรียบร้อย

prisma-11.webp

เพิ่ม Relation ใน CRUD Post API

PostintidPKThe primary keystringtitleThe title of the poststringcontentThe content of the post, nullableintcategoryIdFKThe foreign key to Category, nullabledateTimecreatedAtThe creation timestampCategoryintidPKThe primary keystringnameThe name of the category, uniquehas

เราจะกลับมาที่ API เส้นเดิมกันบ้างของ Post เนื่องจาก category ตอนนี้โดนเปลี่ยน field ไปเป็นเก็บ relation เป็นที่เรียบร้อย ดังนั้นใน code ของ POST API เองก็ต้องแก้ไขเช่นเดียวกัน

ที่ 2 files ของ API จะต้องแก้ไขเป็นตามนี้เพื่อให้สามารถดึง category จาก table ข้างเคียงออกมาได้

ที่ api/posts/[id]/route.ts โดยใน code นี้มีเปลี่ยนแค่การใช้ field จาก category มาเป็น categoryId แทน

import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
/* เปลี่ยนเฉพาะ PUT */
export async function PUT(
req: Request,
{ params }: { params: { id: string } }
) {
try {
// เปลี่ยนเป็น categoryId
const { title, content, categoryId } = await req.json()
const post = await prisma.post.update({
where: { id: Number(params.id) },
data: { title, content, categoryId },
})
return Response.json(post)
} catch (error) {
return new Response(error as BodyInit, {
status: 500,
})
}
}

ที่ api/posts/route.ts

import { type NextRequest } from 'next/server'
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export async function GET(req: NextRequest) {
const searchParams = req.nextUrl.searchParams
const category = searchParams.get('category')
const search = searchParams.get('search') || ''
const sort = searchParams.get('sort') || 'desc'
const whereCondition = category
? {
category: {
is: {
name: category,
},
},
title: {
contains: search,
mode: 'insensitive',
},
}
: {
title: {
contains: search,
mode: 'insensitive',
},
}
try {
const posts = await prisma.post.findMany({
where: whereCondition,
include: {
category: true, // Include category data in the response
},
orderBy: {
createdAt: sort,
},
})
return Response.json(posts)
} catch (error) {
return new Response(error as BodyInit, {
status: 500,
})
}
}
export async function POST(req: Request) {
try {
const { title, content, categoryId } = await req.json()
const newPost = await prisma.post.create({
data: {
title,
content,
categoryId,
},
})
return Response.json(newPost)
} catch (error) {
return new Response(error as BodyInit, {
status: 500,
})
}
}

จาก code

  • ฝั่ง GET เพิ่มเติมจาก condition เดิมโดยการเปลี่ยนจากแต่เดิมหา category ใน Post เอง ให้ไปหาใน table category แทนผ่าน is ใน where condition โดยคุยผ่าน relation category ไป
  • และตัวดึงข้อมูลผ่าน prisma.post.findMany ให้เพิ่ม include: { category: true } เข้าไปเพื่อให้สามารถดึงข้อมูล category มาใช้ร่วมกันกับ Post ได้ เพื่อให้สามารถดึงข้อมูล category จากอีกตารางมาแสดงที่ข้อมูล JSON เดียวกันได้
  • ส่วนฝั่ง POST ก็แค่เพิ่ม categoryId เข้าไปเพื่อให้สามารถ save category เข้ามาได้

เพื่อทดลองให้ถูกต้อง ให้ลองสร้าง post โดยใช้ categoryId และ get post ออกมา หากสามารถดึงข้อมูล category ออกมาได้ก็ถือว่าเป็นอันถูกต้องแล้ว

prisma-13.webp

ปรับใช้กับฝั่ง Frontend

สุดท้ายที่ฝั่ง Frontend แม้จะหน้าตา UI ออกมาเหมือนเดิมก็ตาม แต่ category ได้มีการเปลี่ยนจาก hard code มาเป็นดึงจาก API แทน ดังนั้นโจทย์ของฝั่ง Frontend คือ

  • เปลี่ยนมาใช้ category ผ่าน field ใหม่ที่ mapping ออกมา
  • ตรง dropdown เปลี่ยนไปใช้ category จาก API
  • ฝั่ง สร้าง / แก้ไข เปลี่ยนเป็น categoryId สำหรับการส่ง save แทน

ดังนั้น code 3 ส่วนก็จะประมาณนี้เริ่มจากส่วนของ สร้างและแก้ไข ก่อน

ที่ app/create/page.tsx

pages/create.tsx
'use client'
import { useState, useEffect } from 'react'
import axios from 'axios'
import { useRouter } from 'next/navigation'
const Create = () => {
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [categoryId, setCategoryId] = useState('') // Updated to use categoryId
const [categories, setCategories] = useState([]) // State to hold categories
const router = useRouter()
const fetchCategories = async () => {
try {
const response = await axios.get('/api/categories')
setCategories(response.data)
} catch (error) {
console.error('Failed to fetch categories', error)
}
}
// เพิ่มส่วนดึง category ออกมา
useEffect(() => {
// Fetch categories when the component mounts
fetchCategories()
}, [])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
try {
// เปลี่ยนมารับ categoryId แทน
const numCategoryId = parseInt(categoryId)
await axios.post('/api/posts', { title, content, categoryId: numCategoryId })
router.push('/')
} catch (error) {
console.error(error)
}
}
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<h1 className="text-2xl font-semibold mb-6">Create a New Post</h1>
<form onSubmit={handleSubmit} className="space-y-6">
<!-- เปลี่ยนแค่ส่วน mapping category -->
<div>
<label
htmlFor="category"
className="block text-sm font-medium text-gray-700"
>
Category
</label>
<select
id="category"
value={categoryId}
onChange={(e) => setCategoryId(e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
>
<option value="">Select a category</option>
{categories.map((category: any) => (
<option key={category.id} value={category.id}>
{category.name}
</option>
))}
</select>
</div>
</form>
</div>
)
}
export default Create

ที่ app/edit/[id]/page.tsx

'use client'
import React, { useEffect, useState } from 'react'
import axios from 'axios'
import { useRouter } from 'next/navigation'
const Edit = ({ params }: { params: { id: string } }) => {
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [categoryId, setCategoryId] = useState('') // Updated to use categoryId
const [categories, setCategories] = useState([]) // State to hold categories
const router = useRouter()
const { id } = params
// เพิ่มการดึง category
const fetchCategories = async () => {
try {
const response = await axios.get('/api/categories')
setCategories(response.data)
} catch (error) {
console.error('Failed to fetch categories', error)
}
}
const fetchPost = async (id: Number) => {
try {
const res = await axios.get(`/api/posts/${id}`)
setTitle(res.data.title)
setContent(res.data.content)
setCategoryId(res.data.categoryId || '')
} catch (error) {
console.error(error)
}
}
useEffect(() => {
if (id) {
fetchPost(parseInt(id))
fetchCategories()
}
}, [id])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
try {
const numCategoryId = parseInt(categoryId)
console.log(numCategoryId)
await axios.put(`/api/posts/${id}`, {
title,
content,
categoryId: numCategoryId,
})
router.push('/')
} catch (error) {
console.error(error)
}
}
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<h1 className="text-2xl font-semibold mb-6">Edit Post {id}</h1>
<form onSubmit={handleSubmit} className="space-y-6">
<!-- เปลี่ยนมาดึง category ผ่าน API และ map id แทน -->
<div>
<label
htmlFor="category"
className="block text-sm font-medium text-gray-700"
>
Category
</label>
<select
id="category"
value={categoryId}
onChange={(e) => setCategoryId(e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
>
<option value="">Select a category</option>
{categories.map((category: any) => (
<option key={category.id} value={category.id}>
{category.name}
</option>
))}
</select>
</div>
</form>
</div>
)
}
export default Edit

และสุดท้ายที่หน้าแสดง Post ทั้งหมด app/page.tsx

'use client'
import React, { useEffect, useState } from 'react'
import axios from 'axios'
import Link from 'next/link'
const List = () => {
const [posts, setPosts] = useState([])
const [categories, setCategories] = useState([])
const [search, setSearch] = useState('')
const [category, setCategory] = useState('')
const [sort, setSort] = useState('desc')
useEffect(() => {
fetchPosts()
fetchCategories()
}, [])
const fetchPosts = async () => {
try {
const query = new URLSearchParams({ category, search, sort }).toString()
const res = await axios.get(`/api/posts?${query}`)
setPosts(res.data)
} catch (error) {
console.error(error)
}
}
// เพิ่มการดึง category ผ่าน API เพื่อใช้สำหรับ filter
const fetchCategories = async () => {
try {
const res = await axios.get('/api/categories')
setCategories(res.data)
} catch (error) {
console.error('Failed to fetch categories', error)
}
}
return (
<div className="max-w-6xl mx-auto px-4 py-8">
<h1 className="text-2xl font-semibold mb-6">Blog Posts</h1>
<div className="flex justify-between items-center mb-6">
<div className="flex gap-4">
<!-- เปลี่ยนมาใช้ category ผ่าน API แทน -->
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="">Select Category</option>
{categories.map((cat: any) => (
<option key={cat.id} value={cat.name}>
{cat.name}
</option>
))}
</select>
</div>
</div>
<div className="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<!-- เหมือนเดิม -->
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{posts.map((post: any) => (
<tr key={post.id}>
<!-- เปลี่ยนมาดึง category ผ่าน key category.name -->
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{post.category.name}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
export default List

และนี่คือผลลัพธ์ทั้งหมดของเรื่องราวนี้

blog-06.gif

สังเกตว่า เราก็จะสามารถหยิบ Category จาก API มาได้และผลลัพธ์ก็จะออกมาเหมือนเดิมกับตอนที่เรา set category ผ่าน Post ไว้ได้

และนี่คือตัวอย่างของการใช้ relation ระหว่างกัน จะสังเกตว่า เราปรับ code จากเดิม ไม่เยอะเลย และเรายังคงสามารถใช้คำสั่งเดิมในการดึงข้อมูลออกมาได้เช่นกัน

เพิ่มเติม API ดึงผ่าน Relation ของ Category

รวมถึงการ relation สามารถทำได้ทั้ง 2 ฝั่งเช่นกัน ในเคสด้านบนคือ เราสามารถดึง category ที่ติดอยู่กับ Post ออกมาได้ ทีนี้ หากเราทำกลับกันคือ ดึง Post ที่อยู่ใน category ออกมาแทน ก็สามารถทำได้เช่นเดียวกัน

เช่น สมมุติเราลองเพิ่ม API GET /api/categories/[id]/posts โดยทำการ list post จากภายใน category นั้นออกมา ก็สามารถทำได้ผ่านคำสั่ง includes ตัวเดิมเช่นกัน เช่นแบบนี้

app/api/categories/[id]/posts/route.ts
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export async function GET(
req: Request,
{ params }: { params: { id: string } }
) {
try {
const categoryId = Number(params.id)
const categoryWithPosts = await prisma.category.findUnique({
where: { id: categoryId },
include: {
posts: true, // Include related posts in the response
},
})
return Response.json(categoryWithPosts)
} catch (error) {
return new Response(error as BodyInit, {
status: 500,
})
}
}

เมื่อลองดูผลลัพธ์ผ่าน Postman ก็จะได้ post ทั้งหมดของ category นั้นออกมาได้

prisma-14.webp

และนี่ก็คือตัวอย่าง Prisma ทั้งหมดภายในบทความนี้

เพิ่มเติมและสรุป

อย่างที่เราเห็น Prisma คือเครื่องมือ ORM (Object Relational Mapping) ที่เปี่ยมไปด้วยความสามารถมากมาย ถูกออกแบบมาให้ทำงานได้อย่างราบรื่นกับระบบฐานข้อมูลหลายประเภท นอกเหนือจากที่ Prisma จะรองรับ PostgreSQL ในปัจจุบัน Prisma ก็ได้ขยายขอบเขตความสามารถในการทำงานร่วมกับฐานข้อมูลหลักๆ แบบอื่นๆ ด้วย เช่น MySQL, SQLite, SQL Server รวมถึง MongoDB (ที่เป็น NoSQL) เพื่อตอบสนองความต้องการที่หลากหลายในการจัดการฐานข้อมูล

การที่สามารถทำงานร่วมกับฐานข้อมูลได้หลากหลาย ทำให้นักพัฒนาสามารถใช้ประโยชน์จากความสามารถต่างๆ ของ Prisma ได้เต็มที่ ตั้งแต่การสร้าง model ข้อมูลที่เข้าใจง่าย ระบบจัดการ migration และ client ที่มีระบบตรวจสอบชนิดข้อมูลเพื่อใช้ในการ query ข้อมูลต่างๆ โดยไม่ต้องกังวลว่าเราจะใช้เทคโนโลยีฐานข้อมูลอะไรอยู่เบื้องหลัง Prisma ช่วยลดความซับซ้อนในขั้นตอนการพัฒนาโปรแกรมลงได้ และเพิ่มความยืดหยุ่นรองรับความเปลี่ยนแปลงของฐานข้อมูลที่จะเกิดขึ้นในอนาคตได้อีกด้วย เช่นกัน

สำหรับใครที่สนใจเรื่องการนำ deploy จริงขอแนะนำบทความของ vercel เพิ่มเติม โดยจะแนะนำการใช้งานร่วมกับ Vercel Postgres ไว้ในบทความนี้ด้วย https://vercel.com/guides/nextjs-prisma-postgres

รวมถึง การต่อกับ MongoDB สามารถอ่านเพิ่มเติมผ่านบทความนี้เช่นเดียวกัน

https://www.prisma.io/docs/getting-started/setup-prisma/start-from-scratch/mongodb/connect-your-database-typescript-mongodb

หวังว่าทุกคนจะมีโอกาสได้ประยุกต์ใช้ Prisma กับ Project ตัวเองกันนะครับ


Related Post
  • รู้จักรูปแบบ Authentication ระหว่าง Frontend และ Backend
    มี Video มี Github
    เราจะพามาทำ Authentication กับการ Login กัน ว่ามีกี่วิธีที่สามารถทำได้ และสามารถทำได้ยังไงกันบ้าง ซึ่งจะพาทำกันตั้งแต่ฝั่งของ API Backend
  • Astro และ Static site generator
    มี Video
    มารู้จักกับ Astro Framework สำหรับทำเว็บ Static สำหรับเว็บทำ Content โดยเฉพาะกัน
  • Rabbit MQ และการใช้ Message Queue
    มี Video
    มาทำความรู้จักกับ Message Queue ว่ามันคืออะไร มีหลักการยังไงบ้าง และมาลองเล่นกันผ่าน software อย่าง RabbitMQ กัน
  • ลองเล่น Stripe payment gateway กัน
    มี Video มี Github
    แนะนำ Stripe payment gateway ที่สามารถทำให้เราทำเว็บชำระเงินออกมาได้

Share on social media