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.
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ą.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.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.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.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.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.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.Mur wokół skarbca stoi. Ułożyłeś go warstwami:
@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ść.