Kurs NestJS · Moduł 3: TypeORM i bazy danych

Database Validation - kontrola jakości danych

6 min czytania
W tej lekcji8

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 - 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ść.

Kod do tej lekcji: src/database-validation.ts
1// Database Validation - Kontrola Jakosci Danych
2import {
3  Entity, Column, PrimaryGeneratedColumn, Index,
4  CreateDateColumn, UpdateDateColumn, DeleteDateColumn,
5  Check, BeforeInsert, BeforeUpdate, AfterInsert,
6} from 'typeorm';
7
8console.log("Walidacja bazy danych - ostatnia linia obrony!");
9
10// ===========================================
11// 1. Constraints na kolumnach
12// ===========================================
13
14@Entity('tribute_records')
15@Index(['provincia', 'year']) // Indeks zlozony
16export class TributeRecord {
17  @PrimaryGeneratedColumn()
18  id: number;
19
20  @Column({ length: 100 })
21  provincia: string;
22
23  @Column({ type: 'decimal', precision: 12, scale: 2 })
24  @Check('"amount" > 0') // Wartosc musi byc dodatnia
25  amount: number;
26
27  @Column({ type: 'int' })
28  year: number;
29
30  @Index() // Indeks na kolumnie
31  @Column({ length: 100 })
32  collector: string;
33
34  @Column({ type: 'enum', enum: ['gold', 'silver', 'goods'], default: 'gold' })
35  type: string;
36
37  @CreateDateColumn()
38  createdAt: Date;
39}
40
41// ===========================================
42// 2. Entity hooks (lifecycle events)
43// ===========================================
44
45@Entity('centurions')
46export class Centurion {
47  @PrimaryGeneratedColumn()
48  id: number;
49
50  @Column()
51  name: string;
52
53  @Column()
54  rank: string;
55
56  @Column({ default: 0 })
57  experience: number;
58
59  @Column({ nullable: true })
60  slug: string;
61
62  @CreateDateColumn()
63  createdAt: Date;
64
65  @UpdateDateColumn()
66  updatedAt: Date;
67
68  @BeforeInsert()
69  generateSlug() {
70    if (this.name) {
71      this.slug = this.name.toLowerCase().replace(/ /g, '-');
72    }
73  }
74
75  @BeforeUpdate()
76  validateExperience() {
77    if (this.experience < 0) {
78      this.experience = 0;
79    }
80  }
81
82  @AfterInsert()
83  logCreation() {
84    console.log('Nowy centurion zaciagniety: ' + this.name);
85  }
86}
87
88// ===========================================
89// 3. Soft delete
90// ===========================================
91
92@Entity('decrees')
93export class Decree {
94  @PrimaryGeneratedColumn()
95  id: number;
96
97  @Column({ length: 200 })
98  title: string;
99
100  @Column({ type: 'text' })
101  content: string;
102
103  @Column({ type: 'boolean', default: true })
104  isActive: boolean;
105
106  @CreateDateColumn()
107  createdAt: Date;
108
109  @UpdateDateColumn()
110  updatedAt: Date;
111
112  @DeleteDateColumn() // Soft delete - nie usuwaj naprawde
113  deletedAt: Date;
114}
115
116// Uzycie soft delete:
117// await decreeRepo.softDelete(id);     // Ustawia deletedAt
118// await decreeRepo.restore(id);        // Przywraca
119// await decreeRepo.find();             // Pomija soft-deleted
120// await decreeRepo.find({ withDeleted: true }); // Wszystkie
121
122console.log("\n=== PODSUMOWANIE WALIDACJI DB ===");
123console.log("@Check() - constrainty na poziomie bazy danych");
124console.log("@Index() - indeksy przyspieszajace wyszukiwanie");
125console.log("@BeforeInsert/@BeforeUpdate - hooki cyklu zycia");
126console.log("@DeleteDateColumn - soft delete (nie usuwaj naprawde)");
127console.log("unique: true - unikalnosc kolumny");
128

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Jak dodać walidację na poziomie encji w TypeORM z class-validator?

  2. 2. Jak oznaczyć kolumnę jako unikalną w encji TypeORM?

To 2 z 6 pytań do tej lekcji. Pozostałe rozwiążesz w grze.

Zadania praktyczne w grze

  • Edytor kodu

    Napisz encję Province z @Index() na nazwie, @Column({ unique: true }) dla kodu i dekoratorami @IsString(), @Length(2, 100)

  • Układanie w pionie

    Uporządkuj warstwy walidacji danych od zewnętrznej do wewnętrznej:

  • Klikanie w kolejności

    Ułóż elementy definicji kolumny z wartością domyślną i NOT NULL:

  • Edytor kodu

    Napisz encję z @BeforeInsert() który ustawia createdAt na new Date() i @BeforeUpdate() który ustawia updatedAt

  • Układanie w poziomie

    Ułóż elementy dekoratora @Index dla encji z wieloma kolumnami:

  • Układanie w pionie

    Uporządkuj typy kolumn TypeORM od najprostszego do najbardziej złożonego:

  • Edytor kodu

    Napisz encję z @CreateDateColumn(), @UpdateDateColumn() i @DeleteDateColumn() dla soft delete

  • Klikanie w kolejności

    Ułóż elementy soft delete za pomocą repozytorium w prawidłowej kolejności:

  • Układanie w poziomie

    Ułóż elementy zapytania o rekordy włącznie z soft-deleted:

  • Układanie w pionie

    Uporządkuj techniki optymalizacji zapytań od najprostszej do najbardziej zaawansowanej:

  • Edytor kodu

    Napisz metodę findPaginated(page, limit), która używa findAndCount z skip i take do paginacji legionistów

  • Układanie w poziomie

    Ułóż elementy destrukturyzacji wyniku findAndCount:

Przydatne artykuły