본문 바로가기

[IT/Programming]/Algorithm/Database

prisma with PostgreSQL 를 배워봅시다.

728x90
반응형
# prisma with PostgreSQL 를 배워봅시다. ## PH
  • 2024-09-15
## TOC ## Prisma 초기화 ```[.scrollable] npx prisma init --datasource-provider postgresql ```/
.env 파일 설정하기. 아이디는 자동으로 postgres 로 정해지고 아래에 [password] 는 자신이 설정한 비밀번호를 넣어주고 (비번에 특수문자가 들어간 경우 encodeURIComponent kipid's blog :: Encode/Unescape and Decode/Escape URI Component 함수로 한번 처리해 준 뒤 넣어줘야 함. #, ? 같은게 URL 에선 특수하게 쓰이니...), [database_name] 에는 사용할 DB name 을 입력해준다. 없는 DB 라도 prisma 가 자동으로 생성해주니 이전에 만들었던 DB 를 이용하는 경우라면 오타가 안나게 조심할 것.
macOS 서는 컴퓨터 유저 이름과 비밀번호, Windows 에서는 postgres 와 설치 시 설정한 비밀번호를 사용. ```[.scrollable] DATABASE_URL="postgresql://postgres:[password]@localhost:5432/[database_name]?schema=public" PORT=3000 ```/ 아래와 같은 ./prisma/schema.prisma 가 자동으로 생성되면 초기화 완성. Domain Specific Language: DSL (스키마 정의 전용 언어, 나름의 문법) 사용. ```[.scrollable] // This is your Prisma schema file, // learn more about it in the docs: https://pris.ly/d/prisma-schema // Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? // Try Prisma Accelerate: https://pris.ly/cli/accelerate-init generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } ```/ ## Schcema.prisma 설정 VS code 에선 Ctrl+Shift+P 후 명령어로 Format Document 를 눌러서 자동 formatting 을 해주면 좋음. (바로가기 단축키는 Shift+Alt+F) ```[.scrollable] // 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") } model User { id String @id @default(uuid()) email String @unique firstName String lastName String address String age Int createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@unique([firstName, lastName]) // 여러 필드의 조합이 unique 해야 하는 경우 @@unique 어트리뷰트를 사용할 수 있습니다. @@unique 어트리뷰트는 특정 필드에 종속된 어트리뷰트가 아니기 때문에 모델 아래 부분에 씁니다. } // Int, Float, Boolean <- RDBMS // Nullable NULL 이 들어갈 수 있다. - 선택적 필드 // required -> Non-nullable NULL 이 들어갈 수 없다. - 필수 필드 enum Category { FASHION BEAUTY SPORTS ELECTRONICS HOME_INTERIOR HOUSEHOLD_SUPPLIES KITCHENWARE } model Product { id String @id @default(uuid()) name String description String? category Category price Float stock Int createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } ```/
새로운 field 를 추가할 땐, @default([value]) 를 주거나 optional field (Nuallable) 로 만든 뒤 값을 수동으로 채우고 다시 required field (Non-nullable) 로 바꿔줘야 한다. ./migrations directory 에 Schema 를 바꾼 기록이 모두 남아있다. 지우지 않는것이 좋음.
## migration ```[.scrollable] npx prisma migrate dev ```/ Schema 를 변경한 뒤에는 항상 실행해줘야 함. Dev 환경에서만 사용하길 권함 https://www.prisma.io/docs/orm/prisma-migrate/workflows/development-and-production. ## GUI (Graphical User Interface): npx prisma studio ```[.scrollable] npx prisma studio ```/ ## CRUD (Create Retrieve Update Delete) ```[.scrollable] import express from 'express'; import { PrismaClient } from '@prisma/client'; import * as dotenv from 'dotenv'; dotenv.config(); const prisma = new PrismaClient(); // PostgreSQL 에 접속할 수 있게 해주는 client 객체. 자동완성이 잘 되어 있으니 잘 활용할 것. const HttpStatus = Object.freeze({ SUCCESS: 200, CREATED: 201, ACCEPTED: 202, NON_AUTHORITATIVE_INFORMATION: 203, NO_CONTENT: 204, BAD_REQUEST: 400, UNAUTHORIZED: 401, FORBIDDEN: 403, NOT_FOUND: 404, SERVER_ERROR: 500, }); const app = express(); app.use(express.json()); const asyncHandler = (handler) => { return (async function (req, res) { try { await handler(req, res); } catch (err) { console.log(err.name); console.log(err.message); if (err.name === "ValidationError") { res.status(HttpStatus.BAD_REQUEST).send({message: err.message}); } else if (err.name === "CastError") { res.status(HttpStatus.NOT_FOUND).sned({message: err.message}); } else { res.status(HttpStatus.SERVER_ERROR).send({message: err.message}); } } }); }; app.get('/users', asyncHandler(async (req, res) => { console.log("req.query: ", req.query); const { offset = 0, limit = 10, order = "newest" } = req.query || {}; let orderBy; switch (order) { case "oldest": orderBy = { createdAt: 'asc' }; break; case "newest": default: orderBy = { createdAt: 'desc' }; } const users = await prisma.user.findMany({ orderBy, skip: parseInt(offset), take: parseInt(limit), }); res.send(users); })); app.get('/users/:id', asyncHandler(async (req, res) => { const { id } = req.params; const user = await prisma.user.findUnique({ where: { id }, }); res.send(user); })); app.post('/users', asyncHandler(async (req, res) => { const user = await prisma.user.create({ data: req.body }); res.status(201).send(user); })); app.patch('/users/:id', asyncHandler(async (req, res) => { const { id } = req.params; const user = await prisma.user.update({ where: { id }, data: req.body, }); await user.update(req.body); res.send(user); })); app.patch('/users', asyncHandler(async (req, res) => { const users = await prisma.user.update({ where: req.params, data: req.body, }); await users.update(req.body); res.send(users); })); app.delete('/users/:id', asyncHandler(async (req, res) => { const { id } = req.params; await prisma.user.delete({ where: { id }, }); res.sendStatus(204); })); /*********** products ***********/ app.get('/products', asyncHandler(async (req, res) => { const { offset = 0, limit = 10, order = "newest", category } = req.query; let orderBy; switch (order) { case "oldest": orderBy = { createdAt: 'asc' }; break; case "newest": default: orderBy = { createdAt: 'desc' }; } const products = await prisma.product.findMany({ orderBy, skip: parseInt(offset), take: parseInt(limit), where: category ? { category } : {}, }); res.send(products); })); app.get('/products/:id', asyncHandler(async (req, res) => { const { id } = req.params; const product = await prisma.product.findUnique({ where: { id }, }); res.send(product); })); app.post('/products', asyncHandler(async (req, res) => { const product = await prisma.product.create({ data: req.body }); res.status(201).send(product); })); app.patch('/products/:id', asyncHandler(async (req, res) => { const { id } = req.params; const product = await prisma.product.update({ where: { id }, data: req.body, }); res.send(product); })); app.delete('/products/:id', asyncHandler(async (req, res) => { const { id } = req.params; await prisma.product.delete({ where: { id }, }); res.sendStatus(204); })); app.listen(process.env.PORT || 3000, () => console.log('Server Started')); ```/ ### Request 보내보기 VS code extension 중 뭘 깔아야 이게 실행되는 거였더라? (REST Client https://github.com/Huachao/vscode-restclient 인가?) =ㅇ=;; 아무튼 다음과 같은 .http 파일을 만들면 CRUD 를 테스트 할 수 있음. ```[.scrollable] GET http://localhost:3000/users ### GET http://localhost:3000/users?order=newest&offset=1&limi=15 ### GET http://localhost:3000/users/6f3182a9-c20b-4c8b-aefd-c1b2f2fc35d5 ### POST http://localhost:3000/users Content-Type: application/json { "email": "yjkim@example.com", "firstName": "유진", "lastName": "김", "address": "충청북도 청주시 북문로 210번길 5", "age": 23 } ### PATCH http://localhost:3000/users/6f3182a9-c20b-4c8b-aefd-c1b2f2fc35d5 Content-Type: application/json { "address": "서울특별시 강남구 무실로 234번길 45-6" } ### PATCH http://localhost:3000/users?order=newest Content-Type: application/json { "address": "서울특별시 강남구 무실로 234번길 45-6" } ### DELETE http://localhost:3000/users/6f3182a9-c20b-4c8b-aefd-c1b2f2fc35d5 ```/ ## DB seeding: npx prisma db seed ```[.scrollable] { "dependencies": { "@prisma/client": "^5.19.1", "dotenv": "^16.3.1", "express": "^4.18.2", "is-email": "^1.0.2", "is-uuid": "^1.0.2", "superstruct": "^1.0.3" }, "devDependencies": { "nodemon": "^3.0.1", "prisma": "^5.19.1" }, "type": "module", "scripts": { "dev": "nodemon app.js", "start": "node app.js" }, "prisma": { "seed": "node prisma/seed.js" } } ```/ 와 같이 package.json 을 설정해 주고. prisma/seed.js file 을 다음과 같이 만들어주자. ```[.scrollable] import { PrismaClient } from '@prisma/client'; import { USERS, PRODUCTS } from './mock.js'; const prisma = new PrismaClient(); async function main() { await prisma.user.deleteMany(); await prisma.user.createMany({ data: USERS, skipDuplicates: true, }); await prisma.product.deleteMany(); await prisma.product.createMany({ data: PRODUCTS, skipDuplicates: true, }); } main() .then(async () => { await prisma.$disconnect(); }) .catch(async (e) => { console.error(e); await prisma.$disconnect(); process.exit(1); }); ```/ 그러면 다음과 같은 명령어로 DB seeding 을 할 수 있다. ```[.scrollable] npx prisma db seed ```/ ## findMany() 다음과 같은 설정들을 사용할 수 있다. ```[.scrollable] function asyncHandler(handler) { return async function (req, res) { try { await handler(req, res); } catch (e) { if ( e.name === 'StructError' || e instanceof Prisma.PrismaClientValidationError ) { res.status(400).send({ message: e.message }); } else if ( e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025' ) { res.sendStatus(404); } else { res.status(500).send({ message: e.message }); } } }; } /*********** users ***********/ app.get('/users', asyncHandler(async (req, res) => { const { offset = 0, limit = 10, order = 'newest' } = req.query; let orderBy; switch (order) { case 'oldest': orderBy = { createdAt: 'asc' }; break; case 'newest': default: orderBy = { createdAt: 'desc' }; } const users = await prisma.user.findMany({ orderBy, skip: parseInt(offset), take: parseInt(limit), // include: { // userPreference: { // select: { // receiveEmail: true, // } // } // }, select: { email: true, userPreference: { select: { receiveEmail: true, } }, }, }); res.send(users); })); ```/ include 와 select 는 동시에 못쓴다고 하니 주의. ### .findUnique(), .findUniqueOrThrow(), findFirst(), upsert(), .count() Client method 는 https://www.prisma.io/docs/orm/reference/prisma-client-reference#model-queries 참조. not, in, contains, startsWith 같은 다양한 비교 연산자 사용 가능. #### AND (여러 필터 조건을 모두 만족해야 하는 경우): where 안에 프로퍼티를 여러 개 쓰면 됩니다. ```[.scrollable] // category가 'FASHION'이면서 name에 '나이키'가 들어가는 Product들 필터 const products = await prisma.product.findMany({ where: { category: 'FASHION', name: { contains: '나이키', }, }, }); ```/ #### OR (여러 필터 조건 중 하나만 만족해도 되는 경우): OR 연산자를 사용하면 됩니다. ```[.scrollable] // name에 '아디다스'가 들어가거나 '나이키'가 들어가거는 Product들 필터 const products = await prisma.product.findMany({ where: { OR: [ { name: { contains: '아디다스', }, }, { name: { contains: '나이키', }, }, ], }, }); ```/ #### NOT (필터 조건을 만족하면 안 되는 경우): NOT 연산자를 사용하면 됩니다. ```[.scrollable] // name에 '삼성'이 들어가지만 'TV'는 들어가지 않는 Product들 필터 const products = await prisma.product.findMany({ where: { name: { contains: '삼성', }, NOT: { name: { contains: 'TV', }, }, }, }); ```/ #### 필터 조건에 대한 자세한 내용은 다음을 참고. 참고: https://www.prisma.io/docs/orm/reference/prisma-client-reference#filter-conditions-and-operators ## Superstruct Types 와 Refinements 참조: Types https://docs.superstructjs.org/api-reference/types, Refinements https://docs.superstructjs.org/api-reference/refinements 아래와 같이 작성. superstruct 라이브러리의 .string(), .number(), .integer(), .boolean(), .define(), .object(), .enums(), .array(), .partial() 타입들로 틀을 정의하고 .size(), .min(), .max() 함수로 제약을 추가. ```[.scrollable] import * as s from 'superstruct'; import isEmail from 'is-email'; import isUuid from 'is-uuid'; const CATEGORIES = [ 'FASHION', 'BEAUTY', 'SPORTS', 'ELECTRONICS', 'HOME_INTERIOR', 'HOUSEHOLD_SUPPLIES', 'KITCHENWARE', ]; const STATUSES = ['PENDING', 'COMPLETE']; const Uuid = s.define('Uuid', (value) => isUuid.v4(value)); export const CreateUser = s.object({ email: s.define('Email', isEmail), firstName: s.size(s.string(), 1, 30), lastName: s.size(s.string(), 1, 30), address: s.string(), userPreference: s.object({ receiveEmail: s.boolean(), }), }); export const PatchUser = s.partial(CreateUser); export const CreateProduct = s.object({ name: s.size(s.string(), 1, 60), description: s.string(), category: s.enums(CATEGORIES), price: s.min(s.number(), 0), stock: s.min(s.integer(), 0), }); export const PatchProduct = s.partial(CreateProduct); export const CreateOrder = s.object({ userId: Uuid, orderItems: s.size( s.array( s.object({ productId: Uuid, unitPrice: s.min(s.number(), 0), quantity: s.min(s.integer(), 1), }) ), 1, Infinity ), }); export const PatchOrder = s.object({ status: s.enums(STATUSES), }); export const CreateSavedProducts = s.object({ }); ```/ ### 데이터를 비교할 때는 assert() 함수를 사용. ```[.scrollable] import { assert } from 'superstruct'; import { CreateUser } from './structs.js'; // ... app.post('/users', async (req, res) => { assert(req.body, CreateUser); // CreateUser 형식이 아니라면 오류 발생 // ... }); ```/ ### 오류 처리 ```[.scrollable] import { PrismaClient, Prisma } from '@prisma/client'; // ... function asyncHandler(handler) { return async function (req, res) { try { await handler(req, res); } catch (e) { if ( e instanceof Prisma.PrismaClientValidationError || e.name === 'StructError' ) { res.status(400).send({ message: e.message }); } else if ( e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025' ) { res.sendStatus(404); } else { res.status(500).send({ message: e.message }); } } }; } // ... app.post('/users', asyncHandler(async (req, res) => { assert(req.body, CreateUser); // ... })); ```/
e.name === 'StructError': Superstruct 객체와 형식이 다를 경우 발생 e instanceof Prisma.PrismaClientValidationError: 데이터를 저장할 때 모델에 정의된 형식과 다른 경우 발생 (Superstruct로 철저히 검사하면 이 상황은 잘 발생하지 않지만 안전성을 위해 둘 다 검사) e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025': 객체를 찾을 수 없을 경우 발생
## 1:1 관계, 1:N 관계, N:M 관계 ### 일대다 관계 ```[.scrollable] model User { // ... orders Order[] } model Order { // ... user User @relation(fields: [userId], references: [id]) userId String } ```/ ### 일대일 관계 ```[.scrollable] model User { // ... userPreference UserPreference? } model UserPreference { // ... user User @relation(fields: [userId], references: [id]) userId String @unique } ```/ ### 다대다 관계 ```[.scrollable] model User { // ... savedProducts Product[] } model Product { // ... savedUsers User[] } ```/ ### 최소 카디널리티 ```[.scrollable] model User { // ... orders Order[] } model Order { // ... user User @relation(fields: [userId], references: [id]) userId String } ```/ ```[.scrollable] model User { // ... orders Order[] } model Order { // ... user User? @relation(fields: [userId], references: [id]) userId String? } ```/ ### onDelete 설정하기 참조: https://www.prisma.io/docs/orm/prisma-schema/data-model/relations/referential-actions ```[.scrollable] model Order { // ... user User @relation(fields: [userId], references: [id], onDelete: ...) userId String } ```/
Cascade: userId 가 가리키는 유저가 삭제되면 기존 데이터도 삭제됩니다. Restrict: userId 를 통해 유저를 참조하는 주문이 하나라도 있다면 유저를 삭제할 수 없습니다. SetNull: userId 가 가리키는 유저가 삭제되면 userId 를 NULL 로 설정합니다. user 와 userId 모두 옵셔널해야 합니다. SetDefault: userId 가 가리키는 유저가 삭제되면 userId 를 디폴트 값으로 설정합니다. userId 필드에 @default()를 제공해야 합니다. 관계 필드와 foreign key 가 필수일 경우 Restrict 가 기본값이고 옵셔널할 경우 SetNull 이 기본값입니다.
### Create, Update connected entities/tables. ```[.scrollable] /* create */ const postBody = { email: 'yjkim@example.com', firstName: '유진', lastName: '김', address: '충청북도 청주시 북문로 210번길 5', userPreference: { receiveEmail: false, }, }; const { userPreference, ...userFields } = postBody; const user = await prisma.user.create({ data: { ...userFields, userPreference: { create: userPreference, }, }, include: { userPreference: true, }, }); console.log(user); ```/ ```[.scrollable] /* update */ const id = 'b8f11e76-0a9e-4b3f-bccf-8d9b4fbf331e'; const patchBody = { email: 'honggd2@example.com', userPreference: { receiveEmail: false, }, }; const { userPreference, ...userFields } = patchBody; const user = await prisma.user.update({ where: { id }, data: { ...userFields, userPreference: { update: userPreference, }, }, include: { userPreference: true, }, }); console.log(user); ```/ ### 관련된 객체 연결, 연결 해제하기 관련된 객체 연결. ```[.scrollable] const userId = 'b8f11e76-0a9e-4b3f-bccf-8d9b4fbf331e'; const productId = 'c28a2eaf-4d87-4f9f-ae5b-cbcf73e24253'; const user = await prisma.user.update({ where: { id: userId }, data: { savedProducts: { connect: { id: productId, }, }, }, include: { savedProducts: true, }, }); console.log(user); ```/ 관련된 객체 연결 해제. ```[.scrollable] const userId = 'b8f11e76-0a9e-4b3f-bccf-8d9b4fbf331e'; const productId = 'c28a2eaf-4d87-4f9f-ae5b-cbcf73e24253'; const user = await prisma.user.update({ where: { id: userId }, data: { savedProducts: { disconnect: { id: productId, }, }, }, include: { savedProducts: true, }, }); console.log(user); ```/ 종합 ```[.scrollable] app.patch('/users/:id/saved-products', asyncHandler(async (req, res) => { assert(req.body, PostSavedProducts); const { id: userId } = req.params; const { productId } = req.body; const user = await prisma.user.update({ where: { id: userId }, data: { savedProducts: { connect: { id: productId, }, }, }, include: { savedProducts: true, }, }); res.send(user); })); app.patch('/users/:id/unsave-product', asyncHandler(async (req, res) => { assert(req.body, PostSavedProducts); const { id: userId } = req.params; const { productId } = req.body; const user = await prisma.user.update({ where: { id: userId }, data: { savedProducts: { disconnect: { id: productId, }, }, }, include: { savedProducts: true, }, }); res.send(user); })); ```/ ## 비즈니스 로직 (Business Logic): $transaction Order 와 product stock 과의 로직 완성하기. $transaction: Do All or Nothing. ```[.scrollable] app.post('/orders', asyncHandler(async (req, res) => { assert(req.body, CreateOrder); const { userId, orderItems } = req.body; const productIds = orderItems.map((orderItem) => orderItem.productId); const products = await prisma.product.findMany({ where: { id: { in: productIds } }, }); function getQuantity(productId) { const orderItem = orderItems.find( (orderItem) => orderItem.productId === productId ); return orderItem.quantity; } // 재고 확인 const isSufficientStock = products.every((product) => { const { id, stock } = product; return stock >= getQuantity(id); }); if (!isSufficientStock) { throw new Error('Insufficient Stock'); } const [order] = await prisma.$transaction([ prisma.order.create({ data: { userId, orderItems: { create: orderItems, }, }, include: { orderItems: true, }, }), ...orderItems.map(({ productId, quantity }) => { return prisma.product.update({ where: { id: productId }, data: { stock: { decrement: quantity, }, }, }); }) ]); res.status(201).send(order); })); ```/ ## RRA
  1. 관계형 데이터베이스를 활용한 자바스크립트 서버 만들기 - 코드잇
728x90
반응형