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

Database Validation - kontrola jakości danych

Do skarbca trafił wczoraj legionista bez imienia. Dziś - dwa identyczne wpisy tego samego tributu. Konsul Caesar.js jest wściekły: przecież walidujemy dane w DTO! Owszem - ale walidacja w warstwie API chroni tylko jedną bramę. Dane wchodzą do bazy także innymi drogami: przez seeder, przez inny serwis, przez ręczne zapytanie administratora. Dlatego dojrzały system ustawia strażników warstwami: DTO sprawdza kształt żądania, serwis pilnuje reguł biznesowych, a baza danych jest ostatnią linią obrony - murem, którego nie obejdzie nikt. W tej lekcji budujemy właśnie ten mur.

Reguły wprost w kolumnach

Pierwsza linia muru to definicje kolumn. Dekorator

@Column
przyjmuje opcje, które baza zamieni na twarde ograniczenia:

1@Entity()
2export class Legionary {
3  @Column({ length: 100, nullable: false })
4  name: string;
5
6  @Column({ unique: true })
7  codeName: string;
8
9  @Column({ type: 'int', default: 0 })
10  tributeCount: number;
11
12  @Column({ type: 'boolean', default: true })
13  isActive: boolean;
14}

Przeczytajmy te opcje jak rozkazy dla bazy.

nullable: false
to SQL-owe NOT NULL - rekord bez imienia zostanie odrzucony, zanim dotknie tabeli.
unique: true
zakłada ograniczenie unikalności: drugi legionista o tym samym
codeName
wywoła błąd bazy, nie cichy duplikat.
default
uzupełnia wartość, gdy jej nie podano - nowy legionista startuje z zerem tributów, bez pisania tego w kodzie. A
type
i
length
dobierają typ kolumny do danych: liczba całkowita dla licznika, tekst o ograniczonej długości dla imienia. Zauważ, że te reguły działają zawsze - niezależnie od tego, którą drogą dane wchodzą.

Indeksy - spis treści skarbca

Skoro

codeName
ma być unikalny, baza i tak zbudowała dla niego indeks. Ale indeks przyda się także tam, gdzie często szukamy, choć unikalności nie wymagamy - dekorator
@Index
zakładamy wtedy sami:

1@Entity()
2@Index(['rank', 'isActive'])
3export class Legionary {
4  @Index()
5  @Column()
6  lastName: string;
7}

Indeks działa jak spis treści księgi inwentarzowej: zamiast kartkować całą tabelę, baza skacze prosto do właściwej strony. Pojedynczy

@Index()
nad kolumną przyspiesza wyszukiwanie po
lastName
; indeks złożony nad klasą - zapytania filtrujące jednocześnie po randze i aktywności. To najprostsza optymalizacja zapytań, jaką znasz z tej lekcji: zero zmian w kodzie serwisu, a zapytania z
where
po tych kolumnach przestają skanować całą tabelę. Cena jest jedna - każdy zapis musi zaktualizować także indeks - dlatego indeksujemy kolumny, po których naprawdę szukamy, a nie wszystkie.

Znaczniki czasu - kronika automatyczna

Kiedy rekord powstał? Kiedy go ostatnio zmieniano? Tych dwóch kolumn nie wypełniamy nigdy ręcznie:

1@CreateDateColumn()
2createdAt: Date;
3
4@UpdateDateColumn()
5updatedAt: Date;

@CreateDateColumn
dostaje datę raz, przy tworzeniu rekordu, i już się nie zmienia.
@UpdateDateColumn
odświeża się przy każdym zapisie. Kronika prowadzi się sama - a Ty zyskujesz odpowiedź na pytanie "co się tu ostatnio działo", zanim ktokolwiek je zada.

Hooki encji - rytuał przed zapisem

Czasem przed zapisem trzeba coś dopilnować: przyciąć spacje, znormalizować wielkość liter, wyliczyć pole pochodne. Do tego służą hooki - metody encji oznaczone dekoratorem, które TypeORM wywoła sam w odpowiednim momencie:

1@Entity()
2export class Legionary {
3  @Column()
4  codeName: string;
5
6  @BeforeInsert()
7  @BeforeUpdate()
8  normalizeCodeName() {
9    this.codeName = this.codeName.trim().toLowerCase();
10  }
11}

@BeforeInsert
odpala metodę tuż przed pierwszym zapisem rekordu,
@BeforeUpdate
- przed każdą aktualizacją. Tutaj oba dekoratory wiszą nad jedną metodą, więc normalizacja zadziała w obu sytuacjach: niezależnie od tego, jak niechlujnie wpisano
codeName
, do bazy trafi wersja przycięta i pisana małymi literami. Ważna granica: hook mieszka w encji i widzi tylko ją samą. Gdy potrzebujesz reagować na zapisy wielu encji w jednym miejscu (na przykład logować każdą zmianę w skarbcu), TypeORM oferuje subscribers - osobne klasy nasłuchujące zdarzeń całej bazy. Zapamiętaj podział: hook - rytuał jednej encji, subscriber - obserwator całego systemu.

Soft delete - wygnanie zamiast egzekucji

Rozkaz "usuń legionistę" bywa pochopny. Zwykłe

delete
kasuje rekord bezpowrotnie - a razem z nim historię tributów i powiązania. Dlatego dojrzałe systemy stosują soft delete: rekord dostaje znacznik usunięcia, ale fizycznie zostaje w tabeli. W TypeORM wystarczy jedna kolumna:

1@Entity()
2export class Legionary {
3  @DeleteDateColumn()
4  deletedAt: Date;
5}
6
7// usunięcie miękkie - ustawia deletedAt, rekord zostaje
8await legionaryRepository.softDelete(legionaryId);
9
10// przywrócenie - czyści deletedAt
11await legionaryRepository.restore(legionaryId);
12
13// znalezienie także usuniętych
14const all = await legionaryRepository.find({ withDeleted: true });

Obecność

@DeleteDateColumn
zmienia zachowanie całego repozytorium:
softDelete
wpisuje datę do
deletedAt
, a każde zwykłe
find
zaczyna automatycznie pomijać rekordy z ustawioną datą - usunięty legionista znika z wyników, choć w tabeli wciąż jest.
restore
czyści znacznik i legionista wraca do służby. A gdy audytor chce zobaczyć wszystkich, także wygnanych - dodaje opcję
withDeleted: true
. Egzekucja stała się wygnaniem: odwracalnym i zostawiającym ślad.

Cascade - los rekordów powiązanych

Została ostatnia decyzja strażnika: co z tributami, gdy ich właściciel znika? Odpowiedź zapisujemy w definicji relacji:

1@OneToMany(() => Tribute, (tribute) => tribute.legionariusze, {
2  cascade: true,
3})
4tributes: Tribute[];
5
6@ManyToOne(() => Legionary, { onDelete: 'CASCADE' })
7legionariusze: Legionary;

To dwie różne kaskady i warto je rozróżniać.

cascade: true
działa przy zapisie: zapisując legionistę z nowymi tributami w tablicy, TypeORM zapisze i jego, i je - jednym ruchem.
onDelete: 'CASCADE'
działa przy usuwaniu, po stronie bazy: gdy legionista znika naprawdę, baza sama usuwa jego tributy, nie zostawiając rekordów-sierot. Alternatywy dla
'CASCADE'
to między innymi
'SET NULL'
- tribut zostaje, ale traci właściciela. Wybór należy do Ciebie, @name - ale podejmij go świadomie, bo kaskada usuwania to najostrzejsze narzędzie w tej lekcji.

findAndCount - paginacja po stronie repozytorium

Na koniec spinamy kontrolę jakości z czymś, co znasz z lekcji o Query Builderze: podawaniem wyników porcjami. Repozytorium ma do tego gotową parę metod w jednej:

1const [legionaries, total] = await legionaryRepository.findAndCount({
2  where: { isActive: true },
3  order: { name: 'ASC' },
4  skip: (page - 1) * limit,
5  take: limit,
6});

findAndCount
zwraca tablicę dwuelementową: listę wyników i łączną liczbę pasujących rekordów. Zapis
const [legionaries, total] = ...
to destrukturyzacja - rozpakowujemy parę do dwóch nazwanych zmiennych w jednej linii. Reszta wygląda jak zwykłe
find
:
skip
pomija poprzednie strony,
take
pobiera porcję. To repozytoryjny bliźniak
getManyAndCount()
z Query Buildera - prostszy zapis na prostsze przypadki, a gdy warunki się komplikują, wracasz do buildera.

Podsumowanie

Mur wokół skarbca stoi. Ułożyłeś go warstwami:

  • opcje
    @Column
    :
    nullable
    ,
    unique
    ,
    default
    ,
    type
    i
    length
    - twarde reguły egzekwowane przez samą bazę,
  • @Index
    - spis treści dla kolumn, po których szukasz; przyspiesza odczyty kosztem zapisów,
  • @CreateDateColumn
    i
    @UpdateDateColumn
    - kronika prowadząca się sama,
  • @BeforeInsert
    i
    @BeforeUpdate
    - rytuał encji przed zapisem; subscribers - obserwator zdarzeń całej bazy,
  • @DeleteDateColumn
    z
    softDelete
    ,
    restore
    i
    withDeleted
    - wygnanie zamiast egzekucji,
  • cascade
    przy zapisie i
    onDelete: 'CASCADE'
    przy usuwaniu - świadomy los rekordów powiązanych,
  • findAndCount
    z destrukturyzacją
    [wyniki, licznik]
    - paginacja bez Query Buildera.

Przed Tobą projekt kończący moduł: system inwentaryzacji tributów, w którym te strażnicze mechanizmy spotkają się w jednym kodzie. A na razie zapamiętaj: walidacja w DTO chroni bramę, ale to reguły w bazie są murem - działają zawsze, niezależnie od tego, którędy dane próbują wejść.

Przejdź do CodeWorlds