Übungsaufgaben zu SQL-Unterabfragen und Mengenoperationen

https://bildung.social/@oerinformatik/

https://oer-informatik.de/sql-unterabfragen-uebungsaufgaben

SQL, Unterabfragen, Korrelierte Unterabfrage, EXISTS, UNION, INTERSECT, EXCEPT, MariaDB, Übungsaufgaben, Prüfungsvorbereitung

tl/dr; (Bearbeitungszeit insgesamt 149 min, mehrere Aufgaben): Vier Übungsaufgaben zu Unterabfragen und Mengenoperationen, zwei davon für den Einstieg – Vergleich mit berechneten Werten, IN, NOT IN mit NULL-Falle, EXISTS, korrelierte Unterabfragen, Prozentanteile, ALL, abgeleitete Tabellen, UPDATE und DELETE mit Unterabfrage, Archivieren, Merkmalstabellen, typische Fehlerbilder sowie UNION, UNION ALL, INTERSECT und EXCEPT. Alle Lösungen sind ausgeblendet und mit Punkteverteilung versehen [AI-Assisted]. (Zuletzt geändert am 28.10.2026)

Diese Übungsaufgaben gehören zum Artikel SQL: Unterabfragen und Mengenoperationen. Sie setzen die Artikel SQL: Aggregierende Funktionen und SQL: Abfragen über mehrere Tabellen mit JOIN voraus.

Hinweise zur Bearbeitung: Als FIA oder FIDP musst Du zur Teil-2-Prüfung Aufgaben auf Kernkompetenz-Ebene lösen können, das sind Aufgabe 2.1 und Aufgabe 2.2. Von FIA wird zusätzlich erwartet, dass ihnen Aufgaben auf Profilkompetenz-Ebene (Aufgabe 3.1 und Aufgabe 3.2) keine Probleme bereiten. Schreib Deine Lösung zuerst vollständig auf, bevor Du die Beispiellösung aufklappst. Die SQL-Anweisungen sind in MariaDB-Notation formuliert; andere korrekte Lösungen sind ebenfalls richtig (zum Beispiel ein JOIN statt einer Unterabfrage, sofern die Aufgabe nichts anderes verlangt). In den Datenmodellen sind Primärschlüssel unterstrichen und Fremdschlüssel mit # gekennzeichnet. In den Aufgaben 3.1 und 3.2 ist heute Montag, der 19.10.2026 (CURDATE()). Zu jeder Aufgabe gibt es Tabellenauszüge; am Ende findest Du außerdem eine SQL-Kurzreferenz.

Aufgabe 2.1: Räume und Sessions eines Barcamps (Kernkompetenz)

(ca. 29 min Bearbeitungszeit)

Am Ratz und Rübe Berufskolleg (RRB) in Bielefeld findet am 13.03.2027 das Barcamp „Lernen.Digital.Leben 2027“ statt. Die Sessionplanung läuft über eine MariaDB-Datenbank. Dimitrios Papadopoulos, Gesamtleitung der Organisation, bittet Dich um einige Auswertungen. Die Datenbank enthält unter anderem:

  • raum (raum_id, bezeichnung, name, gebaeude, etage, plaetze, beamer, stream_faehig, barrierefrei)

  • session (session_id, #veranstaltung_id, titel, format, status, eingereicht_am, max_plaetze, #raum_id, #slot_id, livestream)

  • session_buchung (#session_id, #anmeldung_id, gebucht_am)

session.status kann eingereicht, angenommen, abgelehnt oder zurueckgezogen sein. raum_id und slot_id einer Session sind NULL, solange ihr noch kein Raum bzw. Zeitfenster zugeordnet ist; max_plaetze ist NULL, wenn die Platzzahl nicht begrenzt ist. barrierefrei ist ein Wahrheitswert (TRUE/FALSE).

Auszug aus der Tabelle raum
raum_id bezeichnung name gebaeude plaetze barrierefrei
1 A.001 Aula A 250 TRUE
2 A.104 NULL A 32 TRUE
3 A.105 NULL A 32 TRUE
4 B.012 Mensa B 180 TRUE
5 B.201 IT-Labor 1 B 28 TRUE
6 B.202 IT-Labor 2 B 28 TRUE
7 C.110 Elektrowerkstatt C 24 FALSE
8 C.020 Bibliothek C 40 TRUE
Auszug aus der Tabelle session
session_id titel format status max_plaetze raum_id
1 KI-Assistenten im Unterricht – was geht, was nicht? Vortrag angenommen NULL 1
2 Reparieren statt wegwerfen: Laptops retten Workshop angenommen 12 7
3 Barrierefreie Videos: Untertitel und Gebärdensprache Workshop angenommen 20 5
4 Escape-Room mit Mikrocontrollern Workshop angenommen 16 6
5 Datenschutz im Schulalltag – Mythen und Fakten Diskussion eingereicht NULL NULL
6 Barrierefreie Webseiten mit HTML und ARIA Tutorial angenommen 24 6
7 Krypto-Trading für Einsteiger Vortrag abgelehnt NULL NULL
Auszug aus der Tabelle session_buchung
session_id anmeldung_id gebucht_am
2 6 2026-10-12 16:00:00
2 12 2026-10-12 20:15:00
2 14 2026-10-16 18:35:00
3 11 2026-10-10 07:30:00
3 12 2026-10-12 20:16:00
4 6 2026-10-12 16:02:00
4 14 2026-10-16 18:36:00
6 14 2026-10-16 18:37:00
6 6 2026-10-17 10:20:00

a) Für Sessions mit erwartet großem Andrang sucht Dimitrios die großen Räume. Erstelle eine SQL-Abfrage, die Bezeichnung, Name und Platzzahl aller Räume ausgibt, die mehr Plätze haben als der Durchschnitt aller Räume. Die größten Räume sollen oben stehen. Gib das Ergebnis an. (5 Punkte)

SELECT bezeichnung, name, plaetze
FROM raum
WHERE plaetze > (SELECT AVG(plaetze) FROM raum)
ORDER BY plaetze DESC;

Bewertung: Spaltenauswahl und FROM 1 Punkt, WHERE mit Vergleich 1 Punkt, Unterabfrage mit AVG 2 Punkte, Sortierung absteigend 1 Punkt.

Ergebnis (der Durchschnitt beträgt 614 / 8 = 76,75 Plätze):

bezeichnung name plaetze
A.001 Aula 250
B.012 Mensa 180

b) Hannah Klein, Schülerin im Orga-Team, möchte alle Räume finden, in denen noch keine Session geplant ist. Ihre Abfrage liefert ein leeres Ergebnis:

SELECT bezeichnung, name
FROM raum
WHERE raum_id NOT IN (SELECT raum_id FROM session);

ba) Erläutere, warum das Ergebnis leer ist. (3 Punkte)

Die Unterabfrage liefert auch die NULL-Werte der Sessions 5 und 7, die noch keinen Raum haben: (1, 7, 5, 6, NULL, 6, NULL) (1 Punkt). x NOT IN (…, NULL) bedeutet unter anderem x <> NULL; ein Vergleich mit NULL ist weder wahr noch falsch, sondern unbekannt (1 Punkt). Damit ist die ganze Bedingung für keinen Raum wahr, und die Abfrage liefert keine Zeile – ohne Fehlermeldung (1 Punkt).

bb) Korrigiere die Abfrage so, dass sie alle Räume liefert, denen keine angenommene Session zugeordnet ist, sortiert nach Bezeichnung. Gib das Ergebnis an. (4 Punkte)

SELECT bezeichnung, name, plaetze
FROM raum
WHERE raum_id NOT IN (SELECT raum_id
                      FROM session
                      WHERE status = 'angenommen'
                        AND raum_id IS NOT NULL)
ORDER BY bezeichnung;

Gleichwertig mit NOT EXISTS, das die NULL-Falle nicht kennt:

SELECT r.bezeichnung, r.name, r.plaetze
FROM raum AS r
WHERE NOT EXISTS (SELECT *
                  FROM session AS s
                  WHERE s.raum_id = r.raum_id
                    AND s.status = 'angenommen')
ORDER BY r.bezeichnung;

Bewertung: NULL ausgeschlossen bzw. NOT EXISTS mit Korrelation 2 Punkte, Bedingung auf angenommene Sessions 1 Punkt, Sortierung und Ergebnis 1 Punkt.

bezeichnung name plaetze
A.104 NULL 32
A.105 NULL 32
B.012 Mensa 180
C.020 Bibliothek 40

c) Hannah hat es inzwischen mit einem Verbund versucht – wieder ohne Ergebnis:

SELECT r.bezeichnung
FROM raum AS r
INNER JOIN session AS s ON s.raum_id = r.raum_id
WHERE s.session_id IS NULL;

Erläutere den Fehler und gib an, wie die Abfrage mit einem Verbund richtig lautet. (4 Punkte)

Ein INNER JOIN liefert nur Räume, zu denen es mindestens eine Session gibt; Räume ohne Session fallen schon beim Verbund heraus. In den verbleibenden Zeilen ist s.session_id nie NULL (Primärschlüssel), die Bedingung ist also nie erfüllt (2 Punkte).

Richtig ist ein LEFT JOIN: Er liefert alle Räume, bei Räumen ohne Session sind die Spalten von session NULL (2 Punkte):

SELECT r.bezeichnung
FROM raum AS r
LEFT JOIN session AS s ON s.raum_id = r.raum_id
WHERE s.session_id IS NULL
ORDER BY r.bezeichnung;

Ergebnis: A.104, A.105, B.012, C.020 (hier unabhängig vom Status, weil alle Sessions mit Raum angenommen sind; für „angenommen“ gehört die Bedingung AND s.status = 'angenommen' in die ON-Klausel).

d) Erstelle eine SQL-Abfrage, die Titel und Platzbegrenzung des Workshops (bzw. der Workshops) mit der größten Platzbegrenzung ausgibt. Begründe, warum die Bedingung auf das Format sowohl in der äußeren Abfrage als auch in der Unterabfrage stehen muss. (5 Punkte)

SELECT titel, max_plaetze
FROM session
WHERE format = 'Workshop'
  AND max_plaetze = (SELECT MAX(max_plaetze)
                     FROM session
                     WHERE format = 'Workshop');

Ergebnis: „Barrierefreie Videos: Untertitel und Gebärdensprache“ mit 20 Plätzen.

Bewertung: äußere Abfrage mit Bedingung auf das Format 1 Punkt, Unterabfrage mit MAX und Formatbedingung 2 Punkte, Begründung 2 Punkte: Ohne die Bedingung in der Unterabfrage liefert sie das Maximum aller Sessions (24 Plätze beim Tutorial); kein Workshop hat 24 Plätze, das Ergebnis wäre leer. Ohne die Bedingung außen könnten Sessions anderer Formate mit gleicher Platzzahl erscheinen. Eine Lösung mit ORDER BY max_plaetze DESC LIMIT 1 liefert bei Gleichstand nur einen von mehreren Workshops.

e) Gerhard Vogt, Referent des Reparatur-Workshops, ist auf einen ebenerdigen bzw. per Aufzug erreichbaren Raum angewiesen. Alle angenommenen Sessions, die bisher in einem nicht barrierefreien Raum geplant sind, sollen deshalb in den Raum A.104 verlegt werden. Erstelle eine SQL-Anweisung, die das erledigt, ohne Schlüsselwerte als Zahl zu verwenden. (5 Punkte)

UPDATE session
SET raum_id = (SELECT raum_id FROM raum WHERE bezeichnung = 'A.104')
WHERE status = 'angenommen'
  AND raum_id IN (SELECT raum_id FROM raum WHERE barrierefrei = FALSE);

Bewertung: UPDATE … SET 1 Punkt, Unterabfrage für den neuen Raum 1 Punkt, IN-Unterabfrage auf nicht barrierefreie Räume 2 Punkte, Bedingung auf angenommene Sessions 1 Punkt. Betroffen ist eine Zeile: Session 2 „Reparieren statt wegwerfen“ liegt danach in Raum 2 (A.104, 32 Plätze, mehr als die 12 Workshop-Plätze).

f) Für einen Zwischenbericht sollen alle Buchungen vom 12.10. bis einschließlich 16.10.2026 mit Datum, Sessiontitel und Anmeldenummer aufgelistet werden, sortiert zuerst nach Datum und dann nach Sessiontitel. Erstelle die SQL-Abfrage und erkläre, worauf Du bei der Bedingung achten musst, weil gebucht_am auch eine Uhrzeit enthält. (6 Punkte)

SELECT DATE(b.gebucht_am) AS tag, s.titel, b.anmeldung_id
FROM session_buchung AS b
JOIN session AS s ON s.session_id = b.session_id
WHERE b.gebucht_am >= '2026-10-12'
  AND b.gebucht_am <  '2026-10-17'
ORDER BY tag, s.titel;

Bewertung: Spaltenauswahl mit Datum 1 Punkt, Verbund 1 Punkt, Bedingung auf den Zeitraum 2 Punkte, Sortierung nach zwei Spalten 1 Punkt, Erklärung 1 Punkt: BETWEEN '2026-10-12' AND '2026-10-16' vergleicht mit 2026-10-16 00:00:00 und würde alle Buchungen vom 16.10. nach Mitternacht ausschließen. Richtig ist deshalb „kleiner als der Folgetag“ oder BETWEEN '2026-10-12 00:00:00' AND '2026-10-16 23:59:59' bzw. DATE(b.gebucht_am) BETWEEN '2026-10-12' AND '2026-10-16'.

tag titel anmeldung_id
2026-10-12 Barrierefreie Videos: Untertitel und Gebärdensprache 12
2026-10-12 Escape-Room mit Mikrocontrollern 6
2026-10-12 Reparieren statt wegwerfen: Laptops retten 6
2026-10-12 Reparieren statt wegwerfen: Laptops retten 12
2026-10-16 Barrierefreie Webseiten mit HTML und ARIA 14
2026-10-16 Escape-Room mit Mikrocontrollern 14
2026-10-16 Reparieren statt wegwerfen: Laptops retten 14

Punkte gesamt: 32 (a: 5, b: 7, c: 4, d: 5, e: 5, f: 6)

Aufgabe 2.2: Technische Merkmale im Fahrrad-Onlineshop (Kernkompetenz)

(ca. 25 min Bearbeitungszeit)

Die CSB-Rad GmbH in Bielefeld verkauft Fahrräder und Komponenten in einem Onlineshop. Für einen Produktvergleich speichert Geschäftsführerin Sophie Bertram technische Merkmale (Akkukapazität, Reichweite, Zuladung …) nicht als eigene Spalten, sondern in einer Merkmalstabelle. Du erstellst die Abfragen für die Vergleichsseite.

  • kategorie (kategorie_id, bezeichnung, #ober_kategorie_id)

  • artikel (artikel_id, artikelnr, bezeichnung, #kategorie_id, netto_preis, lagerbestand, aktiv, …)

  • merkmal (merkmal_id, bezeichnung, einheit)

  • artikel_merkmal (#artikel_id, #merkmal_id, wert)

Auszug aus der Tabelle artikel
artikel_id artikelnr bezeichnung kategorie_id netto_preis aktiv
1 RAD-CITY-01 Stadtrad „Promenade“ 7-Gang 4 671.43 TRUE
2 RAD-EB-02 E-Bike „Kanal“ Mittelmotor 500 Wh 2 2520.17 TRUE
3 RAD-LR-03 Lastenrad „Long John“ individuell 3 3780.67 TRUE
4 RAH-IND-01 Rahmen nach Maß (Stahl, pulverbeschichtet) 6 840.34 TRUE
5 SCH-BUE-01 Bügelschloss, gleichschließend 7 62.18 TRUE
6 SCH-RAH-01 Rahmenschloss, gleichschließend 7 41.18 TRUE
7 LIC-SET-01 Lichtset Nabendynamo StVZO 8 75.63 TRUE
8 BRE-HYD-01 Hydraulische Scheibenbremse (Paar) 9 133.61 TRUE
9 RAD-EB-01 E-Bike „Altstadt“ Nabenmotor (Auslauf) 2 1595.80 FALSE
Auszug aus der Tabelle kategorie
kategorie_id bezeichnung
2 E-Bikes
3 Lastenräder
4 Stadträder
6 Rahmen
7 Schlösser
8 Beleuchtung
9 Bremsen
Tabelle merkmal
merkmal_id bezeichnung einheit
1 Akkukapazität Wh
2 Reichweite km
3 Zuladung kg
4 Rahmenhöhe cm
5 Lichtstärke lx
Tabelle artikel_merkmal
artikel_id merkmal_id wert
2 1 500.0
2 2 120.0
2 3 130.0
9 1 400.0
9 2 80.0
9 3 120.0
3 1 750.0
3 2 90.0
3 3 220.0
1 3 120.0
1 4 55.0
7 5 70.0

a) Erstelle eine Liste aller Artikel mit einer Akkukapazität von mindestens 500 Wh mit Artikelnummer, Bezeichnung, Akkukapazität und Nettopreis. Sortiere so, dass die Artikel mit der größten Akkukapazität oben stehen und bei gleicher Kapazität der günstigere Artikel zuerst kommt. Wähle das Merkmal über seine Bezeichnung aus, nicht über die merkmal_id. Gib das Ergebnis an. (6 Punkte)

SELECT a.artikelnr, a.bezeichnung, am.wert AS akku_wh, a.netto_preis
FROM artikel AS a
JOIN artikel_merkmal AS am ON am.artikel_id = a.artikel_id
WHERE am.merkmal_id = (SELECT merkmal_id
                       FROM merkmal
                       WHERE bezeichnung = 'Akkukapazität')
  AND am.wert >= 500
ORDER BY am.wert DESC, a.netto_preis ASC;

Gleichwertig mit einem weiteren Verbund JOIN merkmal AS m ON m.merkmal_id = am.merkmal_id und WHERE m.bezeichnung = 'Akkukapazität'.

Bewertung: Spaltenauswahl 1 Punkt, Verbund artikel–artikel_merkmal 1 Punkt, Auswahl des Merkmals über die Bezeichnung (Unterabfrage oder Verbund) 2 Punkte, Bedingung >= 500 1 Punkt, Sortierung nach zwei Spalten mit DESC/ASC 1 Punkt.

artikelnr bezeichnung akku_wh netto_preis
RAD-LR-03 Lastenrad „Long John“ individuell 750.0 3780.67
RAD-EB-02 E-Bike „Kanal“ Mittelmotor 500 Wh 500.0 2520.17

b) Erstelle eine Liste aller Artikel mit der geringsten Zuladung (merkmal_id 3) mit Bezeichnung und Zuladung. Gib das Ergebnis an und erläutere, warum eine Lösung mit ORDER BY wert LIMIT 1 hier nicht ausreicht. (5 Punkte)

SELECT a.bezeichnung, am.wert AS zuladung_kg
FROM artikel AS a
JOIN artikel_merkmal AS am ON am.artikel_id = a.artikel_id
WHERE am.merkmal_id = 3
  AND am.wert = (SELECT MIN(wert)
                 FROM artikel_merkmal
                 WHERE merkmal_id = 3);
bezeichnung zuladung_kg
E-Bike „Altstadt“ Nabenmotor (Auslauf) 120.0
Stadtrad „Promenade“ 7-Gang 120.0

Bewertung: Verbund und Bedingung außen 1 Punkt, Unterabfrage mit MIN 1 Punkt, Einschränkung der Unterabfrage auf Merkmal 3 1 Punkt (ohne sie wäre das Minimum 55 – die Rahmenhöhe in cm – und das Ergebnis leer), Ergebnis und Erläuterung 2 Punkte: Zwei Artikel teilen sich die geringste Zuladung von 120 kg. LIMIT 1 liefert nur einen von beiden, welchen, ist nicht festgelegt; die Unterabfrage liefert alle.

c) Für die Pflege der Vergleichsseite braucht Sophie eine Liste aller Artikel mit Kategorie, zu denen noch keine Merkmale erfasst sind, sortiert nach Kategorie und Artikelbezeichnung. Erstelle die SQL-Abfrage und gib das Ergebnis an. (5 Punkte)

SELECT k.bezeichnung AS kategorie, a.bezeichnung AS artikel
FROM artikel AS a
JOIN kategorie AS k ON k.kategorie_id = a.kategorie_id
WHERE a.artikel_id NOT IN (SELECT artikel_id FROM artikel_merkmal)
ORDER BY k.bezeichnung, a.bezeichnung;

Gleichwertig mit NOT EXISTS (SELECT * FROM artikel_merkmal AS am WHERE am.artikel_id = a.artikel_id) oder einem zusätzlichen LEFT JOIN artikel_merkmal … WHERE am.artikel_id IS NULL (ohne DISTINCT, weil nur Artikel ohne Partner übrig bleiben). NOT IN ist hier sicher, weil artikel_merkmal.artikel_id als Teil des Primärschlüssels nie NULL ist.

Bewertung: Verbund mit kategorie 1 Punkt, NOT IN/NOT EXISTS mit Unterabfrage 2 Punkte, Sortierung 1 Punkt, Ergebnis 1 Punkt.

kategorie artikel
Bremsen Hydraulische Scheibenbremse (Paar)
Rahmen Rahmen nach Maß (Stahl, pulverbeschichtet)
Schlösser Bügelschloss, gleichschließend
Schlösser Rahmenschloss, gleichschließend

d) Erstelle eine Liste der aktiven Artikel, deren Nettopreis über dem Durchschnittspreis aller aktiven Artikel liegt, mit Artikelnummer, Bezeichnung und Nettopreis, den teuersten zuerst. (4 Punkte)

SELECT artikelnr, bezeichnung, netto_preis
FROM artikel
WHERE aktiv = TRUE
  AND netto_preis > (SELECT AVG(netto_preis)
                     FROM artikel
                     WHERE aktiv = TRUE)
ORDER BY netto_preis DESC;

Bewertung: Bedingung aktiv außen 1 Punkt, Unterabfrage mit AVG und Bedingung aktiv 2 Punkte, Sortierung 1 Punkt. Der Durchschnitt der acht aktiven Artikel beträgt rund 1.015,65 €; Ergebnis: RAD-LR-03 (3780.67) und RAD-EB-02 (2520.17).

e) Wegen gestiegener Einkaufspreise sollen alle Artikel der Kategorie „Schlösser“ um 4 % teurer werden (auf zwei Nachkommastellen gerundet). Erstelle die SQL-Anweisung, ohne die kategorie_id als Zahl zu verwenden, und gib die neuen Preise an. (4 Punkte)

UPDATE artikel
SET netto_preis = ROUND(netto_preis * 1.04, 2)
WHERE kategorie_id IN (SELECT kategorie_id
                       FROM kategorie
                       WHERE bezeichnung = 'Schlösser');

Bewertung: UPDATE … SET mit Berechnung 2 Punkte, Unterabfrage auf die Kategorie 1 Punkt, neue Preise 1 Punkt: Bügelschloss 64.67 €, Rahmenschloss 42.83 €. (= statt IN ist hier ebenfalls richtig, weil die Bezeichnung nur einmal vorkommt.)

f) Natalia Wróbel aus dem Controlling wollte die Reichweiten aller Artikel sehen. Ihre Abfrage liefert dreimal das E-Bike „Kanal“ mit den Werten 500, 120 und 130:

SELECT a.bezeichnung, m.bezeichnung, am.wert
FROM artikel AS a
JOIN artikel_merkmal AS am ON am.artikel_id = a.artikel_id
JOIN merkmal AS m ON m.merkmal_id = am.artikel_id
WHERE m.bezeichnung = 'Reichweite';

Erläutere den Fehler und korrigiere die Abfrage. (3 Punkte)

Die zweite Verbundbedingung vergleicht die merkmal_id mit der artikel_id (2 Punkte). Weil „Reichweite“ die merkmal_id 2 hat, werden alle Merkmalszeilen des Artikels 2 ausgegeben und jeweils als „Reichweite“ beschriftet. Richtig ist JOIN merkmal AS m ON m.merkmal_id = am.merkmal_id (1 Punkt). Dann lautet das Ergebnis: E-Bike „Kanal“ 120 km, E-Bike „Altstadt“ 80 km, Lastenrad „Long John“ 90 km.

Punkte gesamt: 27 (a: 6, b: 5, c: 5, d: 4, e: 4, f: 3)

Die Tabellen merkmal und artikel_merkmal mit diesen Daten stehen in src/aufgabe4_merkmale.sql; sie ergänzen den Datenbestand der CSB-Rad GmbH.

Aufgabe 3.1: Auswertungen und Datenpflege in einer Freizeit-App (Profilkompetenz)

(ca. 51 min Bearbeitungszeit)

Du bist Auszubildende*r bei der Let’s Meet GmbH in Bielefeld. Die App „Let’s Meet“ bringt Menschen ab 16 Jahren für gemeinsame Freizeitaktivitäten an öffentlichen Treffpunkten zusammen. Dein Ausbilder Yusuf Demir hat Dir mehrere Wünsche aus dem Team auf den Tisch gelegt: UX-Designerin Anh Nguyen arbeitet am Filter „barrierefreier Treffpunkt“, Geschäftsführerin Clara Neumann braucht Kennzahlen, und die Datenschutzbeauftragte Dr. Marta Kowalczyk möchte, dass nicht mehr benötigte Daten gelöscht werden. Dein Mit-Azubi Jonas Weber hat bereits einige Abfragen vorbereitet.

Die Datenbank enthält unter anderem folgende Tabellen:

  • benutzer (benutzer_id, vorname, nachname, email, geburtsdatum, plz, ort, status, …)

  • hobby (hobby_id, bezeichnung, kategorie)

  • treffpunkt (treffpunkt_id, name, strasse, plz, ort, barrierefrei, oeffentlich)

  • aktivitaet (aktivitaet_id, titel, #hobby_id, #ersteller_id, #treffpunkt_id, beginn, dauer_min, max_teilnehmende, kosten_eur, mindestalter, status)

  • teilnahme (#aktivitaet_id, #benutzer_id, angemeldet_am, status)

  • bewertung (bewertung_id, #aktivitaet_id, #bewerter_id, sterne, kommentar)

  • meldung (meldung_id, #melder_id, #gemeldet_id, #aktivitaet_id, grund, ergebnis)

teilnahme.status kann die Werte angemeldet, warteliste, abgemeldet, teilgenommen und nicht_erschienen haben, aktivitaet.status die Werte geplant, abgesagt und stattgefunden. In meldung ist aktivitaet_id optional: Meldungen, die sich nicht auf eine bestimmte Aktivität beziehen (zum Beispiel Spam im Chat), haben dort NULL. barrierefrei ist ein Wahrheitswert (TRUE/FALSE).

Tabelle aktivitaet (ohne die Spalten ersteller_id, mindestalter und erstellt_am)
aktivitaet_id titel hobby_id treffpunkt_id beginn dauer_min max_teilnehmende kosten_eur status
1 Brettspielabend: Kooperative Spiele 6 1 2026-09-25 19:00:00 180 6 5.00 stattgefunden
2 Sonntagswanderung Teutoburger Wald (12 km) 1 2 2026-10-04 10:00:00 240 8 0.00 stattgefunden
3 Feierabend-Kick 4 3 2026-10-21 18:30:00 90 10 3.00 geplant
4 Sprachtandem Deutsch–Arabisch 5 4 2026-10-23 17:00:00 90 4 0.00 geplant
5 Bouldern für Einsteiger*innen 11 5 2026-10-24 14:00:00 120 5 14.50 geplant
6 Schach im Park 19 6 2026-09-27 15:00:00 120 4 0.00 abgesagt
7 Fotospaziergang Sparrenburg 2 4 2026-10-31 15:30:00 120 6 0.00 geplant
8 Kochabend: Mezze mit Weinbegleitung 3 7 2026-10-17 18:00:00 180 6 8.00 stattgefunden
Tabelle treffpunkt (Auszug)
treffpunkt_id name ort barrierefrei
1 Spielecafé Würfelglück Bielefeld TRUE
2 Wanderparkplatz Hünenburg Bielefeld FALSE
3 Sporthalle Süd (Vereinshalle) Bielefeld TRUE
4 Stadtbibliothek, Foyer Bielefeld TRUE
5 Kletterhalle Nordwand Bielefeld FALSE
6 Sparrenburg, Promenade Bielefeld FALSE
7 Alter Markt Herford Herford TRUE
Tabelle teilnahme (23 Zeilen, hier je Aktivität zusammengefasst, ohne angemeldet_am)
aktivitaet_id Teilnahmen (benutzer_id: status)
1 3: teilgenommen, 5: teilgenommen, 7: nicht_erschienen, 9: teilgenommen, 13: teilgenommen
2 1: teilgenommen, 4: abgemeldet, 6: teilgenommen, 10: teilgenommen
3 2: angemeldet, 7: angemeldet, 12: angemeldet
4 2: angemeldet, 12: angemeldet
5 3: warteliste, 5: angemeldet, 7: angemeldet
6 9: abgemeldet
7 1: angemeldet, 10: angemeldet
8 2: teilgenommen, 6: teilgenommen, 12: teilgenommen
Tabelle benutzer (Auszug)
benutzer_id vorname nachname status
1 Sabine Hoffmann aktiv
2 Malik Haddad aktiv
3 Lea Schmitt aktiv
4 Tobias Lange aktiv
5 Kim Bauer aktiv
6 Petra Novak aktiv
7 Daniel Owusu aktiv
8 Jana Meier aktiv
9 Deniz Çelik aktiv
10 Rainer Krause aktiv
11 Elena Wagner gesperrt
12 Jörg Albrecht aktiv
13 Noah Bergmann aktiv
Tabelle meldung (ohne Beschreibung und Zeitstempel)
meldung_id melder_id gemeldet_id aktivitaet_id grund ergebnis
1 3 7 1 Nicht erschienen unbegruendet
2 9 11 NULL Spam gesperrt

Bewertungen gibt es bisher zu den Aktivitäten 1 (5 und 4 Sterne), 2 (5 und 4 Sterne) und 8 (5 und 4 Sterne). In der Tabelle hobby gehören die Hobbys 6 (Brettspiele) und 19 (Schach) zur Kategorie Spiele; zur Kategorie Kultur (Kino, Konzerte, Chor, Lesen) gibt es noch keine Aktivität.

a) Clara Neumann möchte wissen, welche Aktivitäten mehr kosten als der Brettspielabend (aktivitaet_id 1). Gib Titel, Beginn und Kostenbeitrag dieser Aktivitäten an, die teuerste zuerst. Der Kostenbeitrag des Brettspielabends darf nicht als Zahl in Deiner SQL-Anweisung stehen, weil er sich ändern kann. (4 Punkte)

SELECT titel, beginn, kosten_eur
FROM aktivitaet
WHERE kosten_eur > (SELECT kosten_eur
                    FROM aktivitaet
                    WHERE aktivitaet_id = 1)
ORDER BY kosten_eur DESC;
Ergebnis
titel beginn kosten_eur
Bouldern für Einsteiger*innen 2026-10-24 14:00:00 14.50
Kochabend: Mezze mit Weinbegleitung 2026-10-17 18:00:00 8.00

Bewertung: Spaltenauswahl 1 P, skalare Unterabfrage mit Filter auf aktivitaet_id = 1 2 P, Vergleich > und Sortierung absteigend 1 P.

b) Anh Nguyen möchte die Personen befragen, die schon einmal für eine Aktivität an einem barrierefreien Treffpunkt eingetragen waren – egal mit welchem Teilnahmestatus.

ba) Erstelle eine SQL-Abfrage, die Vorname und Nachname dieser Personen ausgibt, sortiert nach Nachname und Vorname. Verwende ausschließlich Unterabfragen, keinen JOIN. (5 Punkte)

bb) Jonas meint, mit einem JOIN über benutzer, teilnahme, aktivitaet und treffpunkt gehe es genauso. Begründe, warum seine Liste ohne DISTINCT Personen mehrfach enthielte, und nenne am Beispiel einer Person aus den Tabellen, wie oft sie erschiene. (2 Punkte)

ba)

SELECT vorname, nachname
FROM benutzer
WHERE benutzer_id IN (
        SELECT benutzer_id
        FROM teilnahme
        WHERE aktivitaet_id IN (
                SELECT aktivitaet_id
                FROM aktivitaet
                WHERE treffpunkt_id IN (
                        SELECT treffpunkt_id
                        FROM treffpunkt
                        WHERE barrierefrei = TRUE)))
ORDER BY nachname, vorname;

Barrierefrei sind die Treffpunkte 1, 3, 4 und 7, dort finden die Aktivitäten 1, 3, 4, 7 und 8 statt. Ergebnis (10 Zeilen, Sortierung nach der Kollation utf8mb4_unicode_ci, in der „Ç“ wie „C“ einsortiert wird):

Ergebnis
vorname nachname
Jörg Albrecht
Kim Bauer
Noah Bergmann
Deniz Çelik
Malik Haddad
Sabine Hoffmann
Rainer Krause
Petra Novak
Daniel Owusu
Lea Schmitt

WHERE barrierefrei ohne = TRUE ist ebenfalls richtig.

Bewertung: äußere Abfrage mit Spalten und Sortierung 1 P, je richtig verschachtelte Unterabfrage mit IN 1 P (3 P), Bedingung barrierefrei = TRUE 1 P.

bb) Ein JOIN liefert je Teilnahme eine Zeile, nicht je Person. Wer an mehreren Aktivitäten an barrierefreien Treffpunkten eingetragen ist, erscheint entsprechend oft: Jörg Albrecht und Malik Haddad (Aktivitäten 3, 4 und 8) je dreimal, Daniel Owusu (1 und 3) zweimal; insgesamt 15 statt 10 Zeilen. IN prüft dagegen nur, ob die benutzer_id in der Liste vorkommt, und gibt jede Person aus benutzer höchstens einmal aus.

Bewertung: Begründung eine Zeile je Teilnahme 1 P, richtiges Beispiel 1 P.

c) Für eine Infobox in der App hat Jonas folgende Abfrage geschrieben:

SELECT titel
FROM aktivitaet
WHERE treffpunkt_id = (SELECT treffpunkt_id
                       FROM treffpunkt
                       WHERE barrierefrei = TRUE);

MariaDB bricht mit der Meldung Subquery returns more than 1 row ab. Erläutere die Meldung und korrigiere die Abfrage. (3 Punkte)

Hinter dem Vergleichsoperator = erwartet MariaDB genau einen Wert (skalare Unterabfrage). Die Unterabfrage liefert aber vier Treffpunkte (1, 3, 4, 7). Für den Vergleich mit einer Liste ist IN nötig:

SELECT titel
FROM aktivitaet
WHERE treffpunkt_id IN (SELECT treffpunkt_id
                        FROM treffpunkt
                        WHERE barrierefrei = TRUE);

Ergebnis: Brettspielabend, Feierabend-Kick, Sprachtandem, Fotospaziergang und Kochabend (5 Zeilen). = ANY (…) ist gleichwertig.

Bewertung: Erläuterung „mehrere Zeilen, aber skalarer Vergleich“ 2 P, Korrektur mit IN 1 P.

d) Dr. Kowalczyk möchte wissen, zu welchen Aktivitäten noch nie eine Meldung eingegangen ist. Jonas hat dazu geschrieben:

SELECT aktivitaet_id, titel
FROM aktivitaet
WHERE aktivitaet_id NOT IN (SELECT aktivitaet_id FROM meldung);

Die Abfrage läuft fehlerfrei, liefert aber keine einzige Zeile.

da) Erläutere, warum das Ergebnis leer ist. (3 Punkte)

db) Erstelle eine korrigierte Abfrage mit NOT EXISTS und gib das Ergebnis an. (3 Punkte)

da) Die Unterabfrage liefert die Liste (1, NULL), weil die Spam-Meldung keiner Aktivität zugeordnet ist. aktivitaet_id NOT IN (1, NULL) bedeutet aktivitaet_id <> 1 AND aktivitaet_id <> NULL. Der Vergleich mit NULL ergibt weder wahr noch falsch, sondern unbekannt (NULL). Eine AND-Verknüpfung mit „unbekannt“ ist nie wahr, also erfüllt keine Zeile die Bedingung.

Bewertung: NULL in der Unterabfrage erkannt (Spam-Meldung) 1 P, Auflösung in <>-Vergleiche 1 P, dreiwertige Logik/„unbekannt“ 1 P.

db)

SELECT a.aktivitaet_id, a.titel
FROM aktivitaet AS a
WHERE NOT EXISTS (SELECT *
                  FROM meldung AS m
                  WHERE m.aktivitaet_id = a.aktivitaet_id);

Ergebnis: alle Aktivitäten außer dem Brettspielabend, also die Aktivitäten 2 bis 8 (7 Zeilen). Ebenfalls richtig ist Jonas’ Abfrage mit WHERE aktivitaet_id IS NOT NULL in der Unterabfrage.

Bewertung: NOT EXISTS mit Unterabfrage 1 P, Korrelation m.aktivitaet_id = a.aktivitaet_id 1 P, Ergebnis 1 P.

e) Für das Team-Dashboard soll eine Übersicht aller nicht abgesagten Aktivitäten entstehen:

Ergebnisbeispiel
aktivitaet_id titel max_teilnehmende angemeldet warteliste sterne_schnitt
1 Brettspielabend: Kooperative Spiele 6 4 0 4.5
3 Feierabend-Kick 10 3 0 NULL
… … … … … …

angemeldet zählt die Teilnahmen mit Status angemeldet oder teilgenommen, warteliste die mit Status warteliste, sterne_schnitt ist der auf eine Nachkommastelle gerundete Durchschnitt der Bewertungen.

ea) Erstelle die SQL-Abfrage mit Unterabfragen in der SELECT-Liste, sortiert nach aktivitaet_id. (7 Punkte)

eb) Jonas hat dieselbe Übersicht mit zwei LEFT JOINs versucht:

SELECT a.aktivitaet_id, COUNT(t.benutzer_id) AS angemeldet,
       ROUND(AVG(b.sterne), 1) AS sterne_schnitt
FROM aktivitaet AS a
  LEFT JOIN teilnahme AS t ON t.aktivitaet_id = a.aktivitaet_id
                          AND t.status IN ('angemeldet', 'teilgenommen')
  LEFT JOIN bewertung AS b ON b.aktivitaet_id = a.aktivitaet_id
WHERE a.status <> 'abgesagt'
GROUP BY a.aktivitaet_id;

Beim Brettspielabend erscheinen 8 statt 4 Angemeldete. Erläutere, wie diese Zahl zustande kommt. (2 Punkte)

ec) In Deinem Ergebnis aus ea) steht für den Feierabend-Kick bei warteliste eine 0, bei sterne_schnitt aber NULL. Erläutere den Unterschied. (2 Punkte)

ea)

SELECT a.aktivitaet_id,
       a.titel,
       a.max_teilnehmende,
       (SELECT COUNT(*)
          FROM teilnahme AS t
         WHERE t.aktivitaet_id = a.aktivitaet_id
           AND t.status IN ('angemeldet', 'teilgenommen')) AS angemeldet,
       (SELECT COUNT(*)
          FROM teilnahme AS t
         WHERE t.aktivitaet_id = a.aktivitaet_id
           AND t.status = 'warteliste') AS warteliste,
       (SELECT ROUND(AVG(b.sterne), 1)
          FROM bewertung AS b
         WHERE b.aktivitaet_id = a.aktivitaet_id) AS sterne_schnitt
FROM aktivitaet AS a
WHERE a.status <> 'abgesagt'
ORDER BY a.aktivitaet_id;
Ergebnis
aktivitaet_id titel max_teilnehmende angemeldet warteliste sterne_schnitt
1 Brettspielabend: Kooperative Spiele 6 4 0 4.5
2 Sonntagswanderung Teutoburger Wald (12 km) 8 3 0 4.5
3 Feierabend-Kick 10 3 0 NULL
4 Sprachtandem Deutsch–Arabisch 4 2 0 NULL
5 Bouldern für Einsteiger*innen 5 2 1 NULL
7 Fotospaziergang Sparrenburg 6 2 0 NULL
8 Kochabend: Mezze mit Weinbegleitung 6 3 0 4.5

ROUND() darf auch außen um die Unterabfrage stehen. Die beiden Zählungen aus teilnahme lassen sich alternativ mit bedingter Aggregation in einer abgeleiteten Tabelle berechnen (SUM(CASE WHEN status = 'warteliste' THEN 1 ELSE 0 END) je aktivitaet_id), die per LEFT JOIN angebunden wird; das ist kürzer, aber schwerer zu lesen.

Bewertung: Spalten aus aktivitaet, Filter status <> 'abgesagt' und Sortierung 1 P, je Unterabfrage 2 P (Aggregatfunktion, Korrelation, Statusbedingung bzw. Rundung; 6 P).

eb) Beim Brettspielabend gibt es 4 passende Teilnahmen und 2 Bewertungen. Der zweite LEFT JOIN verknüpft jede der 4 Teilnahmezeilen mit jeder der 2 Bewertungen derselben Aktivität; vor dem Gruppieren entstehen also 4 · 2 = 8 Zeilen, und COUNT(t.benutzer_id) zählt 8. Der Durchschnitt bleibt hier zufällig richtig, weil jede Bewertung gleich oft vervielfacht wird. Unterabfragen in der SELECT-Liste rechnen jede Kennzahl getrennt und vermeiden die Vervielfachung. (Alternative: COUNT(DISTINCT t.benutzer_id).)

Bewertung: Kombination Teilnahmen × Bewertungen 1 P, Rechnung 4 · 2 = 8 1 P.

ec) Findet eine Unterabfrage keine passenden Zeilen, liefert COUNT(*) den Wert 0 – es wurden null Zeilen gezählt. AVG() (ebenso SUM, MIN, MAX) hat dagegen keine Werte, aus denen es etwas berechnen kann, und liefert NULL. Soll stattdessen ein Ersatzwert erscheinen, hilft COALESCE(…, …); bei einem Durchschnitt wäre 0 Sterne aber irreführend, NULL („noch keine Bewertung“) ist hier fachlich richtig.

Bewertung: COUNT → 0 1 P, AVG ohne Werte → NULL 1 P.

f) Auf der Startseite sollen geplante Aktivitäten mit freien Plätzen beworben werden. Erstelle eine SQL-Abfrage, die alle geplanten Aktivitäten mit einer Auslastung unter 50 % ausgibt (aktivitaet_id, titel, auslastung_prozent), aufsteigend nach Auslastung. Die Auslastung ist der Anteil der Teilnahmen mit Status angemeldet an max_teilnehmende in Prozent, gerundet auf eine Nachkommastelle. Beachte, dass ein Spaltenalias in der WHERE-Klausel derselben Abfrage nicht verwendet werden kann. (6 Punkte)

Lösung mit abgeleiteter Tabelle:

SELECT aktivitaet_id, titel, auslastung_prozent
FROM (SELECT a.aktivitaet_id,
             a.titel,
             ROUND((SELECT COUNT(*)
                      FROM teilnahme AS t
                     WHERE t.aktivitaet_id = a.aktivitaet_id
                       AND t.status = 'angemeldet')
                   * 100 / a.max_teilnehmende, 1) AS auslastung_prozent
        FROM aktivitaet AS a
       WHERE a.status = 'geplant') AS uebersicht
WHERE auslastung_prozent < 50
ORDER BY auslastung_prozent;
Ergebnis
aktivitaet_id titel auslastung_prozent
3 Feierabend-Kick 30.0
7 Fotospaziergang Sparrenburg 33.3
5 Bouldern für Einsteiger*innen 40.0

Das Sprachtandem (2 von 4, also genau 50 %) gehört nicht dazu. Gleichwertig ist es, die Unterabfrage in der WHERE-Klausel der einfachen Abfrage zu wiederholen (WHERE a.status = 'geplant' AND (SELECT COUNT(*) …) * 100 / a.max_teilnehmende < 50). In MariaDB funktioniert auch HAVING auslastung_prozent < 50, das ist aber nicht standardkonform.

Bewertung: korrelierte Zählung der Teilnahmen 2 P, Berechnung und Rundung der Auslastung 1 P, Filter status = 'geplant' 1 P, Filter < 50 über abgeleitete Tabelle (mit Alias) oder wiederholten Ausdruck 1 P, Sortierung 1 P.

g) Clara Neumann möchte für eine Präsentation wissen, wie sich alle Teilnahmen auf die verschiedenen Status verteilen. Ermittle mit einer SQL-Abfrage je Status die Anzahl und den Anteil an allen Teilnahmen in Prozent (eine Nachkommastelle), den größten Anteil zuerst, und gib das Ergebnis an. (5 Punkte)

SELECT status,
       COUNT(*) AS anzahl,
       ROUND(COUNT(*) * 100 / (SELECT COUNT(*) FROM teilnahme), 1) AS anteil_prozent
FROM teilnahme
GROUP BY status
ORDER BY anteil_prozent DESC;
Ergebnis (23 Teilnahmen; die Reihenfolge der beiden letzten Zeilen ist bei Gleichstand nicht festgelegt)
status anzahl anteil_prozent
teilgenommen 10 43.5
angemeldet 9 39.1
abgemeldet 2 8.7
warteliste 1 4.3
nicht_erschienen 1 4.3

Bewertung: Gruppierung nach Status mit COUNT(*) 1 P, Unterabfrage für die Gesamtzahl 2 P, Prozentrechnung mit Rundung 1 P, Sortierung und Ergebnis 1 P.

h) Clara möchte außerdem wissen, welche Aktivitäten länger dauern als jede Aktivität einer bestimmten Hobby-Kategorie. Jonas hat für die Kategorie Spiele geschrieben:

SELECT titel, dauer_min
FROM aktivitaet
WHERE dauer_min > ALL (SELECT dauer_min
                       FROM aktivitaet
                       WHERE hobby_id IN (SELECT hobby_id
                                          FROM hobby
                                          WHERE kategorie = 'Spiele'));

ha) Ermittle das Ergebnis der Abfrage. (2 Punkte)

hb) Yusuf schlägt vor, statt > ALL (…) den Vergleich > (SELECT MAX(dauer_min) …) zu schreiben. Erläutere, für welche Kategorie aus den Tabellen sich die beiden Varianten unterschiedlich verhalten, und gib jeweils das Ergebnis an. (2 Punkte)

ha) Zur Kategorie Spiele gehören der Brettspielabend (180 min) und Schach im Park (120 min). > ALL verlangt mehr als 180 Minuten. Ergebnis: eine Zeile, „Sonntagswanderung Teutoburger Wald (12 km)“ mit 240 Minuten.

Bewertung: Dauern der Kategorie 1 P, Ergebnis 1 P.

hb) Unterschiedlich verhalten sich die Varianten bei einer Kategorie ohne Aktivität, hier Kultur. Die Unterabfrage ist dann leer. > ALL über eine leere Menge ist immer wahr, die Abfrage liefert alle 8 Aktivitäten. MAX() über eine leere Menge ist NULL, der Vergleich > NULL ist nie wahr, die Abfrage liefert keine Zeile. Bei Kategorien mit Aktivitäten sind beide Varianten gleich.

Bewertung: leere Unterabfrage/Kategorie Kultur erkannt 1 P, beide Ergebnisse mit Begründung 1 P.

i) Durch einen Fehler in der alten App-Version stehen Personen auf der Warteliste, obwohl noch Plätze frei sind.

ia) Erstelle eine SQL-Anweisung, die bei allen geplanten Aktivitäten die Teilnahmen mit Status warteliste auf angemeldet setzt, sofern weniger Teilnahmen mit Status angemeldet vorliegen als max_teilnehmende. Gib an, welche Teilnahme betroffen ist. (5 Punkte)

ib) Nimm Stellung, ob Deine Anweisung auch dann richtig arbeitet, wenn bei einer Aktivität mit nur einem freien Platz zwei Personen auf der Warteliste stehen. (2 Punkte)

ia)

UPDATE teilnahme AS t
SET status = 'angemeldet'
WHERE t.status = 'warteliste'
  AND t.aktivitaet_id IN (SELECT aktivitaet_id
                          FROM aktivitaet
                          WHERE status = 'geplant')
  AND (SELECT COUNT(*)
         FROM teilnahme AS x
        WHERE x.aktivitaet_id = t.aktivitaet_id
          AND x.status = 'angemeldet')
      < (SELECT a.max_teilnehmende
           FROM aktivitaet AS a
          WHERE a.aktivitaet_id = t.aktivitaet_id);

Betroffen ist eine Zeile: Lea Schmitt (benutzer_id 3) beim Bouldern (Aktivität 5, 2 von 5 Plätzen belegt) rückt von der Warteliste nach. MariaDB erlaubt ab Version 10.3.2, in der Unterabfrage aus derselben Tabelle zu lesen, die gerade geändert wird (in MySQL Fehler 1093). Alternativ lässt sich die Platzbedingung mit einem Verbund auf aktivitaet formulieren.

Bewertung: UPDATE … SET status = 'angemeldet' mit Filter warteliste 1 P, Einschränkung auf geplante Aktivitäten 1 P, korrelierte Zählung der Angemeldeten 2 P, Vergleich mit max_teilnehmende 1 P.

ib) Nicht zuverlässig. Die Bedingung wird für jede Zeile mit den Daten vor der Änderung geprüft: Bei einem freien Platz sind beide Wartenden „berechtigt“, und beide würden angemeldet – die Aktivität wäre überbucht. Richtig wäre, nur so viele Personen wie freie Plätze nachrücken zu lassen, und zwar in der Reihenfolge von angemeldet_am (zum Beispiel in der Anwendung oder mit einer Prozedur, die Person für Person nachrücken lässt).

Bewertung: Überbuchung erkannt 1 P, Begründung bzw. Verbesserungsvorschlag 1 P.

j) Dr. Kowalczyk hat festgelegt: Teilnahmen mit Status abgemeldet werden nicht mehr gebraucht, sobald die Aktivität stattgefunden hat oder abgesagt wurde. Lösche diese Teilnahmen mit einer SQL-Anweisung und gib an, wie viele Zeilen gelöscht werden. (3 Punkte)

DELETE FROM teilnahme
WHERE status = 'abgemeldet'
  AND aktivitaet_id IN (SELECT aktivitaet_id
                        FROM aktivitaet
                        WHERE status IN ('stattgefunden', 'abgesagt'));

Gelöscht werden 2 Zeilen: Tobias Lange bei der Wanderung (Aktivität 2) und Deniz Çelik beim abgesagten Schach im Park (Aktivität 6). WHERE status <> 'geplant' in der Unterabfrage ist ebenfalls richtig.

Bewertung: DELETE FROM teilnahme mit Status abgemeldet 1 P, Unterabfrage mit IN auf den Aktivitätsstatus 1 P, Anzahl 1 P.

Aufgabe 3.2: Monitorlisten, Monatsstatistik und Archiv einer Mitfahr-App (Profilkompetenz)

(ca. 44 min Bearbeitungszeit)

Am Ratz und Rübe Berufskolleg (RRB) in Bielefeld organisieren Auszubildende über die Web-App „RatzFahrt“ Fahrgemeinschaften zu den Berufsschultagen. Treffpunkte sind öffentliche Haltepunkte im Umland, Flurmonitore in der Schule zeigen offene Angebote und Gesuche ohne Namen an. Du arbeitest im FIA-Projektteam mit Oksana Melnyk und Jonas Wolff. Die Datenschutzbeauftragte der Schule, Andrea Wolf, betreut die App als Administratorin. Sie hat festgelegt, dass vergangene Fahrten nur noch ohne Personenbezug aufbewahrt werden.

Die Datenbank enthält unter anderem folgende Tabellen:

  • nutzer (nutzer_id, pseudonym, vorname, nachname, email_schule, geburtsdatum, #klasse_id, rolle, einwilligung_eltern, fuehrerschein, …)

  • haltepunkt (haltepunkt_id, name, ort, plz, entfernung_km, …)

  • angebot (angebot_id, #fahrer_id, #fahrzeug_id, #haltepunkt_id, richtung, datum, abfahrt, freie_plaetze, kostenbeitrag_eur, status)

  • gesuch (gesuch_id, #nutzer_id, #haltepunkt_id, richtung, datum, zeit_von, zeit_bis, status)

  • buchung (buchung_id, #angebot_id, #mitfahrer_id, #gesuch_id, status, angefragt_am, beantwortet_am)

  • bewertung (bewertung_id, #buchung_id, #von_nutzer_id, sterne, puenktlich, erstellt_am)

  • fahrt_statistik (jahr, monat, #haltepunkt_id, richtung, angebote, mitfahrten, personen_km)

fahrer_id und mitfahrer_id verweisen auf nutzer. richtung hat die Werte zur Schule und nach Hause. angebot.status kann offen, voll, abgesagt oder durchgefuehrt sein, gesuch.status offen, vermittelt, zurueckgezogen oder abgelaufen, buchung.status angefragt, bestaetigt, abgelehnt oder storniert. Die Fremdschlüssel von buchung auf angebot und von bewertung auf buchung sind mit ON DELETE CASCADE angelegt.

Tabelle angebot (ohne fahrzeug_id, kostenbeitrag_eur und erstellt_am)
angebot_id fahrer_id haltepunkt_id richtung datum abfahrt freie_plaetze status
1 1 1 zur Schule 2026-10-19 07:00:00 2 offen
2 1 1 nach Hause 2026-10-19 14:20:00 3 offen
3 6 3 zur Schule 2026-10-19 07:10:00 0 voll
4 7 2 zur Schule 2026-10-20 06:50:00 3 offen
5 9 4 zur Schule 2026-10-22 06:45:00 6 offen
6 5 1 nach Hause 2026-10-15 15:30:00 3 durchgefuehrt
7 7 2 nach Hause 2026-10-14 15:20:00 4 abgesagt
Tabelle gesuch (ohne erstellt_am)
gesuch_id nutzer_id haltepunkt_id richtung datum zeit_von zeit_bis status
1 2 1 zur Schule 2026-10-20 07:00:00 08:00:00 offen
2 3 3 zur Schule 2026-10-19 06:45:00 07:30:00 vermittelt
3 4 1 zur Schule 2026-10-19 06:30:00 07:15:00 vermittelt
4 8 4 zur Schule 2026-10-23 07:00:00 08:00:00 offen
5 4 1 nach Hause 2026-10-15 15:00:00 16:00:00 vermittelt
Tabelle buchung (ohne Zeitstempel); zu Buchung 4 gibt es zwei Bewertungen
buchung_id angebot_id mitfahrer_id gesuch_id status
1 3 3 2 bestaetigt
2 1 4 3 bestaetigt
3 1 8 NULL abgelehnt
4 6 4 5 bestaetigt
5 4 2 NULL angefragt
Tabelle haltepunkt (Auszug)
haltepunkt_id name ort entfernung_km
1 Hauptbahnhof, ZOB Steig 3 Gütersloh 18.6
2 Park & Ride am Bahnhof Herford 17.9
3 Marktplatz Halle (Westf.) 15.2
4 Busbahnhof Bad Salzuflen 19.4
5 Kirchplatz Werther (Westf.) 13.1
6 Bahnhof Brackwede Bielefeld 6.2
Tabelle nutzer (Auszug, zweispaltig dargestellt)
nutzer_id vorname nachname nutzer_id vorname nachname
1 Ben Richter 6 Jonas Wolff
2 Aylin Kaya 7 Marie Schulz
3 Oksana Melnyk 8 Lina Hartwig
4 Lukas Hofmann 9 Niklas Fischer
5 Thomas Becker 10 Andrea Wolf
Tabelle fahrt_statistik (anonyme Monatsstatistik)
jahr monat haltepunkt_id richtung angebote mitfahrten personen_km
2026 9 1 zur Schule 6 7 130.2
2026 9 1 nach Hause 5 4 74.4
2026 9 2 zur Schule 4 3 53.7
2026 9 3 zur Schule 3 3 45.6
2026 9 4 zur Schule 2 0 0.0
2026 9 5 zur Schule 1 1 13.1

a) Die App soll am Morgen alle Personen erinnern, die heute unterwegs sind. Andrea Wolf möchte dafür intern eine Liste sehen:

Ergebnisbeispiel
rolle_heute vorname nachname abfahrt
Mitfahrer*in Lukas Hofmann 07:00:00
Fahrer*in Ben Richter 07:00:00
… … … …

aa) Erstelle eine SQL-Abfrage, die alle Fahrer*innen heutiger Angebote mit Status offen oder voll und alle Mitfahrer*innen mit bestätigter Buchung für diese Angebote in einer Liste ausgibt, sortiert nach Abfahrt und Nachname. Die Spalte rolle_heute gibt an, aus welcher Teilabfrage eine Zeile stammt. (6 Punkte)

ab) Jonas schlägt vor, statt UNION ALL einfach UNION zu schreiben und die Spalte abfahrt wegzulassen, weil die Erinnerung ohnehin nur den Namen braucht. Beurteile den Vorschlag am Beispiel von Ben Richter. (2 Punkte)

aa)

SELECT 'Fahrer*in' AS rolle_heute, n.vorname, n.nachname, a.abfahrt
FROM angebot AS a
  JOIN nutzer AS n ON n.nutzer_id = a.fahrer_id
WHERE a.datum = CURDATE()
  AND a.status IN ('offen', 'voll')
UNION ALL
SELECT 'Mitfahrer*in', n.vorname, n.nachname, a.abfahrt
FROM buchung AS b
  JOIN angebot AS a ON a.angebot_id = b.angebot_id
  JOIN nutzer AS n ON n.nutzer_id = b.mitfahrer_id
WHERE a.datum = CURDATE()
  AND a.status IN ('offen', 'voll')
  AND b.status = 'bestaetigt'
ORDER BY abfahrt, nachname;
Ergebnis
rolle_heute vorname nachname abfahrt
Mitfahrer*in Lukas Hofmann 07:00:00
Fahrer*in Ben Richter 07:00:00
Mitfahrer*in Oksana Melnyk 07:10:00
Fahrer*in Jonas Wolff 07:10:00
Fahrer*in Ben Richter 14:20:00

Die Spaltennamen stammen aus der ersten Teilabfrage, ORDER BY steht einmal am Ende. Die Teilabfragen dürfen statt JOIN auch Unterabfragen verwenden (z. B. WHERE n.nutzer_id IN (SELECT fahrer_id FROM angebot WHERE …)), dann fehlt aber die Abfahrtszeit. UNION statt UNION ALL ist hier ebenfalls korrekt, weil sich die Zeilen durch abfahrt unterscheiden.

Bewertung: erste Teilabfrage mit Konstante und Alias 2 P, zweite Teilabfrage mit Buchungsstatus 2 P, UNION [ALL] mit gleicher Spaltenzahl 1 P, ORDER BY am Ende 1 P.

ab) Ohne abfahrt wären die beiden Zeilen von Ben Richter (Hin- und Rückfahrt) identisch, und UNION würde eine davon entfernen. Für eine reine Namensliste wäre das sogar gewünscht, die Erinnerung braucht aber die Abfahrtszeit, sonst kann die App Ben nicht vor jeder Fahrt rechtzeitig erinnern. Der Vorschlag ist deshalb abzulehnen; UNION ALL mit abfahrt behält alle Fahrten und ist zudem schneller, weil keine Duplikate gesucht werden müssen.

Bewertung: Wirkung von UNION (Duplikate entfernt) am Beispiel 1 P, begründete Beurteilung 1 P.

b) Für die Flurmonitore möchte Oksana wissen, an welchen Haltepunkten es Angebot und Nachfrage gibt.

ba) Erstelle eine SQL-Abfrage mit INTERSECT, die Name und Ort aller Haltepunkte ausgibt, an denen es ab heute sowohl ein offenes Angebot als auch ein offenes Gesuch gibt. (3 Punkte)

bb) Formuliere eine SQL-Abfrage mit EXCEPT, die alle Haltepunkte (ID, Name, Ort) ausgibt, für die noch nie ein Angebot eingestellt wurde, und gib das Ergebnis an. (2 Punkte)

ba)

SELECT h.name, h.ort
FROM haltepunkt AS h
WHERE h.haltepunkt_id IN (SELECT haltepunkt_id FROM angebot
                          WHERE status = 'offen' AND datum >= CURDATE())
INTERSECT
SELECT h.name, h.ort
FROM haltepunkt AS h
WHERE h.haltepunkt_id IN (SELECT haltepunkt_id FROM gesuch
                          WHERE status = 'offen' AND datum >= CURDATE());

Ergebnis: „Hauptbahnhof, ZOB Steig 3“ in Gütersloh und „Busbahnhof“ in Bad Salzuflen. Eine kürzere Variante bildet die Schnittmenge der IDs (SELECT haltepunkt_id FROM angebot WHERE … INTERSECT SELECT haltepunkt_id FROM gesuch WHERE …) und holt Name und Ort über eine äußere Abfrage mit IN; beides ist richtig. Ohne INTERSECT ginge es mit zwei IN-Bedingungen in einer Abfrage.

Bewertung: zwei Teilabfragen mit Status- und Datumsfilter 2 P, INTERSECT mit gleichen Spalten 1 P.

bb)

SELECT haltepunkt_id, name, ort
FROM haltepunkt
EXCEPT
SELECT h.haltepunkt_id, h.name, h.ort
FROM haltepunkt AS h
  JOIN angebot AS a ON a.haltepunkt_id = h.haltepunkt_id;

Ergebnis: Haltepunkt 5 (Kirchplatz, Werther (Westf.)) und Haltepunkt 6 (Bahnhof Brackwede, Bielefeld). Die zweite Teilabfrage darf auch WHERE h.haltepunkt_id IN (SELECT haltepunkt_id FROM angebot) lauten.

Bewertung: EXCEPT mit gleich aufgebauten Teilabfragen 1 P, Ergebnis 1 P.

c) Oksana entwickelt die Vermittlung: Für jedes offene Gesuch ab heute sucht die App ein passendes offenes Angebot mit gleichem Haltepunkt, gleicher Richtung und gleichem Datum, dessen Abfahrt im Zeitfenster des Gesuchs liegt. Gesuche ohne passendes Angebot sollen auf den Flurmonitoren hervorgehoben werden. Oksana hat die Abfrage begonnen:

SELECT g.gesuch_id, g.datum, g.zeit_von, g.zeit_bis
FROM gesuch AS g
WHERE g.status = 'offen'
  AND g.datum >= CURDATE()
  AND ____(1)____ (SELECT *
                   FROM ____(2)____
                   WHERE a.status = 'offen'
                     AND a.haltepunkt_id = ____(3)____
                     AND a.richtung = g.richtung
                     AND ____(4)____
                     AND a.abfahrt ____(5)____ g.zeit_von AND g.zeit_bis);

ca) Vervollständige die Abfrage, indem Du die Lücken (1) bis (5) ergänzt, und gib das Ergebnis an. (5 Punkte)

cb) Jonas fragt, ob SELECT * in der Unterabfrage nicht unnötig viele Daten lädt und was passieren würde, wenn man Lücke (3) mit a.haltepunkt_id statt mit einer Spalte von g füllt. Erläutere beides. (2 Punkte)

ca) (1) NOT EXISTS, (2) angebot AS a, (3) g.haltepunkt_id, (4) a.datum = g.datum, (5) BETWEEN

SELECT g.gesuch_id, g.datum, g.zeit_von, g.zeit_bis
FROM gesuch AS g
WHERE g.status = 'offen'
  AND g.datum >= CURDATE()
  AND NOT EXISTS (SELECT *
                  FROM angebot AS a
                  WHERE a.status = 'offen'
                    AND a.haltepunkt_id = g.haltepunkt_id
                    AND a.richtung = g.richtung
                    AND a.datum = g.datum
                    AND a.abfahrt BETWEEN g.zeit_von AND g.zeit_bis);
Ergebnis
gesuch_id datum zeit_von zeit_bis
1 2026-10-20 07:00:00 08:00:00
4 2026-10-23 07:00:00 08:00:00

Für Aylin Kayas Gesuch ab Gütersloh am 20.10. gibt es nur ein Angebot am 19.10., für Lina Hartwigs Gesuch ab Bad Salzuflen am 23.10. nur eines am 22.10. Statt BETWEEN ist auch >= g.zeit_von AND a.abfahrt <= richtig.

Bewertung: je Lücke 1 P (5 P); das Ergebnis gehört zur vollständigen Lösung.

cb) EXISTS prüft nur, ob die Unterabfrage mindestens eine Zeile liefert; die ausgewählten Spalten werden nicht übertragen. SELECT *, SELECT 1 oder SELECT a.angebot_id sind gleichwertig. Mit a.haltepunkt_id = a.haltepunkt_id wäre die Bedingung immer wahr und die Unterabfrage hätte keinen Bezug mehr zum Haltepunkt des Gesuchs: Jedes offene Angebot mit passender Richtung, passendem Datum und passender Zeit an irgendeinem Haltepunkt würde als „passend“ gelten, und Gesuche würden fälschlich nicht hervorgehoben.

Bewertung: Bedeutung von SELECT * bei EXISTS 1 P, Folge der fehlenden Korrelation 1 P.

d) Für eine Übersichtsseite auf dem Monitor im Foyer möchte Thomas Becker je Haltepunkt und je Richtung sehen, wie viele offene Angebote und offene Gesuche es ab heute gibt – ausdrücklich auch dann, wenn es keine gibt:

Ergebnisbeispiel (12 Zeilen)
ort richtung angebote gesuche
Bad Salzuflen zur Schule 1 1
Bad Salzuflen nach Hause 0 0
Bielefeld zur Schule 0 0
… … … …

Erstelle die SQL-Abfrage, sortiert nach Ort und innerhalb des Orts mit zur Schule zuerst. Hinweis: Die beiden Richtungen kannst Du mit einer abgeleiteten Tabelle erzeugen, die aus zwei SELECT-Anweisungen mit einer Konstanten besteht. (7 Punkte)

SELECT h.ort,
       r.richtung,
       (SELECT COUNT(*)
          FROM angebot AS a
         WHERE a.haltepunkt_id = h.haltepunkt_id
           AND a.richtung = r.richtung
           AND a.status = 'offen'
           AND a.datum >= CURDATE()) AS angebote,
       (SELECT COUNT(*)
          FROM gesuch AS g
         WHERE g.haltepunkt_id = h.haltepunkt_id
           AND g.richtung = r.richtung
           AND g.status = 'offen'
           AND g.datum >= CURDATE()) AS gesuche
FROM haltepunkt AS h,
     (SELECT 'zur Schule' AS richtung
      UNION
      SELECT 'nach Hause') AS r
ORDER BY h.ort, r.richtung DESC;
Ergebnis
ort richtung angebote gesuche
Bad Salzuflen zur Schule 1 1
Bad Salzuflen nach Hause 0 0
Bielefeld zur Schule 0 0
Bielefeld nach Hause 0 0
Gütersloh zur Schule 1 1
Gütersloh nach Hause 1 0
Halle (Westf.) zur Schule 0 0
Halle (Westf.) nach Hause 0 0
Herford zur Schule 1 0
Herford nach Hause 0 0
Werther (Westf.) zur Schule 0 0
Werther (Westf.) nach Hause 0 0

Das Kreuzprodukt aus 6 Haltepunkten und 2 Richtungen erzeugt alle 12 Kombinationen, die korrelierten Unterabfragen zählen je Kombination (COUNT(*) liefert 0, wenn nichts passt). CROSS JOIN statt Komma ist gleichwertig. „zur Schule“ steht wegen DESC vor „nach Hause“ (alphabetisch absteigend).

Bewertung: abgeleitete Tabelle mit UNION der beiden Konstanten und Alias 2 P, Kreuzprodukt mit haltepunkt 1 P, je korrelierte Zählung mit beiden Bezügen und Filtern 2 P (4 P).

e) Andrea Wolf möchte im Schulausschuss zeigen, von welchen Orten aus die Fahrgemeinschaften im September 2026 genutzt wurden.

ea) Erstelle eine SQL-Abfrage, die aus fahrt_statistik für September 2026 je Ort die Summe der Mitfahrten (beide Richtungen zusammen) und ihren Anteil an allen Mitfahrten dieses Monats in Prozent (eine Nachkommastelle) ausgibt, den größten Anteil zuerst. Gib das Ergebnis an. (4 Punkte)

eb) Erläutere, warum die Einschränkung auf September 2026 auch in der Unterabfrage stehen muss, obwohl fahrt_statistik derzeit nur Daten für September enthält. (2 Punkte)

ea)

SELECT h.ort,
       SUM(s.mitfahrten) AS mitfahrten,
       ROUND(SUM(s.mitfahrten) * 100 / (SELECT SUM(x.mitfahrten)
                                        FROM fahrt_statistik AS x
                                        WHERE x.jahr = 2026 AND x.monat = 9), 1) AS anteil_prozent
FROM fahrt_statistik AS s
  JOIN haltepunkt AS h ON h.haltepunkt_id = s.haltepunkt_id
WHERE s.jahr = 2026 AND s.monat = 9
GROUP BY h.haltepunkt_id, h.ort
ORDER BY anteil_prozent DESC;
Ergebnis (insgesamt 18 Mitfahrten; bei Gleichstand ist die Reihenfolge nicht festgelegt)
ort mitfahrten anteil_prozent
Gütersloh 11 61.1
Halle (Westf.) 3 16.7
Herford 3 16.7
Werther (Westf.) 1 5.6
Bad Salzuflen 0 0.0

Ohne GROUP BY fasst SUM alle Zeilen zu einer einzigen Ergebniszeile zusammen (bzw. MariaDB meldet im Modus ONLY_FULL_GROUP_BY einen Fehler). Ohne Gruppierung und ohne SUM, also mit s.mitfahrten je Statistikzeile, erschiene Gütersloh zweimal (je Richtung eine Zeile mit 38,9 und 22,2 %).

Bewertung: Verbund mit haltepunkt und Filter auf September 2026 1 P, Gruppierung je Ort mit SUM 1 P, Unterabfrage für die Gesamtsumme mit gleichem Filter 1 P, Prozentrechnung, Rundung und Sortierung 1 P.

eb) Die WHERE-Bedingung der äußeren Abfrage wirkt nicht auf die Unterabfrage – sie ist eine eigenständige Abfrage. Ohne eigenen Filter würde die Unterabfrage die Mitfahrten aller Monate summieren. Sobald die Statistik für Oktober hinzukommt, wären die September-Anteile zu klein und ergäben zusammen nicht mehr 100 %. Die Abfrage soll aber dauerhaft richtig rechnen, nicht nur mit dem heutigen Datenbestand.

Bewertung: Unabhängigkeit der Unterabfrage vom äußeren Filter 1 P, Folge für spätere Monate 1 P.

f) Andrea Wolf hat festgelegt: Angebote vor dem heutigen Tag, die durchgeführt oder abgesagt wurden, werden ohne Personenbezug in eine Archivtabelle übertragen und anschließend gelöscht. Im Archiv sollen ID, Haltepunkt, Richtung, Datum, Abfahrt, Status und die Zahl der bestätigten Mitfahrten stehen.

fa) Erstelle die SQL-Anweisung zum Anlegen der Tabelle angebot_archiv mit geeigneten Datentypen, Primärschlüssel und Fremdschlüssel auf haltepunkt. (2 Punkte)

fb) Erstelle die SQL-Anweisung, die die betroffenen Angebote mit der Zahl ihrer bestätigten Buchungen in das Archiv überträgt, und gib den Inhalt des Archivs an. (3 Punkte)

fc) Erstelle die SQL-Anweisung, die alle archivierten Angebote aus angebot löscht, und verwende dabei die Archivtabelle. (2 Punkte)

fd) Begründe, warum die Anweisungen aus fb) und fc) in einer Transaktion ausgeführt werden sollten, und gib an, welche Datensätze anderer Tabellen beim Löschen ebenfalls entfernt werden. (2 Punkte)

fa)

CREATE TABLE angebot_archiv (
  angebot_id     INT UNSIGNED NOT NULL PRIMARY KEY,
  haltepunkt_id  INT UNSIGNED NOT NULL,
  richtung       ENUM('zur Schule', 'nach Hause') NOT NULL,
  datum          DATE NOT NULL,
  abfahrt        TIME NOT NULL,
  status         ENUM('durchgefuehrt', 'abgesagt') NOT NULL,
  mitfahrten     TINYINT UNSIGNED NOT NULL,
  CONSTRAINT fk_archiv_haltepunkt FOREIGN KEY (haltepunkt_id)
    REFERENCES haltepunkt (haltepunkt_id)
);

fahrer_id und fahrzeug_id fehlen bewusst (kein Personenbezug). angebot_id ist kein AUTO_INCREMENT, weil die ursprüngliche ID übernommen wird. VARCHAR statt ENUM ist ebenfalls richtig.

Bewertung: Spalten mit passenden Datentypen ohne Personenbezug 1 P, Primär- und Fremdschlüssel 1 P.

fb)

INSERT INTO angebot_archiv
       (angebot_id, haltepunkt_id, richtung, datum, abfahrt, status, mitfahrten)
SELECT a.angebot_id, a.haltepunkt_id, a.richtung, a.datum, a.abfahrt, a.status,
       (SELECT COUNT(*)
          FROM buchung AS b
         WHERE b.angebot_id = a.angebot_id
           AND b.status = 'bestaetigt')
FROM angebot AS a
WHERE a.datum < CURDATE()
  AND a.status IN ('durchgefuehrt', 'abgesagt');
Inhalt von angebot_archiv
angebot_id haltepunkt_id richtung datum abfahrt status mitfahrten
6 1 nach Hause 2026-10-15 15:30:00 durchgefuehrt 1
7 2 nach Hause 2026-10-14 15:20:00 abgesagt 0

Bewertung: INSERT INTO … SELECT mit passender Spaltenliste 1 P, korrelierte Unterabfrage für die Mitfahrten 1 P, Filter auf Datum und Status 1 P.

fc)

DELETE FROM angebot
WHERE angebot_id IN (SELECT angebot_id FROM angebot_archiv);

Gelöscht werden die Angebote 6 und 7. Die gleiche Bedingung wie in fb) (WHERE datum < CURDATE() AND status IN (…)) ist ebenfalls möglich, birgt aber das Risiko, dass zwischen beiden Anweisungen ein weiterer Datensatz die Bedingung erfüllt und ungesichert gelöscht wird.

Bewertung: DELETE FROM angebot 1 P, Unterabfrage auf angebot_archiv mit IN 1 P.

fd) Übertragen und Löschen bilden eine Einheit: Bricht die Verarbeitung nach dem INSERT ab, stehen Angebote doppelt; würde nur gelöscht, gingen Daten verloren. In einer Transaktion (START TRANSACTION; … COMMIT;) werden entweder beide Schritte oder keiner wirksam, und parallele Änderungen können sich nicht dazwischenschieben. Durch ON DELETE CASCADE werden mit dem Angebot 6 auch die Buchung 4 (Lukas Hofmann) und deren zwei Bewertungen gelöscht – gewollt, weil sie personenbezogen sind; die anonyme Zahl der Mitfahrten bleibt im Archiv erhalten.

Bewertung: Begründung der Transaktion 1 P, gelöschte Buchung und Bewertungen 1 P.

g) Nach dem Archivieren möchte Jonas die Zahl der Angebote je Haltepunkt über aktuelle und archivierte Angebote auswerten. Seine Abfrage:

SELECT haltepunkt_id, COUNT(*) AS angebote
FROM angebot
GROUP BY haltepunkt_id
UNION ALL
SELECT haltepunkt_id, COUNT(*)
FROM angebot_archiv
GROUP BY haltepunkt_id
ORDER BY haltepunkt_id;
Ergebnis von Jonas’ Abfrage
haltepunkt_id angebote
1 2
1 1
2 1
2 1
3 1
4 1

ga) Erläutere, warum die Haltepunkte 1 und 2 zweimal erscheinen. (2 Punkte)

gb) Korrigiere die Abfrage so, dass jeder Haltepunkt mit seinem Ort genau einmal mit der Gesamtzahl seiner Angebote erscheint, die meisten zuerst, und gib das Ergebnis an. Begründe, warum Du dabei UNION ALL und nicht UNION verwendest. (4 Punkte)

ga) GROUP BY wirkt nur innerhalb der jeweiligen Teilabfrage. Die erste Teilabfrage gruppiert die aktuellen Angebote, die zweite die archivierten. UNION ALL hängt beide Ergebnisse nur untereinander, ohne Zeilen mit gleicher haltepunkt_id zusammenzufassen. Gütersloh (1) und Herford (2) haben Angebote in beiden Tabellen und erscheinen deshalb mit zwei Teilsummen.

Bewertung: Gruppierung je Teilabfrage 1 P, keine Zusammenfassung durch UNION ALL 1 P.

gb)

SELECT h.ort, COUNT(*) AS angebote
FROM (SELECT haltepunkt_id FROM angebot
      UNION ALL
      SELECT haltepunkt_id FROM angebot_archiv) AS alle
  JOIN haltepunkt AS h ON h.haltepunkt_id = alle.haltepunkt_id
GROUP BY h.haltepunkt_id, h.ort
ORDER BY angebote DESC;
Ergebnis (bei Gleichstand ist die Reihenfolge nicht festgelegt)
ort angebote
Gütersloh 3
Herford 2
Halle (Westf.) 1
Bad Salzuflen 1

Erst werden die Rohdaten beider Tabellen in einer abgeleiteten Tabelle vereinigt, dann wird außen gruppiert. UNION würde gleiche Zeilen entfernen: Weil die abgeleitete Tabelle nur haltepunkt_id enthält, bliebe jeder Haltepunkt nur einmal übrig, und alle Anzahlen wären 1. Ebenfalls richtig ist, Jonas’ Abfrage als abgeleitete Tabelle zu verwenden und außen mit SUM(angebote) zu gruppieren.

Bewertung: abgeleitete Tabelle mit UNION ALL und Alias 1 P, Gruppierung außen mit Verbund auf haltepunkt 1 P, Ergebnis mit Sortierung 1 P, Begründung UNION ALL 1 P.

Anlage: SQL-Kurzreferenz (MariaDB)

SQL-Kurzreferenz für MariaDB
Syntax Bedeutung
SELECT spalten FROM tabelle WHERE … GROUP BY … HAVING … ORDER BY … [ASC\|DESC] Grundform einer Abfrage
ausdruck = (SELECT …) Vergleich mit einer skalaren Unterabfrage (genau ein Wert)
ausdruck [NOT] IN (SELECT …) Wert kommt (nicht) in der Ergebnisliste vor
ausdruck > ANY (SELECT …) / ausdruck > ALL (SELECT …) größer als mindestens ein / als jeder Wert der Liste
[NOT] EXISTS (SELECT …) wahr, wenn die Unterabfrage mindestens eine (bzw. keine) Zeile liefert
SELECT …, (SELECT …) AS name FROM … Unterabfrage als berechnete Spalte
SELECT … FROM (SELECT …) AS alias abgeleitete Tabelle (Alias Pflicht)
abfrage1 UNION [ALL] abfrage2 Vereinigung (ohne ALL ohne Duplikate)
abfrage1 INTERSECT abfrage2 / abfrage1 EXCEPT abfrage2 Schnittmenge / Differenz (ab MariaDB 10.3)
INNER JOIN / LEFT JOIN … ON … / CROSS JOIN Verbünde
CREATE TABLE name (spalte TYP [NOT NULL], …, PRIMARY KEY (…), FOREIGN KEY (…) REFERENCES t (…)) Tabelle anlegen
INSERT INTO tabelle (spalten) SELECT … Ergebnis einer Abfrage einfügen
UPDATE tabelle SET spalte = wert WHERE … Datensätze ändern
DELETE FROM tabelle WHERE … Datensätze löschen
START TRANSACTION; … COMMIT; / ROLLBACK; Transaktion
COUNT(*) / COUNT(spalte) / COUNT(DISTINCT spalte) Anzahl Zeilen / Nicht-NULL-Werte / verschiedene Werte
SUM(…), AVG(…), MIN(…), MAX(…) Summe, Mittelwert, kleinster, größter Wert
ROUND(zahl, stellen) runden
COALESCE(wert1, wert2, …) erster Wert, der nicht NULL ist
CURDATE(), NOW(), YEAR(datum), MONTH(datum) heutiges Datum, aktueller Zeitpunkt, Jahr, Monat
ausdruck BETWEEN a AND b Bereich einschließlich der Grenzen
ausdruck IS [NOT] NULL Test auf NULL

Hinweis zur Nachnutzung als Open Educational Resource (OER)

Dieser Artikel und seine Texte, Bilder, Grafiken, Code und sonstiger Inhalt sind - sofern nicht anders angegeben - lizenziert unter CC BY 4.0. Nennung gemäß TULLU-Regel bitte wie folgt: “Übungsaufgaben zu SQL-Unterabfragen und Mengenoperationen” von oer-informatik.de (H. Stein), Lizenz: CC BY 4.0. Der Artikel wurde unter https://oer-informatik.de/sql-unterabfragen-uebungsaufgaben veröffentlicht, die Quelltexte sind in weiterverarbeitbarer Form verfügbar im Repository unter https://gitlab.com/oer-informatik/db-sql/sql. Erstellt mit KI-Assistenz; von mir umfassend redaktionell überarbeitet, geprüft und freigegeben. Stand: 28.10.2026.

[Kommentare zum Artikel lesen, schreiben] / [Artikel teilen] / [gitlab-Issue zum Artikel schreiben]

Kommentare gerne per Mastodon, Verbesserungsvorschläge per gitlab issue (siehe oben). Beitrag teilen: