Kurs NestJS · Moduł 3: TypeORM i bazy danych
Database Validation - kontrola jakości danych
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,typeilength- twarde reguły egzekwowane przez samą bazę, @Index- spis treści dla kolumn, po których szukasz; przyspiesza odczyty kosztem zapisów,@CreateDateColumni@UpdateDateColumn- kronika prowadząca się sama,@BeforeInserti@BeforeUpdate- rytuał encji przed zapisem; subscribers - obserwator zdarzeń całej bazy,@DeleteDateColumnzsoftDelete,restoreiwithDeleted- wygnanie zamiast egzekucji,cascadeprzy zapisie ionDelete: 'CASCADE'przy usuwaniu - świadomy los rekordów powiązanych,findAndCountz 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");
128Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Jak dodać walidację na poziomie encji w TypeORM z class-validator?
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: