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.
Pięć obszarów, które system ma obsłużyć:
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.
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
, nie decimal
. 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. float
precision: 15, scale: 2 znaczy: piętnaście cyfr znaczących, dwie po przecinku.
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.@Index(['name', 'isActive'])
to ostatnia linia obrony, wpisana w samą bazę. Walidacja w DTO chroni przed złym żądaniem HTTP, ale nie przed skryptem migracyjnym, ręcznym @Check
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.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.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
, nigdy wstrzykniętego repozytorium. Wywołanie manager
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.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.
System ma trzy miejsca, w których dane mogą zostać odrzucone, i każde łapie co innego:
class-validator - kształt żądania HTTP. Tu odrzucisz brakującą nazwę czy ujemną wartość, zanim cokolwiek dotknie bazy.unique, nullable: false, @Check. Działa niezależnie od tego, którędy dane przyszły.Zdublowanie reguły w dwóch miejscach nie jest błędem - jest tanim ubezpieczeniem.
Projekt jest gotowy, gdy spełnia sześć warunków:
synchronize: true.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, @name.Prześlij link do repozytorium, gdy skończysz.