Kurs NestJS · Moduł 3: TypeORM i bazy danych

PROJEKT - system inwentaryzacji tributów

6 min czytania
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ć:

  1. Legionariusze - rejestracja z walidacją, rangi i uprawnienia, historia operacji każdego z nich.
  2. Katalog tributów - typy (złoto, srebro, klejnoty, artefakty), wycena, ocena autentyczności, historia odkrycia.
  3. Mapy - współrzędne geograficzne z walidacją zakresu, wskazówki poszukiwań, status odkrycia, powiązanie z konkretnym tributem.
  4. Transakcje i transfery - bezpieczne przeniesienie własności między legionariuszami, wymiana, pełna historia operacji.
  5. 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:

  1. Encje z relacjami - wszystkie cztery rodzaje tam, gdzie pasują, z indeksami na kolumnach, po których naprawdę filtrujesz.
  2. Migracje - schemat powstaje z migracji, nie z synchronize: true.
  3. Repozytoria z co najmniej dwoma zapytaniami napisanymi w Query Builderze.
  4. Transfer w transakcji, z rollbackiem sprawdzonym testem.
  5. Seedery idempotentne, w poprawnej kolejności.
  6. 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)");
116

Widzisz błąd w tej lekcji?

Przydatne artykuły