Mit KI-Unterstützung geschrieben
Korrektur
Aktualisiert am 6. März 2026 —

Referenzen hinzugefügt, um das Löschen von Entitäten zu vermeiden, die in Relationen verwendet werden

Nuxt + externe API. So habe ich Nuxt in meinen Projekten immer genutzt, und so wurde es auch bei den verschiedenen Kunden eingesetzt, für die ich gearbeitet habe. Aber kürzlich habe ich an einem Projekt gearbeitet, bei dem die Vorgabe war, alles in Nuxt zu machen, und wir sind bei Nuxt + Drizzle + internen API-Routen gelandet.

Deshalb nutze ich die Gelegenheit, hier eine Vorstellung davon zu schreiben, wie man eine Fullstack-Anwendung mit Nuxt aufsetzt. Wir stützen uns auf ein kleines, sehr einfaches Projekt: eine leichte Verwaltung von Astronomie-Equipment mit Liste und CRUD. Wir bleiben schlicht, aber das reicht, um ein paar interessante Punkte von Drizzle ORM zu sehen, zum Beispiel Relationen.

Nur eine Voraussetzung: eine lokale Datenbank (PostgreSQL, MySQL usw.).

Die Quellen könnt ihr auch vom öffentlichen GitLab holen.

Einrichtung

Der Stack sieht so aus:

  • Nuxt mit:
    • NuxtUi
    • NuxtHub
    • Drizzle

Bei der UI bleiben wir nicht lange hängen, das bleibt basic.

Installation von Nuxt

Jetzt können wir unsere Umgebung installieren.

Terminal
npm create nuxt@latest tuto-nuxt
npm install @nuxt/ui tailwindcss
npx nuxi module add hub
npm i drizzle-orm@beta -D
# Falls PostgreSQL
npm install drizzle-orm drizzle-kit postgres @electric-sql/pglite
# Falls MySQL
npm install drizzle-orm drizzle-kit mysql2
# Falls SQLite
npm install drizzle-orm drizzle-kit @libsql/client

Wir prüfen in nuxt.config.ts, dass die Module geladen sind, und passen die Konfiguration an.

nuxt.config.ts
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
  ...
  modules: ['@nuxt/ui', '@nuxthub/core'],
  hub: {
    db: 'postgresql' // oder 'mysql' / 'sqlite'
  }
})

Wir bauen uns eine kleine UI-Basis (nichts Weltbewegendes).

app/app.vue
<template>
  <div>
    <header class="topbar">
      <NuxtLink to="/" class="brand">Astronomy Demo</NuxtLink>
      <nav class="nav">
        <NuxtLink to="/manufacturers">Manufacturers</NuxtLink>
        <NuxtLink to="/stars">Stars</NuxtLink>
        <NuxtLink to="/mounts">Mounts</NuxtLink>
        <NuxtLink to="/telescopes">Telescopes</NuxtLink>
        <NuxtLink to="/sessions">Sessions</NuxtLink>
      </nav>
    </header>
    <NuxtPage />
  </div>
</template>

<style scoped>
.topbar { display:flex; align-items:center; justify-content:space-between; padding:.75rem 1rem; border-bottom:1px solid #eee; position:sticky; top:0; background:#fff; z-index:10; }
.brand { font-weight:700; text-decoration:none; color:#222; }
.nav { display:flex; gap:10px; }
.nav a { text-decoration:none; color:#0b5ed7; }
.nav a.router-link-exact-active { text-decoration:underline; }
</style>

Konfiguration der Datenbank

In der .env fügen wir die Konfiguration für die Datenbank hinzu.

.env
#PostgreSQL
DATABASE_URL='postgres://<user>:<password>@<host>:<port>/<database>'
#MySQL
DATABASE_URL='mysql://<user>:<password>@<host>:<port>/<database>'

Wir starten die erste Migration (erstmal leer) und den Server, um zu prüfen.

Terminal
npx nuxt db generate
npm run dev

npx nuxt db generate erzeugt die SQL-Migrationen (entspricht bin/console make\:migration mit Doctrine und Symfony). Diese Migrationen werden entweder beim Starten des Dev-Servers mit npm run dev ausgeführt, oder beim Build mit npx nuxt build, oder über npx nuxt db migrate (entspricht bin/console do:mi:mi).

Das Modul bringt ein DrizzleStudio-Plugin mit, mit dem ihr von den Nuxt DevTools aus auf eure Datenbank zugreifen könnt.

DB-Zugriff über die Nuxt DevTools
DB-Zugriff über die Nuxt DevTools

Ihr könnt eure Tabellen ansehen und bearbeiten, ziemlich praktisch!

Die Schemas

Erstellung der Schemas

Jetzt, wo die Umgebung steht, können wir zur Erstellung der Schemas übergehen.

Schemas sind in etwa das Pendant zu den Entitäten mit Symfony und Doctrine. Damit definieren wir unsere verschiedenen Entitäten.

Für unser Mini-CRUD definieren wir die folgenden Entitäten:

  • Teleskop: jedes Teleskop hat eine ID, einen Hersteller, eine Öffnung und eine Brennweite.
  • Hersteller: jeder Hersteller hat eine ID und einen Namen.
  • Montierung: jede Montierung hat eine ID, einen Typ und eine maximale Nutzlast.
  • Beobachtungssession: jede Session hat eine ID, ein Datum, das verwendete Teleskop und die Montierung sowie die anvisierten Himmelskörper.
  • Himmelskörper: jeder Himmelskörper wird über eine ID identifiziert und hat einen Typ.

Die Relationen zwischen diesen Entitäten sehen so aus:

Jedes Teleskop ist mit einem Hersteller verknüpft und kann in mehreren Sessions verwendet werden. Jeder Hersteller kann mehrere Teleskope bauen. Jede Beobachtungssession nutzt ein Teleskop und eine Montierung und kann mehrere Himmelskörper anvisieren. Und schließlich kann jede Montierung in mehreren Sessions verwendet werden.

Diese Struktur übersetzen wir jetzt in ein Drizzle-Schema. Der Übersichtlichkeit halber liegen alle Schemas in einer einzigen Datei schema.ts, aber nichts spricht dagegen, eine Datei pro Tabelle anzulegen, wenn ihr das lieber habt.

Entweder
server/
|-db/
|--schema.ts

Oder
server/
|-db
|--schema/
|----sessions.ts
|----manufacturers.ts
...

Unser Schema sieht also so aus:

server/db/schema.ts
import {pgEnum, pgTable, serial, text, integer, date, primaryKey} from "drizzle-orm/pg-core";
import {defineRelations} from "drizzle-orm";

export const manufacturers = pgTable('manufacturers', {
    id: serial().primaryKey(),
    name: text().notNull()
})

export const stars = pgTable('stars', {
    id: serial().primaryKey(),
    name: text().notNull()
})

export const mountTypeEnum = pgEnum('mount_type', ['Altazimutal', 'Equatorial'])
export const mounts = pgTable('mounts', {
    id: serial().primaryKey(),
    mount_type: mountTypeEnum().default('Altazimutal'),
    maxPayload: integer('max_payload').notNull()
})

export const telescopes = pgTable('telescopes', {
    id: serial().primaryKey(),
    manufacturerId: integer('manufacturer_id').references(() => manufacturers.id),
    focale: integer().notNull(),
    apperture: integer().notNull()
})

export const sessions = pgTable('sessions', {
    id: serial().primaryKey(),
    telescopeId: integer('telescope_id').references(() => telescopes.id),
    mountId: integer('mount_id').references(() => mounts.id),
    date: date().notNull()
})

export const starsToSessions = pgTable('stars_to_sessions', {
    starId: integer('star_id').notNull().references(() => stars.id),
    sessionId: integer('session_id').notNull().references(() => sessions.id)
},
    (t) => [primaryKey({columns: [t.starId, t.sessionId]})]
)

const relations = defineRelations(
    { telescopes, manufacturers, sessions, stars, mounts, starsToSessions},
    (r) => ({
    telescopes: {
        manufacturer: r.one.manufacturers({
            from: r.telescopes.manufacturerId,
            to: r.manufacturers.id
        }),
        sessions: r.many.sessions()
    },
    manufacturers: {
        telescopes: r.many.telescopes(
        )
    },
    sessions: {
        telescope: r.one.telescopes({
            from: r.sessions.telescopeId,
            to: r.telescopes.id
        }),
        stars: r.many.stars({
            from: r.sessions.id.through(r.starsToSessions.sessionId),
            to: r.stars.id.through(r.starsToSessions.starId)
        }),
        mount: r.one.mounts({
            from: r.sessions.mountId,
            to: r.mounts.id
        })
    },
    stars: {
        sessions: r.many.sessions()
    }
}));

Keine Panik, wir erklären das alles.

Erklärungen

Eine Tabelle definieren

Jede Tabelle wird durch eine TypeScript-Konstante abgebildet, hier manufacturers, stars usw. Wir deklarieren die Tabelle mit pgTable (oder mysqlTable / sqliteTable je nach Datenbank), geben ihr einen Namen und konfigurieren die Felder.

export const maTable = pgTable('ma_table', {/** Felddefinitionen **/})

Die IDs werden mit serial().primaryKey() definiert. Für die anderen Felder legen wir den Typ fest (text, integer usw., abhängig von den Möglichkeiten des Dialekts eurer Datenbank) und ob sie nullable sind oder nicht, optional auch einen Default-Wert.

name: text().default('hallo')

Über check() könnt ihr weitere Constraints hinzufügen. Dazu verweise ich euch auf die offizielle Dokumentation.

export const mountTypeEnum = pgEnum('mount_type', ['Altazimutal', 'Equatorial'])

erzeugt einen object_type in PostgreSQL, den man danach auf anderen Feldern verwenden kann

mount_type: mountTypeEnum().default('Altazimutal'),

Bei MySQL und SQLite unterscheidet sich das ein wenig

// Mysql
mount_type: t.mysqlEnum(["Altazimutal", "Equatorial"]).default("Altazimutal"),
//SQLite
mount_type: t.text().$type<"Altazimutal" | "Equatorial">().default("Altazimutal"),

Letzter Punkt: wenn ihr zwischen TypeScript und der Datenbank einen anderen Namen haben wollt, könnt ihr das Feld direkt im Typ umbenennen:

manufacturerId: integer('manufacturer_id') 

Ihr könnt Drizzle auch global so konfigurieren, dass camelCase-Definitionen automatisch nach snake_case gemappt werden:

server/db/db.ts
const db = drizzle({ connection: process.env.DATABASE_URL, casing: 'snake_case' })

Die Relationen

Ihr merkt in meinem Beispiel, dass bei manchen Schemas andere IDs referenziert werden: manufacturerId im Telescope-Schema zum Beispiel. Das ist der einfachste Fall, den wir für One-To-One, Many-To-One und One-To-Many nutzen. In unserem Schema ist ein Teleskop mit einem Hersteller verknüpft, also speichern wir die ID des Herstellers am Teleskop, dasselbe bei den Sessions: eine Session speichert die ID des Teleskops und der verwendeten Montierung.

Aber was ist mit Many-To-Many-Relationen? Wir haben gesagt, dass eine Session mehrere Himmelskörper betreffen kann und derselbe Himmelskörper in mehreren Sessions beobachtet werden kann. Technisch sitzt dahinter eine Verknüpfungstabelle, die Session-IDs und Himmelskörper-IDs speichert. In ORMs wie Doctrine wird das direkt in der Entität definiert.

Session.php
<?php

class Session
{
    public function __construct(
        #[ORM\ManyToMany(targetEntity: Star::class, inversedBy: 'sessions')]
        private Collection $stars
    ){}
}
Star.php
<?php

class Star
{
    public function __construct(
        #[ORM\ManyToMany(targetEntity: Session::class, mappedBy: 'stars')]
        private Collection $sessions
    ){}
}

Mit Drizzle ist das ausführlicher: diese Verknüpfungstabelle müsst ihr selbst deklarieren, und genau das tun wir in:

export const starsToSessions = pgTable('stars_to_sessions', {
    starId: integer('star_id').notNull().references(() => stars.id),
    sessionId: integer('session_id').notNull().references(() => sessions.id)
},
    (t) => [primaryKey({columns: [t.starId, t.sessionId]})]
)

Wir definieren unsere Verknüpfungstabelle wie jede andere Tabelle und setzen einen Key auf die IDs.

Schön und gut, aber nur zu sagen, dass wir in telescope ein integer-Feld namens manufacturerId angelegt haben, reicht nicht, um die Verknüpfung zu erzeugen. Da sind wir uns einig, und auch hier müssen wir die Relationen zwischen unseren Schemas manuell deklarieren. Das ist ausführlich, aber am Ende sehr lesbar und logisch. In unserem Beispiel ist das der ganze Teil:

const relations = defineRelations(
    { telescopes, manufacturers, sessions, stars, mounts, starsToSessions},
    (r) => ({
    telescopes: {
        manufacturer: r.one.manufacturers({
            from: r.telescopes.manufacturerId,
            to: r.manufacturers.id
        }),
        sessions: r.many.sessions()
    },
    manufacturers: {
        telescopes: r.many.telescopes(
        )
    },
    sessions: {
        telescope: r.one.telescopes({
            from: r.sessions.telescopeId,
            to: r.telescopes.id
        }),
        stars: r.many.stars({
            from: r.sessions.id.through(r.starsToSessions.sessionId),
            to: r.stars.id.through(r.starsToSessions.starId)
        }),
        mount: r.one.mounts({
            from: r.sessions.mountId,
            to: r.mounts.id
        })
    },
    stars: {
        sessions: r.many.sessions()
    }
}));

Der Teil { telescopes, manufacturers, sessions, stars, mounts, starsToSessions} übergibt alle Tabellen, für die wir Relationen haben, und in (r) => {} definieren wir diese Relationen dann fast wörtlich.

// Tabellen telescopes
telescopes: {
        manufacturer: r.one.manufacturers({ // verknüpft mit 1 Hersteller
            from: r.telescopes.manufacturerId, // die Hersteller-ID liegt in manufacturerId
            to: r.manufacturers.id // und verweist auf die ID eines Herstellers
        }),
        sessions: r.many.sessions() // verknüpft mit mehreren Sessions
    },

// das Inverse auf der Manufacturer-Seite
manufacturers: {
        telescopes: r.many.telescopes() // 1 Hersteller baut mehrere Teleskope
    },

// und auf der Sessions-Seite
sessions: {
        // 1 Session ist mit 1 Teleskop verknüpft
        telescope: r.one.telescopes({
            from: r.sessions.telescopeId, // ID liegt in telescopeId
            to: r.telescopes.id // Verweis auf telescope.id
        }),
    },

Das ist am Ende ziemlich logisch. 

Trickiger wird es bei Many-To-Many: wir müssen uns auf die Join-Tabelle stützen, nicht mehr direkt auf die beiden Tabellen. Zur Erinnerung: eine Session kann mehrere Himmelskörper betreffen, und Himmelskörper sind mit mehreren Sessions verknüpft.

sessions: {
        ...
        // Eine Session, mehrere Himmelskörper
        stars: r.many.stars({
            // Session-ID verknüpft mit sessionId der Join-Tabelle
            from: r.sessions.id.through(r.starsToSessions.sessionId),
            // dasselbe für die ID des Himmelskörpers
            to: r.stars.id.through(r.starsToSessions.starId)
        }),
        ...
    },
stars: {
        // Der einfachste Fall, ein Himmelskörper verknüpft mit mehreren Sessions
        sessions: r.many.sessions()
    }

Die Migration ausführen

Jetzt, wo unser Schema stimmt, können wir die Migration starten

Terminal
npx nuxt db generate
npx nuxt db migrate

Der erste Befehl erzeugt die Migrationsskripte in server/db/migrations/postgresql/, der zweite führt die Migration aus. Wie oben erwähnt, führen npm run dev oder npx nuxt build die Migrationen ebenfalls aus.

Wie bei Doctrine werden die Migrationen in einer Tabelle _hub_migrations gespeichert.

Migrationstabelle
Migrationstabelle

Wir haben unsere Datenbank, jetzt können wir zu unserem kleinen CRUD übergehen.

Einrichtung der API

Wir fangen damit an, unsere API-Routen für unsere paar Entitäten aufzusetzen. Wir behandeln hier nicht alle (das komplette Set findet ihr im GitHub-Repo), aber wir gehen je einen Typ durch: manufacturers für eine einfache Entität ohne Verknüpfung, telescopes für eine One-To-Many-Entität, und zum Schluss Sessions, die Many-To-Many abbildet. Zuerst machen wir einen allgemeinen Punkt zur API-Einrichtung mit Nuxt.

APIs mit Nuxt

Die gesamte server-Seite liegt im Verzeichnis server/. Ein kurzer Blick in die Doku, und man findet diese Struktur:

-| server/
---| api/
-----| hello.ts      # /api/hello
---| routes/
-----| bonjour.ts    # /bonjour
---| middleware/
-----| log.ts        # log all requests

Uns interessiert also der Teil api/. Das Verzeichnis api/ enthält unsere verschiedenen Endpoints für unsere Entitäten. Alles, was in api/ liegt, wird von Nuxt automatisch als Route mit dem Präfix /api geladen.

Mehrere Möglichkeiten, die Dateien zu organisieren:

  • Entweder alles im Root von api/ in der Form <entitaet>.<verb>.ts (z. B. manufacturers.get.ts usw.).
  • Oder in Unterverzeichnissen, eines pro Entität. Diese Variante behalte ich, für manufacturers ergibt sich also folgende Baumstruktur:
-| server/
---| api/
-----| manufacturers/
-------| [id].delete.ts # Löschen eines Herstellers
-------| [id].get.ts # Abrufen eines Herstellers
-------| [id].put.ts # Aktualisieren eines Herstellers
-------| index.get.ts # Abrufen aller Hersteller
-------| index.post.ts # Anlegen eines Herstellers

Weil wir ein eigenes Unterverzeichnis nutzen, heißen Endpoints, die nicht gezielt ein einzelnes Element ansprechen, index.<verb>.ts und [id].<verb>.ts für die anderen. Auch wenn man zum Beispiel [name].<verb>.ts haben könnte: der Punkt ist, die Route-Parameter anzugeben.

So erzeugt [id].delete.ts automatisch eine Route /api/manufacturers/id und wird automatisch über ein DELETE ausgelöst.

Jede Datei exportiert einen defineEventHandler, der euer JSON, eine Promise usw. zurückgibt.

export default defineEventHandler((event) => {
  return {
    hello: 'world',
  }
})

Die Datenbank abfragen

Jetzt, wo die Struktur steht, können wir uns an die Queries gegen unsere Datenbank machen. Der Zugriff läuft über SELECT, UPDATE, INSERT und DELETE. Der Aufbau der Queries ist sehr nah am einfachen SQL: die Uni-Unterlagen müssen wieder raus, keine magischen Methoden wie bei Doctrine.

import { db, schema } from '@nuxthub/db'

await db.select().from(schema.manufacturers) // select
await db.update(schema.manufacturers).set().where() // update
await db.insert(schema.manufacturers).values() // insert
await db.delete(schema.manufacturers).where() // delete

Es gibt natürlich weitere Funktionen, um Queries zu ergänzen, Joins zu machen, zu filtern usw. Ich lade euch ein, in der offiziellen Dokumentation nachzuschauen, was möglich ist.

Zurück zu unserer Herstellerverwaltung.

Einfaches Beispiel: Verwaltung der Hersteller

Das ist das Einfachste, keine komplizierten Relationen.

server/api/manufacturers/[id].delete.ts
import { db, schema } from '@nuxthub/db'
import {eq} from "drizzle-orm";

export default eventHandler(async (event) => {
    const { id } = getRouterParams(event) // holt die id aus den Route-Parametern, auch möglich: const id = getRouterParam(event, 'id')
    const deletedManufacturer = await db
        .delete(schema.manufacturers)
        .where(eq(schema.manufacturers.id, Number(id)))

    if (!deletedManufacturer) {
        throw createError({
            status: 404,
            message: `Manufacturer with id ${id} not found`
        })
    }

    return { deleted: true }
})
server/api/manufacturers/[id].get.ts
import { db, schema } from '@nuxthub/db'
import { eq } from 'drizzle-orm'

export default eventHandler(async (event) => {
  const { id } = getRouterParams(event)

  const rows = await db
    .select()
    .from(schema.manufacturers)
    .where(eq(schema.manufacturers.id, Number(id)))
    .limit(1)

  const manufacturer = rows?.[0]
  if (!manufacturer) {
    throw createError({ status: 404, message: `Manufacturer with id ${id} not found` })
  }

  return manufacturer
})
server/api/manufacturers/[id].put.ts
import { db, schema } from '@nuxthub/db'
import {eq} from "drizzle-orm";

export default eventHandler(async (event) => {
    const { name } = await readBody(event)
    const { id } = getRouterParams(event)

    await db
        .update(schema.manufacturers)
        .set({ name })
        .where(eq(schema.manufacturers.id, Number(id)))
})
server/api/manufacturers/index.get.ts
import { db, schema } from '@nuxthub/db'

export default eventHandler(async (event) => {
    return await db.select().from(schema.manufacturers)
})
server/api/manufacturers/index.post.ts
import { db, schema } from '@nuxthub/db'

export default eventHandler(async (event) => {
    const { name } = await readBody(event)
    await db
        .insert(schema.manufacturers)
        .values({
            name
        })
})

Zu beachten die Nutzung von:

  • getRouterParams(event) um die Route-Parameter zu holen
  • readBody(event) um den Payload zu holen

Beispiel Many-To-One / One-To-Many: Verwaltung der Teleskope

Nach einem einfachen Einstieg ziehen wir leicht an mit einem One-To-Many: der Relation telescopes <> Hersteller. Wir behalten dieselbe Ordnerstruktur wie bei den Herstellern. Die delete-, post- und update-Handler sind vom gleichen Schlag wie bei den Herstellern, darauf gehen wir nicht nochmal ein.

Bei den gets (Liste und Bearbeitung) können wir dagegen mit Joins spielen.

Ziel ist, alle Teleskope und den Namen des zugehörigen Herstellers zu holen. Mit Doctrine hätte man zum Beispiel einfach $telescope->getManufacturer()->getName() gemacht und fertig, aber solche Bequemlichkeiten gibt es hier nicht. Schlagt die Handbücher auf der Join-Seite auf, da müssen wir wieder ran.

Wir wollen also die Teleskope und den Namen des zugehörigen Herstellers holen, in SQL sähe das so aus

SELECT 
    t.id,
    t.apperture,
    t.focale,
    m.name as manufacturerName
FROM telescope t
INNER JOIN manufacturer m 
    ON t.manufacturer_id = m.id;

Geht klar, das bleibt ein sehr leichter Join. Jetzt müssen wir diese Query in Drizzle übersetzen

server/api/telecopes/index.get.ts
import { db, schema } from '@nuxthub/db'
import {eq, getColumns} from "drizzle-orm";

export default eventHandler(async (event) => {
    const telescopes = schema.telescopes
    const manufacturers = schema.manufacturers

    return await db
        .select({
            ...getColumns(telescopes),
            manufacturerName: manufacturers.name
        })
        .from(telescopes)
        .leftJoin(manufacturers, eq(telescopes.manufacturerId, manufacturers.id))
        .orderBy(telescopes.id)
})

Ziemlich ähnlich, oder? Ich hatte ja gesagt, dass Drizzle-Queries mehr oder weniger darauf hinauslaufen, direkt SQL zu schreiben.

...getColumns() erspart einfach, alle Felder, die man holen will, nochmal manuell anzugeben, wenn man das Ganze will.

Um ein bestimmtes Teleskop zu holen, wäre man von der klassischen SQL-Query ausgegangen:

SELECT 
    t.id,
    t.apperture,
    t.focale,
    m.name as manufacturerName
FROM telescope t
INNER JOIN manufacturer m 
    ON t.manufacturer_id = m.id
WHERE t.id = <id_telescope>;

Mit Drizzle übersetzt man das fast wörtlich:

server/api/telescopes/[id].get.ts
import { db, schema } from '@nuxthub/db'
import {eq, getColumns} from 'drizzle-orm'

export default eventHandler(async (event) => {
  const { id } = getRouterParams(event)
  const telescopes = schema.telescopes
  const manufacturers = schema.manufacturers

  const rows = await db
    .select({
        ...getColumns(telescopes),
        manufacturerName: manufacturers.name,
    })
    .from(telescopes)
    .innerJoin(manufacturers, eq(telescopes.manufacturerId, manufacturers.id))
    .where(eq(telescopes.id, Number(id)))
    .limit(1)

  const telescope = rows?.[0]
  if (!telescope) {
    throw createError({ status: 404, message: `Telescope with id ${id} not found` })
  }

  return telescope
})

Beispiel Many-To-Many: die Sessions

Das Beste kommt zum Schluss! Die Verwaltung von Many-To-Many ist etwas komplexer und braucht ein bisschen mehr „Gymnastik“.

Löschen / Anlegen / Ändern

Vergesst hier die Bequemlichkeiten, die Doctrine zum Beispiel bietet, wo ein Delete automatisch die Verknüpfung mitlöscht: hier wird alles von Hand gemacht.

/server/api/sessions/[id].delete.ts
import { db, schema } from '@nuxthub/db'
import { eq } from 'drizzle-orm'

export default eventHandler(async (event) => {
  const { id } = getRouterParams(event)
  const sessionId = Number(id)

  // Zuerst die Verknüpfung sessions <=> stars löschen
  await db
    .delete(schema.starsToSessions)
    .where(eq(schema.starsToSessions.sessionId, sessionId))

  // Dann die Session löschen
  await db
    .delete(schema.sessions)
    .where(eq(schema.sessions.id, sessionId))
})

Ja, hier müssen wir die Verknüpfungstabelle manuell führen, also die Verknüpfung „von Hand“ löschen.

Dasselbe gilt für POST und UPDATE: wir fügen die Verknüpfung sessions <=> stars manuell hinzu oder aktualisieren sie.

import { db, schema } from '@nuxthub/db'
import { eq } from 'drizzle-orm'

export default eventHandler(async (event) => {
  const { id } = getRouterParams(event)
  const sessionId = Number(id)
  const { telescopeId, mountId, date, starIds } = await readBody(event)

  await db
    .update(schema.sessions)
    .set({
      telescopeId,
      mountId,
      date
    })
    .where(eq(schema.sessions.id, sessionId))

  // Die Verknüpfungstabelle aktualisieren
  // 1. Die alten Einträge löschen
  await db
    .delete(schema.starsToSessions)
    .where(eq(schema.starsToSessions.sessionId, sessionId))

  // 2. Die neuen hinzufügen
  if (starIds && starIds.length > 0) {
    await db.insert(schema.starsToSessions).values(
      starIds.map(starId => ({
        sessionId,
        starId
      }))
    )
  }
})

Abrufen

Das Collection-GET

Jetzt wird's spaßig! Für unser Session-Listing wollen wir etwas relativ Lesbares:

  • Die Basisinfos der Session: id und Datum
  • Lesbare Labels für Teleskop und Montierung (z. B.: Skywatcher 127/1500 und Equatorial (10kg))
  • Die Liste der Sterne (z. B.: "M42", "M45" )
SELECT
  s.*,
  mfr.name || ' ' || t.apperture || '/' || t.focale AS telescopeLabel,
  mt.mount_type || ' (' || mt.maxPayload || 'kg)' AS mountLabel,
  COALESCE(stars_per_session.stars, '[]'::json) AS starNames
FROM sessions s
LEFT JOIN telescopes t ON s.telescopeId = t.id
LEFT JOIN manufacturers mfr ON t.manufacturerId = mfr.id
LEFT JOIN mounts mt ON s.mountId = mt.id
LEFT JOIN (
  SELECT sts.sessionId,
         COALESCE(JSON_AGG(st.name) FILTER (WHERE st.name IS NOT NULL), '[]'::json) AS stars
  FROM starsToSessions sts
  LEFT JOIN stars st ON sts.starId = st.id
  GROUP BY sts.sessionId
) stars_per_session ON s.id = stars_per_session.sessionId
ORDER BY s.date;

Ja, ich habe das absichtlich etwas brachial gemacht, damit die Query alles schon formatiert zurückgibt, um euch ein komplexeres Beispiel zu zeigen.

Wenn wir diese Query nehmen und nach Drizzle übertragen

/server/api/sessions/index.get.ts
import {db, schema} from '@nuxthub/db'
import {eq, getColumns, sql} from 'drizzle-orm'

export default eventHandler(async () => {
    const { sessions, telescopes, mounts, starsToSessions, stars, manufacturers } = schema

    // Subquery, um die Sterne pro Session zu holen
    const starsSubquery = db
        .select({
            sessionId: starsToSessions.sessionId,
            stars: sql<string[]>`
        COALESCE(
          JSON_AGG(${stars.name}) FILTER (WHERE ${stars.name} IS NOT NULL),
          '[]'::json
        )
      `.as('stars') // <- wichtig, Alias ist Pflicht, um ihn in der Hauptquery zu nutzen
        })
        .from(starsToSessions)
        .leftJoin(stars, eq(starsToSessions.starId, stars.id))
        .groupBy(starsToSessions.sessionId)
        .as('stars_per_session')

    // Hauptquery
    return await db
        .select({
            ...getColumns(sessions),
            telescopeLabel: sql<string>`
                    (${manufacturers.name} || ' ' || ${telescopes.focale} || '/' || ${telescopes.apperture})
            `,
            mountLabel: sql<string>`
                    (${mounts.mount_type} || ' (' || ${mounts.maxPayload} || 'kg)')
            `,
            starNames: starsSubquery.stars
        })
        .from(sessions)
        .leftJoin(telescopes, eq(sessions.telescopeId, telescopes.id))
        .leftJoin(manufacturers, eq(telescopes.manufacturerId, manufacturers.id))
        .leftJoin(mounts, eq(sessions.mountId, mounts.id))
        .leftJoin(starsSubquery, eq(sessions.id, starsSubquery.sessionId))
        .orderBy(sessions.date)
})

Ein Traum, oder?

  • Die Subquery starsSubquery wird:
    • alle Sterne einer Session dank JSON_AGG zu einem JSON-Array aggregieren
    • dank COALESCE(..., '[]'::json) standardmäßig ein leeres Array liefern, wenn es keine Sterne gibt
    • über .as() einen Alias anlegen, damit wir ihn in der Hauptquery nutzen können
  • Die Hauptquery wird:
    • alle Spalten von sessions über getColumns() holen (haben wir oben schon gesehen)
    • lesbare Labels für Teleskop und Montierung mit sql<T> erzeugen (dazu gleich mehr)
    • alle Sterne der Session über leftJoin(starsSubquery) zuordnen

Die anderen Joins sind klassisch, darauf gehen wir nicht weiter ein, aber kommen wir kurz auf die Nutzung von sql<T> zurück.

Der Operator sql<T> erlaubt natives SQL zu schreiben und den Rückgabewert zu typen: sql<number[]> sagt zum Beispiel, dass die native Query ein Array von Zahlen zurückgibt. Damit lassen sich PostgreSQL-Funktionen ausführen, die auf Drizzle-Seite kein Pendant haben. Typischerweise nutzen wir das hier, um unsere Labels und unser Sterne-Array zu bauen. Über ${} kann man auch Variablen injizieren. Der Operator lässt sich mit .as() koppeln (in unserem Beispiel, um ihn wiederzuverwenden), .mapWith() und anderen, die ihr in der Drizzle-Dokumentation findet.

Das GET /id

Zurück zu etwas Leichterem. Um eine bestimmte Session zu holen, haben wir weniger Aufbereitung, wollen aber trotzdem unser Sterne-Array behalten.

SELECT
  s.*,
  COALESCE(JSON_AGG(sts.starId) FILTER (WHERE sts.starId IS NOT NULL), '[]'::json) AS starIds
FROM sessions s
LEFT JOIN starsToSessions sts ON s.id = sts.sessionId
WHERE s.id = <id_session>
GROUP BY s.id
LIMIT 1;

Deutlich einfacher zum Abschluss, ich bin nett.

/server/api/sessions/[id].get.ts
import { db, schema } from '@nuxthub/db'
import {eq, getColumns, sql} from 'drizzle-orm'

export default eventHandler(async (event) => {
    const { id } = getRouterParams(event)
    const sessionId = Number(id)

    // Eine Query mit JSON-Aggregation für die Sterne
    const rows = await db
        .select({
            ...getColumns(schema.sessions),
            starIds: sql<number[]>`
        COALESCE(
          JSON_AGG(${schema.starsToSessions.starId}) FILTER (WHERE ${schema.starsToSessions.starId} IS NOT NULL),
          '[]'::json
        )
      `
        })
        .from(schema.sessions)
        .leftJoin(schema.starsToSessions, eq(schema.sessions.id, schema.starsToSessions.sessionId))
        .where(eq(schema.sessions.id, sessionId))
        .groupBy(schema.sessions.id)
        .limit(1)

    const session = rows?.[0]
    if (!session) {
        throw createError({ status: 404, message: `Session with id ${id} not found` })
    }

    return session
})

Wir finden ein paar gemeinsame Elemente mit dem Collection-GET:

  • die Aggregation der Sterne mit JSON_AGG
  • das leere Array als Default dank COALESCE(..., '[]'::json)
  • das ewige getColumns()
  • .as()

Das Frontend

Hier gehen wir sehr schnell durch: das Frontend bleibt Nuxt/VueJS und klassische Datenanzeige. Für mehr Details könnt ihr ins Repo schauen, wir erklären nur kurz, wie man unsere Routen aufruft.

Nuxt stellt 3 Methoden bereit, um Daten zu holen:

  • $fetch: die grundlegendste
  • useFetch: ein Wrapper um $fetch
  • useAsyncData: ähnlich wie useFetch, aber mit mehr Kontrolle

Wir schauen uns die ersten 2 an, die in meinem Beispiel genutzt werden (siehe Repo). Wann nutzt man welches?

$fetch eignet sich eher für POST-, DELETE-, PUT-Requests usw. Kurz: alles, was kein GET ist. Warum? Weil $fetch ein einfacher HTTP-Wrapper ist, der den SSR/Hydration-Teil von Nuxt nicht integriert, und wenn man es zum Beispiel im onMounted zum Datenholen nutzt, kann die Request zweimal laufen:

  • Einmal auf dem Server (SSR)
  • Ein zweites Mal auf dem Client

Deshalb nutzen wir es eher für Nicht-GETs, weil das in der Regel nach einer Aktion läuft, etwa dem Absenden eines Formulars, also einmal auf dem Client.

useFetch und useAsyncData sind Wrapper um $fetch, die genau diese Doppelaufrufe vermeiden: der API-Call läuft auf dem Server, und die Daten kommen über den Payload zum Client, der die Request nicht nochmal machen muss.

Zurück zu unseren APIs: wir brauchen also nur useFetch, um unsere Werte zu holen, und $fetch für Schreibzugriffe.

const id = computed(() => Number(route.params.id)) // Wir holen die id aus der Route
const { data: manufacturer } = await useFetch(`/api/manufacturers/${id.value}`)

Und zum Senden

async function submit() {
  await $fetch('/api/manufacturers', {
    method: 'POST',
    body: {name: name.value},
  })
  await router.push('/manufacturers')
}

Fazit

Ich hoffe, ich konnte euch einen Überblick über die Möglichkeiten geben, die Nuxt für Fullstack bietet. Ich verhehle nicht, dass ich etwas näher an Doctrine erwartet habe, als man mir von Drizzle erzählt hat (ursprünglich komme ich aus der PHP- und Symfony-Welt), aber der Vorteil war, dass ich wieder etwas tiefer SQL schreiben musste (mit Doctrine war es schon eine Weile her, dass ich SQL geschrieben hatte).

Gerade wenn man von Doctrine kommt, sieht man die unterschiedlichen Philosophien: Doctrine ist höher angesiedelt (auch wenn natives SQL und Komplexes bei Bedarf möglich sind) und stärker objektorientiert, während Drizzle klar den Akzent auf SQL setzt, mit Typing, aber nichts hinter magischen Methoden versteckt, die die ganze Arbeit machen. Ja, aus meiner Sicht ist das weniger angenehm zu nutzen, aber andererseits lässt es auch mehr Kontrolle.

Aktualisiert am 6. März 2026