Kurs NestJS · Moduł 3: TypeORM i bazy danych
Relacje w bazie danych - więzy legionu
W tej lekcji5
Masz już tabelę legionistów i tabelę legionów. Każda z osobna trzyma się dobrze - ale skąd wiadomo, kto pod kim służy? Można by wpisać nazwę legionu w kolumnę tekstową przy każdym legioniście. Do pierwszej literówki: "Legio X Equestris" i "Legio X Equestriss" to dla bazy dwa różne legiony, a Ty właśnie rozbiłeś kohortę na dwie.
Rzymianie znali lepszy sposób. Legionista nie nosi przy sobie nazwy legionu - nosi jego numer. Jeden legion, jeden numer, wszyscy wskazują na ten sam wpis w rejestrze. To jest relacja: powiązanie oparte na identyfikatorze, a nie na przepisywanym tekście.
W tej lekcji poznasz trzy rodzaje takich więzów i nauczysz się je czytać ze strony kodu.
Jeden do wielu - legion i jego legioniści
Zacznijmy od najczęstszego układu. Legion ma wielu legionistów; legionista należy do jednego legionu. Ta relacja ma dwie strony i obie trzeba opisać - każdą w swojej encji:
1@Entity()
2export class Legion {
3 @OneToMany(() => Legionary, (legionary) => legionary.legion)
4 legionaries: Legionary[];
5}
6
7@Entity()
8export class Legionary {
9 @ManyToOne(() => Legion, (legion) => legion.legionaries)
10 legion: Legion;
11}Kluczowa jest zasada, po której poznasz, gdzie co postawić: dekorator opisuje stronę, na której stoi. Legion jest "jednym", a ma "wielu" legionistów - więc w encji Legion stoi @OneToMany, a pole jest tablicą. Legionista jest jednym z "wielu" i należy do "jednego" legionu - w encji Legionary stoi @ManyToOne, a pole jest pojedyncze. Jeśli kiedykolwiek się zawahasz, przeczytaj zdanie od strony encji, którą właśnie piszesz: "legionista należy do jednego legionu" - i masz @ManyToOne.
Przyjrzyjmy się dwóm argumentom dekoratora, bo oba są obowiązkowe i oba mylą. Pierwszy, () => Legionary, wskazuje encję po drugiej stronie. Dlaczego funkcja, a nie sama nazwa klasy? Bo obie encje odwołują się do siebie nawzajem, a przy takim zapętleniu jedna z klas nie byłaby jeszcze zdefiniowana w chwili odczytu. Funkcja odracza to sięgnięcie do momentu, gdy obie już istnieją.
Drugi argument, (legionary) => legionary.legion, to strona odwrotna: wskazuje pole w tamtej encji, które opisuje tę samą relację. Dzięki niemu TypeORM wie, że Legion.legionaries i Legionary.legion to dwa końce jednego mostu, a nie dwie niezależne relacje.
A co dzieje się w bazie? Kolumna z identyfikatorem powstaje tylko po stronie @ManyToOne - w tabeli legionistów pojawi się legionId. Tabela legionów nie zmienia się wcale. To jest ten "numer noszony przy sobie": każdy legionista wskazuje na swój legion, a nie odwrotnie.
Wiele do wielu - legioniści i umiejętności
Drugi układ: legionista opanował wiele umiejętności, a każdą umiejętność opanowało wielu legionistów. Tu żadna ze stron nie może przechowywać identyfikatora - jedna kolumna nie pomieści listy.
1@Entity()
2export class Legionary {
3 @ManyToMany(() => Skill)
4 @JoinTable()
5 skills: Skill[];
6}
7
8@Entity()
9export class Skill {
10 @ManyToMany(() => Legionary, (legionary) => legionary.skills)
11 legionaries: Legionary[];
12}Rozwiązaniem jest osobna tabela pośrednia, w której jeden wiersz to jedna para: ten legionista zna tę umiejętność. Tworzy ją dla Ciebie dekorator @JoinTable() - i to jest szczegół, na którym potyka się najwięcej osób: @JoinTable() stawiamy tylko po jednej stronie, tej, którą uznajemy za właścicielkę relacji. Postawienie go po obu stronach kończy się dwiema tabelami pośrednimi opisującymi to samo. Postawienie po żadnej - błędem przy starcie aplikacji.
Zwróć uwagę, że @ManyToMany po stronie Legionary obywa się bez drugiego argumentu. Strona odwrotna jest opcjonalna: podajesz ją wtedy, gdy chcesz też z drugiej strony sięgać po powiązania - tu z umiejętności odczytać, kto ją posiadł.
Jeden do jednego - legionista i jego mapa
Trzeci układ jest najprostszy: legionista ma jedną osobistą mapę tributów, a mapa należy do jednego legionisty.
1@Entity()
2export class Legionary {
3 @OneToOne(() => TributeMap)
4 @JoinColumn()
5 map: TributeMap;
6}Tu decyzję o tym, w której tabeli wyląduje kolumna z identyfikatorem, podejmujesz sam - wskazuje ją @JoinColumn(). Postawiony w encji Legionary sprawia, że to tabela legionistów dostanie kolumnę mapId. Nie myl go z @JoinTable() z poprzedniej sekcji: @JoinColumn wskazuje kolumnę w istniejącej tabeli, @JoinTable tworzy całą nową tabelę pośrednią.
Pobieranie relacji
Relacja opisana w encji nie znaczy, że dane przyjdą same. Domyślnie find() przynosi sam rekord, a pole relacji zostaje puste - i to celowo, bo inaczej pobranie jednego legionisty ciągnęłoby za sobą pół bazy. Powiązania dociągasz świadomie:
1const legionaries = await this.legionaryRepo.find({
2 relations: ['legion', 'skills'],
3});Klucz relations wylicza, co ma dojechać razem z rekordem. To jest jawne ładowanie i domyślny sposób pracy: w każdym zapytaniu decydujesz osobno, czego potrzebujesz.
Jest też wariant automatyczny. Dopisanie eager: true do relacji sprawia, że dociągnie się ona zawsze, bez proszenia:
1@ManyToOne(() => Legion, (legion) => legion.legionaries, { eager: true })
2legion: Legion;Wygodne - i właśnie dlatego niebezpieczne. eager działa w każdym zapytaniu o legionistów, także tam, gdzie legion jest zupełnie niepotrzebny, i po cichu obciąża zapytania w całej aplikacji. Zauważ też, czego eager nie zmienia: to nadal jedno zapytanie z JOIN-em, tylko wykonywane zawsze zamiast na żądanie. Dlatego polecam trzymać się relations i sięgać po eager wyjątkowo - gdy relacja naprawdę jest potrzebna za każdym razem.
Podsumowanie
Legion trzyma się razem, a Ty umiesz opisać jego więzy:
- relacja łączy tabele przez identyfikator, nie przez przepisywany tekst,
- dekorator opisuje stronę, na której stoi:
@OneToManypo stronie "jednego" (pole tablicowe),@ManyToOnepo stronie "wielu" (pole pojedyncze), - pierwszy argument to funkcja
() => Encja- odracza odczyt klasy, bo encje wskazują na siebie nawzajem, - drugi argument wskazuje stronę odwrotną, czyli drugi koniec tego samego mostu,
- kolumna z identyfikatorem powstaje po stronie
@ManyToOne, @ManyToManywymaga tabeli pośredniej:@JoinTable()tylko po jednej stronie,@OneToOnez@JoinColumn()decyduje, w której tabeli wyląduje kolumna -@JoinColumnwskazuje kolumnę,@JoinTabletworzy nową tabelę,- relacje pobierasz jawnie przez
relations: ['...'];eager: truerobi to zawsze i dlatego stosuj go oszczędnie.
W następnej lekcji poznasz repozytoria - archiwistów, którzy sięgają po te dane w imieniu serwisów. A na razie zapamiętaj: relacja to numer noszony przy sobie - jeden wpis w rejestrze, na który wskazują wszyscy, zamiast nazwy przepisywanej przy każdym legioniście.
Kod do tej lekcji: src/relations.ts
1// Relacje w Bazie Danych - Wiezy Miedzy Legionariuszami
2import {
3 Entity, Column, PrimaryGeneratedColumn,
4 OneToMany, ManyToOne, ManyToMany,
5 JoinColumn, JoinTable, OneToOne,
6} from 'typeorm';
7
8console.log("Relacje TypeORM - wiezi miedzy danymi imperium!");
9
10// ===========================================
11// 1. OneToMany / ManyToOne - Legion i Zolnierze
12// ===========================================
13
14@Entity('legions')
15export class Legion {
16 @PrimaryGeneratedColumn()
17 id: number;
18
19 @Column()
20 name: string;
21
22 @Column()
23 commander: string;
24
25 @OneToMany(() => Soldier, soldier => soldier.legion)
26 soldiers: Soldier[];
27}
28
29@Entity('soldiers')
30export class Soldier {
31 @PrimaryGeneratedColumn()
32 id: number;
33
34 @Column()
35 name: string;
36
37 @Column()
38 rank: string;
39
40 @ManyToOne(() => Legion, legion => legion.soldiers)
41 @JoinColumn({ name: 'legion_id' })
42 legion: Legion;
43}
44
45// ===========================================
46// 2. OneToOne - Legionista i Mapa
47// ===========================================
48
49@Entity('tribute_maps')
50export class TributeMap {
51 @PrimaryGeneratedColumn()
52 id: number;
53
54 @Column()
55 location: string;
56
57 @Column()
58 description: string;
59
60 @OneToOne(() => Soldier)
61 @JoinColumn()
62 owner: Soldier;
63}
64
65// ===========================================
66// 3. ManyToMany - Bitwy i Legiony
67// ===========================================
68
69@Entity('battles')
70export class Battle {
71 @PrimaryGeneratedColumn()
72 id: number;
73
74 @Column()
75 name: string;
76
77 @Column()
78 location: string;
79
80 @ManyToMany(() => Legion)
81 @JoinTable({
82 name: 'battle_legions',
83 joinColumn: { name: 'battle_id' },
84 inverseJoinColumn: { name: 'legion_id' },
85 })
86 legions: Legion[];
87}
88
89// ===========================================
90// 4. Ladowanie relacji (eager vs lazy)
91// ===========================================
92
93// Eager loading (automatyczne):
94// @OneToMany(() => Soldier, s => s.legion, { eager: true })
95// soldiers: Soldier[];
96
97// Lazy loading (na zadanie):
98// const legion = await legionRepo.findOne({
99// where: { id: 1 },
100// relations: ['soldiers'],
101// });
102
103// Kaskadowe operacje:
104// @OneToMany(() => Soldier, s => s.legion, { cascade: true })
105// soldiers: Soldier[];
106
107console.log("\n=== PODSUMOWANIE RELACJI ===");
108console.log("@OneToMany + @ManyToOne - jeden do wielu (legion -> zolnierze)");
109console.log("@OneToOne - jeden do jednego (legionista -> mapa)");
110console.log("@ManyToMany + @JoinTable - wiele do wielu (bitwy -> legiony)");
111console.log("@JoinColumn - strona posiadajaca klucz obcy");
112console.log("relations: ['x'] - ladowanie relacji w zapytaniu");
113Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Co oznacza relacja @OneToMany w TypeORM?
2. Po której stronie relacji umieszcza się @ManyToOne?
To 2 z 3 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Edytor kodu
Napisz encje Legion z @OneToMany(() => Legionary, ...) i Legionary z @ManyToOne(() => Legion, ...)
- Układanie w poziomie
Ułóż elementy deklaracji @OneToMany w prawidłowej kolejności:
- Klikanie w kolejności
Ułóż elementy konfiguracji eager loading relacji w prawidłowej kolejności:
- Edytor kodu
Napisz encję Legionary z @ManyToMany(() => Skill) i @JoinTable() oraz encję Skill z @ManyToMany(() => Legionary)