Zum Inhalt springen
DasPeTe Tutorials

Eine schnelle GraphQL-API mit Fastify und Yoga · Teil 2 von 8 der Serie

Teil 2: Eine Datenbank mit PostgreSQL und Drizzle

Starte PostgreSQL in Docker, beschreib die Tabellen des Gildenbretts in TypeScript mit Drizzle, leg sie mit einer Migration an und füll sie mit Beispieldaten.

Quest-Ziele

  • PostgreSQL mit Docker Compose betreiben
  • Tabellen in TypeScript mit Drizzle ORM schreiben
  • Eine Migration erzeugen und anwenden
  • Die Datenbank mit Gilden, Abenteurern und Quests füllen

Das Gildenbrett kennt drei Arten von Dingen. Eine Gilde hat einen Namen und ein Motto. Ein Abenteurer gehört zu einer Gilde und hat eine Klasse und eine Stufe. Eine Quest wird von einer Gilde ausgeschrieben, zahlt eine Belohnung und geht von OPEN auf ACCEPTED, sobald ein Abenteurer sie annimmt, und auf DONE, wenn sie erledigt ist.

Sie leben in PostgreSQL. Die Tabellen beschreibst du mit Drizzle, einem ORM, dessen Abfragen wie SQL aussehen und dessen Ergebnisse aus den Tabellendefinitionen typisiert sind. Und weil es jede Abfrage zeigt, die es schickt, siehst du genau, was deine GraphQL-API die Datenbank fragt.

PostgreSQL in Docker

Docker betreibt PostgreSQL für dich, es gibt also nichts zu installieren. Prüf, ob Docker da ist:

~/projects/guild-api
$ docker --versionDocker version 29.9.2, build a82e528

Leg im Projekt eine compose.yml an:

compose.yml
services:
  postgres:
    image: postgres:18-alpine
    environment:
      POSTGRES_USER: guild
      POSTGRES_PASSWORD: guild
      POSTGRES_DB: guild
    ports:
      - 127.0.0.1:5432:5432
    volumes:
      - postgres-data:/var/lib/postgresql

volumes:
  postgres-data:

Der Port ist an 127.0.0.1 gebunden, nur dein eigener Computer erreicht die Datenbank also. Die Daten liegen im Volume postgres-data und überstehen Neustarts. Start sie:

~/projects/guild-api
$ docker compose up -d Network guild-api_default Created Volume guild-api_postgres-data Created Container guild-api-postgres-1 Started$ docker compose psNAME                   IMAGE                COMMAND                  SERVICE    CREATED        STATUS                  PORTSguild-api-postgres-1   postgres:18-alpine   "docker-entrypoint.s…"   postgres   1 second ago   Up Less than a second   127.0.0.1:5432->5432/tcp

Schreib die Adresse der Datenbank in die .env:

.env
PORT=4000
DATABASE_URL=postgres://guild:guild@localhost:5432/guild

Drizzle

Installier Drizzle mit pg, dem PostgreSQL-Treiber, und drizzle-kit, dem Werkzeug, das Migrationen schreibt:

~/projects/guild-api
$ pnpm add drizzle-orm pgPackages: +14Progress: resolved 14, reused 25, downloaded 14, added 14, donedependencies:+ drizzle-orm 0.45.4+ pg 8.23.1Done in 260ms using pnpm v12.16.1$ pnpm add -D drizzle-kit @types/pg[WARN] 2 deprecated subdependencies found: @esbuild-kit/core-utils@3.3.2, @esbuild-kit/esm-loader@2.6.5Packages: +18 -1Progress: resolved 18, reused 38, downloaded 17, added 18, donedevDependencies:+ @types/pg 8.23.1+ drizzle-kit 0.31.11Error: ERR_PNPM_IGNORED_BUILDS  × adding a new package  ╰─▶ Ignored build scripts: esbuild@0.18.20, esbuild@0.25.12, esbuild@0.28.2  help: Run "pnpm approve-builds" to pick which dependencies should be allowed        to run scripts.

Die Pakete sind installiert, aber pnpm bricht mit einem Fehler ab. pnpm führt keine Installationsskripte aus, die du nicht erlaubt hast, und drizzle-kit hängt von esbuild ab, das mit einem solchen Skript sein Programm einrichtet. Erlaub es:

~/projects/guild-api
$ pnpm approve-builds esbuild.../esbuild@0.25.12/node_modules/esbuild postinstall$ node install.js.../esbuild@0.18.20/node_modules/esbuild postinstall$ node install.js.../esbuild@0.28.2/node_modules/esbuild postinstall$ node install.js.../esbuild@0.25.12/node_modules/esbuild postinstall: Done.../esbuild@0.28.2/node_modules/esbuild postinstall: Done.../esbuild@0.18.20/node_modules/esbuild postinstall: Done

pnpm schreibt deine Entscheidung in die pnpm-workspace.yaml. Committe die Datei, damit jede Installation des Projekts dieselben Skripte erlaubt:

pnpm-workspace.yaml
allowBuilds:
  esbuild: true

Die Tabellen

Leg src/db/schema.ts an:

src/db/schema.ts
import { index, integer, pgEnum, pgTable, text } from 'drizzle-orm/pg-core';

export const adventurerClass = pgEnum('adventurer_class', ['WARRIOR', 'MAGE', 'ROGUE', 'RANGER']);
export const questStatus = pgEnum('quest_status', ['OPEN', 'ACCEPTED', 'DONE']);

export const guilds = pgTable('guilds', {
  id: integer().primaryKey().generatedAlwaysAsIdentity(),
  name: text().notNull(),
  motto: text(),
});

export const adventurers = pgTable(
  'adventurers',
  {
    id: integer().primaryKey().generatedAlwaysAsIdentity(),
    name: text().notNull(),
    class: adventurerClass().notNull(),
    level: integer().notNull().default(1),
    guildId: integer()
      .notNull()
      .references(() => guilds.id),
  },
  (table) => [index().on(table.guildId)],
);

export const quests = pgTable(
  'quests',
  {
    id: integer().primaryKey().generatedAlwaysAsIdentity(),
    title: text().notNull(),
    reward: integer().notNull(),
    status: questStatus().notNull().default('OPEN'),
    guildId: integer()
      .notNull()
      .references(() => guilds.id),
    assigneeId: integer().references(() => adventurers.id),
  },
  (table) => [index().on(table.guildId), index().on(table.assigneeId)],
);

export type GuildRow = typeof guilds.$inferSelect;
export type AdventurerRow = typeof adventurers.$inferSelect;
export type QuestRow = typeof quests.$inferSelect;
  • pgEnum legt PostgreSQL-Enums für die Klassen und den Status an, die Datenbank selbst lehnt also alles andere ab.

  • generatedAlwaysAsIdentity() nummeriert die Zeilen. Die Datenbank vergibt die IDs, nie der Client.

  • references() erzeugt Fremdschlüssel: Ein Abenteurer kann zu keiner Gilde gehören, die es nicht gibt.

  • Die index()-Zeilen indizieren die Fremdschlüssel. PostgreSQL macht das nicht von selbst, und in Teil 4 wird jede Liste von Abenteurern und Quests genau über diese Spalten geladen.

  • $inferSelect macht aus jeder Tabelle den Typ einer ihrer Zeilen. GuildRow, AdventurerRow und QuestRow triffst du im nächsten Teil wieder, wo die GraphQL-Resolver mit ihnen arbeiten.

Die Spalten haben Namen in camelCase wie guildId, und die Datenbank bekommt guild_id: Dafür sorgt die Option casing unten. Leg die Verbindung in src/db/index.ts an:

src/db/index.ts
import { drizzle } from 'drizzle-orm/node-postgres';

export const db = drizzle(process.env.DATABASE_URL!, {
  casing: 'snake_case',
  logger: process.env.LOG_SQL === 'true',
});

export type Database = typeof db;

Mit einer Adresse öffnet Drizzle einen Pool von bis zu zehn Verbindungen, den sich der ganze Prozess teilt. logger gibt jede SQL-Abfrage aus, wenn LOG_SQL auf true steht. Das schaltest du im nächsten Teil ein.

Die Migration

drizzle-kit vergleicht die Tabellen in deinem Code mit seiner letzten Migration und schreibt das SQL für den Unterschied. Sag ihm in der drizzle.config.ts, wo alles liegt:

drizzle.config.ts
import { defineConfig } from 'drizzle-kit';

process.loadEnvFile();

export default defineConfig({
  dialect: 'postgresql',
  schema: './src/db/schema.ts',
  out: './drizzle',
  casing: 'snake_case',
  dbCredentials: { url: process.env.DATABASE_URL! },
});

drizzle-kit liest die .env nicht selbst, das übernimmt process.loadEnvFile(). Ergänz drei Skripte in der package.json:

package.json
"db:generate": "drizzle-kit generate",
    "db:migrate": "drizzle-kit migrate",
    "db:seed": "node --env-file=.env src/db/seed.ts"

Erzeug die erste Migration:

~/projects/guild-api
$ pnpm db:generate --name initNo config path provided, using default 'drizzle.config.ts'Reading config file '/home/robin/projects/guild-api/drizzle.config.ts'3 tablesadventurers 5 columns 1 indexes 1 fksguilds 3 columns 0 indexes 0 fksquests 6 columns 2 indexes 2 fks[✓] Your SQL migration file ➜ drizzle/0000_init.sql 🚀

Sie ist reines SQL in drizzle/0000_init.sql, und du kannst sie lesen, bevor sie läuft. Das ist die Tabelle der Quests:

drizzle/0000_init.sql
CREATE TABLE "quests" (
	"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "quests_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
	"title" text NOT NULL,
	"reward" integer NOT NULL,
	"status" "quest_status" DEFAULT 'OPEN' NOT NULL,
	"guild_id" integer NOT NULL,
	"assignee_id" integer
);

Wende sie an:

~/projects/guild-api
$ pnpm db:migrateNo config path provided, using default 'drizzle.config.ts'Reading config file '/home/robin/projects/guild-api/drizzle.config.ts'Using 'pg' driver for database querying[✓] migrations applied successfully!

Beispieldaten

Leg src/db/seed.ts an:

src/db/seed.ts
import { sql } from 'drizzle-orm';
import { db } from './index.ts';
import { adventurers, guilds, quests } from './schema.ts';

await db.execute(sql`truncate quests, adventurers, guilds restart identity cascade`);

const [ember, tide, moss] = await db
  .insert(guilds)
  .values([
    { name: 'Ember Lions', motto: 'First into the fire.' },
    { name: 'Tidecallers', motto: 'The sea remembers.' },
    { name: 'Moss Wardens', motto: 'Slow roots, strong trees.' },
  ])
  .returning();

const heroes = await db
  .insert(adventurers)
  .values([
    { name: 'Brakka', class: 'WARRIOR', level: 7, guildId: ember.id },
    { name: 'Ilse', class: 'MAGE', level: 5, guildId: ember.id },
    { name: 'Corin', class: 'ROGUE', level: 3, guildId: ember.id },
    { name: 'Nerys', class: 'MAGE', level: 8, guildId: tide.id },
    { name: 'Tamsin', class: 'RANGER', level: 4, guildId: tide.id },
    { name: 'Odo', class: 'WARRIOR', level: 2, guildId: tide.id },
    { name: 'Fen', class: 'RANGER', level: 6, guildId: moss.id },
    { name: 'Wren', class: 'ROGUE', level: 5, guildId: moss.id },
    { name: 'Hollis', class: 'MAGE', level: 3, guildId: moss.id },
  ])
  .returning();

const [brakka, ilse, , nerys, tamsin, , fen] = heroes;

await db.insert(quests).values([
  { title: 'Slay the cellar rats', reward: 20, guildId: ember.id, status: 'DONE', assigneeId: brakka.id },
  { title: 'Escort the spice caravan', reward: 120, guildId: ember.id, status: 'ACCEPTED', assigneeId: ilse.id },
  { title: 'Find the lost forge hammer', reward: 80, guildId: ember.id },
  { title: 'Tame the volcano drake', reward: 500, guildId: ember.id },
  { title: 'Chart the sunken reef', reward: 90, guildId: tide.id, status: 'ACCEPTED', assigneeId: tamsin.id },
  { title: 'Calm the storm spirit', reward: 300, guildId: tide.id, status: 'DONE', assigneeId: nerys.id },
  { title: 'Recover the pearl crown', reward: 250, guildId: tide.id },
  { title: 'Clear the harbour of krakenlings', reward: 150, guildId: tide.id },
  { title: 'Guard the seed vault', reward: 60, guildId: moss.id, status: 'ACCEPTED', assigneeId: fen.id },
  { title: 'Wake the old treant', reward: 400, guildId: moss.id },
]);

console.log('Seeded 3 guilds, 9 adventurers and 10 quests.');
await db.$client.end();

Das Skript leert zuerst die drei Tabellen und setzt ihre IDs zurück, du kannst es also so oft ausführen, wie du willst, und bekommst immer dasselbe Brett. returning() gibt die eingefügten Zeilen mit ihren neuen IDs zurück, die die Abenteurer und Quests brauchen. Führ es aus und schau in die Datenbank:

~/projects/guild-api
$ pnpm db:seedSeeded 3 guilds, 9 adventurers and 10 quests.$ docker compose exec postgres psql -U guild -c 'select g.name, count(a.id) as adventurers from guilds g join adventurers a on a.guild_id = g.id group by g.id order by g.id;'     name     | adventurers--------------+------------- Ember Lions  |           3 Tidecallers  |           3 Moss Wardens |           3(3 rows)

Drei Gilden mit je drei Abenteurern und zehn Quests dazwischen. Im nächsten Teil schreibst du das GraphQL-Schema für diese Daten und lässt GraphQL Codegen deine Resolver typisieren.

Kommentare

Die Kommentare sind GitHub Discussions: Zum Schreiben mit GitHub anmelden.

Die Kommentare kommen über giscus von GitHub. Beim Laden gehen deine IP-Adresse an giscus.app und GitHub.