Ü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).
| 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 |
| 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 |
| 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)
| 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 |
| kategorie_id | bezeichnung |
|---|---|
| 2 | E-Bikes |
| 3 | Lastenräder |
| 4 | Stadträder |
| 6 | Rahmen |
| 7 | Schlösser |
| 8 | Beleuchtung |
| 9 | Bremsen |
| merkmal_id | bezeichnung | einheit |
|---|---|---|
| 1 | Akkukapazität | Wh |
| 2 | Reichweite | km |
| 3 | Zuladung | kg |
| 4 | Rahmenhöhe | cm |
| 5 | Lichtstärke | lx |
| 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).
| 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 |
| 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 |
| 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 |
| 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 |
| 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;| 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):
| 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:
| 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;| 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;| 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;| 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.
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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:
| 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;| 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);| 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:
| 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;| 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;| 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');| 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;| 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;| 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)
| 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 |
Links und weitere Informationen
Artikel „SQL: Unterabfragen und Mengenoperationen“ auf oer-informatik.de
SQL: Abfragen über mehrere Tabellen mit JOIN und die Übungsaufgaben zu JOIN
als Nachschlagewerk: MariaDB Knowledge Base: Subqueries
als Nachschlagewerk: MariaDB Knowledge Base: UNION, INTERSECT und EXCEPT
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]
