Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds

PROJEKT - system inwentaryzacji tributów

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
, @name.

Prześlij link do repozytorium, gdy skończysz.

Przejdź do CodeWorlds