Zum Hauptinhalt springen

AB 01 – Die Datenbank zieht um

Die Situation

Erstes Treffen zum Webshop. Die Einkaufsabteilung bringt einen Zettel mit: „Lieferant, dazu die Anschrift. Ohne Anschrift kein Lieferant — und die Anschrift gehört zu genau einem."

Der Kollege aus der IT ergänzt: „Die Datenbank läuft nicht mehr als Datei neben dem Projekt. Wir nehmen PostgreSQL in einem Container — dieselbe Datenbank, die auch im Betrieb läuft."

Heute baust du die Grundlage: das Projekt, den Container, und die erste Beziehung zwischen zwei Tabellen.

Lernziele

Nach Bearbeitung dieses Arbeitsblatts kannst du:

  • eine PostgreSQL-Datenbank über Docker Compose starten, stoppen und löschen
  • eine Spring-Boot-Anwendung an eine PostgreSQL statt an eine H2 anbinden
  • mit dem Datenbankwerkzeug von IntelliJ in laufende Tabellen sehen
  • ein Klassendiagramm mit einer 1:1-Beziehung in JPA-Entitäten übersetzen
  • erklären, was @OneToOne mit cascade = ALL mit der gefüllten Raute im Diagramm zu tun hat
  • an der erzeugten Tabelle ablesen, wie Hibernate eine 1:1-Beziehung abbildet
Was du schon kannst

Entität, Repository, Controller, ResponseEntity — alles aus dem Gästebuch. Neu sind hier nur der Container und die Beziehung.

Aufgaben

Aufgabe 1 – Projekt erzeugen

FeldWert
Project / LanguageMaven / Java
Spring Bootdie neueste 4.1.x
Groupde.szut
Artifactwebshop
Package namede.szut.webshop
Java26

Zwei davon sind neu:

AbhängigkeitWofür
PostgreSQL Driverstatt H2 — der Treiber, mit dem Java mit einer PostgreSQL spricht
Validationbrauchst du erst in Arbeitsblatt 04; du sparst dir damit, die pom.xml später anzufassen

Aufgabe 2 – Die Datenbank in den Container

Bisher lag deine Datenbank als Datei im Projektverzeichnis. Jetzt läuft sie als eigenes Programm in einem Container — abgeschottet vom Rest deines Rechners, mit einer eigenen Version, eigenen Einstellungen und eigenem Speicher.

Was ein Container ist, steht nebenan

Hier brauchst du nur die Befehle. Was ein Container von einer virtuellen Maschine unterscheidet, warum latest keine Version ist und was down -v wirklich löscht, steht im Infoblatt Docker und Container — mit zwei Filmen zum Ansehen.

Bevor du eine Datei anlegst, muss Docker auf deinem Rechner sein. Überspringe diesen Teil, wenn docker --version im Terminal schon eine Versionsnummer ausgibt.

Windows

Docker braucht unter Windows den Unterbau WSL 2 (das Linux-Subsystem). Der Installer bringt ihn mit; fehlt er, richtet ihn ein Befehl in einer PowerShell als Administrator ein:

wsl --install

Danach einmal neu starten. Ausführlich beschreibt das Microsoft unter Installieren von WSL.

Dann Docker Desktop von docker.com herunterladen und installieren — beim Setup die Vorgabe Use WSL 2 instead of Hyper-V stehen lassen. Nach dem Neustart erscheint das Wal-Symbol in der Taskleiste.

Wenn der Start mit „Virtualization is not enabled" abbricht

Dann ist die Virtualisierung im BIOS/UEFI ausgeschaltet. Sie heißt dort je nach Hersteller Intel VT-x, AMD-V oder SVM Mode. Ohne sie läuft kein Container — Docker ist daran nicht schuld.

Die vollständige Anleitung: Install Docker Desktop on Windows.

macOS

Auf docker.com gibt es zwei Fassungen. Nimm die richtige, sonst startet nichts:

Dein MacFassung
M1, M2, M3, M4 …Apple Silicon
Intel-ProzessorIntel Chip

Welchen du hast, sagt das Apfel-Menü oben links → Über diesen Mac.

Das geladene .dmg öffnen, das Docker-Symbol in den Ordner Programme ziehen, Docker aus dem Launchpad starten und die Rechteabfrage bestätigen. Läuft es, steht das Wal-Symbol oben in der Menüleiste.

Die vollständige Anleitung: Install Docker Desktop on Mac.

Linux

Hier brauchst du kein Docker Desktop. Der Docker-Dienst läuft unter Linux ohnehin im Hintergrund — die grafische Oberfläche wäre nur zusätzliches Beiwerk.

Für Ubuntu und Debian richtest du die Paketquelle von Docker ein und installierst daraus. Die Befehle dafür stehen Schritt für Schritt unter Install Docker Engine on Ubuntu — nimm den Abschnitt Install using the apt repository, nicht das Paket docker.io aus den Ubuntu-Quellen. Das ist meist veraltet und bringt docker compose nicht mit.

Ein Schritt wird gern vergessen: Ohne ihn brauchst du für jeden Docker-Befehl sudo.

sudo usermod -aG docker $USER

Danach ab- und wieder anmelden. Nachzulesen unter Post-installation steps.

Für dich heißt das: Wo unten „Starte Docker Desktop" steht, prüfst du stattdessen, ob der Dienst läuft:

systemctl status docker
docker --version
docker compose version

Beide Befehle müssen eine Versionsnummer ausgeben. Meldet der zweite „is not a docker command", hast du eine sehr alte Fassung erwischt — dann hilft nur die Neuinstallation nach der Anleitung oben.

Das Rezept für die Datenbank steht in einer Datei.

docker-compose.yml
services:
webshop-db:
image: postgres:17.6
container_name: webshop-db
environment:
POSTGRES_DB: webshop
POSTGRES_USER: webshop
POSTGRES_PASSWORD: geheim
ports:
# links: Port auf deinem Rechner - rechts: Port im Container
- "5432:5432"
volumes:
- webshop-daten:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U webshop -d webshop"]
interval: 5s
timeout: 3s
retries: 10

volumes:
webshop-daten:
ZeileBedeutung
image: postgres:17.6Welches Programm im Container läuft — hier PostgreSQL in genau dieser Version
POSTGRES_DB / _USER / _PASSWORDDatenbank, Benutzer und Kennwort, die beim ersten Start angelegt werden
ports: "5432:5432"Der Port im Container wird nach außen durchgereicht. Links dein Rechner, rechts der Container
volumesWo die Daten liegen. Ohne diese Zeile wären sie weg, sobald der Container verschwindet
healthcheckWie Docker prüft, ob die Datenbank schon bereit ist
Wenn Docker Desktop nicht läuft

Der nächste Befehl bricht dann mit einer Meldung ab, der man die Ursache nicht ansieht:

error during connect: Get "http://%2F%2F.%2Fpipe%2FdockerDesktopLinuxEngine/v1.47/containers/json":
open //./pipe/dockerDesktopLinuxEngine: The system cannot find the file specified.

Übersetzt heißt das schlicht: Ich finde den Docker-Dienst nicht. Weder mit deiner docker-compose.yml noch mit deinem Projekt stimmt etwas nicht — es läuft nur nichts, mit dem sich der Befehl unterhalten könnte.

docker compose up -d
docker compose ps

In der Spalte STATUS muss nach einigen Sekunden Up (healthy) stehen.

„port is already allocated"

Diese Meldung heißt: Auf deinem Rechner läuft bereits etwas auf Port 5432 — meistens eine örtlich installierte PostgreSQL.

Dann ändere in docker-compose.yml die linke Zahl, zum Beispiel auf "5433:5432", und trage dieselbe Zahl gleich in Aufgabe 3 in die Datenbankadresse ein. Die rechte Zahl bleibt, wie sie ist — sie gilt im Container, und dort ist nichts belegt.

Die drei Befehle, die du brauchst
BefehlWirkung
docker compose up -dContainer starten (-d = im Hintergrund)
docker compose stopContainer anhalten — die Daten bleiben
docker compose down -vContainer und Daten löschen. Der nächste Start beginnt bei null

down -v ist dein Notausgang, wenn die Tabellen durcheinandergeraten sind.

Aufgabe 3 – Die Anwendung an den Container hängen

src/main/resources/application.properties
spring.application.name=webshop

# --- Datenbank: PostgreSQL im Docker-Container ---
# Die Zahl hinter localhost muss zur linken Zahl in docker-compose.yml passen.
spring.datasource.url=jdbc:postgresql://localhost:5432/webshop
spring.datasource.username=webshop
spring.datasource.password=geheim

# --- Hibernate ---
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.open-in-view=false

# --- Fehlerantworten ---
spring.mvc.problemdetails.enabled=true
server.error.include-stacktrace=never

Bis auf die vier Datenbankzeilen ist das dieselbe Datei wie im Gästebuch. Benutzername, Kennwort und Datenbankname stehen genau so in der docker-compose.yml — die beiden Dateien müssen zueinander passen.

Warum steht das Kennwort im Klartext im Projekt?

Weil es hier keins schützt: Die Datenbank läuft auf deinem Rechner und ist von außen nicht erreichbar. In einem echten Projekt käme das Kennwort aus einer Umgebungsvariablen und die docker-compose.yml würde es von dort lesen. Für dieses Tutorial wäre das nur ein zusätzlicher Handgriff ohne Erkenntnis.

Das Klassendiagramm

Aus der Besprechung ist ein Klassendiagramm geworden. Es beschreibt den ersten Teil des Modells:

Supplier- id: Long- name: StringContact- id: Long- street: String- postcode: String- city: String- phone: String11contact

Eines steht darin, das im Gästebuch nicht vorkam: die gefüllte Raute.

Die Notation kennst du aus dem ersten Lehrjahr. Hier geht es nicht darum, sie noch einmal durchzunehmen, sondern darum, was sie für den Code bedeutet — denn genau daraus wird gleich eine Annotation.

Was die gefüllte Raute bedeutet

Der Contact ist ein Teil des Supplier und von ihm existenzabhängig. Daraus folgen drei Dinge:

  • Wird der Lieferant gelöscht, wird seine Anschrift mitgelöscht. Ohne Lieferanten hat sie keinen Sinn — sie bliebe als Datensatz zurück, auf den niemand mehr zeigt.
  • Eine Anschrift gehört zu genau einem Lieferanten. Kein zweiter darf dieselbe mitbenutzen. Zieht ein Lieferant um, ändert das an keinem anderen etwas.
  • Sie entsteht mit ihm zusammen, nicht getrennt und dann verbunden.

Wäre die Raute leer (Aggregation), hieße das das Gegenteil: Die Anschrift lebt weiter, auch wenn dieser Lieferant verschwindet — und andere dürften sie ebenfalls verwenden.

Merk dir die drei Punkte bis Aufgabe 4. Dort wird aus ihnen eine einzige Zeile Code.

Warum heißt das Feld id und nicht sid?

In vielen Datenbankentwürfen — auch in der Vorlage zu diesem Diagramm — heißen die Schlüssel sid, cid, aid: der erste Buchstabe der Tabelle plus id. Das stammt aus einer Zeit, in der man SQL-Abfragen ohne Tabellenaliase schrieb; dort half der Präfix, id von id zu unterscheiden.

Im objektorientierten Modell trägt schon die Klasse diese Auskunft. supplier.getId() sagt alles; supplier.getSid() sagt dasselbe zweimal. Dazu kommt: Wenn in jeder Klasse ein anderes Wort für dieselbe Sache steht, muss man es sich für jede Klasse einzeln merken.

Deshalb heißt der Schlüssel in diesem Tutorial überall id — und das Klassendiagramm oben ist entsprechend angepasst. JPA wäre beides recht: Der Feldname ist frei wählbar.

Wohin die Klassen kommen

Im Gästebuch lagen die Klassen nach ihrer Art beieinander: alle Entitäten in model, alle Controller in controller, alle Repositories in repository. Bei einer einzigen Entität war das übersichtlich.

Der Webshop hat drei. Wer am Lieferanten etwas ändert, fasst dann fünf Dateien an, die alle dasselbe Thema haben — und muss dafür durch fünf Ordner springen. In jedem dieser Ordner liegen daneben die Dateien der beiden anderen Themen.

Deshalb wird hier anders geschnitten: nach Sachen, nicht nach Bauteilen.

de.szut.webshop
├── WebshopApplication
├── supplier ← alles zum Lieferanten
├── article ← alles zum Artikel
├── contact ← alles zur Anschrift
└── common ← was zu keiner der drei Sachen gehört

Am Ende dieses Tutorials steht im Paket supplier das hier — die vollständige Bearbeitung einer Sache:

supplier
├── Supplier die Entität
├── SupplierRepository der Datenbankzugriff
├── SupplierService die fachlichen Regeln
├── SupplierController die Endpunkte
├── SupplierMapper die Umwandlung nach außen
├── SupplierDto, CreateSupplierDto
└── SupplierNotFoundException, SupplierHasArticlesException
Die Schichten sind nicht weg — sie sind nur nicht mehr die Ordner

Entität, Repository, Service, Controller: Diese Schichten gibt es weiterhin, jede hat weiterhin genau eine Aufgabe, und keine darf die Aufgabe der anderen übernehmen. Was sich ändert, ist allein, wonach die Ordner benannt sind.

Woran du die Schicht erkennst, ist ab jetzt der Name der Klasse, nicht der Ordner darüber. SupplierController ist ein Controller, in welchem Paket er auch liegt.

Der Gewinn zeigt sich beim Arbeiten: Alles, was du für eine Änderung am Lieferanten brauchst, liegt nebeneinander. Der Preis: Wer wissen will, wie viele Controller es insgesamt gibt, muss in drei Ordner sehen statt in einen. Im Alltag stellt sich die erste Frage viel häufiger als die zweite.

common klein halten

Dort kommt nur hinein, was wirklich zu keiner einzelnen Sache gehört — in diesem Tutorial genau eine Klasse. Wächst common, ist das meist ein Zeichen, dass eine Sache fehlt, die noch keinen Namen hat.

Aufgabe 4 – Die beiden Entitäten

Die erste Klasse schreibst du selbst. Eine JPA-Entität hast du im Gästebuch schon gebaut — hier ist nichts dabei, was du nicht kennst.

EntitätDie Klasse wird auf eine Tabelle abgebildet
Tabellennamecontact — schreib ihn ausdrücklich hin, verlass dich nicht auf die Vorgabe
Schlüsselein id vom Typ Long, den die Datenbank hochzählt
Felderdie vier aus dem Diagramm, alle String

Getter, Setter und den parameterlosen Konstruktor überlässt du Lombok — genau wie im Gästebuch.

Zwei Entscheidungen, die du treffen musst

Welche Strategie zählt den Schlüssel hoch? Es gibt mehrere. Gefragt ist die, bei der die Datenbank selbst die nächste Zahl vergibt — nicht Hibernate über eine eigene Tabelle oder Sequenz.

Warum den Tabellennamen hinschreiben, wenn JPA ihn ohnehin aus dem Klassennamen ableitet? Weil contact in manchen Datenbanken ein reserviertes Wort sein kann und weil eine Umbenennung der Klasse sonst still die Tabelle wechselt. Was in der Datenbank steht, gehört sichtbar in den Code.

Lösung: die Klasse Contact
src/main/java/de/szut/webshop/contact/Contact.java
package de.szut.webshop.contact;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Entity
@Table(name = "contact")
@Getter
@Setter
@NoArgsConstructor
public class Contact {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

private String street;

private String postcode;

private String city;

private String phone;
}

GenerationType.IDENTITY ist die gesuchte Strategie: Die Datenbank vergibt die Zahl beim Einfügen. Hibernate fragt sie danach ab, statt sie selbst zu bestimmen.

@NoArgsConstructor ist keine Kür, sondern Pflicht: Hibernate legt beim Laden aus der Datenbank ein leeres Objekt an und füllt es danach. Ohne parameterlosen Konstruktor geht das nicht.

src/main/java/de/szut/webshop/supplier/Supplier.java
package de.szut.webshop.supplier;

import jakarta.persistence.CascadeType;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import de.szut.webshop.contact.Contact;

@Entity
@Table(name = "supplier")
@Getter
@Setter
@NoArgsConstructor
public class Supplier {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

private String name;

@OneToOne(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "contact_id")
private Contact contact;
}

Eine Stelle verdient einen zweiten Blick, bevor es an die Beziehung geht.

@Getter @Setter @NoArgsConstructor statt @Data. Im Gästebuch stand @Data an der Entität. Hier nicht — und der Grund ist die Beziehung: @Data erzeugt unter anderem toString(), equals() und hashCode(). Sobald zwei Klassen aufeinander zeigen, rufen diese Methoden sich gegenseitig auf und kommen nie zurück. Hier ist es noch harmlos — Contact zeigt nicht zurück. Im nächsten Arbeitsblatt schon. Die drei einzelnen Annotationen erzeugen genau das, was gebraucht wird.

Vom Klassendiagramm zu den Tabellen

Was hier zu leisten ist, steht in einem Bild:

KlassendiagrammDatenbankSupplier- id: Long- name: String11Contact- id: Long- street: String- city: String- phone: Stringabgebildet aufsupplieridnamecontact_id1Nordmetall GmbH7contactidstreetcityphone7Werftstr. 3Bremen0421 55512Fremdschlüssel verweist auf Primärschlüssel

Die ganze Beziehung besteht in der Datenbank aus einer einzigen zusätzlichen Spalte: supplier.contact_id. Darin steht der Primärschlüssel der Anschrift, die zu diesem Lieferanten gehört.

Wohin der Fremdschlüssel kommt

Bei einer 1:1-Beziehung hast du die Wahl, in welche Tabelle du den Fremdschlüssel einfügst — der Fremdschlüssel könnte also ebenso gut in der Tabelle contact stehen und contact.supplier_id heißen. Beide Varianten bilden dieselbe Beziehung korrekt ab.

Hier liegt sie beim Lieferanten, und der Grund steht im Klassendiagramm: Die Pfeilspitze zeigt auf den Contact, nicht zurück. Gelesen wird nur in diese eine Richtung — du holst einen Lieferanten und willst seine Anschrift dazu. Umgekehrt fragt niemand: „Zu welchem Lieferanten gehört diese Anschrift?"

Damit bleibt auch die Abhängigkeit einseitig: Das Paket supplier kennt contact, das Paket contact kennt niemanden.

Was der Code davon bewirkt

@OneToOne erzeugt in der korrespondierenden Tabelle die Spalte mit dem Fremdschlüssel. Sobald diese Annotation an einem Feld steht, weiß JPA: In meine Tabelle gehört ein Fremdschlüssel auf die andere — in diesem Fall gehört in die Tabelle supplier der Fremdschlüssel contact_id. Der Name ist aus der Sicht der Klasse gelesen, in der die Annotation steht: ein Lieferant zu einer Anschrift.

@JoinColumn konfiguriert diese Spalte. name = "contact_id" legt ihren Namen fest. Ohne die Annotation würde JPA ihn selbst aus dem Feldnamen ableiten — hingeschrieben steht er im Code und wandert nicht mit, wenn jemand das Feld umbenennt. Dasselbe Argument wie beim Tabellennamen weiter oben.

cascade = CascadeType.ALL setzt die Komposition um. Hier werden zwei der drei Punkte von vorhin zu Code: Die Anschrift entsteht mit dem Lieferanten zusammen und verschwindet mit ihm zusammen. Was mit ihm geschieht, geschieht mit ihr auch — Speichern, Löschen, alles wird weitergereicht. orphanRemoval = true ergänzt den Fall, dass die Anschrift nur abgehängt wird, ohne dass der Lieferant verschwindet: Auch dann wird sie gelöscht statt herrenlos liegen zu bleiben.

Ein Punkt von vorhin steht in keiner dieser Zeilen: Eine Anschrift gehört zu genau einem Lieferanten. Wo der landet, siehst du in Aufgabe 7.

Exkurs: Der Fremdschlüssel könnte auch im Contact liegen

Läge die Spalte in der Tabelle contact, sähe der Code so aus:

// im Contact — hier liegt jetzt der Fremdschlüssel
@OneToOne
@JoinColumn(name = "supplier_id")
private Supplier supplier;

// im Supplier — die Spalte liegt nicht mehr hier
@OneToOne(mappedBy = "supplier", cascade = CascadeType.ALL, orphanRemoval = true)
private Contact contact;

@JoinColumn wandert mit der Spalte mit. Im Supplier steht dafür mappedBy — was das bedeutet, ist das Thema von Arbeitsblatt 02.

Mehr zu den Besitzverhältnissen bei Beziehungen steht im Infoblatt Beziehungen mit JPA abbilden.

Aufgabe 5 – Repository und Controller

Auch das Repository kennst du — im Gästebuch hast du eines angelegt. Hier ist nur die Entität eine andere.

Welche zwei Typen kommen in die spitzen Klammern?

Der erste nennt die Entität, die verwaltet wird. Der zweite den Typ ihres Schlüssels — nicht noch einmal die Entität, sondern den Typ, den das Feld id hat.

Beides steht schon in deiner gerade geschriebenen Klasse. Nachsehen genügt.

Wenn du nicht weiterkommst: das Repository
src/main/java/de/szut/webshop/supplier/SupplierRepository.java
package de.szut.webshop.supplier;

import org.springframework.data.jpa.repository.JpaRepository;

public interface SupplierRepository extends JpaRepository<Supplier, Long> {
}

Kein Rumpf, keine Implementierung: save, findAll, findById und deleteById bringt JpaRepository mit. Ein interface genügt — die Klasse dahinter baut Spring Data beim Start.

Wenn du nicht weiterkommst: der Controller
src/main/java/de/szut/webshop/supplier/SupplierController.java
@RestController
@RequestMapping("/api/v1/suppliers")
public class SupplierController {

private final SupplierRepository repository;

public SupplierController(SupplierRepository repository) {
this.repository = repository;
}

@PostMapping
public ResponseEntity<Supplier> createSupplier(@RequestBody Supplier supplier) {
Supplier saved = repository.save(supplier);

URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(saved.getId())
.toUri();

return ResponseEntity.created(location).body(saved);
}

@GetMapping
public ResponseEntity<List<Supplier>> findAllSuppliers() {
return ResponseEntity.ok(repository.findAll());
}

@GetMapping("/{id}")
public ResponseEntity<Supplier> findSupplierById(@PathVariable Long id) {
return repository.findById(id)
.map(ResponseEntity::ok)
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND, "Kein Lieferant mit der Id " + id));
}
}
Der Controller gibt die Entität direkt heraus

Das ist genau das, was du im Gästebuch auch gemacht hast — und es funktioniert heute noch. Merke dir diese drei Endpunkte trotzdem gut: In Arbeitsblatt 02 wirst du keinen einzigen Buchstaben daran ändern, und zwei von ihnen werden trotzdem aufhören zu funktionieren.

Aufgabe 6 – Ausprobieren

requests.http
### Lieferanten anlegen
POST http://localhost:8080/api/v1/suppliers
Content-Type: application/json

{
"name": "Nordmetall GmbH",
"contact": {
"street": "Hafenstr. 12",
"postcode": "28217",
"city": "Bremen",
"phone": "0421 123456"
}
}

### Zweiter Lieferant, ohne Telefonnummer
POST http://localhost:8080/api/v1/suppliers
Content-Type: application/json

{
"name": "Weser Werkzeug KG",
"contact": {
"street": "Am Deich 4",
"postcode": "28199",
"city": "Bremen"
}
}

### Alle Lieferanten
GET http://localhost:8080/api/v1/suppliers

### Einen Lieferanten
GET http://localhost:8080/api/v1/suppliers/1

Zwei Lieferanten sind Absicht: Die Aufgaben der nächsten Arbeitsblätter brauchen mehr als einen. Beim zweiten fehlt die Telefonnummer — auch das ist gewollt.

Sieh dir die Antwort auf den POST genau an: Die Anschrift hat eine eigene id bekommen, obwohl du sie nirgends einzeln gespeichert hast. Das ist die Kaskade bei der Arbeit.

Aufgabe 7 – In die Datenbank sehen

Bei der Maklerdatenbank hast du die Tabellen selbst angelegt. Hier entstehen sie aus deinem Java-Code — und du solltest nachsehen, was daraus geworden ist. Wie das Datenbank-Werkzeug bedient wird, steht im Infoblatt Werkzeuge in IntelliJ.

FeldWert
Hostlocalhost
Port5432 (oder deine geänderte Zahl)
Databasewebshop
Userwebshop
Passwordgeheim

Beim ersten Mal bietet IntelliJ unten an, den Treiber herunterzuladen (Download missing driver files) — das ist nötig. Danach prüfst du mit Test Connection und bestätigst mit OK.

Du solltest das hier finden:

supplieridPKnamecontact_idFK · UNIQUEcontactidPKstreetpostcodecityphonezeigt auf

Die entscheidende Zeile ist contact_id. Sie trägt zwei Bedingungen:

  • Fremdschlüssel — der Wert muss eine vorhandene contact.id sein.
  • UNIQUE — derselbe Wert darf nur einmal vorkommen. Und genau das ist die zweite 1 aus dem Klassendiagramm: Ohne diese Bedingung könnten sich zwei Lieferanten eine Anschrift teilen, und aus der 1:1-Beziehung wäre eine n:1-Beziehung geworden.

Hibernate hat die Bedingung von selbst gesetzt, weil @OneToOne dort steht und nicht @ManyToOne. Ein Buchstabe im Java-Code, eine Bedingung in der Datenbank.

In der Spaltenansicht siehst du nur die Hälfte

Klickst du im Baum auf die Tabelle und siehst dir die Spalten an, findest du den Fremdschlüssel — die Unique-Bedingung aber nicht. Das ist kein Fehler des Werkzeugs: Eindeutigkeit ist keine Eigenschaft einer Spalte, sondern ein eigenes Objekt der Tabelle.

Zu sehen ist sie an drei Stellen:

WoWas dort steht
Reiter DDLder ganze CREATE TABLE-Befehl samt aller Bedingungen — der schnellste Weg
Baum: supplierkeysder Primärschlüssel und die Unique-Bedingung, mit ihrem erzeugten Namen
Baum: supplierindexesderselbe Eintrag noch einmal: PostgreSQL setzt UNIQUE über einen Index um

Der Name sieht aus wie ukmlyijxk67davdvmis2t3pd7vl. Den hat Hibernate erzeugt, weil im Java-Code keiner steht — inhaltlich sagt er nichts.

Das Datenbankfenster ist ab jetzt dein zweites Auge

Bis hierhin konntest du alles über die Schnittstelle prüfen. Ab dem nächsten Arbeitsblatt gibt es Fälle, in denen die Schnittstelle etwas behauptet und die Tabelle etwas anderes sagt. Dann entscheidet die Tabelle.

Aufgabe 8 – Testfälle

IDBeschreibungVorbedingungTestschritteErwartetes ErgebnisErgebnis
TF-01Container läuft und ist bereitdocker compose up -d wurde ausgeführt
  1. docker compose ps aufrufen
In der Spalte STATUS steht Up mit dem Zusatz (healthy).
TF-02Tabellen entstehen beim StartContainer läuft, Anwendung wurde noch nie gestartet
  1. Anwendung starten
  2. Konsolenausgabe durchsehen
Im Log stehen create table supplier und create table contact.
TF-03Lieferant anlegenAnwendung läuft
  1. POST mit name und vollständigem contact
Status 201, eine Kopfzeile Location, und im Rumpf haben sowohl der Lieferant als auch die Anschrift eine id.
TF-04Die Anschrift wurde mitgespeichertTF-03 wurde ausgeführt
  1. Im Datenbankfenster die Tabelle contact öffnen
Die Tabelle enthält eine Zeile mit den gesendeten Werten.
TF-05Die 1:1-Beziehung steht in der TabelleTabellen wurden angelegt
  1. Im Datenbankfenster die Spalten von supplier ansehen
Die Spalte contact_id hat einen Fremdschlüssel auf contact.id und eine UNIQUE-Bedingung.
TF-06Unbekannte KennungEs gibt keinen Lieferanten mit der Kennung 999
  1. GET auf /api/v1/suppliers/999
Status 404 — kein 200 mit leerem Rumpf.
TF-07Daten überleben den Neustart der AnwendungMindestens zwei Lieferanten wurden angelegt
  1. Anwendung beenden
  2. Anwendung neu starten
  3. GET auf /api/v1/suppliers
Alle Lieferanten sind noch da. Der Container lief die ganze Zeit weiter.

Was du jetzt hast

Drei Endpunkte, zwei Tabellen, eine Beziehung — und eine Datenbank, die nicht mehr im Projektverzeichnis liegt.

Im nächsten Arbeitsblatt kommt die zweite Beziehung dazu: Artikel. Es ist eine einzige Annotation, und du wirst sie an einer Stelle spüren, an der du sie nicht erwartest.