Chapter 7: A Labels modul és a hibajegyek összekapcsolása
A hibajegy-kezelő rendszerek (mint például a Jira vagy a Trello) egyik elengedhetetlen funkciója, hogy a jegyeket különböző színű és nevű címkékkel (Labels) láthassuk el. Ebben a fejezetben elkészítjük a teljes Labels modult — a Prisma sémától a CRUD végpontokon át egészen a hibajegyekkel való több-a-többhöz (M:N) összekapcsolásig.
A Labels modul generálása
A már jól ismert módon hívjuk segítségül a NestJS CLI-t az új erőforrás (Resource) létrehozásához:
nest g res labels
(Ahogy eddig is, válaszd a REST API-t, és kérd a CRUD végpontok legenerálását!)
Ha a NestJS CLI-t használod, az automatikusan frissíti a src/app.module.ts fájlt, és beleteszi a LabelsModule-t az imports tömbbe. Ha bármilyen okból kifolyólag ezeket a fájlokat manuálisan hoznád létre, sose felejtsd el kézzel beimportálni a modult a fő modulba, különben a végpontjaid nem fognak élni!
Adatbázis-kapcsolatok röviden
A korábbi fejezetekben már használtunk egy egy-a-többhöz (1:N) kapcsolatot: egy Board-hoz sok Ticket tartozhat, de egy Ticket csak egy Board-hoz.
A Labels modul bevezetéséhez egy új kapcsolattípusra lesz szükségünk: több-a-többhöz (M:N).
Miért M:N a kapcsolat a Ticket és a Label között?
- Egy hibajegynek (
Ticket) több címkéje is lehet (pl.bug,urgent) - Egy címkét (
Label) több hibajegyre is fel lehet rakni
Ha ezt egy hagyományos 1:N kapcsolattal próbálnánk megoldani, az nem működne: a Ticket-ben nem tárolhatnánk több labelId-t egy mezőben.
A megoldás egy kapcsolótábla (junction table): egy rejtett, köztes tábla, amely párokat tárol — minden sor egy (ticket, label) összerendelést jelent.
Ticket _LabelToTicket Label
────── ────────────── ─────
id: 1 ──────────► ticketId: 1 ◄──────── id: 10 (bug)
labelId: 10
id: 1 ──────────► ticketId: 1 ◄──────── id: 11 (urgent)
labelId: 11
id: 2 ──────────► ticketId: 2 ◄──────── id: 10 (bug)
labelId: 10
A Prisma ezt a kapcsolótáblát automatikusan kezeli — nekünk soha nem kell közvetlenül hozzányúlnunk.
A Prisma séma bővítése
Mielőtt bármilyen kódot írnánk, bővítsük az adatbázis sémánkat. Nyisd meg a prisma/schema.prisma fájlt, add hozzá a Label modellt, és egészítsd ki a Ticket modellt a kapcsolatmezővel:
model Label {
id Int @id @default(autoincrement())
name String
color String
tickets Ticket[]
}
A Ticket modellhez add hozzá a labels Label[] sort az utolsó mező után (de a záró } elé):
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
labels Label[]
}
Magyarázat: A Prisma felismeri, hogy mindkét modell hivatkozik a másikra egy tömb típusú mezőn keresztül, és ebből automatikusan egy implicit M:N relációt hoz létre. A háttérben Prisma egy rejtett kapcsolótáblát (_LabelToTicket) generál, amelyet a connect és disconnect műveletek fognak kezelni — nekünk ezt a táblát soha nem kell közvetlenül érintenünk.
Migráció futtatása
npx prisma migrate dev --name add-labels
Ez a parancs:
- Létrehozza a szükséges SQL migrációs fájlokat (a
Labeltábla és a_LabelToTicketkapcsolótábla). - Lefuttatja a migrációt, így a
dev.db-ben megjelennek az új táblák. - Automatikusan újragenerálja a Prisma klienst — ezután a
this.prisma.labelmetódusok és alabels.connect/labels.disconnectoperátorok is elérhetők lesznek.
LabelEntity
A címke egy nagyon egyszerű objektum lesz: van egy azonosítója (id), egy neve (name), és egy színe (color), amit hexadecimális kódként (pl. #FF0000) fogunk tárolni.
Cseréld le a labels/entities/label.entity.ts tartalmát:
import { IsHexColor, IsNotEmpty, IsNumber, IsString, Min } from 'class-validator';
export class Label {
@IsNumber()
@Min(1)
id: number = 0;
@IsString()
@IsNotEmpty()
name: string = '';
// Új validációs dekorátor!
@IsString()
@IsHexColor()
color: string = '';
}
@IsHexColor()?A class-validator könyvtár rengeteg beépített ellenőrzőt tartalmaz. Ahelyett, hogy nekünk kéne egy bonyolult Reguláris Kifejezést (Regex) írnunk annak ellenőrzésére, hogy a felhasználó tényleg egy érvényes színkódot küldött-e (pl. #1a2b3c), az @IsHexColor() dekorátor ezt automatikusan elvégzi helyettünk. Ha a kliens "piros"-t küld értéknek, a szerver azonnal egy 400 Bad Request hibával fog válaszolni.
A Label DTO-k
A létrehozáshoz és frissítéshez használt DTO-k szinte azonosak lesznek a korábbi fejezetekben látottakkal. Továbbra is a @nestjs/swagger csomagból importáljuk a segédfüggvényeket, hogy az API dokumentációnk is tükrözze a modellt.
import { OmitType } from '@nestjs/swagger';
import { Label } from '../entities/label.entity';
// Létrehozásnál az 'id'-t az adatbázis generálja, így azt kihagyjuk
export class CreateLabelDto extends OmitType(Label, ['id'] as const) {}
import { PartialType } from '@nestjs/swagger';
import { CreateLabelDto } from './create-label.dto';
// Frissítésnél minden mező opcionálissá válik
export class UpdateLabelDto extends PartialType(CreateLabelDto) {}
A LabelsController
A vezérlőben beállítjuk a megfelelő végpontokat és felparaméterezzük őket a Swagger dokumentációs dekorátorokkal.
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post } from '@nestjs/common';
import {
ApiBadRequestResponse,
ApiBody,
ApiCreatedResponse,
ApiNotFoundResponse,
ApiOkResponse,
} from '@nestjs/swagger';
import { CreateLabelDto } from './dto/create-label.dto';
import { UpdateLabelDto } from './dto/update-label.dto';
import { Label } from './entities/label.entity';
import { LabelsService } from './labels.service';
@Controller('labels')
export class LabelsController {
constructor(private readonly labelsService: LabelsService) {}
@Post()
@ApiBody({ type: CreateLabelDto })
@ApiCreatedResponse({
description: 'Label successfully created',
type: Label,
})
@ApiBadRequestResponse({ description: 'Could not create label' })
create(@Body() createLabelDto: CreateLabelDto): Promise<Label> {
return this.labelsService.create(createLabelDto);
}
@Get()
findAll(): Promise<Label[]> {
return this.labelsService.findAll();
}
@Get(':id')
@ApiOkResponse({ type: Label })
@ApiNotFoundResponse({ description: 'Label not found' })
findOne(@Param('id', ParseIntPipe) id: number): Promise<Label> {
return this.labelsService.findOne(id);
}
@Patch(':id')
@ApiBody({ type: UpdateLabelDto })
@ApiOkResponse({ type: Label })
@ApiNotFoundResponse({ description: 'Label not found' })
update(@Param('id', ParseIntPipe) id: number, @Body() updateLabelDto: UpdateLabelDto): Promise<Label> {
return this.labelsService.update(id, updateLabelDto);
}
@Delete(':id')
@ApiOkResponse({ type: Label })
@ApiNotFoundResponse({ description: 'Label not found' })
remove(@Param('id', ParseIntPipe) id: number): Promise<Label> {
return this.labelsService.remove(id);
}
}
A LabelsService
Végül megírjuk a Prisma lekérdezéseket a labels.service.ts fájlban. Ez a logika már ismerős lehet a Boards és Tickets modulokból.
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Label, Prisma } from '../generated/prisma/client';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class LabelsService {
constructor(private readonly prisma: PrismaService) {}
async create(createLabelDto: Prisma.LabelCreateInput): Promise<Label> {
try {
return await this.prisma.label.create({
data: createLabelDto,
});
} catch (e) {
console.error(e);
throw new BadRequestException('Could not create label');
}
}
async findAll(): Promise<Label[]> {
return await this.prisma.label.findMany();
}
async findOne(id: number): Promise<Label> {
const label = await this.prisma.label.findUnique({
where: { id },
});
if (!label) {
throw new NotFoundException(`Label with id ${id} not found`);
}
return label;
}
async update(id: number, updateLabelDto: Prisma.LabelUpdateInput): Promise<Label> {
try {
return await this.prisma.label.update({
where: { id },
data: updateLabelDto,
});
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === 'P2025') {
throw new NotFoundException(`Label with id ${id} not found`);
}
}
console.error(e);
throw new BadRequestException(`Could not update label with id ${id}`);
}
}
async remove(id: number): Promise<Label> {
try {
return await this.prisma.label.delete({
where: { id },
});
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === 'P2025') {
throw new NotFoundException(`Label with id ${id} not found`);
}
}
console.error(e);
throw new BadRequestException(`Could not delete label with id ${id}`);
}
}
}
A LabelsService működésének áttekintése:
- Típusbiztonság Prisma típusokkal: A
createésupdatemetódusokPrisma.LabelCreateInputésPrisma.LabelUpdateInputtípusokat használnak — ugyanúgy, ahogy a Boards modulban tanultuk. - Keresés és Hibakezelés (
findOne): Ha a Prismanullértékkel tér vissza, egy manuálisNotFoundException(404) hibát dobunk. - Módosítás és Törlés (
update,remove): AP2025-ös hibakódot elkapva érthető 404-es hibaüzenetet adunk vissza, minden más váratlan hibánál 400-ast.
Címkék összekapcsolása a hibajegyekkel
Most, hogy a Labels CRUD végpontjai készen vannak, megvalósítjuk a hibajegyekkel való összekapcsolást. Az ehhez szükséges végpontokat a Tickets modulban helyezzük el.
TicketWithLabels entitás
Ahhoz, hogy a Swagger dokumentációnk és a TypeScript típusaink is tudják, hogy egy hibajegy lekérdezésekor most már a címkéket is visszaadjuk, egy kiterjesztett osztályt kell létrehoznunk.
Hozd létre a src/tickets/entities/ticket-with-labels.entity.ts fájlt:
import { Label } from '../../labels/entities/label.entity';
import { Ticket } from './ticket.entity';
export class TicketWithLabels extends Ticket {
labels: Label[] = [];
}
Magyarázat: Mivel ez az osztály leszármazik (öröklődik) a Ticket osztályból, ezért annak az összes validációs szabálya és Swagger dekorátora megmarad rajta. Egyetlen dologgal bővítjük: egy labels tömbbel.
A GET végpontok frissítése a Tickets modulban
Módosítanunk kell a meglévő findAll és findOne végpontjainkat, hogy ne csak a nyers hibajegyet, hanem a hozzá tartozó címkéket is visszaadják.
1. A TicketsController módosítása
Importáld be az új entitást, és frissítsd a két GET metódust a src/tickets/tickets.controller.ts fájlban:
// ... korábbi importok
import { TicketWithLabels } from './entities/ticket-with-labels.entity';
// ... az osztály korábbi részei
@Get()
@ApiOkResponse({
type: TicketWithLabels,
isArray: true,
description: 'All tickets',
})
findAll(): Promise<TicketWithLabels[]> {
return this.ticketsService.findAll();
}
@Get(':id')
@ApiOkResponse({ type: TicketWithLabels })
@ApiNotFoundResponse({ description: 'Ticket with given id not found' })
findOne(@Param('id', ParseIntPipe) id: number): Promise<TicketWithLabels> {
return this.ticketsService.findOne(id);
}
// ... az osztály további részei
2. A TicketsService módosítása
Frissítsd a Prisma lekérdezéseket a src/tickets/tickets.service.ts fájlban:
// ... korábbi importok
import { TicketWithLabels } from './entities/ticket-with-labels.entity';
// ... az osztály korábbi részei
async findAll(): Promise<TicketWithLabels[]> {
return await this.prisma.ticket.findMany({
include: {
labels: true,
},
});
}
async findOne(id: number): Promise<TicketWithLabels> {
const ticket = await this.prisma.ticket.findUnique({
where: { id },
include: {
labels: true,
},
});
if (!ticket) {
throw new NotFoundException(`Ticket with id ${id} not found`);
}
return ticket;
}
Magyarázat: Az include: { labels: true } hatására a lekérdezés a hibajegy adatai mellett egy labels tömböt is visszaad az összes hozzárendelt címkével.
Új végpontok a Controllerben
A hibajegyhez egy címkét hozzárendelni alapvetően a hibajegy egyfajta "módosítása", ezért PATCH kérést fogunk használni. A leválasztáshoz pedig a szemantikus DELETE metódust választjuk.
Nyisd meg a src/tickets/tickets.controller.ts fájlt, és add hozzá az osztályhoz a következő két új metódust:
@Patch(':ticketId/assign/:labelId')
@ApiOkResponse({ type: TicketWithLabels })
@ApiNotFoundResponse({
description:
'Ticket or label with given id not found. You can deduce which one from the error message',
})
@ApiBadRequestResponse({ description: 'Could not assign label' })
assignLabel(
@Param('ticketId', ParseIntPipe) ticketId: number,
@Param('labelId', ParseIntPipe) labelId: number,
): Promise<TicketWithLabels> {
return this.ticketsService.assignLabel(ticketId, labelId);
}
@Delete(':ticketId/assign/:labelId')
@ApiOkResponse({
description:
"The label was successfully removed from the ticket. Worth to note, that this returns 200 even if the label wasn't connected to the ticket.",
type: TicketWithLabels,
})
@ApiNotFoundResponse({ description: 'Ticket with given id not found' })
@ApiBadRequestResponse({ description: 'Could not remove label' })
removeLabel(
@Param('ticketId', ParseIntPipe) ticketId: number,
@Param('labelId', ParseIntPipe) labelId: number,
): Promise<TicketWithLabels> {
return this.ticketsService.removeLabel(ticketId, labelId);
}
Magyarázat: Az útvonal mindkét esetben két dinamikus paramétert vár: ticketId és labelId. Például a PATCH /tickets/5/assign/2 azt jelenti, hogy az 5-ös számú hibajegyre rárakjuk a 2-es azonosítójú címkét.
Az üzleti logika a Service-ben
Most megírjuk a Prisma lekérdezéseket a src/tickets/tickets.service.ts fájlban. Először is győződj meg róla, hogy beimportáltad a Prisma névteret a fájl tetején:
import { Prisma } from '../generated/prisma/client';
Ezután add hozzá az osztályhoz az alábbi két metódust:
async assignLabel(
ticketId: number,
labelId: number,
): Promise<TicketWithLabels> {
try {
return await this.prisma.ticket.update({
where: { id: ticketId },
data: {
labels: {
// A 'connect' kulcsszó köti össze a két meglévő rekordot
connect: { id: labelId },
},
},
include: {
labels: true,
},
});
} catch (e) {
console.error(e);
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === 'P2025') {
throw new NotFoundException('Invalid label id');
}
if (e.code === 'P2016') {
throw new NotFoundException('Invalid ticket id');
}
}
throw new BadRequestException(`Could not assign label to ticket`);
}
}
async removeLabel(
ticketId: number,
labelId: number,
): Promise<TicketWithLabels> {
try {
return await this.prisma.ticket.update({
where: { id: ticketId },
data: {
labels: {
// A 'disconnect' megszünteti a kapcsolatot a két rekord között
disconnect: { id: labelId },
},
},
include: {
labels: true,
},
});
} catch (e) {
console.error(e);
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === 'P2025') {
throw new NotFoundException('Invalid label id');
}
}
throw new BadRequestException(`Could not remove label from ticket`);
}
}
A kód működésének magyarázata:
connectésdisconnecta Prismában: Amikor M:N kapcsolatot kezelünk, a Prisma nagyon elegáns megoldást nyújt. Ahelyett, hogy manuálisan SQL beszúrásokat végeznénk a rejtett kapcsolótáblába, egyszerűen aconnectparanccsal összekötjük az azonosítókat, adisconnectparanccsal pedig felbontjuk a kapcsolatot.- Az
includehasználata módosításkor: Azinclude: { labels: true }segítségével a sikeres összekötés (vagy leválasztás) után a válasz tartalmazza a hibajegy jelenlegi összes címkéjét is. - P2016 és P2025 hibakódok:
- Ha olyan címkét próbálunk hozzákapcsolni, ami nem létezik, a Prisma P2025-ös hibát dob.
- Ha a hibajegy nem létezik, a Prisma P2016-os hibát ad vissza ebben a relációs kontextusban.
- Idempotens műveletek:
- A
connectidempotens: ha a kapcsolat már létezik, a Prisma nem dob hibát. - A
disconnectszintén idempotens: ha a kapcsolat nem is létezik, a Prisma nem dob hibát.
- A
Ha elakadtál, akkor a chapter-7 branch-en megtalálod az eddigi kódot, amit összehasonlíthatsz a sajátoddal, vagy checkoutolhatod, hogy onnan folytasd.
Készítette: Tarjányi Csanád, Bujdosó Gergő