Kurs NestJS · Moduł 3: TypeORM i bazy danych
PROJEKT - system inwentaryzacji tributów
W tej lekcji7
Osiem lekcji tego modułu przeszło drogę od pierwszego połączenia z bazą do walidacji na poziomie schematu. Projekt jest miejscem, w którym wszystko to musi zadziałać razem - i gdzie po raz pierwszy zobaczysz, że decyzje podjęte przy schemacie wracają do ciebie tydzień później, przy pisaniu zapytań.
Zbudujesz system inwentaryzacji tributów dla rzymskiego legionu: kto co posiada, skąd to pochodzi i jak przechodzi z rąk do rąk.
Dziedzina
Pięć obszarów, które system ma obsłużyć:
- Legionariusze - rejestracja z walidacją, rangi i uprawnienia, historia operacji każdego z nich.
- Katalog tributów - typy (złoto, srebro, klejnoty, artefakty), wycena, ocena autentyczności, historia odkrycia.
- Mapy - współrzędne geograficzne z walidacją zakresu, wskazówki poszukiwań, status odkrycia, powiązanie z konkretnym tributem.
- Transakcje i transfery - bezpieczne przeniesienie własności między legionariuszami, wymiana, pełna historia operacji.
- Legiony i kohorty - przypisanie legionariuszy, limity liczebności, centurionowie.
Nie zaczynaj od kodu. Zacznij od kartki i wypisz, co z czym jest powiązane - to jedyna decyzja w tym projekcie, której późniejsza zmiana kosztuje naprawdę dużo.
Krok 1 - schemat i relacje
Encja legionariusza pokazuje trzy rzeczy naraz: typy kolumn, indeksy i ograniczenia na poziomie bazy:
1@Entity('legionaries')
2@Check('tribute_count_non_negative', '"tributeCount" >= 0')
3@Index(['name', 'isActive'])
4export class Legionary {
5 @PrimaryGeneratedColumn()
6 id: number;
7
8 @Column({ length: 100, unique: true })
9 name: string;
10
11 @Column({ length: 50 })
12 rank: string;
13
14 @Column({ type: 'int', default: 0 })
15 tributeCount: number;
16
17 @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
18 totalTributeValue: number;
19
20 @Column({ default: true })
21 isActive: boolean;
22
23 @CreateDateColumn()
24 joinedAt: Date;
25
26 @OneToMany(() => Tribute, (tribute) => tribute.owner)
27 tributes: Tribute[];
28
29 @ManyToOne(() => Cohort, (cohort) => cohort.legionaries)
30 cohort: Cohort;
31}Trzy decyzje w tym kodzie warto rozumieć, bo będziesz je powtarzał w każdym projekcie.
Wartość tributu to decimal, nie float. Liczba zmiennoprzecinkowa nie potrafi dokładnie zapisać nawet 0,1; po tysiącu transferów sumy przestaną się zgadzać o grosze, a przy pieniądzach to koniec zaufania do systemu. precision: 15, scale: 2 znaczy: piętnaście cyfr znaczących, dwie po przecinku.
@Index(['name', 'isActive']) zakłada indeks złożony, przydatny wtedy, gdy zapytania filtrują po obu kolumnach naraz. Indeksy zakładaj po tym, jak wiesz, o co będziesz pytał - nie „na wszelki wypadek", bo każdy z nich spowalnia zapis.
@Check to ostatnia linia obrony, wpisana w samą bazę. Walidacja w DTO chroni przed złym żądaniem HTTP, ale nie przed skryptem migracyjnym, ręcznym UPDATE czy błędem w twoim własnym serwisie. Ograniczenie w bazie działa niezależnie od tego, kto pisze.
Relacje dobieraj według liczności: @OneToMany i @ManyToOne to dwie strony tej samej relacji (legionariusz ma wiele tributów, tribut ma jednego właściciela), @OneToOne pasuje do mapy przypisanej do jednego tributu, a @ManyToMany - do wymian, w których uczestniczy wielu.
Krok 2 - warstwa repozytoriów
Zapytania złożone wydzielaj do własnego repozytorium:
1@Injectable()
2export class TributeRepository extends Repository<Tribute> {
3 async findValuableByLegionary(legionaryId: number, minValue: number) {
4 return this.createQueryBuilder('tribute')
5 .innerJoinAndSelect('tribute.owner', 'owner')
6 .where('owner.id = :legionaryId', { legionaryId })
7 .andWhere('tribute.value >= :minValue', { minValue })
8 .orderBy('tribute.value', 'DESC')
9 .getMany();
10 }
11}Podział odpowiedzialności jest prosty: repozytorium wie, jak o coś zapytać bazę; serwis wie, kiedy i po co. Gdy w serwisie pojawia się createQueryBuilder, zwykle znaczy to, że metoda zaczęła robić dwie rzeczy naraz.
Zwróć uwagę na parametry :legionaryId i :minValue. To nie jest kwestia stylu - sklejanie zapytania z napisów otwiera drogę do wstrzyknięcia SQL. Query Builder zawsze przekazuje wartości osobno.
Krok 3 - transfer, czyli transakcja
Przeniesienie tributu to sedno tego projektu, bo składa się z operacji, które muszą wykonać się wszystkie albo żadna:
1async transferTribute(tributeId: number, fromId: number, toId: number) {
2 return this.dataSource.transaction(async (manager) => {
3 const tribute = await manager.findOne(Tribute, {
4 where: { id: tributeId, owner: { id: fromId } },
5 relations: ['owner'],
6 });
7
8 if (!tribute) {
9 throw new BadRequestException('Tribut nie istnieje lub nie należy do nadawcy');
10 }
11
12 tribute.owner = await manager.findOneOrFail(Legionary, { where: { id: toId } });
13 await manager.save(tribute);
14
15 await manager.decrement(Legionary, { id: fromId }, 'tributeCount', 1);
16 await manager.increment(Legionary, { id: toId }, 'tributeCount', 1);
17
18 return manager.save(Transaction, {
19 tribute,
20 fromLegionaryId: fromId,
21 toLegionaryId: toId,
22 });
23 });
24}Bez transakcji awaria w połowie zostawia system w stanie niemożliwym: tribut ma nowego właściciela, ale licznik starego się nie zmniejszył - albo, gorzej, licznik spadł i wzrósł, a właściciel został ten sam. Takie rozjazdy wychodzą na jaw miesiącami później i nie da się już ustalić, które zapisy są prawdziwe.
I teraz najważniejsza pułapka całego projektu. Wewnątrz transakcji używaj wyłącznie manager, nigdy wstrzykniętego repozytorium. Wywołanie this.tributeRepository.save(...) w tym bloku sięgnie po inne połączenie - takie, które o transakcji nic nie wie. Kod skompiluje się, testy najpewniej przejdą, a przy wycofaniu zmian ta jedna operacja zostanie zapisana mimo wszystko. Reguła jest prosta: skoro jesteś w transaction(async (manager) => ...), to manager jest twoją jedyną drogą do bazy.
Krok 4 - seedery
Dane początkowe wypełniają bazę na potrzeby developmentu i testów:
1export class LegionarySeeder {
2 constructor(private dataSource: DataSource) {}
3
4 async run() {
5 const repo = this.dataSource.getRepository(Legionary);
6
7 const existing = await repo.count();
8 if (existing > 0) {
9 return;
10 }
11
12 await repo.save([
13 { name: 'Marcus Aurelius', rank: 'Centurion' },
14 { name: 'Gaius Julius', rank: 'Optio' },
15 ]);
16 }
17}Jedno wymaganie odróżnia seeder dobry od uciążliwego: musi dać się uruchomić dwa razy. Sprawdzenie count() przed zapisem albo użycie upsert sprawia, że powtórne wywołanie niczego nie duplikuje. Bez tego każdy restart środowiska mnoży legionariuszy, a testy zaczynają zależeć od tego, ile razy ktoś wcześniej uruchomił seeder.
Kolejność też ma znaczenie: najpierw byty niezależne (legiony, kohorty), potem te, które się do nich odwołują (legionariusze), na końcu tributy i transakcje. Odwrotna kolejność kończy się błędem klucza obcego.
Walidacja na trzech poziomach
System ma trzy miejsca, w których dane mogą zostać odrzucone, i każde łapie co innego:
- DTO z
class-validator- kształt żądania HTTP. Tu odrzucisz brakującą nazwę czy ujemną wartość, zanim cokolwiek dotknie bazy. - Encja -
unique,nullable: false,@Check. Działa niezależnie od tego, którędy dane przyszły. - Serwis - reguły dziedziny, których baza nie wyrazi: „nie można przekazać tributu samemu sobie", „kohorta ma limit stu ludzi".
Zdublowanie reguły w dwóch miejscach nie jest błędem - jest tanim ubezpieczeniem.
Kryteria oceny
Projekt jest gotowy, gdy spełnia sześć warunków:
- Encje z relacjami - wszystkie cztery rodzaje tam, gdzie pasują, z indeksami na kolumnach, po których naprawdę filtrujesz.
- Migracje - schemat powstaje z migracji, nie z
synchronize: true. - Repozytoria z co najmniej dwoma zapytaniami napisanymi w Query Builderze.
- Transfer w transakcji, z rollbackiem sprawdzonym testem.
- Seedery idempotentne, w poprawnej kolejności.
- Walidacja na trzech poziomach opisanych wyżej.
Zanim wyślesz, wykonaj jeden test: przerwij transfer w połowie - podaj nieistniejącego odbiorcę - i sprawdź w bazie liczniki obu legionariuszy. Jeśli którykolwiek się zmienił, gdzieś w twoim kodzie stoi wstrzyknięte repozytorium zamiast manager.
Prześlij link do repozytorium, gdy skończysz.
Kod do tej lekcji: src/tribute-inventory-project.ts
1// PROJEKT: System Inwentaryzacji Tributow Rzymskich
2import {
3 Module, Controller, Injectable, Get, Post, Put, Delete,
4 Body, Param, Query,
5} from '@nestjs/common';
6
7console.log("=== PROJEKT: System Inwentaryzacji Tributow ===");
8
9// ===========================================
10// 1. Encje projektu
11// ===========================================
12
13// Entity: Tribute
14// id, name, province, amount, type, collectedBy, verified, createdAt
15
16// Entity: Province
17// id, name, governor, population, totalTribute
18
19// Entity: Collector (ManyToOne -> Province)
20// id, name, rank, assignedProvince
21
22// Relacja: Province OneToMany -> Tribute
23// Relacja: Collector ManyToOne -> Province
24
25// ===========================================
26// 2. Serwis z repozytorium i QueryBuilder
27// ===========================================
28
29@Injectable()
30export class TributeInventoryService {
31 private tributes = [
32 { id: 1, name: 'Aurum Galliae', province: 'Gallia', amount: 50000, type: 'gold', verified: true },
33 { id: 2, name: 'Argentum Hispaniae', province: 'Hispania', amount: 30000, type: 'silver', verified: false },
34 { id: 3, name: 'Merces Britanniae', province: 'Britannia', amount: 15000, type: 'goods', verified: true },
35 { id: 4, name: 'Aurum Aegypti', province: 'Aegyptus', amount: 80000, type: 'gold', verified: true },
36 ];
37
38 // CRUD
39 findAll(filters?: { province?: string; type?: string; verified?: boolean }) {
40 let result = [...this.tributes];
41 if (filters?.province) result = result.filter(t => t.province === filters.province);
42 if (filters?.type) result = result.filter(t => t.type === filters.type);
43 if (filters?.verified !== undefined) result = result.filter(t => t.verified === filters.verified);
44 return result;
45 }
46
47 findById(id: number) {
48 return this.tributes.find(t => t.id === id);
49 }
50
51 create(data: any) {
52 const tribute = { id: this.tributes.length + 1, verified: false, ...data };
53 this.tributes.push(tribute);
54 return tribute;
55 }
56
57 // Transakcja - transfer miedzy prowincjami
58 async transfer(fromId: number, toProvince: string) {
59 const tribute = this.findById(fromId);
60 if (tribute) {
61 const oldProvince = tribute.province;
62 tribute.province = toProvince;
63 console.log('Transfer: ' + tribute.name + ' z ' + oldProvince + ' do ' + toProvince);
64 }
65 return tribute;
66 }
67
68 // Statystyki (agregacja)
69 getStatistics() {
70 const total = this.tributes.reduce((sum, t) => sum + t.amount, 0);
71 const byProvince = this.tributes.reduce((acc, t) => {
72 acc[t.province] = (acc[t.province] || 0) + t.amount;
73 return acc;
74 }, {} as Record<string, number>);
75
76 return { total, byProvince, count: this.tributes.length };
77 }
78}
79
80// ===========================================
81// 3. Kontroler z pelnym API
82// ===========================================
83
84@Controller('inventory')
85export class InventoryController {
86 constructor(private service: TributeInventoryService) {}
87
88 @Get()
89 findAll(@Query('province') province?: string, @Query('type') type?: string) {
90 return this.service.findAll({ province, type });
91 }
92
93 @Get('stats')
94 getStats() { return this.service.getStatistics(); }
95
96 @Get(':id')
97 findOne(@Param('id') id: string) { return this.service.findById(+id); }
98
99 @Post()
100 create(@Body() data: any) { return this.service.create(data); }
101}
102
103@Module({
104 controllers: [InventoryController],
105 providers: [TributeInventoryService],
106})
107export class InventoryModule {}
108
109console.log("\n=== STRUKTURA PROJEKTU ===");
110console.log("Encje z relacjami (Province, Tribute, Collector)");
111console.log("Repository Pattern z CRUD");
112console.log("QueryBuilder do zaawansowanych zapytan");
113console.log("Transakcje do bezpiecznych transferow");
114console.log("Seedery z danymi poczatkowymi");
115console.log("Walidacja na wielu poziomach (DTO + DB constraints)");
116Widzisz błąd w tej lekcji?