Chapter 3: Konfiguráció, Prisma ORM és a Boards implementálása
Egy komolyabb backend alkalmazásnál kiemelten fontos, hogy az adatbázis-kapcsolatokat, portokat és egyéb környezetfüggő beállításokat egy központosított, típusbiztos módon kezeljük. Mielőtt bekötnénk az adatbázist, létrehozunk egy konfigurációs interfészt.
Miért hasznos a konfigurációs interfész?
Képzeld el, hogy az alkalmazásodban 20 helyen van szükséged az adatbázis URL-jére. Ha mindenhol a process.env.DATABASE_URL kódot használod, azzal több probléma is van:
- Nincs típusbiztonság (Type Safety): A TypeScript nem tudja, hogy ez a változó biztosan létezik-e, vagy milyen típusú.
- Nincsenek alapértelmezett értékek: Ha elfelejted beállítani a
.envfájlban, az alkalmazás elszállhat. - Nehéz refaktorálni: Ha megváltozik a változó neve, 20 helyen kell átírnod.
Ezt oldja meg a NestJS beépített konfigurációs modulja.
Függőségek telepítése
Telepítsük a konfiguráció kezeléséhez szükséges hivatalos NestJS csomagot:
npm install @nestjs/config
A konfigurációs fájl létrehozása
Hozzuk létre a src/config/ mappát, és abban egy configuration.ts fájlt:
export interface Config {
port: number;
database: {
url: string;
};
}
export default (): Config => ({
// Számmá alakítjuk a portot, ha nem sikerül, 3000 lesz az alapértelmezett
port: parseInt(process.env.PORT || '3000', 10) || 3000,
database: {
url: process.env.DATABASE_URL || 'file:./dev.db',
},
});
Magyarázat: Itt definiáltuk a Config interfészt, ami megmondja a TypeScriptnek, hogy pontosan milyen adatok érhetőek el a konfigurációnkban. Az exportált függvény pedig kiolvassa a környezeti változókat (a .env fájlból), és alapértelmezett (fallback) értékeket is biztosít.
A ConfigModule bekötése az AppModule-ba
Most regisztrálnunk kell ezt a konfigurációt a fő modulunkban (src/app.module.ts).
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { BoardsModule } from './boards/boards.module';
import { ConfigModule } from '@nestjs/config';
import configuration from './config/configuration';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [configuration],
}),
BoardsModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
Magyarázat:
ConfigModule.forRoot(...): Inicializálja a modult.isGlobal: true: Ez teszi lehetővé, hogy az alkalmazásunk bármelyik részében (bármelyik másik modulban) használhassuk aConfigService-t anélkül, hogy mindenhol újra importálnunk kellene aConfigModule-t.load: [configuration]: Betölti az általunk előbb megírt egyedi konfigurációs logikát.
A Prisma ORM bevezetése
Mi az a relációs adatbázis és az ORM?
Egy relációs adatbázis táblákban tárolja az adatokat — pont mint egy Excel tábla: sorok (rekordok) és oszlopok (mezők). A táblák között kapcsolatok (relációk) vannak: például egy Boards táblához sok Ticket sor tartozhat.
Az adatbázissal hagyományosan SQL (Structured Query Language) lekérdezésekkel kommunikálunk:
SELECT * FROM "Ticket" WHERE "boardsId" = 1;
INSERT INTO "Ticket" ("name", "boardsId") VALUES ('Fix login bug', 1);
Az ORM (Object-Relational Mapper) egy közvetítő réteg, amely ezeket az SQL lekérdezéseket elrejti előlünk. Ahelyett, hogy SQL-t írnánk, TypeScript metódusokat hívunk, és az ORM generálja a megfelelő SQL-t a háttérben.
A Prisma egy modern, típusbiztos ORM (Object-Relational Mapper) Node.js-hez és TypeScript-hez. Segítségével ahelyett, hogy nyers SQL lekérdezéseket írnánk, TypeScript objektumokon és metódusokon keresztül kommunikálhatunk az adatbázissal. SQLite adatbázist fogunk használni.
Függőségek letöltése
Telepítsük a működéshez szükséges csomagokat. Futassuk az alábbi parancsokat:
npm install @prisma/adapter-better-sqlite3@7 @prisma/client@7
npm install -D prisma@7 @types/better-sqlite3
Parancs magyarázata:
npm install ...: A futáshoz (produkciós környezetben is) szükséges csomagok. A@prisma/clientfelel az adatbázis lekérdezésekért, az adapter pedig a gyorsabb SQLite kezelésért.npm install -D ...: A-D(vagy--save-dev) azt jelenti, hogy ezek csak fejlesztői (Development) függőségek. Maga aprismaCLI csak a séma generálásához és migrációkhoz kell.
Prisma inicializálása
Inicializáljuk a Prismát a projektünkben:
npx prisma init
Az npx parancs az npm-mel érkező segédeszköz: anélkül futtat le egy csomagban lévő parancsot, hogy azt globálisan telepítenünk kellene. Tehát az npx prisma init a lokálisan telepített prisma csomag CLI-jét hívja meg.
Ez létrehoz egy prisma/schema.prisma fájlt. Ez a fájl az alkalmazásunk "szíve", itt definiáljuk az adatbázis tábláit és kapcsolatait.
A Prisma Séma kialakítása
Építsük fel a schema.prisma fájlunkat lépésről lépésre, megértve az összefüggéseket!
1. Generátor és Adatforrás
Cseréld ki a schema.prisma tetejét a következőre:
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "sqlite"
}
Magyarázat:
- A
datasourcemegmondja, hogy SQLite-ot használunk. - A
generatorkliens felelős a TypeScript típusok legenerálásáért. Azoutputparaméterrel megváltoztattuk az alapértelmezett generálási helyet. Így a Prisma azsrc/generated/prismamappába fogja tenni a kész kódokat, amit könnyebb lesz beimportálni és kezelni a projektünkön belül.
2. A Táblák (Modellek) és Egy-a-Többhöz kapcsolat
Adjuk hozzá a Táblák (Boards) és a Hibajegyek (Tickets) modelljét:
model Boards {
id Int @id @default(autoincrement())
title String
tickets Ticket[]
createdAt DateTime @default(now())
}
model Ticket {
id Int @id @default(autoincrement())
name String
description String?
ticketPhase TicketPhase @default(CREATED)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
board Boards @relation(fields: [boardsId], references: [id])
boardsId Int
}
Magyarázat:
@id @default(autoincrement()): Ez jelzi, hogy azidmező az elsődleges kulcs (Primary Key), ami automatikusan növekszik.description String?: A?jelzi, hogy ez a mező opcionális (lehet NULL az adatbázisban).createdAt/updatedAt: A Prisma automatikusan kitölti az aktuális dátummal létrehozáskor (now()), és automatikusan frissíti a dátumot módosításkor (@updatedAt).- Kapcsolat (Relation): Egy táblához (
Boards) több hibajegy (Ticket) tartozhat. ATicketmodellben aboardsIdfogja tárolni a kapcsolódó tábla azonosítóját (Foreign Key), amit a@relationannotáció köt össze.
3. Státusz enum definiálása
Végül adjuk hozzá a státuszokat:
enum TicketPhase {
CREATED
IN_PROGRESS
UNDER_REVIEW
}
Prismában a kapcsolatokat mindkét modellben definiálni kell, de a módja persze függ a kapcsolat típusától.
Magyarázat:
enum: Egy előre definiált értékhalmaz. A ticket csak ezeket az állapotokat veheti fel.
Migráció és a kliens generálása
Most, hogy kész a séma, hozzuk létre a fizikai adatbázist és generáljuk le a TypeScript típusokat:
npx prisma migrate dev --name init
Mit csinál ez a parancs?
- Létrehoz egy SQL migrációs fájlt (ami leírja, hogyan jönnek létre a táblák).
- Lefuttatja ezt a fájlt, így létrejön a
dev.dbSQLite adatbázis fájl. - Automatikusan lefuttatja az
npx prisma generateparancsot, ami legenerálja aPrismaClient-et a mi egyedisrc/generated/prismamappánkba!
Prisma modul és Service létrehozása
Hogy használni tudjuk a Prismát a NestJS-ben, létre kell hoznunk számára egy Modult és egy Service-t. Generáljuk le a CLI-vel:
nest g mo prisma
nest g s prisma
(mo = Module, s = Service — a CLI rövidítések ugyanúgy működnek, mint a res (Resource) a 2. fejezetben.)
A Prisma modul
Módosítsd az src/prisma/prisma.module.ts fájlt a következőre:
import { DynamicModule, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {
static forRoot({ isGlobal }: { isGlobal: boolean }): DynamicModule {
return {
global: isGlobal,
module: PrismaModule,
providers: [PrismaService],
exports: [PrismaService],
};
}
}
A Dinamikus Modulok lehetővé teszik, hogy a modul betöltésekor paramétereket adjunk át neki (például konfigurációkat). Itt egy forRoot statikus metódust hoztunk létre. Amikor az AppModule-ban meghívtuk a PrismaModule.forRoot({ isGlobal: true }) kódot, ez a metódus futott le. Ezzel elérjük, hogy a PrismaService globális legyen, azaz bármelyik másik modulban (pl. a BoardsModule-ban) használható legyen anélkül, hogy újra be kellene importálni.
A Prisma Service (Adatbázis kapcsolat)
Módosítsd az src/prisma/prisma.service.ts fájlt a következőre:
import { Injectable, OnModuleInit } from '@nestjs/common';
// Figyeld meg az importot: A saját generált mappánkból húzzuk be a klienst!
import { PrismaClient } from '../generated/prisma/client';
import { ConfigService } from '@nestjs/config';
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3';
import { Config } from '../config/configuration';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
constructor(private readonly config: ConfigService<Config, true>) {
// Kiolvassuk a db url-t a korábban létrehozott ConfigService-ből
// Az { infer: true } opció lehetővé teszi, hogy a TypeScript automatikusan
// kikövetkeztesse a visszatérési típust a Config interfészből
const connectionString = config.get('database.url', { infer: true });
if (!connectionString) {
throw new Error('DATABASE_URL environment variable is not set');
}
// Inicializáljuk a gyorsabb SQLite adaptert
const adapter = new PrismaBetterSqlite3({ url: connectionString });
// Átadjuk az adaptert a szülő osztálynak (PrismaClient)
super({ adapter });
}
// Ez a metódus automatikusan lefut, amikor a modul inicializálódik
async onModuleInit(): Promise<void> {
await this.$connect();
}
}
Magyarázat a Service-hez:
- A
PrismaServicekibővíti (extends) a legeneráltPrismaClientosztályt. Ezért van az, hogy aPrismaService-en keresztül elérjük majd az összes adatbázis metódust (pl.this.prisma.boards.findMany()). - Kiemelten fontos a beimportált útvonal:
../generated/prisma/client. Ezt azért tudjuk így használni, mert aschema.prismafájlban módosítottuk azoutputcélmappáját. - Itt használjuk fel a fejezet elején létrehozott ConfigModule-t! A
ConfigServicesegítségével biztonságosan, típusosan (Configinterfésszel) olvassuk ki adatabase.urlértékét. - Az
OnModuleInitinterfész implementálásával biztosítjuk, hogy amint elindul a NestJS szerverünk, a Prisma azonnal felépítse az adatbázis-kapcsolatot (this.$connect()).
A PrismaModule bekötése az AppModule-ba
Most, hogy megvan a PrismaModule és a PrismaService, regisztrálnunk kell a fő modulunkban is. Nyisd meg a src/app.module.ts fájlt, és add hozzá a PrismaModule.forRoot(...) hívást:
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { BoardsModule } from './boards/boards.module';
import { ConfigModule } from '@nestjs/config';
import { PrismaModule } from './prisma/prisma.module';
import configuration from './config/configuration';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [configuration],
}),
PrismaModule.forRoot({
isGlobal: true,
}),
BoardsModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
Magyarázat:
PrismaModule.forRoot({ isGlobal: true }): AforRootstatikus metódus (amit az előbb írtunk meg a modulban) itt hívódik meg. AzisGlobal: trueparaméter hatására aPrismaServiceaz egész alkalmazásban elérhető lesz — nem kell minden egyes modulban (pl.BoardsModule,TicketsModule) külön importálni aPrismaModule-t.
A Boards modul összekötése a Prisma ORM-mel
Most, hogy megvan az adatbázisunk, keltjük életre a 2. fejezetben létrehozott Boards végpontokat!
A Service összekötése a Prisma ORM-mel
Írjuk meg az üzleti logikát a boards.service.ts fájlban. Itt fogjuk a fejezet elején létrehozott PrismaService-t használni.
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Boards, Ticket, Prisma } from '../generated/prisma/client';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class BoardsService {
constructor(private readonly prisma: PrismaService) {}
async create(createBoardDto: Prisma.BoardsCreateInput) {
try {
return await this.prisma.boards.create({
data: createBoardDto,
});
} catch (e) {
console.error(e);
throw new BadRequestException('Could not create board');
}
}
async findAll(): Promise<Boards[]> {
return await this.prisma.boards.findMany();
}
async findOne(id: number): Promise<Boards & { tickets: Ticket[] }> {
const board = await this.prisma.boards.findUnique({
where: { id },
include: { tickets: true },
});
if (!board) {
throw new NotFoundException(`Board with id ${id} not found`);
}
return board;
}
async update(id: number, updateBoardDto: Prisma.BoardsUpdateInput): Promise<Boards> {
try {
return await this.prisma.boards.update({
where: { id },
data: updateBoardDto,
});
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === 'P2025') {
throw new NotFoundException(`Board with id ${id} not found`);
}
}
console.error(e);
throw new BadRequestException(`Could not update board with id ${id}`);
}
}
async remove(id: number): Promise<Boards> {
try {
return await this.prisma.boards.delete({
where: { id },
});
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === 'P2025') {
throw new NotFoundException(`Board with id ${id} not found`);
}
}
console.error(e);
throw new BadRequestException(`Could not delete board with id ${id}`);
}
}
}
1. A PrismaService Injektálása
A konstruktorban (constructor(private readonly prisma: PrismaService) {}) a NestJS Dependency Injection (Függőség injektálás) rendszerét használjuk. Mivel a 3. fejezetben a PrismaModule-t globálissá tettük, a NestJS automatikusan átadja nekünk az adatbázis-kapcsolatot kezelő szolgáltatást. Ezen a this.prisma objektumon keresztül érjük el a generált adatbázis tábláinkat (pl. this.prisma.boards).
2. Típusbiztonság a Prisma beépített típusaival
Ha megfigyeled a metódusok paramétereit (pl. createBoardDto: Prisma.BoardsCreateInput), láthatod, hogy nem a mi saját DTO-nkat használjuk típusként, hanem a Prisma által generált típusokat. Ez azért nagyon hasznos, mert a Prisma pontosan tudja, milyen mezőket vár az adatbázis egy új rekord létrehozásakor vagy frissítésekor. Ha a sémánk változik, ezek a típusok automatikusan frissülnek, így a TypeScript azonnal jelezni fogja, ha valahol rossz adatot akarunk az adatbázisba küldeni.
3. Kapcsolatok lekérdezése a findOne metódusban
A findOne metódusban ezt a lekérdezést használjuk:
include: { tickets: true }
A relációs adatbázisokban (mint az SQLite) a táblák külön vannak. Alapértelmezés szerint a Prisma csak a boards tábla adatait adja vissza, hogy gyors maradjon a lekérdezés. Az include kulcsszóval (Eager Loading) viszont megmondjuk a Prismának, hogy menjen el a kapcsolódó tickets táblába is, és hozza el az összes olyan hibajegyet, ami ehhez a táblához tartozik. Ezért a visszatérési típus Boards & { tickets: Ticket[] } — a Prisma válasza tartalmazza az összes board mezőt, plusz a kapcsolódó jegyeket. A 4. fejezetben ezt egy saját entitásba fogjuk kiszervezni (BoardWithTickets).
4. Hibakezelés és HTTP státuszkódok
A HTTP kéréseknél nagyon fontos, hogy megfelelő státuszkódot adjunk vissza hiba esetén (pl. 404, ha nem található valami, vagy 400, ha rossz az adat).
A findOne esetében manuálisan ellenőrizzük, hogy létezik-e az adat: if (!board) throw new NotFoundException(...). Ez egy beépített NestJS hiba, ami automatikusan egy 404-es HTTP választ generál a kliensnek.
5. A Prisma hibakódok (P2025) elkapása az update és remove metódusokban
A frissítésnél és a törlésnél a Prisma automatikusan hibát dob, ha olyan ID-jú elemet próbálunk módosítani, ami nem is létezik az adatbázisban.
Ezt a hibát a try-catch blokkban kapjuk el. Az e instanceof Prisma.PrismaClientKnownRequestError sorral ellenőrizzük, hogy a Prisma dobott-e ismert hibát.
A P2025 a Prisma hivatalos hibakódja arra, ha "Egy olyan rekordot próbálsz frissíteni vagy törölni, amely nem található". Ha ezt a kódot látjuk, pontosan tudjuk, hogy az elem nem létezik, ezért visszadobunk egy NotFoundException-t (404-es hiba). Minden más váratlan hiba esetén BadRequestException-t (400-as hiba) küldünk vissza.
A Controller összekötése a Service-szel
Most frissítsük a boards.controller.ts fájlt, hogy a generált stub metódusok helyett a valódi Service-t hívják, és a @Body() paraméterek is a Prisma típusait használják. Frissítsd a create és az update metódusok paraméter-típusait az alábbiak szerint (a többi metódus marad változatlan):
@Post()
create(@Body() createBoardDto: Prisma.BoardsCreateInput): Promise<Boards> {
return this.boardsService.create(createBoardDto);
}
@Patch(':id')
update(
@Param('id') id: string,
@Body() updateBoardDto: Prisma.BoardsUpdateInput,
): Promise<Boards> {
return this.boardsService.update(+id, updateBoardDto);
}
Magyarázat:
- A
@Body()paraméterek (createBoardDto,updateBoardDto) most a Prisma generált típusait (Prisma.BoardsCreateInput,Prisma.BoardsUpdateInput) kapják meg típusként — ugyanazokat, amiket a Service-ben is használunk. - Az URL paraméter (
id) egyelőre szövegként (string) érkezik, ezért a+idoperátorral alakítjuk számmá. A 4. fejezetben ezt egyParseIntPipe-pal fogjuk kiváltani, ami automatikusan elvégzi az átalakítást és validálja is az értéket.
Ha elakadtál, akkor a chapter-3 branch-en megtalálod az eddigi kódot, amit összehasonlíthatsz a sajátoddal, vagy checkoutolhatod, hogy onnan folytasd.
A Végpontok tesztelése
A kérések küldéséhez a REST Client VS Code-bővítményt fogjuk használni. A workspace requests/boards.http fájljában megtalálod az összes előre elkészített kérést — minden kérés felett megjelenik egy "Send Request" gomb, kattints rá a küldéshez.
@baseUrl = http://localhost:3000
### Get all boards
GET {{baseUrl}}/boards
###
### Create a board
POST {{baseUrl}}/boards
Content-Type: application/json
{
"title": "My first board"
}
###
### Get one board (notice the tickets array in the response!)
GET {{baseUrl}}/boards/1
###
### Update a board
PATCH {{baseUrl}}/boards/1
Content-Type: application/json
{
"title": "Updated board title"
}
###
### Delete a board
DELETE {{baseUrl}}/boards/1
Mit várjunk a válaszoktól?
GET /boards— Először üres tömböt ([]) ad vissza. Létrehozás után az összes boardot listázza.POST /boards— Visszaadja az újonnan létrehozott boardot az automatikusan generáltid-vel éscreatedAtdátummal.GET /boards/1— Visszaadja az adott boardot egytickets: []tömbbel együtt. Ez azért jelenik meg, mert afindOnemetódusbaninclude: { tickets: true }segítségével a kapcsolódó jegyeket is lekérdezzük.PATCH /boards/1— Visszaadja a frissített boardot.DELETE /boards/1— Visszaadja a törölt boardot (utolsó állapotát), majd a következőGET /boards-on már nem szerepel.
Készítette: Tarjányi Csanád, Bujdosó Gergő