Kurs NestJS · Moduł 3: TypeORM i bazy danych

Relacje w bazie danych - więzy legionu

5 min czytania
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: @OneToMany po stronie "jednego" (pole tablicowe), @ManyToOne po 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,
  • @ManyToMany wymaga tabeli pośredniej: @JoinTable() tylko po jednej stronie,
  • @OneToOne z @JoinColumn() decyduje, w której tabeli wyląduje kolumna - @JoinColumn wskazuje kolumnę, @JoinTable tworzy nową tabelę,
  • relacje pobierasz jawnie przez relations: ['...']; eager: true robi 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");
113

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. Co oznacza relacja @OneToMany w TypeORM?

  2. 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)

Przydatne artykuły