Skip to content

Commit 71d48a1

Browse files
committed
feat: all block 5
1 parent f8c56da commit 71d48a1

9 files changed

Lines changed: 1038 additions & 10 deletions

File tree

docs/7_query_design/0_intro.md

Lines changed: 238 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,241 @@
11
# Query Design
22

3-
Dieser Block wird Repository Methods, JPQL, native SQL Queries und Reporting-Abfragen vergleichen. Ziel ist, Datenzugriffe situationsgerecht nach Lesbarkeit, Wartbarkeit und Performance zu wählen.
3+
Eine API-Anforderung klingt oft einfach: «Zeige offene Tickets», «Liefere eine kompakte Liste» oder «Wie viele Tickets hat jede Priorität?». Trotzdem ist der Datenzugriff eine Designentscheidung. Eine sehr kurze Repository Method kann eine klare Anforderung gut ausdrücken. Bei mehreren Bedingungen wird derselbe Methodenname schnell schwer lesbar. Und ein Report mit Zählungen ist in SQL häufig klarer als eine verschachtelte Objektabfrage.
44

5-
Die inhaltliche Ausarbeitung folgt später.
5+
In diesem Block lernst du nicht möglichst viele Abfragesprachen. Du lernst, für eine konkrete Anforderung die kleinste verständliche Query-Form zu wählen. Das Ticket-System bleibt dabei der gemeinsame Fall: aktive Tickets listen, nur die nötigen Felder laden und eine Auswertung für den Support erstellen.
6+
7+
Falls du nach Block 4 in den Kurs einsteigst, erzeugst du zuerst den Checkpoint `block-5-start`. Für die Kontrolle steht `block-5-complete` bereit. Die Befehle und die Idee dahinter findest du im Abschnitt [Checkpoints](../checkpoints.md).
8+
9+
:::{important} Lernziele
10+
Nach diesem Block kannst du:
11+
12+
- Repository Methods, JPQL und native SQL Queries passend einordnen.
13+
- begründen, wann ein Methodenname noch verständlich ist.
14+
- mit JPQL eine schlanke DTO-Projektion formulieren und lesen.
15+
- eine native SQL-Reporting-Query mit `LEFT JOIN`, `GROUP BY` und Aggregationen erklären.
16+
- aktive und soft-gelöschte Tickets in Queries korrekt unterscheiden.
17+
- Query-Varianten nach Lesbarkeit, Wartbarkeit, Schema-Nähe und erwarteter Datenmenge vergleichen.
18+
- einen Datenzugriff im Code Review fachlich begründen.
19+
:::
20+
21+
## Die API-Anforderung bestimmt die Query
22+
23+
Ein Repository ist keine Sammlung möglichst cleverer Queries. Es ist die Übersetzung einer fachlichen Leseanforderung in einen gezielten Datenzugriff. Vor jeder technischen Entscheidung helfen vier Fragen:
24+
25+
1. **Was braucht der aufrufende Endpunkt wirklich?** Eine Liste braucht häufig weniger Felder als eine Detailansicht.
26+
2. **Wie komplex ist die fachliche Bedingung?** Ein einzelner Statusfilter ist etwas anderes als eine gruppierte Auswertung mit Kommentaren.
27+
3. **Muss die Query über Java-Entities oder direkt über das Schema sprechen?** Beide Perspektiven sind sinnvoll, aber nicht für denselben Zweck.
28+
4. **Welche Daten dürfen nicht erscheinen?** Im Ticket-System gehören soft-gelöschte Tickets nicht in normale Listen und Reports.
29+
30+
```{mermaid}
31+
flowchart LR
32+
Requirement["API-Anforderung<br/>Was soll die Person sehen?"]
33+
Choice{"Kleinste klare<br/>Query-Form?"}
34+
Method["Repository Method<br/>einfacher Filter"]
35+
Jpql["JPQL + DTO<br/>fachliche Liste"]
36+
Sql["native SQL<br/>Report und Aggregation"]
37+
Response["gezielte API-Antwort"]
38+
39+
Requirement --> Choice
40+
Choice --> Method --> Response
41+
Choice --> Jpql --> Response
42+
Choice --> Sql --> Response
43+
```
44+
45+
Die Entscheidung ist kein Wettbewerb. Gute Query Design bedeutet nicht, immer SQL oder immer JPQL zu verwenden. Es bedeutet, dass ein Team die gewählte Form später noch lesen, testen und bei einer Anforderungsänderung anpassen kann.
46+
47+
| Kriterium | Frage im Review |
48+
| --- | --- |
49+
| Lesbarkeit | Versteht eine neue Entwicklerin die fachliche Absicht ohne die ganze Query zu entschlüsseln? |
50+
| Wartbarkeit | Ist die Query an einer passenden Stelle und mit einem stabilen Vertrag gekapselt? |
51+
| Schema-Nähe | Braucht die Anforderung Tabellen, PostgreSQL-Funktionen oder ein exakt sichtbares SQL-Statement? |
52+
| Geladene Daten | Werden nur Felder und Zeilen geladen, die der Endpunkt tatsächlich verwendet? |
53+
| Datenwachstum | Gibt es eine Annahme, die bei vielen Tickets kritisch werden könnte? |
54+
55+
Der letzte Punkt ist eine Erwartung, noch keine Optimierung. Pagination, Indexe, Query-Pläne und ORM-Probleme vertiefst du in Block 6.
56+
57+
## Repository Methods für einfache Filter
58+
59+
Spring Data kann aus einem konventionellen Methodennamen eine parametrisierte Query ableiten. Das ist für einfache, sprechende Filter nützlich {cite}`spring_data_jpa_reference`. Im Block-4-Lösungsstand verwendet das Repository bereits diese Methode:
60+
61+
```java
62+
List<TicketEntity> findByStatusAndDeletedAtIsNullOrderByCreatedAtDesc(String status);
63+
```
64+
65+
Sie beantwortet eine konkrete Anforderung: «Zeige aktive Tickets eines Status, neueste zuerst.» Der Name enthält Filter, Soft-Delete-Regel und Sortierung. Das ist noch gut lesbar, weil jede Komponente eine offensichtliche Bedeutung hat.
66+
67+
```java
68+
@GetMapping
69+
List<TicketResponse> findTickets(@RequestParam(required = false) String status) {
70+
return ticketService.findTickets(status);
71+
}
72+
```
73+
74+
Der Endpunkt `GET /api/tickets?status=open` verwendet diese Repository Method. Ohne Parameter ruft der Service die noch einfachere Methode `findByDeletedAtIsNullOrderByCreatedAtDesc()` auf.
75+
76+
:::{tip} Entscheidungsregel
77+
Eine Repository Method passt, wenn du die Anforderung in einem kurzen, eindeutigen Satz lesen kannst: *aktive Tickets eines Status, nach Erstellzeitpunkt sortiert*.
78+
:::
79+
80+
### Wann der Name zu viel versteckt
81+
82+
Eine hypothetische Methodensignatur wie diese ist ein Warnsignal:
83+
84+
```java
85+
findByStatusAndPriorityAndCreatedAtAfterAndDeletedAtIsNullOrderByPriorityDescCreatedAtDesc(...)
86+
```
87+
88+
Sie ist nicht falsch. Aber der Methodenname wird zur eigenen kleinen Abfragesprache. Bei Änderungen muss ein Team die Grammatik des Namens statt eine klar formatierte Query lesen. Spätestens bei Projektionen, optionalen Kriterien, Joins oder Aggregationen ist JPQL beziehungsweise native SQL häufig die bessere Erklärung.
89+
90+
## JPQL für fachliche Listen
91+
92+
JPQL spricht über Entities und deren Java-Felder, nicht direkt über Tabellen und Spalten. Im Ticket-System heisst die Entity `TicketEntity`, ihr Feld für den Soft Delete `deletedAt` und ihr Feld für den Erstellzeitpunkt `createdAt`. JPQL nutzt genau diese Namen {cite}`spring_data_jpa_reference`.
93+
94+
Die Ticketübersicht benötigt keine externe Referenz, keine Version und keinen Änderungszeitpunkt. Deshalb ist eine ganze `TicketEntity` als Rückgabewert unnötig breit. Der Block-5-Lösungsstand führt dafür ein eigenes Antwortmodell ein:
95+
96+
```java
97+
@Value
98+
public class TicketListItemResponse {
99+
Long id;
100+
String title;
101+
String status;
102+
String priority;
103+
OffsetDateTime createdAt;
104+
}
105+
```
106+
107+
Die Repository Query erzeugt dieses DTO direkt mit einer Constructor-Projektion:
108+
109+
```java
110+
@Query("""
111+
SELECT new ch.hftm.db2.ticketsystem.ticket.TicketListItemResponse(
112+
t.id, t.title, t.status, t.priority, t.createdAt
113+
)
114+
FROM TicketEntity t
115+
WHERE t.deletedAt IS NULL
116+
AND (:status IS NULL OR t.status = :status)
117+
ORDER BY t.createdAt DESC
118+
""")
119+
List<TicketListItemResponse> findActiveTicketOverview(@Param("status") String status);
120+
```
121+
122+
Wichtig sind drei Aspekte:
123+
124+
- `TicketEntity` und `t.createdAt` sind Java-Namen. Die Tabelle `app_starter.tickets` und die Spalte `created_at` erscheinen hier absichtlich nicht.
125+
- `new ...TicketListItemResponse(...)` legt fest, welche fünf Werte das DTO erhält. Die Reihenfolge muss zum Konstruktor passen.
126+
- `t.deletedAt IS NULL` ist kein Detail, das der Endpunkt weglassen darf. Es ist Teil der fachlichen Bedeutung von *aktive Tickets*.
127+
128+
Der Service normalisiert einen vorhandenen Status und übergibt sonst `null`. Damit kann derselbe Endpunkt alle oder nur offene, wartende oder geschlossene Tickets anzeigen:
129+
130+
```java
131+
@GetMapping("/overview")
132+
List<TicketListItemResponse> findTicketOverview(
133+
@RequestParam(required = false) String status
134+
) {
135+
return ticketService.findTicketOverview(status);
136+
}
137+
```
138+
139+
Die resultierende API ist bewusst schlank:
140+
141+
```http
142+
GET /api/tickets/overview?status=open
143+
```
144+
145+
Eine Projektion ist keine pauschale Performance-Garantie. Sie ist zuerst ein klarer Vertrag: Diese Liste braucht genau diese Felder. Ob und wann das zudem relevant Datenmenge spart, prüfst du im nächsten Block mit konkreten Zugriffsmustern.
146+
147+
## Native SQL für Reporting
148+
149+
Ein Support-Team möchte wissen, wie viele aktive Tickets jede Kombination aus Status und Priorität hat und wie viele Kommentare zu diesen Tickets gehören. Diese Anforderung verbindet zwei Tabellen und zwei unterschiedliche Zählungen. Genau hier kann native SQL die klarste Form sein.
150+
151+
```sql
152+
SELECT
153+
t.status AS status,
154+
t.priority AS priority,
155+
COUNT(DISTINCT t.id) AS "ticketCount",
156+
COUNT(c.id) AS "commentCount"
157+
FROM app_starter.tickets t
158+
LEFT JOIN app_starter.ticket_comments c ON c.ticket_id = t.id
159+
WHERE t.deleted_at IS NULL
160+
GROUP BY t.status, t.priority
161+
ORDER BY t.status, t.priority;
162+
```
163+
164+
Diese Query spricht absichtlich die PostgreSQL-Tabelle und ihre Spaltennamen an. Sie liefert keine Entity, sondern eine Zeile pro Status-Prioritäts-Gruppe.
165+
166+
| Teil | Bedeutung |
167+
| --- | --- |
168+
| `LEFT JOIN` | Tickets ohne Kommentar bleiben im Report sichtbar. |
169+
| `COUNT(DISTINCT t.id)` | Ein Ticket mit zwei Kommentaren wird trotzdem nur einmal als Ticket gezählt. |
170+
| `COUNT(c.id)` | Es zählt nur vorhandene Kommentarzeilen; ein Ticket ohne Kommentar erhöht diese Zahl nicht. |
171+
| `GROUP BY t.status, t.priority` | Jede Kombination aus Status und Priorität wird zu einer Report-Zeile. |
172+
| `WHERE t.deleted_at IS NULL` | Ausgeblendete Tickets verfälschen den aktiven Support-Report nicht. |
173+
174+
Die Aliase bilden einen Vertrag zwischen SQL und einer internen Interface-Projektion:
175+
176+
```java
177+
interface TicketStatusPriorityReportProjection {
178+
String getStatus();
179+
String getPriority();
180+
long getTicketCount();
181+
long getCommentCount();
182+
}
183+
```
184+
185+
Das Repository führt die Query als `nativeQuery = true` aus. Der Service übersetzt die interne Projektion anschliessend explizit in `TicketStatusPriorityReportResponse`. So bleibt die API nicht an die technische Projektion des Repositories gekoppelt.
186+
187+
```java
188+
@GetMapping("/reports/status-priority")
189+
List<TicketStatusPriorityReportResponse> findStatusPriorityReport() {
190+
return ticketService.findStatusPriorityReport();
191+
}
192+
```
193+
194+
Native SQL ist besonders passend, wenn die SQL-Struktur selbst wichtig ist: Reporting, datenbankspezifische Funktionen oder eine Abfrage, die über Tabellen und Spalten klarer verständlich wird. Sie koppelt den Code aber stärker an das konkrete Schema. Eine Umbenennung von `deleted_at` ist hier zum Beispiel nicht durch das Entity-Mapping verborgen.
195+
196+
## Soft Deletes gehören in die Anforderung
197+
198+
Block 4 hat festgelegt, dass `DELETE /api/tickets/{id}` keine Zeile entfernt. Der Service setzt `deleted_at`. Damit hat jede normale Lesefunktion eine zusätzliche Pflichtfrage:
199+
200+
> Soll dieser Use Case nur aktive Tickets sehen oder ausdrücklich auch ausgeblendete?
201+
202+
Für die normale Ticketliste, die Übersicht und den Status-/Prioritätsreport lautet die Antwort: nur aktive Tickets. Deshalb erscheint der Filter in jeder der drei Query-Formen:
203+
204+
| Query-Form | Schutz vor gelöschten Tickets |
205+
| --- | --- |
206+
| Repository Method | `AndDeletedAtIsNull` im Namen |
207+
| JPQL | `WHERE t.deletedAt IS NULL` |
208+
| native SQL | `WHERE t.deleted_at IS NULL` |
209+
210+
Ein häufiger Fehler ist, nur den Service zu betrachten. Wenn ein neues Repository eine direkte Query ergänzt, kann es den bisher sicheren Listenpfad umgehen. Soft Delete ist daher ein gutes Beispiel, warum Query Reviews nicht nur nach Syntax fragen, sondern nach fachlichen Filtern.
211+
212+
## Query Review: klein, klar und überprüfbar
213+
214+
Nutze diese Checkliste, bevor du eine neue Query akzeptierst:
215+
216+
- Welche API-Anforderung beantwortet sie in einem Satz?
217+
- Ist eine Repository Method noch kürzer und klarer?
218+
- Wenn JPQL verwendet wird: Stimmen Entity- und Feldnamen, Rückgabetyp und DTO-Konstruktor überein?
219+
- Wenn native SQL verwendet wird: Sind Tabellen, Joins, Aliasnamen und Datenbankspezifika bewusst gewählt?
220+
- Lädt die Query nur benötigte Spalten und Zeilen?
221+
- Sind Soft Deletes, Mandantenfilter oder andere fachliche Sichtbarkeitsregeln enthalten?
222+
- Bei Aggregationen: Werden Join-Zeilen versehentlich mehrfach gezählt?
223+
- Kann ein Test mit realem PostgreSQL den wichtigsten Datenfall beweisen?
224+
225+
### Typische Fehlentscheide
226+
227+
| Beobachtung | Bessere Frage |
228+
| --- | --- |
229+
| «SQL ist immer schneller.» | Welche Daten braucht dieser Use Case, und wie messen wir bei Bedarf die tatsächliche Ausführung? |
230+
| «Eine Entity kann ich immer zurückgeben.» | Welche Felder soll diese API wirklich versprechen? |
231+
| «Ein langer Methodenname spart eine Query.» | Macht der Name die Bedingung für das Team wirklich verständlicher? |
232+
| «`COUNT(t.id)` reicht.» | Vervielfacht der Join Tickets durch Kommentare oder Labels? |
233+
| «Der Soft-Delete-Filter ist bereits im Service.» | Kann diese neue Repository Query den Service-Pfad umgehen? |
234+
235+
## Transfer: vom Query Design zur Performance
236+
237+
Nach diesem Block kannst du die Query-Form begründen. Das ist die Voraussetzung für die nächste Frage: Wie verhält sie sich mit vielen Daten und Beziehungen? In Block 6 untersuchst du N+1-Probleme, zu breite Entity-Ladungen, Pagination, `EXPLAIN` und Indexe.
238+
239+
Der wichtigste Merksatz bleibt:
240+
241+
> Wähle die kleinste Query-Form, die die fachliche Anforderung klar, vollständig und testbar ausdrückt.

docs/checkpoints.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ Ohne `--skip-slow` werden auch langsamere Testcontainers-Tests ausgeführt, sofe
5656
| `block-3-complete` | Lösung mit Transaktionsworkflow, Kommentaren, Events und Versionierung |
5757
| `block-4-start` | Einstieg in Block 4 auf Basis des abgeschlossenen Block 3 |
5858
| `block-4-complete` | Lösung mit fachlicher Referenz, Priorität, Audit-Feld und Soft-Delete-Markierung |
59+
| `block-5-start` | Einstieg in Block 5 auf Basis des abgeschlossenen Block 4 |
60+
| `block-5-complete` | Lösung mit JPQL-Ticketübersicht und nativem Status-/Prioritätsreport |
5961

6062
Die Liste wird erweitert, sobald weitere Blöcke vollständige App-Zustände brauchen.
6163

@@ -80,6 +82,13 @@ Für Block 4 funktioniert dasselbe Muster:
8082
./course-state create block-4-complete ../work/db-2-app-block-4-loesung
8183
```
8284

85+
Für Block 5 funktioniert dasselbe Muster:
86+
87+
```bash
88+
./course-state create block-5-start ../work/db-2-app-block-5
89+
./course-state create block-5-complete ../work/db-2-app-block-5-loesung
90+
```
91+
8392
Die Zielordner sind Beispiele. Entscheidend ist, dass der Zielordner ausserhalb von `db-2-app` liegt. Dadurch bleibt der Starter unverändert.
8493

8594
## Zustände prüfen

docs/myst.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ project:
2626
file: 5_transactions_applications/0_intro.md
2727
- title: Schema Design für Softwareprojekte
2828
file: 6_schema_design/0_intro.md
29+
- title: Query Design
30+
file: 7_query_design/0_intro.md
2931
- title: Checkpoints
3032
file: checkpoints.md
3133
license: CC-BY-SA-4.0

0 commit comments

Comments
 (0)