Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds
Powrót do kolekcji
Przewodnik11 min czytania

Drizzle ORM, typowany SQL bez warstwy magii

Drizzle ORM daje typowane zapytania SQL w TypeScripcie. Schemat, migracje, zapytania relacyjne, praca w środowisku brzegowym i porównanie z Prismą.

Drizzle ORM, SQL, który zna Twoje typy

Większość narzędzi mapujących bazę na obiekty zaczyna od ukrycia SQL i kończy na tym, że i tak musisz wiedzieć, jakie zapytanie powstało. Drizzle idzie inaczej: zapytanie piszesz w składni bliskiej SQL, a TypeScript sprawdza nazwy kolumn, typy warunków i kształt wyniku.

Efekt jest taki, że nie uczysz się nowego języka zapytań, tylko zyskujesz podpowiadanie i sprawdzanie w tym, który już znasz. To najkrótszy opis tego, czym ta biblioteka różni się od poprzedniej generacji narzędzi.

Schemat w TypeScripcie

Tabele opisujesz kodem, a typy wynikają z tego opisu automatycznie. Nie ma osobnego języka schematu ani kroku generowania klienta.

Code
TypeScript
import { pgTable, serial, text, timestamp, boolean, integer } from 'drizzle-orm/pg-core'

export const uzytkownicy = pgTable('uzytkownicy', {
  id: serial('id').primaryKey(),
  email: text('email').notNull().unique(),
  nazwa: text('nazwa').notNull(),
  utworzony: timestamp('utworzony').defaultNow().notNull()
})

export const zadania = pgTable('zadania', {
  id: serial('id').primaryKey(),
  tytul: text('tytul').notNull(),
  zrobione: boolean('zrobione').default(false).notNull(),
  autorId: integer('autor_id').references(() => uzytkownicy.id).notNull()
})

export type Zadanie = typeof zadania.$inferSelect
export type NoweZadanie = typeof zadania.$inferInsert

Dwa typy na końcu są tym, co w praktyce oszczędza najwięcej czasu. Pierwszy opisuje wiersz odczytany z bazy, drugi dane potrzebne do wstawienia, gdzie kolumny z wartością domyślną są opcjonalne. Zmiana schematu natychmiast przenosi się do obu.

Nazwa kolumny w bazie i nazwa pola w kodzie są rozdzielone, więc baza może używać podkreśleń, a kod notacji wielbłądziej, bez warstwy tłumaczącej w środku. To drobiazg, który usuwa jeden z częstszych powodów niezgody między zespołem bazodanowym a aplikacyjnym.

Zapytania

Składnia odpowiada SQL na tyle, że przekład w obie strony jest oczywisty.

Code
TypeScript
const otwarte = await db
  .select({ id: zadania.id, tytul: zadania.tytul, autor: uzytkownicy.nazwa })
  .from(zadania)
  .innerJoin(uzytkownicy, eq(zadania.autorId, uzytkownicy.id))
  .where(and(eq(zadania.zrobione, false), eq(uzytkownicy.id, autorId)))
  .orderBy(desc(zadania.id))
  .limit(20)

Typ wyniku wynika z tego, co wybrałeś, więc odwołanie do pola spoza listy jest błędem kompilacji, a nie niespodzianką w czasie działania. Ta jedna właściwość usuwa całą kategorię pomyłek przy zmianie zapytania.

Gdy potrzebujesz czegoś, czego nie da się wyrazić w tym interfejsie, piszesz zwykły SQL i nadal masz nad nim kontrolę typów.

Code
TypeScript
const statystyki = await db.execute(sql`
  SELECT autor_id, count(*)::int AS liczba
  FROM zadania
  WHERE zrobione = true
  GROUP BY autor_id
`)

To ważna właściwość praktyczna. W narzędziach ukrywających SQL zejście do zapytania surowego zwykle oznacza utratę typów i wyjście poza model. Tutaj jest to normalna droga, a nie wyjście awaryjne.

Zapytania relacyjne

Sam interfejs w stylu SQL zwraca płaskie wiersze, więc pobranie zadania razem z autorem i komentarzami wymaga złączeń i ręcznego składania struktury. Do tego służy drugi interfejs, zwracający dane w postaci zagnieżdżonej.

Code
TypeScript
const wynik = await db.query.zadania.findMany({
  where: (z, { eq }) => eq(z.zrobione, false),
  with: {
    autor: true,
    komentarze: { limit: 5, orderBy: (k, { desc }) => desc(k.utworzony) }
  }
})

Kluczowa zaleta polega na tym, że powstaje jedno zapytanie do bazy, a nie osobne dla każdej relacji. Problem wielu zapytań, znany z narzędzi poprzedniej generacji, po prostu tu nie występuje.

Druga wersja tego interfejsu, dostępna w wydaniu wstępnym, dokłada możliwości, których pierwsza nie miała: pełne operatory logiczne w warunkach oraz filtrowanie wierszy nadrzędnych po kolumnie z relacji podrzędnej. Ta druga rzecz była najczęściej zgłaszanym brakiem, bo zapytanie o użytkowników mających choć jedno otwarte zadanie wymagało zejścia do złączeń.

Zmieniła się też składnia deklarowania relacji, więc migracja wymaga przepisania pliku z definicjami. Warto to zaplanować, zamiast odkryć w trakcie aktualizacji.

Migracje

Narzędzie towarzyszące porównuje schemat w kodzie ze stanem bazy i generuje pliki SQL z różnicą.

Code
Bash
pnpm drizzle-kit generate
pnpm drizzle-kit migrate

Wygenerowany plik jest zwykłym SQL, który możesz przeczytać i poprawić przed uruchomieniem. To istotne przy zmianach ryzykownych, jak usunięcie kolumny albo zmiana typu, gdzie automat nie wie, co zrobić z istniejącymi danymi.

Jest też tryb wypychający zmiany wprost do bazy bez pliku migracji. Sprawdza się przy prototypowaniu i w środowisku deweloperskim, natomiast na produkcji chcesz mieć historię zmian w repozytorium, więc trzymaj się migracji z plikami.

Osobne narzędzie graficzne pozwala przeglądać dane i schemat w przeglądarce. Ostatnie wydania rozbudowały widok schematu i dołożyły edytor wieloliniowy, co czyni je użytecznym zamiennikiem klienta bazodanowego przy codziennej pracy.

Praca w środowisku brzegowym

To obszar, w którym ta biblioteka wypadła lepiej od konkurencji i zyskała większość użytkowników. Brak zależności w czasie działania i brak silnika binarnego oznaczają, że kod działa tam, gdzie narzędzia poprzedniej generacji wymagały obejść.

Code
TypeScript
import { drizzle } from 'drizzle-orm/neon-http'
import { neon } from '@neondatabase/serverless'

const db = drizzle(neon(process.env.DATABASE_URL!), { schema })

Przy funkcjach bezserwerowych i środowisku brzegowym w Next.js liczy się też czas zimnego startu, a on rośnie razem z rozmiarem paczki. Biblioteka bez silnika binarnego dokłada tu ułamek tego, co rozwiązania z osobnym procesem.

Osobną sprawą jest pula połączeń. Baza ma ograniczoną liczbę połączeń, a funkcje bezserwerowe potrafią uruchomić się w setkach instancji naraz. Rozwiązaniem jest sterownik działający po HTTP albo pula połączeń po stronie dostawcy bazy, nie sama biblioteka.

Drugą drogą jest baza mówiąca po HTTP z założenia. Turso, czyli baza zgodna z SQLite uruchamiana jako usługa, dokłada do tego replikę osadzoną w procesie aplikacji, więc odczyt przestaje być wywołaniem sieciowym. Sterownik libSQL podpina się tu tak samo jak każdy inny, a cena jest podwójna: zapisy nadal idą do instancji głównej i wracają do repliki z opóźnieniem, a cała baza musi zmieścić się w aplikacji.

Transakcje i operacje złożone

Operacje dotykające kilku tabel naraz muszą wykonać się w całości albo wcale. Transakcja obejmuje blok kodu, a wyjątek wewnątrz cofa wszystko.

Code
TypeScript
await db.transaction(async (tx) => {
  const [zamowienie] = await tx
    .insert(zamowienia)
    .values({ klientId, kwota })
    .returning()

  await tx.insert(pozycje).values(
    koszyk.map((p) => ({ zamowienieId: zamowienie.id, produktId: p.id, ilosc: p.ilosc }))
  )

  await tx
    .update(magazyn)
    .set({ stan: sql`${magazyn.stan} - ${1}` })
    .where(inArray(magazyn.produktId, koszyk.map((p) => p.id)))
})

Zwróć uwagę na aktualizację stanu magazynu wyrażoną przez odwołanie do bieżącej wartości kolumny, a nie przez odczyt i zapis w kodzie. Ta druga droga otwiera wyścig, w którym dwa równoległe zamówienia odczytają ten sam stan i zapiszą tę samą wartość, gubiąc jedno odjęcie.

Warto też pamiętać, że wewnątrz transakcji trzeba używać przekazanego obiektu, a nie globalnego połączenia. Wywołanie na tym drugim wykona się poza transakcją i nie cofnie się przy błędzie, co jest błędem trudnym do zauważenia, bo kod wygląda poprawnie.

Wstawianie wielu wierszy naraz rób jednym wywołaniem z listą wartości, a nie pętlą. Różnica przy tysiącu wierszy to sekunda wobec kilkudziesięciu, bo każde osobne wywołanie to osobna podróż do bazy.

Testy i środowisko deweloperskie

Baza w testach to obszar, w którym łatwo o rozwiązania pozornie wygodne, a kosztowne w utrzymaniu. Podmiana warstwy dostępu na atrapę sprawia, że testujesz własne atrapy, a nie zapytania.

Praktyczne podejście polega na uruchomieniu prawdziwej bazy w kontenerze i wykonaniu migracji przed zestawem testów. Zapytania sprawdzają się wtedy naprawdę, łącznie z warunkami, indeksami i ograniczeniami.

Izolacja między testami najprościej wychodzi przez transakcję cofaną po każdym przypadku. Test wykonuje się w transakcji, a jej wycofanie przywraca stan początkowy szybciej niż czyszczenie tabel.

Osobna sprawa to dane początkowe. Funkcja wypełniająca bazę zestawem realistycznych rekordów, trzymana w repozytorium, przydaje się i w testach, i przy wdrażaniu nowej osoby do projektu. Warto ją napisać wcześnie, bo później nikt nie ma na to czasu.

Drizzle kontra alternatywy

NarzędzieMocna stronaSłabośćKiedy wybrać
DrizzleBliskość SQL, typy bez generowania, lekkośćMniej gotowych rozwiązań na typowe problemyZespół znający SQL, środowisko brzegowe
PrismaDojrzały ekosystem, czytelny język schematuCięższy klient, mniej kontroli nad SQLZespół ceniący wygodę nad kontrolą
KyselyCzysty budowniczy zapytań, bardzo lekkiBrak migracji i warstwy relacyjnejProjekt, w którym wystarczy sam SQL z typami
SupabaseBaza z API i uwierzytelnianiem w komplecieInna kategoria, to platformaSzybki start bez własnego zaplecza

Wybór między dwoma pierwszymi sprowadza się do jednego pytania: czy zespół zna SQL i chce nad nim panować. Jeśli tak, ta biblioteka daje typy bez odbierania kontroli. Jeśli nie, dojrzalszy ekosystem konkurencji oszczędza uczenia się.

Jest jeszcze argument, który rzadko pada, a bywa rozstrzygający: koszt wyjścia. Zapytania napisane w składni bliskiej SQL przekłada się na czysty SQL niemal mechanicznie, więc porzucenie biblioteki po dwóch latach nie oznacza przepisania warstwy dostępu od zera. Przy narzędziu z własnym językiem zapytań ten koszt jest znacznie wyższy, co warto uwzględnić przy projekcie, który ma żyć długo.

Warto dodać, że dwa ostatnie wiersze to inne kategorie. Budowniczy zapytań nie ma migracji ani zapytań relacyjnych, a platforma bazodanowa to całe zaplecze, w którym ta biblioteka może pełnić rolę warstwy dostępu.

Wydajność i miejsca, w których się traci

Typowany interfejs nie chroni przed napisaniem wolnego zapytania, więc kilka rzeczy warto sprawdzać samodzielnie.

Pierwsza to plan zapytania. Metoda zwracająca gotowy SQL pozwala wkleić go do klienta bazodanowego i sprawdzić, czy używany jest indeks.

Code
TypeScript
const zapytanie = db.select().from(zadania).where(eq(zadania.autorId, 42))
console.log(zapytanie.toSQL())

Druga to liczba podróży do bazy w jednym żądaniu. Kod pobierający listę, a potem w pętli dociągający dane dla każdego elementu, wygląda niewinnie i generuje sto zapytań zamiast jednego. Interfejs relacyjny albo złączenie rozwiązuje to jednym wywołaniem.

Trzecia to zapytania przygotowane. Zapytanie wykonywane często można przygotować raz i wywoływać z parametrami, oszczędzając czas na budowaniu SQL po stronie biblioteki.

Code
TypeScript
const poAutorze = db
  .select()
  .from(zadania)
  .where(eq(zadania.autorId, sql.placeholder('autor')))
  .prepare('zadania_po_autorze')

const wynik = await poAutorze.execute({ autor: 42 })

Czwarta to stronicowanie po przesunięciu przy dużych tabelach. Pominięcie stu tysięcy wierszy wymaga ich przejrzenia, więc przy głębokich stronach lepiej sprawdza się stronicowanie po kluczu, czyli warunek na identyfikatorze ostatniego widzianego wiersza. Różnica jest zauważalna dopiero przy dużych zbiorach, ale wtedy jest to różnica między setką milisekund a kilkoma sekundami.

Typowe błędy

Pierwszy to używanie trybu wypychania na produkcji. Bez pliku migracji nie ma historii zmian ani możliwości cofnięcia, a różnica wykryta automatycznie bywa zinterpretowana inaczej, niż zakładałeś.

Drugi to brak indeksów na kolumnach używanych w warunkach. Biblioteka pozwala je zadeklarować w schemacie, więc nie ma powodu, żeby czekać na problem z wydajnością.

Trzeci to pobieranie wszystkich kolumn, gdy potrzebne są trzy. Interfejs pozwala wybrać dokładnie to, czego potrzebujesz, a przy szerokich tabelach różnica w przesyle jest odczuwalna.

Czwarty to zapominanie o transakcjach przy operacjach powiązanych. Wstawienie zamówienia i jego pozycji bez transakcji zostawia przy błędzie zamówienie bez pozycji.

Piąty to trzymanie połączenia jako zmiennej modułu w środowisku bezserwerowym bez sprawdzenia, jak dostawca zarządza instancjami. Przy złej konfiguracji wyczerpiesz limit połączeń do bazy przy pierwszym większym ruchu.

Szósty to pomijanie przeglądu wygenerowanego pliku migracji. To zwykły SQL i przeczytanie go zajmuje minutę, a chroni przed utratą danych przy zmianie typu kolumny.

Migracja z innego narzędzia

Przejście z narzędzia poprzedniej generacji rzadko wymaga przepisania wszystkiego naraz i lepiej wychodzi rozłożone w czasie.

Pierwszy krok to odtworzenie schematu. Narzędzie towarzyszące potrafi wczytać istniejącą bazę i wygenerować z niej plik schematu, więc nie przepisujesz tabel ręcznie. Wygenerowany plik warto przejrzeć, bo nazwy i typy nie zawsze wypadają tak, jak byś je napisał.

Drugi to współistnienie. Obie biblioteki mogą działać obok siebie na tej samej bazie, bo żadna nie wymaga wyłączności. Nowe zapytania piszesz w nowej, stare zostawiasz, dopóki działają.

Trzeci to migracje. Tu wyłączność jest konieczna, bo dwa narzędzia śledzące stan schematu wejdą sobie w drogę. Ustal jedno miejsce, w którym powstają migracje, i trzymaj się go od początku przejścia.

Czwarty to kolejność przepisywania. Zacznij od zapytań najczęściej zmienianych, bo tam typy dają najwięcej. Zapytania stabilne, których nikt nie rusza od roku, przepisz na końcu albo wcale, skoro działają i nikt ich nie dotyka.

FAQ

Drizzle czy Prisma?

Drizzle wybierzesz, gdy zespół zna SQL i chce mieć kontrolę nad zapytaniami, gdy zależy Ci na lekkości albo gdy pracujesz w środowisku brzegowym. Prisma wygrywa dojrzałością ekosystemu i czytelnym językiem schematu, więc bywa lepsza dla zespołu, który wolałby o SQL nie myśleć.

Czy Drizzle nadaje się na produkcję?

Tak, biblioteka jest używana produkcyjnie od lat i ma stabilny interfejs podstawowy. Warto natomiast sprawdzać status funkcji, po które sięgasz, bo część nowości, jak druga wersja zapytań relacyjnych, bywa udostępniana najpierw w wydaniach wstępnych.

Jakie bazy są obsługiwane?

PostgreSQL, MySQL i SQLite wraz z ich wariantami u dostawców chmurowych, w tym rozwiązaniami bezserwerowymi. Schemat i zapytania piszesz podobnie w każdym przypadku, przy czym typy kolumn i część funkcji są specyficzne dla konkretnej bazy.

Czy potrzebny jest krok generowania kodu?

Nie do typów, bo wynikają one bezpośrednio ze schematu w TypeScripcie. Krok generowania dotyczy wyłącznie plików migracji, czyli SQL opisującego zmiany w bazie, i uruchamiasz go świadomie, gdy zmieniasz schemat.

Jak to działa z wyszukiwaniem wektorowym?

Przez rozszerzenie bazy, na przykład pgvector, które deklarujesz w schemacie jako typ kolumny i odpytujesz zwykłym zapytaniem. Nie potrzebujesz osobnej warstwy, bo to nadal ta sama baza i to samo połączenie.

Dokumentacja stoi na stronie projektu, a opis zmian w zapytaniach relacyjnych znajdziesz w przewodniku migracji.