No night of drinking or drugs or sex could ever compare to a long evening of productive hacking.
—Lynn Voedisch

Tabellen combineren met INNER JOIN

In vorige hoofdstukken zijn alle componenten van een SELECT query de revue gepasseerd, behalve eentje: de FROM component. Er viel ook niet zo veel over te vertellen, want we hadden toch maar één tabel. In dit deel gebruiken we meerdere tabellen in een query.

Volgend filmpje legt de principes van het cartesisch product en join goed uit. Bekijk het eerst, volg dan onderstaande tekst en maak de oefeningen die je in de tekst vindt.

Impliciete JOIN

We gebruiken een eenvoudig voorbeeld om het principe uit te leggen. Stel dat je een lijst wilt van de mailadressen van alle OPOcoördinatoren zoals in onderstaande figuur. Na het vorig hoofdstuk zou je in je eigen schema twee gekoppelde tabellen moeten hebben: ‘lector’ en ‘opleidingsonderdeel’.

Alle mailadressen van de coördinatoren van elk OPO

Werk nu volgend voorbeeld uit door de code van alle stappen uit te proberen in je eigen schema. Vergeet niet de naam van je schema te specifiëren bij elke tabel, ofwel je search_path goed te zetten. In de codevoorbeelden hieronder kozen we voor deze laatste oplossing.

Cartesisch product van meerdere tabellen

De lectortabel bevat 18 rijen, wat je eenvoudig met een COUNT(*) kan controleren:

SELECT COUNT(*)
FROM lector

Hoeveel rijen bevat de tabel ‘opleidingsonderdeel’?

Deze tabel bevat 21 rijen.

Probeer nu volgende code uit en probeer aan de hand van de output te achterhalen wat ze doet:

SELECT *
FROM lector, opleidingsonderdeel

Door in de FROM component twee tabellen te vermelden (gescheiden door een komma) wordt elke rij van de tabel ‘lector’ gekoppeld aan elke rij van de tweede tabel ‘opleidingsonderdeel’ . Elk van de 18 lectorrijen wordt dus gecombineerd met elk van de 21 OPO’s. Dat geeft in totaal 18 × 21 = 378 rijen in het resultaat. Je zal de horizontale schuifbalk in het resultaatveld nodig hebben, want deze rijen worden lang aangezien alle kolommen van beide tabellen samen in het resultaat aanwezig zijn.

Dit product – een set waarbij elke rij uit de eerste tabel voorkomt met elke rij uit de tweede tabel – wordt het cartesisch product genoemd. Twee tabellen vermelden achter het woord FROM is een impliciete join. We zullen later vragen om altijd een expliciete join te schrijven, maar voorlopig doen we het zo.

Kolommen selecteren in het cartesisch product

Je weet dat de FROM component altijd als eerste wordt uitgevoerd. Die laadt in dit geval het cartesisch product van beide tabellen in het werkgeheugen. De SELECT komt pas later. Hiermee selecteer je bepaalde kolommen.

Probeer deze code:

SELECT opocode, coordinator, lector_id, email
FROM lector, opleidingsonderdeel

Stel dat je nu, naast deze vier kolommen, ook nog de naam van het OPO wil tonen, dan volstaat een simpele toevoeging van deze kolom in de SELECT

SELECT opocode, naam, coordinator, lector_id, email
FROM lector, opleidingsonderdeel

… zou je denken. Je krijgt echter een foutmelding:

ERROR: column reference "naam" is ambiguous
LINE 2: SELECT opocode, naam,coordinator,lector_id,email

Wat loopt er mis? Pas dan de SELECT aan zodat je ook de OPOnaam te zien krijgt.

De databankserver laat weten dat de gevraagde kolom ‘naam’ dubbelzinnig (‘ambiguous’) is. Er zijn namelijk twee kolommen ‘naam’. Die dubbelzinnigheid kan je wegnemen door de naam van de kolom te laten voorafgaan door de naam van de tabel, m.a.w. iets in de stijl van SELECT tabelnaam.kolomnaam. Als er geen dubbelzinnigheid is, is de kolomnaam natuurlijk voldoende!

Dit kan dan een mogelijke oplossing zijn:

SELECT opocode, opleidingsonderdeel.naam, coordinator, lector_id, email
FROM lector, opleidingsonderdeel

Alias voor tabelnamen

Je moet geregeld de kolom specifiëren door de tabelnaam ervoor te schrijven. Dat is redelijk wat typwerk. Een kortere optie is het gebruik van een alias (ook wel ‘pseudoniem’ genoemd). Je vervangt als het ware de naam van de tabel tijdelijk door iets korter (vaak slechts één of een paar letters). Vergelijk volgende versies van dezelfde query. Eerst de lange versie:

SELECT opocode, opleidingsonderdeel.naam, coordinator, lector_id, lector.naam, email
FROM lector, opleidingsonderdeel

Korter en daardoor mogelijk vlotter leesbaar is deze versie met twee aliassen:

SELECT opocode, O.naam, coordinator, lector_id, L.naam, email
FROM lector AS L, opleidingsonderdeel AS O

Je mag eventueel het woordje AS weglaten en dus kan het nog iets korter:

SELECT opocode, O.naam, coordinator, lector_id, L.naam, email
FROM lector L, opleidingsonderdeel O

Probeer eens de combinatie te maken waarbij je een alias definieert, maar toch de volledige naam van de tabel gebruikt in de SELECT door volgende code uit te testen:

SELECT opocode, opleidingsonderdeel.naam, coordinator, lector_id, L.naam, email
FROM lector L, opleidingsonderdeel O

Wat besluit je?

De foutmelding helpt je verder:

ERROR:  invalid reference to FROM-clause entry for table "opleidingsonderdeel"
LINE 2: SELECT opocode, opleidingsonderdeel.naam, coordinator, lecto...
                        ^
HINT:  Perhaps you meant to reference the table alias "o".
SQL state: 42P01
Character: 50

Dit betekent dus concreet dat eens je een alias gedefinieerd hebt voor een tabelnaam, je verplicht bent om die alias te gebruiken. De tabelnaam is op dat moment tijdelijk vervangen door de alias!

Join voorwaarde

Door de impliciete join in de FROM component heb je het cartesisch product gemaakt: een combinatie van elke rij van de ene tabel met elke rij van de tweede tabel. In totaal dus 378 combinaties van rijen. Die lange lijst moet je nu beperken tot de ‘zinvolle’ rijen. Rijen verwijderen of behouden doe je met een WHERE. Deze WHERE noemen we de ‘join conditie’ of ‘join voorwaarde’.

Wat zijn hier de zinvolle rijen? Elke coördinator van elk OPO wordt gekoppeld aan gelijk welke lector. We waren echter alleen op zoek naar het mailadres van de coördinator. De enige zinvolle rijen zijn dan die waar de coördinator in de OPOtabel gelijk is aan het lector_id in de lectortabel:

SELECT opocode, email
FROM lector, opleidingsonderdeel
WHERE coordinator = lector_id -- join conditie

Van de 378 rijen in het cartesisch product zijn er maar 21 die aan deze voorwaarde voldoen. Dat is ook logisch: er zijn 21 OPO’s en elk OPO heeft juist één coördinator. De figuur bij het begin van dit hoofdstuk toont het resultaat in pgAdmin.

In deze oefening komen heel wat dingen uit vorige hoofdstukken aan bod. Schrijf de query die voor elk OPO van minder dan 6 studiepunten het volgende geeft: de naam van het OPO, het aantal studiepunten, voornaam en familienaam van de coördinator in één kolom en het jaar waarin dat OPO voor het eerst op het programma van de opleiding stond. Sorteer volgens stijgend aantal studiepunten. Bij gelijk aantal studiepunten moet je verder sorteren volgens jaartal (nieuwste eerst). Je moet de volgende figuur bekomen met een tabel van 13 rijen.

SELECT O.naam AS OPOnaam, studiepunten, L.voornaam || ' ' || L.naam AS coordinator,
  EXTRACT(YEAR FROM O.van) as startjaar
FROM lector L, opleidingsonderdeel O
WHERE coordinator = lector_id AND studiepunten < 6
ORDER BY 2 ASC, 4 DESC

Expliciete JOIN

De vorige oefening legt een zwakheid van de impliciete join (meerdere tabellen in de FROM gescheiden door een komma) bloot. Zowel de join conditie (coordinator = lector_id) als de ‘gewone’ conditie (studiepunten < 6) staan in de WHERE component. Er bestaat een alternatieve notatie voor de JOIN waarbij je deze bewerking expliciet zo benoemt.

De oplossing van de vorige oefening ziet er dan zo uit met een expliciete JOIN:

SELECT O.naam AS OPOnaam, studiepunten, L.voornaam || ' ' || L.naam AS coordinator,
  EXTRACT(YEAR FROM O.van) as startjaar
FROM lector L INNER JOIN opleidingsonderdeel O ON coordinator = lector_id  -- leesbaarder!
WHERE studiepunten < 6
ORDER BY 2 ASC, 4 DESC

De join conditie staat nu in de FROM en we vermelden expliciet het woord JOIN. Deze notatie is logischer en je maakt minder kans om de join conditie te vergeten of iets fout te doen in de WHERE, want deze component is nu eenvoudiger.

De volgorde waarin je de tabellen schrijft is niet belangrijk. Het woord INNER mag je ook weglaten. We raden aan om het toch altijd te schrijven. Er bestaat namelijk ook een OUTER JOIN.

Er bestaat ook een alternatieve notatie voor de join conditie die je kan gebruiken als beide kolommen in die conditie identiek dezelfde naam hebben en als je test op gelijkheid van beiden. Dat is in dit voorbeeld niet het geval, maar stel dat de kolom ‘coordinator’ ook ‘lector_id’ als naam had, dan hadden we bovenstaande code zo geschreven:

SELECT O.naam AS OPOnaam, studiepunten, L.voornaam || ' ' || L.naam AS coordinator,
  EXTRACT(YEAR FROM O.van) as startjaar
FROM lector L INNER JOIN opleidingsonderdeel O ON O.lector_id = L.lector_id. -- alias nodig!
WHERE studiepunten < 6
ORDER BY 2 ASC, 4 DESC

Dit kan korter door het gebruik van USING:

SELECT O.naam AS OPOnaam, studiepunten, L.voornaam || ' ' || L.naam AS coordinator,
  EXTRACT(YEAR FROM O.van) as startjaar
FROM lector L INNER JOIN opleidingsonderdeel O USING(lector_id)
WHERE studiepunten < 6
ORDER BY 2 ASC, 4 DESC

We komen hierop later terug bij de oefeningen. De notatie met ON is altijd bruikbaar, die met USING alleen in bepaalde gevallen.

Een tabel joinen met zichzelf

In een JOIN operatie combineer je twee (of meer) tabellen. Je mag ook twee keer dezelfde tabel gebruiken. Het gebruik van aliassen is in dit geval verplicht. Probeer volgende oefening te maken m.b.v. de tips.

Voor een wedstrijd vormen lectoren een team van twee personen. Eén persoon is de kapitein van het team, de andere is dan gewoon teamlid. Schrijf de query die een lijst genereert van alle mogelijke teams zoals getoond in bijhorende figuur. Deze lijst bevat 306 rijen. Enkele tips:

  • Het eerste teamlid zit in de tabel ‘lector’, het tweede teamlid ook. Je doet dus een INNER JOIN van de tabel ‘lector’ met zichzelf.
  • Je zal moeten onderscheid maken tussen de eerste tabel ‘lector’ en de tweede tabel ‘lector’. Gebruik als aliassen bvb. ‘L1’ en ‘L2’.
  • Wat wordt de join conditie? Je kan geen team met jezelf vormen …
  • Let op de kolomhoofdingen in de figuur.
SELECT L1.voornaam || ' ' || L1.naam AS kapitein,
  L2.voornaam || ' ' || L2.naam AS "teamlid 2"
FROM lector L1 INNER JOIN lector L2 on L1.lector_id != L2.lector_id

We breiden de vorige oefening een beetje uit:

We willen nog steeds een lijst van mogelijke teams van lectoren, maar met de extra voorwaarde dat beide teamleden in dezelfde gemeente moeten wonen. Je zal nu slechts 6 mogelijke teams kunnen maken. Toon ook de gemeente.

SELECT L1.voornaam || ' ' || L1.naam AS "kapitein",
  L2.voornaam || ' ' || L2.naam AS "teamlid 2", L1.gemeente
FROM lector L1 INNER JOIN lector L2 on
  L1.lector_id != L2.lector_id AND L1.gemeente = L2.gemeente

Oefeningen

Welke OPO’s die nu in het huidige programma van TI zitten hebben een coördinator die in een gemeente woont waarvan de postcode begint met ‘3’. Schrijf de SQL query die onderstaande figuur genereert. Je hoeft niet te sorteren.

SELECT opocode, O.naam, lector_id, voornaam, L.naam, gemeente
FROM opleidingsonderdeel O INNER JOIN lector L on O.coordinator = L.lector_id
WHERE gemeente LIKE '3%' AND O.tot IS NULL

Schrijf de SQL query die de lijst genereert (zie figuur) van alle lectoren die jonger zijn dan Gerben Adriaens. Deze lijst is gesorteerd zodat de jongste lector bovenaan staat. Je mag veronderstellen dat er maar één Gerben Adriaens is. Je mag niet eerst met een aparte query de geboortedatum van Gerben Adriaens opvragen. Subquery’s zijn ook niet toegelaten, want die hebben we nog niet gezien. Het kan met één query door een JOIN te gebruiken. Let ook op de kolomhoofding (zoals altijd).

Je kan de tabel ‘lector’ opnieuw met zichzelf joinen. Let op de speciale voorwaarde in de join conditie. Als je beter wilt zien wat er gebeurt kan je best een SELECT * vragen zodat je de volledige rij ziet. In de WHERE wordt getest op wie jonger is. Je bent jonger als je geboortedatum groter is.

SELECT L1.voornaam, L1.naam, EXTRACT(YEAR FROM L1.geboortedatum) AS geboortejaar
FROM lector L1 INNER JOIN lector Adr ON Adr.naam = 'Adriaens' AND Adr.voornaam = 'Gerben'
WHERE L1.geboortedatum > Adr.geboortedatum
ORDER BY 3 DESC

Een student heeft nog 8 SP te kort om te kunnen afstuderen. Genereer een lijst (zie onderstaande figuur) met alle mogelijke combinaties van 2 OPO’s uit het oude programma (OPO’s die nu niet meer gegeven worden) die samen 8 studiepunten vertegenwoordigen. De kans is groot dat je een lijst bekomt waar elke combinatie twee keer in staat (als A en B samen 8 SP zijn, dan is B en A ook 8 SP en dan zijn er twee rijen met dezelfde twee OPO’s in een andere volgorde). Vind je een trucje om deze dubbels te vermijden? Sorteren hoeft niet.

SELECT O1.opocode, O1.naam || ' (' || O1.studiepunten ||')' AS OPO1,
  O2.opocode, O2.naam || ' (' || O2.studiepunten ||')' AS OPO2
FROM opleidingsonderdeel O1 INNER JOIN opleidingsonderdeel O2 on O1.opocode != O2.opocode
WHERE O1.studiepunten + O2.studiepunten = 8 AND
  O1.tot IS NOT NULL AND
  O2.tot IS NOT NULL AND
  O1.opocode < O2.opocode -- een van mogelijke trucs om geen dubbels te hebben

Oefeningen SQLzoo

Schema ‘Euro2012’

Een interessante oefenreeks op SQLzoo maakt gebruik van data i.v.m. het het Europees voetbalkampioenschap in 2012. Het logisch model op de site van SQLzoo dat bij deze oefening hoort, heeft een wat afwijkende notatie. Daarom maakten we het opnieuw volgens onze conventies:

Een woordje uitleg over dit model:

Oefeningen op INNER JOIN

Opmerking bij deze oefeningenreeks: op de site van SQLzoo gebruikt men telkens JOIN zonder het woordje INNER. Wij verkiezen de volledige benaming, dus schrijf liefst altijd INNER JOIN.

Maak oefenreeks 6 op JOIN. Kleine opmerking: oefening 13 van deze oefenreeks is niet oplosbaar met een INNER JOIN, maar wel met een OUTER JOIN. Je kan deze oefening overslaan.