Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds

Repository Pattern - organizacja skarbca

Znasz już encje i relacje - skarbiec ma spisane regały i wiadomo, co z czym się łączy. Ale kto właściwie do niego sięga? Gdyby każdy serwis w Imperium sam pisał zapytania SQL, ta sama operacja "znajdź legionistę po imieniu" powstałaby w pięciu miejscach, w pięciu wersjach, z pięcioma różnymi błędami.

Rzymianie rozwiązali to inaczej: do skarbca ma dostęp archiwista. Serwis nie schodzi do piwnicy z pochodnią - mówi archiwiście, czego szuka, a ten wie, na której półce to leży. W kodzie tym archiwistą jest repozytorium: warstwa, która oddziela logikę biznesową od sposobu, w jaki dane są przechowywane.

Trzy kroki do repozytorium

TypeORM daje repozytorium za darmo dla każdej encji - wystarczy je poprosić. Droga jest zawsze ta sama i warto ją zapamiętać jako sekwencję.

Krok pierwszy: encja. To ją poznałeś w poprzednich lekcjach - klasa z dekoratorem

@Entity()
, opisująca regał w skarbcu. Bez niej nie ma czego archiwizować.

Krok drugi: rejestracja w module. Mówimy NestJS, dla których encji ma przygotować archiwistów:

1// legionary.module.ts
2@Module({
3  imports: [TypeOrmModule.forFeature([Legionary])],
4  providers: [LegionaryService],
5  controllers: [LegionaryController],
6})
7export class LegionaryModule {}

Zwróć uwagę na nazwę:

forFeature
, nie
forRoot
. Ta różnica jest częstym źródłem pomyłek, więc rozdzielmy je raz na zawsze.
forRoot
wywołujesz raz, w module głównym - konfiguruje połączenie z bazą dla całej aplikacji.
forFeature
wywołujesz w każdym module z osobna i wyliczasz w nim tylko te encje, których ten moduł faktycznie używa. Jeden ustawia drogę do skarbca, drugi przydziela archiwistów konkretnej kohorcie.

Krok trzeci: wstrzyknięcie do serwisu. Zarejestrowanego archiwistę odbieramy w konstruktorze:

1@Injectable()
2export class LegionaryService {
3  constructor(
4    @InjectRepository(Legionary)
5    private legionaryRepo: Repository<Legionary>,
6  ) {}
7}

Dekorator

@InjectRepository(Legionary)
mówi NestJS, o którego archiwistę prosimy - bo w aplikacji jest ich wielu, po jednym na encję. Typ
Repository<Legionary>
to zapis generyczny:
Repository
to ogólny rodzaj archiwisty, a
<Legionary>
zawęża go do tej jednej encji. Dzięki temu TypeScript wie, że
find()
zwróci legionistów, a nie cokolwiek - i podpowie Ci pola przy pisaniu kodu.

Zapamiętaj tę kolejność: encja,

forFeature
w module,
@InjectRepository
w serwisie. Pominięcie środkowego kroku daje przy starcie aplikacji błąd o nieznanym providerze - i to najczęstsza przyczyna tego komunikatu.

Odczyt - find z opcjami

Archiwista przyjmuje polecenia w postaci obiektu opcji. Najważniejszy klucz to

where
:

1async findAll(): Promise<Legionary[]> {
2  return this.legionaryRepo.find();
3}
4
5async findCenturions(): Promise<Legionary[]> {
6  return this.legionaryRepo.find({
7    where: { rank: 'Centurion' },
8    relations: ['centurion'],
9    order: { name: 'ASC' },
10  });
11}

find()
bez argumentów przynosi wszystko. Z obiektem opcji - zawęża. Uwaga na jeden szczegół, na którym potyka się wielu: warunki muszą siedzieć w kluczu
where
. Zapis
find({ rank: 'Centurion' })
nie jest filtrem - to nieznana opcja, którą TypeORM zignoruje, i dostaniesz wszystkich legionistów zamiast samych centurionów. Błąd nie zostanie zgłoszony, więc łatwo go przeoczyć.

Klucz

relations
dociąga powiązane encje - to repozytoryjny odpowiednik
leftJoinAndSelect
z Query Buildera.
order
sortuje. Gdy potrzebujesz jednego rekordu, wołasz
findOne({ where: { id } })
- zwróci encję albo
null
, jeśli nic nie pasuje.

Zapis - create i save

Tworzenie nowego legionisty to para metod, i warto rozumieć, dlaczego dwie, a nie jedna:

1async create(dto: CreateLegionaryDto): Promise<Legionary> {
2  const legionary = this.legionaryRepo.create(dto);
3  return this.legionaryRepo.save(legionary);
4}

create()
nie dotyka bazy. Buduje tylko obiekt encji w pamięci - przepisuje pola z DTO i nadaje mu klasę
Legionary
, dzięki czemu zadziałają dekoratory, hooki i wartości domyślne. Dopiero
save()
wysyła go do skarbca i zwraca zapisaną encję, już z nadanym przez bazę
id
.

Ten podział ma sens praktyczny: między

create
a
save
możesz jeszcze coś zmienić albo sprawdzić.
save()
ma też drugą twarz - wywołany na encji, która ma już
id
, wykona aktualizację zamiast wstawienia. Jedna metoda, dwa zachowania, zależnie od tego, czy rekord istnieje.

Aktualizacja - preload

Do zmiany istniejącego rekordu służy

preload
, metoda o nieoczywistej nazwie:

1async update(id: number, dto: UpdateLegionaryDto): Promise<Legionary> {
2  const legionary = await this.legionaryRepo.preload({ id, ...dto });
3
4  if (!legionary) {
5    throw new NotFoundException(`Legionista ${id} nie istnieje`);
6  }
7
8  return this.legionaryRepo.save(legionary);
9}

preload
pobiera z bazy rekord o podanym
id
, nakłada na niego pola z DTO i zwraca gotową do zapisania encję - ale jeszcze nic nie zapisuje. Zaletą jest to, czego nie musisz robić: pola nieobecne w DTO zachowają swoje dotychczasowe wartości, więc częściowa aktualizacja nie wyzeruje reszty rekordu. Gdy rekordu o takim
id
nie ma,
preload
zwraca
undefined
- stąd sprawdzenie przed zapisem. Dopiero
save()
utrwala zmianę.

Usuwanie - remove i delete

Na koniec dwie metody usuwania, które robią to samo w różny sposób:

1async remove(id: number): Promise<void> {
2  const legionary = await this.legionaryRepo.findOne({ where: { id } });
3
4  if (!legionary) {
5    throw new NotFoundException(`Legionista ${id} nie istnieje`);
6  }
7
8  await this.legionaryRepo.remove(legionary);
9}

remove()
przyjmuje encję - dlatego najpierw ją pobieramy. Kosztuje to dodatkowe zapytanie, ale w zamian dostajesz pewność, że rekord istniał, i uruchamiasz hooki usuwania.
delete(id)
przyjmuje samo
id
i kasuje jednym zapytaniem - szybciej, ale bez sprawdzenia i bez hooków. Wybierz świadomie, @name:
remove
tam, gdzie liczy się poprawność i reakcja na brak rekordu,
delete
tam, gdzie liczy się szybkość.

Podsumowanie

Skarbiec ma archiwistę i nikt nie schodzi do piwnicy na własną rękę:

  • repozytorium oddziela logikę biznesową od sposobu przechowywania danych,
  • droga jest zawsze trzystopniowa: encja z
    @Entity()
    ,
    TypeOrmModule.forFeature([Encja])
    w module,
    @InjectRepository(Encja)
    w konstruktorze serwisu,
  • forRoot
    konfiguruje połączenie raz dla aplikacji,
    forFeature
    przydziela encje pojedynczemu modułowi,
  • find()
    przyjmuje opcje
    where
    ,
    relations
    i
    order
    - warunki poza kluczem
    where
    są po cichu ignorowane,
  • create()
    buduje encję w pamięci,
    save()
    zapisuje ją do bazy i potrafi też aktualizować,
  • preload()
    scala istniejący rekord ze zmianami, nie zapisując - zwraca
    undefined
    , gdy rekordu nie ma,
  • remove(encja)
    usuwa bezpieczniej i z hookami,
    delete(id)
    szybciej i bez nich.

W następnej lekcji poznasz Query Builder - narzędzie na pytania, których archiwista nie potrafi obsłużyć samym

find()
. A na razie zapamiętaj: repozytorium to archiwista skarbca - serwis mówi, czego potrzebuje, a nie jak to wyjąć z półki.

Przejdź do CodeWorlds