Tietokantojen perusteet

syksy 2025

1. Johdanto

Mikä on tietokanta?

Tietokanta (database) on tietokoneella oleva kokoelma tietoa, johon voidaan suorittaa hakuja ja jonka sisältöä voidaan muuttaa. Tietokantoja ovat esimerkiksi:

Tietokantoja on nykyään valtavasti, ja useimmat ihmiset käyttävät tavallisen päivän aikana useita palveluja, jotka ovat yhteydessä tietokantoihin.

Tietokantojen haasteet

Tietokantojen tekniseen toteutukseen liittyy haasteita, kuten:

Tietokantajärjestelmät

Tietokantajärjestelmä pitää huolta tietokannan sisällöstä ja tarjoaa tietokannan käyttäjälle toiminnot, joiden avulla tietoa pystyy hakemaan ja muuttamaan. Huomaa, että sana tietokanta viittaa usein myös tietokantajärjestelmään.

Useimmat käytössä olevat tietokannat perustuvat relaatiomalliin ja SQL-kieleen, joiden teoreettinen perusta syntyi 1970-luvulla. Tutustumme tällä kurssilla SQLite-tietokantaan, joka soveltuu hyvin SQL:n opettelemiseen. Muita SQL-tietokantoja ovat esimerkiksi MySQL ja PostgreSQL.

Termi NoSQL viittaa tietokantaan, joka perustuu muuhun kuin relaatiomalliin ja SQL-kieleen. Esimerkiksi MongoDB ja Redis ovat tällaisia tietokantoja. Tällä kurssilla emme kuitenkaan juurikaan käsittele NoSQL-tietokantoja.

Tee-se-itse-tietokanta

Ennen tutustumista olemassa oleviin tietokantajärjestelmiin on hyvä miettiä, mitä tarvetta tällaisille järjestelmille on. Miksi emme voisi vain toteuttaa tietokantaa itse vaikkapa tallentamalla tietokannan sisällön tekstitiedostoon sopivassa muodossa?

Esimerkki

Haluamme tallentaa tietokantaan tietoa kurssin opiskelijoiden ratkaisuista kurssin tehtäviin. Kun opiskelija lähettää ratkaisun, tietokantaan tallennetaan opiskelijanumero, tehtävän numero, ratkaisun lähetysaika sekä ratkaisun tuottama pistemäärä.

Yksinkertainen tapa toteuttaa tietokanta on luoda yksi tekstitiedosto, jonka jokaisella rivillä on yksi lähetys. Aina kun joku opiskelija lähettää ratkaisun, tiedostoon lisätään yksi rivi. Voisimme käytännössä tallentaa tietokannan CSV-formaatissa tiedostoon database.csv seuraavaan tapaan:

012121212;1;2020-05-03 12:50:32;100
012341234;1;2020-05-03 14:02:12;20
012121212;2;2020-05-04 14:05:50;70
012121212;3;2020-05-04 14:43:12;0
012341234;2;2020-05-04 10:15:23;0
012341234;2;2020-05-04 16:40:39;0
013371337;1;2020-05-06 18:11:13;0
012341234;2;2020-05-07 10:02:15;100

CSV-tiedostossa tietty erotinmerkki erottaa riveillä olevat kentät. Tässä tapauksessa erotinmerkkinä on puolipiste ;. Esimerkiksi tiedoston ensimmäinen rivi kertoo, että opiskelija 012121212 on lähettänyt ratkaisun tehtävään 1 ja saanut siitä 100 pistettä.

Nyt jos haluamme vaikkapa luoda tilaston, jossa on jokaisen opiskelijan lähetysten määrä, voimme hoitaa asian näin Python-kielellä:

stats = {}

for line in open("database.csv"):
    student_id = line.split(";")[0]

    if student_id not in stats:
        stats[student_id] = 0

    stats[student_id] += 1

print(stats)

Koodin tulostus on seuraava:

{'012121212': 3, '012341234': 4, '013371337': 1}

Tämä tarkoittaa, että opiskelija 012121212 on lähettänyt kolme ratkaisua, opiskelija 012341234 on lähettänyt neljä ratkaisua ja opiskelija 013371337 on lähettänyt yhden ratkaisun.

Mahdolliset ongelmat

Tällainen CSV-tiedostoa käyttävä tietokanta on periaatteessa toimiva, mutta sen käyttäminen voi johtaa ongelmiin:

Tehokkuus

Kun tiedon määrä kasvaa, tiedon hakeminen CSV-tiedostosta voi alkaa viedä aikaa. Tämä johtuu siitä, että joudumme useimmissa tilanteissa käymään läpi koko tiedoston sisällön alusta loppuun, kun haluamme saada haettua tietyn asian.

Esimerkiksi jos haluamme selvittää, minkä pistemäärän opiskelija 012341234 on saanut tehtävästä 2, joudumme käymään läpi tiedoston kaikki rivit, jotta löydämme oikeat rivit. Joudumme tekemään näin, koska tiettyyn opiskelijaan liittyvät rivit voivat olla eri puolilla tiedostoa eikä meillä ole etukäteen tietoa niiden sijainnista.

Tiedoston läpikäynti ei ole ongelma, jos tiedosto on pieni. Esimerkiksi jos tiedostossa on sata riviä, läpikäynti sujuu hyvin nopeasti. Mutta tiedoston ollessa suuri on hidasta käydä kaikki rivit läpi aina, kun haluamme saada selville jonkin asian tietokannasta.

Samanaikaisuus

Mitä tapahtuu, jos kaksi opiskelijaa lähettävät ratkaisun samaan aikaan? Tällöin tiedoston loppuun pitäisi tulla kaksi riviä tietoa seuraavaan tapaan:

012341234;3;2020-05-07 15:42:02;0
013371337;7;2020-05-07 15:42:02;0

Jos käy huonosti, voi kuitenkin käydä näin:

012341234;3;2020013371337;7;2020-05-07 15:42:02;0
-05-07 15:42:02;0

Tällainen tilanne voi syntyä, kun kaksi samaan aikaan käynnissä olevaa prosessia muuttaa tiedoston sisältöä. Ensimmäinen prosessi kirjoittaa ensin tiedoston loppuun 012341234;3;2020, sitten toinen prosessi kirjoittaa väliin 013371337;7;2020-05-07 15:42:02;0 ja lopuksi ensimmäinen prosessi kirjoittaa -05-07 15:42:02;0. Tämän seurauksena tiedoston rakenne menee sekaisin.

Kun tiedostoon kirjoitetaan tietoa, ei ole itsestään selvää, että tieto menee perille yhtenäisenä, jos joku muu koettaa kirjoittaa samaan aikaan. Tämä riippuu tiedostojärjestelmästä, tiedon määrästä ja tiedoston käsittelytavasta.

Yllätykset

Tarkastellaan tilannetta, jossa haluamme poistaa tietokannasta opiskelijan 012341234 lähetykset. Voimme tehdä tämän lukemalla ensin kaikki rivit muistiin ja kirjoittamalla sitten takaisin tiedostoon kaikki rivit, joissa opiskelija ei ole 012341234.

Mitä jos sähköt katkeavat juuri, kun olemme kirjoittaneet puolet riveistä takaisin? Kun käynnistämme tietokoneen uudestaan, huomaamme, että tiedostossa on vain puolet riveistä ja loput ovat kadonneet eikä meillä ole keinoa saada niitä takaisin.

Mitä opimme tästä?

Yksinkertainen tekstitiedosto ei ole sinänsä huono tapa tallentaa tietoa, mutta se ei sovellu kaikkiin käyttötarkoituksiin. Tämän vuoksi tarvitsemme erillisiä tietokantajärjestelmiä, joihin tutustumme tällä kurssilla.

Tietokantajärjestelmien kehittäjät ovat miettineet tarkasti, miten järjestelmä kannattaa toteuttaa, jotta tietoon pääsee käsiksi tehokkaasti, samanaikaiset käyttäjät eivät aiheuta ongelmia eikä tietoa katoa yllättävissä tilanteissa. Kun käytämme tietokantajärjestelmää, meidän ei tarvitse huolehtia tästä kaikesta itse.

Relaatiomalli ja SQL-kieli

Tällä kurssilla tutustumme tietokantoihin relaatiomallin ja SQL-kielen kautta. Tähän liittyvät perusideat ovat:

  1. Kaikki tieto tallennetaan tauluihin riveinä, jotka voivat viitata toisiinsa.
  2. Tietokannan käyttäjä käsittelee tietoa SQL-kielellä, joka kätkee käyttäjältä tietokannan sisäisen toiminnan yksityiskohdat.

Tietokannan rakenne

Tietokanta muodostuu tauluista (table), joissa on kiinteät sarakkeet (column). Tauluihin tallennetaan tietoa riveinä (row), joilla on tietyt arvot sarakkeissa. Jokaisessa taulussa on kokoelma tiettyyn asiaan liittyvää tietoa.

Seuraavassa kuvassa on esimerkki tietokannasta, jota voisi käyttää osana verkkokaupan toteutusta. Tauluissa Products, Customers ja Purchases on tietoa tuotteista, asiakkaista ja heidän ostoskoriensa sisällöstä.

id name price
1 retiisi 7
2 porkkana 5
3 nauris 4
4 lanttu 8
5 selleri 4
id name
1 Uolevi
2 Maija
3 Aapeli
customer_id product_id
1 2
1 5
2 1
2 4
2 5

Esimerkiksi taulun Products sarakkeet ovat id, name ja price. Taulussa on tällä hetkellä viisi riviä tietoa, jotka kuvaavat tietokannassa olevat tuotteet.

Tauluissa Products ja Customers jokaisella rivillä on yksilöllinen id-numero, jonka avulla niihin voi viitata. Tämä on yleinen tapa tietokantojen suunnittelussa. Tämän ansiosta taulussa Purchases voidaan esittää id-numeroiden avulla, mitä tuotteita kukin asiakas on valinnut. Tässä esimerkissä Uolevin korissa on porkkana ja selleri ja Maijan korissa on retiisi, lanttu ja selleri. Aapelin kori on puolestaan tyhjä.

SQL-kieli

SQL (Structured Query Language) on vakiintunut tapa käsitellä tietokannan sisältöä. Kielessä on komentoja, joiden avulla tietokannan käyttäjä (esimerkiksi tietokantaa käyttävä ohjelmoija) voi lisätä, hakea, muuttaa ja poistaa tietoa.

SQL-komennot muodostuvat avainsanoista (kuten SELECT ja WHERE), taulujen ja sarakkeiden nimistä sekä muista arvoista. Esimerkiksi komento

SELECT price FROM Products WHERE name = 'retiisi';

hakee tietokannan tuotteista retiisin hinnan. Komennon lopussa on puolipiste ; ja voimme käyttää välilyöntejä ja rivinvaihtoja haluamallamme tavalla. Voisimme kirjoittaa komennon myös usealle riville esimerkiksi seuraavasti:

SELECT price
FROM Products
WHERE name = 'retiisi';
SELECT
  price
FROM
  Products
WHERE
  name = 'retiisi';

Tutustumme SQL-kieleen tarkemmin materiaalin luvuissa 2–4.

SQL-kieli syntyi 1970-luvulla, ja siinä on paljon muistumia vanhan ajan ohjelmoinnista. Tällaisia piirteitä ovat esimerkiksi:

SQL-kielestä on olemassa standardeja, jotka pyrkivät antamaan yhteisen pohjan kielelle. Käytännössä jokaisen tietokannan SQL-kielen toteutus toimii kuitenkin hieman omalla tavallaan. Tällä kurssilla keskitymme SQL:n ominaisuuksiin, jotka ovat yleisesti käytettävissä eri tietokannoissa.

Tietokannan sisäinen toiminta

Tietokantajärjestelmän tehtävänä on käsitellä käyttäjän antamat SQL-komennot. Esimerkiksi kun käyttäjä antaa komennon, joka hakee tietoa tietokannasta, tietokantajärjestelmän tulee löytää jokin hyvä tapa käsitellä komento ja toimittaa tulokset käyttäjälle mahdollisimman nopeasti.

SQL-kielen hienoutena on, että käyttäjän riittää kuvailla, mitä tietoa hän haluaa, minkä jälkeen tietokantajärjestelmä hoitaa työn ja hankkii tiedot tietokannan uumenista. Tämä on mukavaa käyttäjälle, koska hänen ei tarvitse tietää mitään tietokannan sisäisestä toiminnasta vaan voi luottaa tietokantajärjestelmään.

Tietokantajärjestelmän toteuttaminen on vaikea tehtävä, koska järjestelmän täytyy sekä osata käsitellä tehokkaasti SQL-komentoja että huolehtia siitä, että kaikki toimii oikein samanaikaisilla käyttäjillä ja yllättävissä tilanteissa. Tällä kurssilla tutustumme tietokantoihin lähinnä tietokannan käyttäjän näkökulmasta emmekä perehdy tietokantojen sisäiseen toimintaan.

2. SQL-kielen perusteet

Peruskomennot

Tässä luvussa tutustumme tavallisimpiin SQL-komentoihin, joiden avulla voidaan lisätä, hakea, muuttaa ja poistaa tietokannan sisältöä. Nämä komennot muodostavat perustan tietokannan käyttämiselle.

Taulun luonti

Komento CREATE TABLE luo taulun, jossa on halutut sarakkeet. Esimerkiksi seuraava komento luo taulun Products, jossa on kolme saraketta:

CREATE TABLE Products (
  id INTEGER PRIMARY KEY,
  name TEXT,
  price INTEGER
);

Voimme nimetä taulun ja sarakkeet haluamallamme tavalla. Tällä kurssilla käytäntönä on, että kirjoitamme taulun nimen suurella alkukirjaimella ja monikkomuotoisena. Sarakkeiden nimet puolestaan kirjoitamme pienellä alkukirjaimella.

Jokaisesta sarakkeesta ilmoitetaan nimen lisäksi tyyppi. Tässä taulussa sarakkeet id ja price ovat kokonaislukuja (INTEGER) ja sarake name on merkkijono (TEXT). Sarake id on lisäksi taulun pääavain (PRIMARY KEY), mikä tarkoittaa, että se yksilöi jokaisen taulun rivin ja voimme viitata sen avulla kätevästi taulun riveihin.

Pääavain

Tietokannan taulun pääavain (primary key) on jokin sarake (tai sarakkeiden yhdistelmä), joka yksilöi taulun jokaisen rivin. Millään kahdella taulun rivillä ei voi olla samaa arvoa pääavaimessa. Käytännössä hyvin tavallinen valinta pääavaimeksi on kokonaislukumuotoinen id-numero.

Usein haluamme lisäksi, että id-numerolla on juokseva numerointi. Tämä tarkoittaa, että kun tauluun lisätään rivejä, ensimmäinen rivi saa automaattisesti id-numeron 1, toinen rivi saa id-numeron 2, jne. Juoksevan numeroinnin toteuttaminen riippuu tietokannasta. Esimerkiksi SQLite-tietokannassa INTEGER PRIMARY KEY -tyyppinen sarake saa automaattisesti juoksevan numeroinnin.

Tiedon lisääminen

Komento INSERT lisää uuden rivin tauluun. Esimerkiksi seuraava komento lisää rivin äsken luomaamme tauluun Products:

INSERT INTO Products (name, price) VALUES ('retiisi', 7);

Tässä annamme arvot lisättävän rivin sarakkeille name ja price. Koska sarakkeessa id on juokseva numerointi, se saa automaattisesti arvon 1, kun kyseessä on taulun ensimmäinen rivi. Niinpä tauluun ilmestyy seuraava rivi:

id  name     price
--  -------  -----
1   retiisi  7    

Jos emme anna arvoa jollekin sarakkeelle, se saa oletusarvon. Tavallisesti oletusarvo on NULL, mikä tarkoittaa tiedon puuttumista. Esimerkiksi seuraavassa komennossa emme anna arvoa sarakkeelle price:

INSERT INTO Products (name) VALUES ('retiisi');

Tällöin tauluun ilmestyy rivi, jossa hinta on NULL (eli hinta puuttuu):

id  name     price
--  -------  -----
1   retiisi  NULL 

Esimerkkitaulu

Oletamme tämän osion tulevissa esimerkeissä, että olemme lisänneet tauluun Products seuraavat viisi riviä:

INSERT INTO Products (name, price) VALUES ('retiisi', 7);
INSERT INTO Products (name, price) VALUES ('porkkana', 5);
INSERT INTO Products (name, price) VALUES ('nauris', 4);
INSERT INTO Products (name, price) VALUES ('lanttu', 8);
INSERT INTO Products (name, price) VALUES ('selleri', 4);

Taulun sisältö on siis seuraavanlainen:

id  name      price
--  --------  -----
1   retiisi   7    
2   porkkana  5    
3   nauris    4    
4   lanttu    8    
5   selleri   4    

Tiedon hakeminen

Komento SELECT suorittaa kyselyn (query) eli hakee tietoa taulusta. Yksinkertaisin tapa tehdä kysely on hakea kaikki tiedot taulusta:

SELECT * FROM Products;

Tässä tapauksessa kyselyn tulos on seuraava:

id  name      price
--  --------  -----
1   retiisi   7    
2   porkkana  5    
3   nauris    4    
4   lanttu    8    
5   selleri   4    

Kyselyssä tähti * ilmaisee, että haluamme hakea kaikki sarakkeet. Kuitenkin voimme myös hakea vain osan sarakkeista antamalla sarakkeiden nimet. Esimerkiksi seuraava kysely hakee vain tuotteiden nimet:

SELECT name FROM Products;

Kyselyn tulos on seuraava:

name
--------
retiisi    
porkkana             
nauris               
lanttu               
selleri              

Seuraava kysely puolestaan hakee tuotteiden nimet ja hinnat:

SELECT name, price FROM Products;

Nyt kyselyn tulos muuttuu näin:

name      price
--------  -----
retiisi   7    
porkkana  5    
nauris    4    
lanttu    8    
selleri   4    

Kyselyn tuloksena olevat rivit muodostavat taulun, jota kutsutaan nimellä tulostaulu (result table). Sen sarakkeet ja rivit riippuvat kyselyn sisällöstä. Esimerkiksi äskeinen kysely loi tulostaulun, jossa on kaksi saraketta ja viisi riviä.

Tietokannan käsittelyssä esiintyy siis kahdenlaisia tauluja: tietokannassa kiinteästi olevia tauluja, joihin on tallennettu tietokannan sisältö, sekä kyselyjen muodostamia väliaikaisia tulostauluja, joiden tiedot on koostettu kiinteistä tauluista.

Hakuehto

Liittämällä SELECT-kyselyyn WHERE-osan voimme valita vain osan riveistä halutun ehdon perusteella. Esimerkiksi seuraava kysely hakee tiedot lantusta:

SELECT * FROM Products WHERE name = 'lanttu';

Kyselyn tulos on seuraava:

id  name    price
--  ------  -----
4   lanttu  8    

Ehdoissa voi käyttää vertailuja ja sanoja AND ja OR samaan tapaan kuin ohjelmoinnissa. Esimerkiksi seuraava kysely etsii tuotteet, joiden hinta on välillä 4…6:

SELECT * FROM Products WHERE price >= 4 AND price <= 6;

Kyselyn tulos on seuraava:

id  name      price
--  --------  -----
2   porkkana  5    
3   nauris    4    
5   selleri   4    

SQL:ssä operaattori <> tarkoittaa erisuuruutta. Esimerkiksi seuraava kysely hakee rivit, joissa hinta ei ole 4:

SELECT * FROM Products WHERE price <> 4;

Kyselyn tulos on seuraava:

id  name      price
--  --------  -----
1   retiisi   7    
2   porkkana  5    
4   lanttu    8    

Järjestäminen

Oletuksena tulostaulun rivien järjestys voi olla mikä tahansa. Voimme kuitenkin määrittää halutun järjestyksen ORDER BY -osan avulla. Esimerkiksi seuraava kysely hakee tuotteet aakkosjärjestyksessä nimen mukaan:

SELECT * FROM Products ORDER BY name;

Kyselyn tulos on seuraava:

id  name      price
--  --------  -----
4   lanttu    8    
3   nauris    4    
2   porkkana  5    
1   retiisi   7    
5   selleri   4    

Järjestys on oletuksena pienimmästä suurimpaan. Kuitenkin jos haluamme järjestyksen suurimmasta pienimpään, voimme lisätä sanan DESC sarakkeen nimen jälkeen:

SELECT * FROM Products ORDER BY name DESC;

Tämän seurauksena kyselyn tulos on seuraava:

id  name      price
--  --------  -----
5   selleri   4    
1   retiisi   7    
2   porkkana  5    
3   nauris    4    
4   lanttu    8    

Tietokantakielessä järjestys on joko nouseva (ascending) eli pienimmästä suurimpaan tai laskeva (descending) eli suurimmasta pienimpään. Oletuksena järjestys on nouseva, ja avainsana DESC tarkoittaa siis laskevaa järjestystä.

SQL-kielessä on myös avainsana ASC, joka tarkoittaa nousevaa järjestystä. Seuraavat kyselyt toimivat siis samalla tavalla:

SELECT * FROM Products ORDER BY name;
SELECT * FROM Products ORDER BY name ASC;

Käytännössä sanaa ASC käytetään kuitenkin harvoin.

Voimme myös järjestää rivejä usealla eri perusteella. Esimerkiksi seuraava kysely järjestää rivit ensisijaisesti kalleimmasta halvimpaan hinnan mukaan ja toissijaisesti aakkosjärjestykseen nimen mukaan:

SELECT * FROM Products ORDER BY price DESC, name;

Kyselyn tulos on seuraava:

id  name      price
--  --------  -----
4   lanttu    8    
1   retiisi   7    
2   porkkana  5    
3   nauris    4    
5   selleri   4    

Tässä tapauksessa tuotteet nauris ja selleri järjestetään aakkosjärjestyksessä nimen mukaan, koska ne ovat yhtä kalliita.

Erilliset tulosrivit

Joskus tulostaulussa voi olla useita samanlaisia rivejä. Näin käy esimerkiksi seuraavassa kyselyssä:

SELECT price FROM Products;

Koska kahden tuotteen hinta on 4, kahden tulosrivin sisältönä on 4:

price
-----
7         
5         
4         
8         
4         

Jos kuitenkin haluamme vain erilaiset tulosrivit, voimme lisätä kyselyyn sanan DISTINCT:

SELECT DISTINCT price FROM Products;

Tämän seurauksena kyselyn tulos muuttuu näin:

price
-----
7         
5         
4         
8         

Tulosrivien rajaus

Kun lisäämme kyselyn loppuun LIMIT x, kysely antaa vain x ensimmäistä tulosriviä. Esimerkiksi LIMIT 3 tarkoittaa, että kysely antaa kolme ensimmäistä tulosriviä.

Yleisempi muoto on LIMIT x OFFSET y, mikä tarkoittaa, että haluamme x riviä kohdasta y alkaen (0-indeksoituna). Esimerkiksi LIMIT 3 OFFSET 1 tarkoittaa, että kysely antaa toisen, kolmannen ja neljännen tulosrivin.

Tarkastellaan esimerkkinä kyselyä, joka hakee tuotteita halvimmasta kalleimpaan:

SELECT * FROM Products ORDER BY price;

Kyselyn tuloksena on seuraava tulostaulu:

id  name      price
--  --------  -----
3   nauris    2    
5   selleri   4    
2   porkkana  5    
1   retiisi   7    
4   lanttu    8    

Saamme haettua kolme halvinta tuotetta seuraavasti:

SELECT * FROM Products ORDER BY price LIMIT 3;

Kyselyn tulos on seuraava:

id  name      price
--  --------  -----
3   nauris    2    
5   selleri   4    
2   porkkana  5    

Seuraava kysely puolestaan hakee kolme halvinta tuotetta toiseksi halvimmasta tuotteesta alkaen:

SELECT * FROM Products ORDER BY price LIMIT 3 OFFSET 1;

Tämän kyselyn tulos on seuraava:

id  name      price
--  --------  -----
5   selleri   4    
2   porkkana  5    
1   retiisi   7    

Tietokantojen erot

Tulosrivien rajaus on esimerkki asiasta, jonka toteutus vaihtelee eri tietokannoissa. Tässä esitetty syntaksi LIMIT x OFFSET y toimii SQLiten lisäksi MySQL:ssä ja PostgreSQL:ssä.

SQL-standardiin tulosrivien rajaus tuli vasta vuonna 2008. Standardin mukainen syntaksi on OFFSET y ROWS FETCH FIRST x ROWS ONLY. Tämä syntaksi toimii esimerkiksi PostgreSQL:ssä mutta ei MySQL:ssä eikä SQLitessä.

Tiedon muuttaminen

Komento UPDATE muuttaa taulun rivejä, jotka täsmäävät haluttuun ehtoon. Esimerkiksi seuraava komento muuttaa tuotteen 2 hinnaksi 6:

UPDATE Products SET price = 6 WHERE id = 2;

Useita sarakkeita voi muuttaa yhdistämällä muutokset pilkuilla. Esimerkiksi seuraava komento muuttaa tuotteen 2 nimeksi ananas ja hinnaksi 6:

UPDATE Products SET name = 'ananas', price = 6 WHERE id = 2;

Jos komennossa ei ole ehtoa, muutos vaikuttaa kaikkiin riveihin. Esimerkiksi seuraava komento muuttaa jokaisen tuotteen hinnaksi 1:

UPDATE Products SET price = 1;

Tiedon poistaminen

Komento DELETE poistaa taulusta rivit, jotka täsmäävät annettuun ehtoon. Esimerkiksi seuraava komento poistaa taulusta tuotteen 5:

DELETE FROM Products WHERE id = 5;

Kuten muuttamisessa, jos ehtoa ei ole, niin komento vaikuttaa kaikkiin riveihin. Seuraava komento siis poistaa kaikki tuotteet taulusta:

DELETE FROM Products;

Komento DROP TABLE poistaa tietokannan taulun (ja kaiken sen sisällön). Esimerkiksi seuraava komento poistaa taulun Products:

DROP TABLE Products;

Kommentit

Merkintä -- aloittaa rivin loppuun päättyvän kommentin:

CREATE TABLE Products (
  id INTEGER PRIMARY KEY,
  name TEXT,
  price INTEGER -- price in euros
)

Toinen tapa on aloittaa kommentti /* ja lopettaa */:

CREATE TABLE Products (
  id INTEGER PRIMARY KEY,
  name TEXT,
  price INTEGER /* price in euros */
)

Yhteenveto ja ryhmittely

Yhteenvetokysely antaa tuloksena jonkin yksittäisen arvon taulun riveistä, kuten taulun rivien määrän tai sarakkeen kaikkien arvojen summan. Tällaisen kyselyn tulostaulussa on vain yksi rivi.

Yhteenvetokyselyn perustana on koostefunktio (aggregate function), joka laskee yhteenvetoarvon taulun riveistä. Tavallisimmat koostefunktiot ovat seuraavat:

Esimerkkejä

Tarkastellaan taas taulua Products:

id  name      price
--  --------  -----
1   retiisi   7    
2   porkkana  5    
3   nauris    4    
4   lanttu    8    
5   selleri   4    

Seuraava kysely hakee taulun rivien määrän:

SELECT COUNT(*) FROM Products;
COUNT(*)
--------
5

Seuraava kysely hakee niiden rivien määrän, joissa hinta on 4:

SELECT COUNT(*) FROM Products WHERE price = 4;
COUNT(*)
--------
2

Seuraava kysely hakee summan tuotteiden hinnoista:

SELECT SUM(price) FROM Products;
SUM(price)
----------
28

Tässä tapauksessa kyselyn tuloksena on 7 + 5 + 4 + 8 + 4 = 28.

Seuraava kysely hakee pienimmän ja suurimman hinnan:

SELECT MIN(price), MAX(price) FROM Products;
MIN(price)  MAX(price)
----------  ----------
4           8

Lisää COUNT-funktiosta

Jos COUNT-funktion sisällä on tähti *, kysely laskee kaikki rivit. Jos taas funktion sisällä on sarakkeen nimi, kysely laskee rivit, joissa sarakkeessa on jokin arvo eli sarakkeen arvo ei ole NULL.

Tarkastellaan esimerkkinä seuraavaa taulua, jossa rivillä 3 ei ole hintaa:

id  name     price
--  -------  -----
1   retiisi  7    
2   nauris   4    
3   lanttu   NULL 
4   selleri  4    

Seuraava kysely hakee rivien yhteismäärän:

SELECT COUNT(*) FROM Products;
COUNT(*)  
--------
4

Seuraava kysely taas hakee niiden rivien määrän, joilla on hinta:

SELECT COUNT(price) FROM Products;
COUNT(price)
------------
3

Voimme myös käyttää sanaa DISTINCT, jotta saamme laskettua, montako eri arvoa jossakin sarakkeessa on:

SELECT COUNT(DISTINCT price) FROM Products;
COUNT(DISTINCT price)
---------------------
2

Tämän kyselyn tuloksena on 2, koska sarakkeessa price on kaksi eri arvoa (4 ja 7). Tähän ei lasketa mukaan NULL-arvoa.

Tyhjän aineiston käsittely

Koostefunktioiden toiminta saattaa aiheuttaa yllätyksiä, jos käsiteltävässä aineistossa ei ole yhtään riviä. Tarkastellaan esimerkkinä seuraavaa tyhjää taulua:

id  name     price
--  -------  -----

Funktio COUNT antaa rivien määräksi 0:

SELECT COUNT(price) FROM Products;
COUNT(price)
------------
0

Sen sijaan funktio SUM antaa summaksi NULL:

SELECT SUM(price) FROM Products;
SUM(price)
----------
NULL

Tämä voi tuntua yllättävältä, koska tyhjän summan voisi olettaa olevan 0 eikä NULL. Vastaavasti myös MIN, MAX ja AVG antavat tuloksen NULL tyhjälle aineistolle.

Funktion IFNULL avulla voi varautua tilanteeseen, jossa koostefunktio saattaa antaa tuloksen NULL. Seuraavassa kyselyssä IFNULL(SUM(price), 0) tarkoittaa muuten samaa kuin SUM(price), mutta tulos NULL muutetaan tulokseksi 0.

SELECT IFNULL(SUM(price), 0) FROM Products;
IFNULL(SUM(price), 0)
---------------------´
0

NULL-arvojen käsittely

Funktio IFNULL(x, y) palauttaa arvon x, jos x ei ole NULL, ja muuten arvon y. Tämän avulla voidaan käsitellä tilanne, jossa SQL-kyselyssä oleva arvo saattaa olla NULL. Esimerkiksi IFNULL(x, 0) muuttaa arvon x nollaksi, jos se on NULL.

Funktio IFNULL ei kuulu SQL-standardiin, eikä se toimi kaikissa tietokannoissa. SQL-standardin mukainen funktio on COALESCE, jota voi käyttää samalla tavalla. Esimerkiksi COALESCE(x, 0) tarkoittaa samaa kuin IFNULL(x, 0).

Palaamme NULL-arvojen käsittelyyn tarkemmin myöhemmin.

Ryhmittely

Ryhmittelyn avulla voimme yhdistää rivikohtaista ja koostefunktion antamaa tietoa. Ideana on, että rivit jaetaan ryhmiin GROUP BY -osassa annettujen sarakkeiden mukaan ja tämän jälkeen koostefunktion arvo lasketaan jokaiselle ryhmälle erikseen.

Tarkastellaan esimerkkinä seuraavaa taulua Employees:

id  name      company   salary
--  --------  --------  ------
1   Anna      Google    8000  
2   Liisa     Google    7500  
3   Kaaleppi  Amazon    5000  
4   Uolevi    Amazon    8000  
5   Maija     Google    9500  
6   Vihtori   Facebook  5000  

Seuraava kysely hakee kunkin yrityksen työntekijöiden määrän:

SELECT company, COUNT(*) FROM Employees GROUP BY company;

Kyselyn tulos on seuraava:

company   COUNT(*)
--------  --------
Amazon    2       
Facebook  1       
Google    3       

Tämä tarkoittaa, että Amazonilla on 2 työntekijää, Facebookilla on 1 työntekijä ja Googlella on 3 työntekijää.

Miten ryhmittely toimii?

Ryhmittelyssä jokainen ryhmä sisältää kaikki rivit, joissa on sama sisältö ryhmittelyssä käytetyissä sarakkeissa. Ryhmittely tuottaa tulostaulun, jonka rivien määrä on sama kuin ryhmien määrä. Jokaisella rivillä voi esiintyä ryhmittelyssä käytettyjä sarakkeita sekä koostefunktioita.

Äskeisessä kyselyssä ryhmittelyn ehtona on GROUP BY company, joten rivit jaetaan ryhmiin sarakkeen company mukaan. Tässä tapauksessa ryhmät ovat:

Ryhmä 1:

id  name      company  salary
--  --------  -------  ------
3   Kaaleppi  Amazon   5000  
4   Uolevi    Amazon   8000  

Ryhmä 2:

id  name     company   salary
--  -------  --------  ------
6   Vihtori  Facebook  5000  

Ryhmä 3:

id  name   company  salary
--  -----  -------  ------
1   Anna   Google   8000  
2   Liisa  Google   7500  
5   Maija  Google   9500  

Tämän jälkeen jokaiselle ryhmälle lasketaan rivien määrä funktiolla COUNT(*).

Ryhmittelyn sarakkeet

Melko usein ryhmittelyn perusteena on tasan yksi sarake, kuten sarake company äskeisessä esimerkissä. Jos ryhmittelyssä on useampi sarake, jokainen ryhmä muodostuu riveistä, joilla on sama arvoyhdistelmä ryhmittelyyn kuuluvissa sarakkeissa.

Esimerkiksi seuraavassa kyselyssä ryhmittelyn perusteena on kaksi saraketta:

SELECT company, salary, COUNT(*) FROM Employees GROUP BY company, salary;

Koska jokaisella rivillä on eri arvoyhdistelmä näissä sarakkeissa, tulostaulussa jokainen rivi on omassa ryhmässään:

company   salary  COUNT(*)
--------  ------  --------
Amazon    5000    1       
Amazon    8000    1       
Facebook  5000    1       
Google    7500    1       
Google    8000    1       
Google    9500    1       

Ryhmittelyn jälkeen tulostaulussa voi olla ryhmittelyyn kuuluvia sarakkeita mutta ei ryhmittelyn ulkopuolisia sarakkeita. Esimerkiksi seuraava kysely ei olisi mielekäs:

SELECT company, name FROM Employees GROUP BY company;

Tässä sarake name ei kuulu ryhmittelyyn, minkä takia olisi epäselvää, mikä sarakkeen arvoksi tulisi tulostaulussa. Esimerkiksi tulostauluun tulee vain yksi rivi, joka liittyy yritykseen Amazon, mutta yrityksellä on kaksi työntekijää, joilla on eri nimet (Kaaleppi ja Uolevi).

Ryhmittely SQLitessä

Huomaa, että SQLite sallii äskeisen kyselyn, jossa haetaan ryhmittelyn ulkopuolinen sarake:

SELECT company, name FROM Employees GROUP BY company;
company   name   
--------  -------
Amazon    Uolevi 
Facebook  Vihtori
Google    Maija  

Koska sarake name ei kuulu ryhmittelyyn, sillä voi olla useita arvoja ryhmässä ja tulostauluun tulee yksi niistä jollain logiikalla valittuna. Tällainen kysely ei kuitenkaan yleensä toimi muissa tietokannoissa.

Lisää kyselyjä

Seuraava kysely hakee joka yrityksestä palkkojen summan:

SELECT company, SUM(salary) FROM Employees GROUP BY company;
company   SUM(salary)
--------  -----------
Amazon    13000      
Facebook  5000       
Google    25000      

Tässä Amazonin palkkojen summa on 5000 + 8000 = 13000, Facebookin palkkojen summa on 5000 ja Googlen palkkojen summa on 8000 + 7500 + 9500 = 25000.

Seuraava kysely puolestaan hakee korkeimman palkan:

SELECT company, MAX(salary) FROM Employees GROUP BY company;
company   MAX(salary)
--------  -----------
Amazon    8000       
Facebook  5000       
Google    9500       

Tässä Amazonin suurin palkka on 8000, Facebookin suurin palkka on 5000 ja Googlen suurin palkka on 9500.

Tulossarakkeen nimentä

Oletuksena tulostaulun sarake saa nimen suoraan kyselyn perusteella, mutta voimme halutessamme antaa myös oman nimen AS-sanan avulla. Tämän ansiosta voimme esimerkiksi selventää, mistä yhteenvetokyselyssä on kyse.

Esimerkiksi seuraavassa kyselyssä toisen sarakkeen nimeksi tulee max_salary:

SELECT
  company, MAX(salary) AS max_salary
FROM
  Employees
GROUP BY
  company;
company   max_salary
--------  ----------
Amazon    8000      
Facebook  5000      
Google    9500      

Itse asiassa sana AS ei ole pakollinen, eli voimme kirjoittaa kyselyn myös näin:

SELECT
  company, MAX(salary) max_salary
FROM
  Employees
GROUP BY
  company;

Rajaus ryhmittelyn jälkeen

Voimme lisätä kyselyyn myös HAVING-osan, joka rajaa tuloksia ryhmittelyn jälkeen. Esimerkiksi seuraava kysely hakee yritykset, joissa on ainakin kaksi työntekijää:

SELECT
  company, COUNT(*)
FROM
  Employees
GROUP BY
  company
HAVING
  COUNT(*) >= 2;
company  COUNT(*)
-------  --------
Amazon   2       
Google   3       

Voimme myös käyttää koostefunktiota vain HAVING-osassa:

SELECT
  company
FROM
  Employees
GROUP BY
  company
HAVING
  COUNT(*) >= 2;
company
-------
Amazon    
Google    

Kyselyn yleiskuva

SQL-kyselyn yleiskuva on seuraava:

SELECTFROMWHEREGROUP BYHAVINGORDER BYLIMIT

Kyselystä riippuu, mitkä näistä osista siinä esiintyvät. Tämä on kuitenkin aina järjestys, jossa kyselyn osat sijaitsevat toisiinsa nähden.

Tarkastellaan seuraavaksi esimerkkiä kyselystä, joka sisältää yhtä aikaa kaikki yllä mainitut osat. Kysely suoritetaan tauluun Tasks, jossa on projekteihin liittyviä tehtäviä. Jokaisella tehtävällä on tietty prioriteetti. Tehtävä on kriittinen, jos sen prioriteetti on ainakin 3.

id  project_id  priority
--  ----------  --------
1   1           3       
2   1           4       
3   1           4       
4   2           1       
5   2           5       
6   3           2       
7   3           4       
8   3           5       

Seuraava kysely etsii projektit, joissa on vähintään kaksi kriittistä tehtävää. Kysely järjestää tulokset projektin id-numeron mukaan ja antaa 10 ensimmäistä tulosta.

SELECT
  project_id, COUNT(*)
FROM
  Tasks
WHERE
  priority >= 3
GROUP BY
  project_id
HAVING
  COUNT(*) >= 2
ORDER BY
  project_id
LIMIT
  10;

Kyselyn tulos on tässä:

project_id  COUNT(*)
----------  --------
1           3       
3           2       

Tämä tarkoittaa, että projektissa 1 on kolme kriittistä tehtävää ja projektissa 3 on kaksi kriittistä tehtävää.

Katsotaan nyt tarkemmin, miten kysely toimii. Kyselyn lähtökohtana ovat kaikki taulussa Tasks olevat rivit. Ehto WHERE priority >= 3 valitsee käsittelyyn kriittiset tehtävät:

id  project_id  priority
--  ----------  --------
1   1           3       
2   1           4       
3   1           4       
5   2           5       
7   3           4       
8   3           5       

Kyselyn ryhmittely GROUP BY project_id jakaa rivit ryhmiin näin:

Ryhmä 1:

id  project_id  priority
--  ----------  --------
1   1           3       
2   1           4       
3   1           4       

Ryhmä 2:

id  project_id  priority
--  ----------  --------
5   2           5       

Ryhmä 3:

id  project_id  priority
--  ----------  --------
7   3           4       
8   3           5       

Osa HAVING COUNT(*) >= 2 valitsee tulostauluun ryhmät, joissa on ainakin kaksi riviä. Tässä tapauksessa valitaan ryhmät 1 ja 3.

Tulostaulussa on joka ryhmästä sarake project_id sekä funktion COUNT(*) antama tulos eli ryhmän rivien määrä. Tässä tapauksessa projektissa 1 on kolme tärkeää tehtävää ja projektissa 3 on kaksi tärkeää tehtävää.

Osa ORDER BY project_id järjestää tulostaulun rivit projektin id-numeron mukaan. Tässä tapauksessa projektit ovat 1 ja 3. Osa LIMIT 10 ei vaikuta tässä tapauksessa, koska tulostaulussa on muutenkin alle 10 riviä.

Kysely tuottaa seuraavan tulostaulun:

project_id  COUNT(*)
----------  --------
1           3       
3           2       

SQLite-tietokanta

SQLite on yksinkertainen avoimesti saatavilla oleva tietokantajärjestelmä, joka soveltuu hyvin SQL-kielen opetteluun. Voit kokeilla helposti SQL-kieleen liittyviä asioita SQLiten avulla, ja käytämme sitä tämän kurssin harjoituksissa.

SQLite on mainio valinta SQL-kielen harjoitteluun, mutta siinä on tiettyjä rajoituksia, jotka voivat aiheuttaa ongelmia todellisissa sovelluksissa. Muita suosittuja avoimesti saatavilla olevia tietokantajärjestelmiä ovat MySQL ja PostgreSQL. Niissä on suuri määrä ominaisuuksia, jotka puuttuvat SQLitestä, mutta toisaalta niiden asentaminen ja käyttäminen on vaikeampaa.

Eri tietokantajärjestelmien välillä siirtyminen on onneksi helppoa, koska kaikissa on samantapainen SQL-kieli.

SQLite-tulkki

SQLite-tulkki on ohjelma, jonka kautta voidaan käyttää SQLite-tietokantaa. Tulkki käynnistyy antamalla komentorivillä komento sqlite3. Tämän jälkeen tulkkiin voi kirjoittaa joko suoritettavia SQL-komentoja tai pisteellä alkavia tulkin omia komentoja.

SQLite-tulkin asentaminen riippuu käyttöjärjestelmästä:

(Video: SQLiten asennus ja käyttö Windowsissa)

Esimerkki

SQLite-tulkissa tietokanta on oletuksena muistissa (in-memory database), jolloin se on aluksi tyhjä ja katoaa, kun tulkki suljetaan. Tämä on hyvä tapa testailla SQL-kielen ominaisuuksia. Keskustelu tulkin kanssa voi näyttää vaikkapa tältä:

$ sqlite3
SQLite version 3.31.1 2020-01-27 19:55:54
Enter ".help" for usage hints.
Connected to a transient in-memory database.
Use ".open FILENAME" to reopen on a persistent database.
sqlite> CREATE TABLE Products (id INTEGER PRIMARY KEY,
   ...>                        name TEXT, price INTEGER);
sqlite> .tables
Products
sqlite> INSERT INTO Products (name, price) VALUES ('retiisi', 7);
sqlite> INSERT INTO Products (name, price) VALUES ('porkkana', 5);
sqlite> INSERT INTO Products (name, price) VALUES ('nauris', 4);
sqlite> INSERT INTO Products (name, price) VALUES ('lanttu', 8);
sqlite> INSERT INTO Products (name, price) VALUES ('selleri', 4);
sqlite> SELECT * FROM Products;
1|retiisi|7
2|porkkana|5
3|nauris|4
4|lanttu|8
5|selleri|4
sqlite> .mode column
sqlite> .headers on
sqlite> SELECT * FROM Products;
id  name      price
--  --------  -----
1   retiisi   7    
2   porkkana  5    
3   nauris    4    
4   lanttu    8    
5   selleri   4    
sqlite> .quit

Esimerkissä luodaan aluksi taulu Products ja tarkastetaan sitten tulkin komennolla .tables, mitä tauluja tietokannassa on. Ainoa taulu on Products, mikä kuuluu asiaan, koska tietokanta on alussa tyhjä.

Sitten tauluun lisätään rivejä, minkä jälkeen rivit haetaan taulusta. SQLite-tulkin oletustapa näyttää tulosrivit pystyviivoin erotettuina voi olla vaikea lukea, mutta tulostusta voi parantaa tulkin komennoilla .mode column (jokaisella sarakkeella on kiinteä leveys) ja .headers on (sarakkeiden nimet näytetään).

Lopuksi suoritetaan tulkin komento .quit, joka sulkee tulkin.

Tietokanta tiedostossa

Käynnistyksen yhteydessä SQLite-tulkille voi antaa parametrina tiedoston, johon tietokanta tallennetaan. Tällöin tietokannan sisältö säilyy tallessa tulkin sulkemisen jälkeen.

Seuraavassa esimerkissä tietokanta tallennetaan tiedostoon test.db. Tämän ansiosta tietokannan sisältö on edelleen tallessa, kun tulkki käynnistetään uudestaan.

$ sqlite3 test.db
SQLite version 3.31.1 2020-01-27 19:55:54
Enter ".help" for usage hints.
sqlite> CREATE TABLE Products (id INTEGER PRIMARY KEY,
   ...>                        name TEXT, price INTEGER);
sqlite> .tables
Products
sqlite> .quit
$ sqlite3 test.db
SQLite version 3.31.1 2020-01-27 19:55:54
Enter ".help" for usage hints.
sqlite> .tables
Products
sqlite> .quit

Komennot tiedostosta

Voimme myös ohjata SQLite-tulkille tiedoston, jossa olevat komennot suoritetaan peräkkäin. Tämän avulla voimme automatisoida komentojen suorittamista. Esimerkiksi voimme laatia seuraavan tiedoston commands.sql:

commands.sql

CREATE TABLE Products (id INTEGER PRIMARY KEY, name TEXT, price INTEGER);
INSERT INTO Products (name, price) VALUES ('retiisi', 7);
INSERT INTO Products (name, price) VALUES ('porkkana', 5);
INSERT INTO Products (name, price) VALUES ('nauris', 4);
INSERT INTO Products (name, price) VALUES ('lanttu', 8);
INSERT INTO Products (name, price) VALUES ('selleri', 4);
.mode column
.headers on
SELECT * FROM Products;

Tämän jälkeen voimme ohjata komennot tiedostosta tulkille näin:

$ sqlite3 < commands.sql
id  name      price
--  --------  -----
1   retiisi   7    
2   porkkana  5    
3   nauris    4    
4   lanttu    8    
5   selleri   4    

3. Monen taulun kyselyt

Taulujen viittaukset

Keskeinen idea tietokannoissa on, että taulun rivi voi viitata toisen taulun riviin. Tällöin voidaan muodostaa kyselyjä, jotka keräävät tietoa useista tauluista viittausten perusteella. Käytännössä viittauksena on yleensä toisessa taulussa olevan rivin id-numero.

Esimerkki

Tarkastellaan esimerkkinä tilannetta, jossa tietokannassa on tietoa kursseista ja niiden opettajista. Oletamme, että jokaisella kurssilla on yksi opettaja ja sama opettaja voi opettaa monta kurssia.

Tallennamme tauluun Teachers tietoa opettajista. Jokaisella opettajalla on id-numero, jolla voimme viitata siihen.

CREATE TABLE Teachers (
  id INTEGER PRIMARY KEY,
  name TEXT
);

Taulun sisältö voisi olla esimerkiksi seuraava:

id  name      
--  ----------
1   Kaila     
2   Luukkainen
3   Kivinen   
4   Laaksonen 

Taulussa Courses on puolestaan tietoa kursseista ja jokaisen kurssin kohdalla viittaus kurssin opettajaan.

CREATE TABLE Courses (
  id INTEGER PRIMARY KEY,
  name TEXT,
  teacher_id INTEGER REFERENCES Teachers
);

Tässä sarake teacher_id viittaa taulussa Teachers olevan rivin id-numeroon, minkä tuo esille merkintä REFERENCES Teachers.

Taulun sisältö voisi olla esimerkiksi seuraava:

id  name              teacher_id
--  ----------------  ----------
1   Laskennan mallit  3         
2   Tietoverkot       1         
3   Graduseminaari    1         
4   PHP-ohjelmointi   4         
5   Neuroverkot       3         

Tämä tarkoittaa, että esimerkiksi kurssia Laskennan mallit luennoi opettaja, jonka id-numero on 3. Taulusta Teachers selviää, että kyseinen opettaja on Kivinen.

Voimme nyt hakea kurssit opettajineen seuraavalla kyselyllä, joka hakee tietoa samaan aikaan tauluista Courses ja Teachers:

SELECT
  Courses.name, Teachers.name
FROM
  Courses, Teachers
WHERE
  Courses.teacher_id = Teachers.id;

Koska kyselyssä on monta taulua, ilmoitamme sarakkeiden taulut. Esimerkiksi Courses.name viittaa taulun Courses sarakkeeseen name.

Kysely antaa seuraavan tuloksen:

name              name     
----------------  ---------
Laskennan mallit  Kivinen  
Tietoverkot       Kaila    
Graduseminaari    Kaila    
PHP-ohjelmointi   Laaksonen
Neuroverkot       Kivinen  

Mitä tässä tapahtui?

Yllä olevassa kyselyssä uutena asiana on, että kysely koskee useaa taulua (FROM Courses, Teachers), mutta mitä tämä tarkoittaa?

Ideana on, että kun kyselyssä on monta taulua, tulostaulun lähtökohtana ovat kaikki tavat valita rivien yhdistelmiä tauluista. Tämän jälkeen WHERE-osan ehdoilla voi määrittää, mitkä rivien yhdistelmät ovat kiinnostuksen kohteena.

Hyvä tapa saada ymmärrystä monen taulun kyselyn toiminnasta on tarkastella ensin kyselyä, joka hakee kaikki sarakkeet ja jossa ei ole WHERE-osaa. Yllä olevassa esimerkkitilanteessa tällainen kysely on seuraava:

SELECT * FROM Courses, Teachers;

Koska taulussa Courses on 5 riviä ja taulussa Teachers on 4 riviä, kyselyn tulostaulussa on 5 * 4 = 20 riviä. Tulostaulu sisältää kaikki mahdolliset tavat valita ensin jokin rivi taulusta Courses ja sitten jokin rivi taulusta Teachers:

id  name              teacher_id  id  name      
--  ----------------  ----------  --  ----------
1   Laskennan mallit  3           1   Kaila     
1   Laskennan mallit  3           2   Luukkainen
1   Laskennan mallit  3           3   Kivinen   
1   Laskennan mallit  3           4   Laaksonen 
2   Tietoverkot       1           1   Kaila     
2   Tietoverkot       1           2   Luukkainen
2   Tietoverkot       1           3   Kivinen   
2   Tietoverkot       1           4   Laaksonen 
3   Graduseminaari    1           1   Kaila     
3   Graduseminaari    1           2   Luukkainen
3   Graduseminaari    1           3   Kivinen   
3   Graduseminaari    1           4   Laaksonen 
4   PHP-ohjelmointi   4           1   Kaila     
4   PHP-ohjelmointi   4           2   Luukkainen
4   PHP-ohjelmointi   4           3   Kivinen   
4   PHP-ohjelmointi   4           4   Laaksonen 
5   Neuroverkot       3           1   Kaila     
5   Neuroverkot       3           2   Luukkainen
5   Neuroverkot       3           3   Kivinen   
5   Neuroverkot       3           4   Laaksonen 

Suurin osa tulosriveistä ei ole kuitenkaan kiinnostavia, koska niillä olevat tiedot eivät liity toisiinsa. Esimerkiksi ensimmäinen tulosrivi kertoo vain, että on olemassa kurssi Laskennan mallit ja on olemassa opettaja Kaila.

Jotta kysely antaisi mielekkäitä tuloksia, rajaamme hakua niin, että opettajan id-numeron tulee olla sama kummankin taulun riveissä:

SELECT
  *
FROM
  Courses, Teachers
WHERE
  Courses.teacher_id = Teachers.id;

Tämän seurauksena kysely alkaa antaa mielekkäitä tuloksia:

id  name              teacher_id  id  name     
--  ----------------  ----------  --  ---------
1   Laskennan mallit  3           3   Kivinen  
2   Tietoverkot       1           1   Kaila    
3   Graduseminaari    1           1   Kaila    
4   PHP-ohjelmointi   4           4   Laaksonen
5   Neuroverkot       3           3   Kivinen  

Tämän jälkeen voimme vielä siistiä kyselyn tuloksia valitsemalla meitä kiinnostavat sarakkeet:

SELECT
  Courses.name, Teachers.name
FROM
  Courses, Teachers
WHERE
  Courses.teacher_id = Teachers.id;

Näin päädymme samaan tulokseen kuin aiemmin:

name              name     
----------------  ---------
Laskennan mallit  Kivinen  
Tietoverkot       Kaila    
Graduseminaari    Kaila    
PHP-ohjelmointi   Laaksonen
Neuroverkot       Kivinen  

Lisää ehtoja kyselyssä

Monen taulun kyselyissä WHERE-osa kytkee toisiinsa meitä kiinnostavat taulujen rivit, mutta lisäksi voimme laittaa WHERE-osaan muita ehtoja samaan tapaan kuin ennenkin. Esimerkiksi voimme suorittaa seuraavan kyselyn:

SELECT
  Courses.name, Teachers.name
FROM
  Courses, Teachers
WHERE
  Courses.teacher_id = Teachers.id AND Teachers.name = 'Kivinen';

Näin saamme haettua kurssit, joiden opettajana on Kivinen:

name              name   
----------------  -------
Laskennan mallit  Kivinen
Neuroverkot       Kivinen

Taulujen lyhyet nimet

Voimme tiivistää monen taulun kyselyä antamalla tauluille vaihtoehtoiset lyhyet nimet, joiden avulla voimme viitata niihin kyselyssä. Esimerkiksi kysely

SELECT
  Courses.name, Teachers.name
FROM
  Courses, Teachers
WHERE
  Courses.teacher_id = Teachers.id;

voidaan esittää lyhemmin näin:

SELECT
  C.name, T.name
FROM
  Courses AS C, Teachers AS T
WHERE
  C.teacher_id = T.id;

Sana AS ei ole pakollinen, eli voimme lyhentää kyselyä lisää:

SELECT
  C.name, T.name
FROM
  Courses C, Teachers T
WHERE
  C.teacher_id = T.id;

Saman taulun toistaminen

Monen taulun kyselyssä voi esiintyä myös monta kertaa sama taulu, kunhan toistuvalle taululle annetaan eri nimet. Esimerkiksi seuraava kysely hakee kaikki tavat valita kahden opettajan pari:

SELECT A.name, B.name FROM Teachers A, Teachers B;

Kyselyn tulos on seuraava:

name        name      
----------  ----------
Kaila       Kaila     
Kaila       Luukkainen
Kaila       Kivinen   
Kaila       Laaksonen 
Luukkainen  Kaila     
Luukkainen  Luukkainen
Luukkainen  Kivinen   
Luukkainen  Laaksonen 
Kivinen     Kaila     
Kivinen     Luukkainen
Kivinen     Kivinen   
Kivinen     Laaksonen 
Laaksonen   Kaila     
Laaksonen   Luukkainen
Laaksonen   Kivinen   
Laaksonen   Laaksonen 

Liitostaulut

Taulujen välillä esiintyy yleensä kahdenlaisia suhteita:

  1. Yksi moneen -suhde: Taulun A rivi liittyy enintään yhteen taulun B riviin. Taulun B rivi voi liittyä useaan taulun A riviin.

  2. Monta moneen -suhde: Taulun A rivi voi liittyä useaan taulun B riviin. Taulun B rivi voi liittyä useaan taulun A riviin.

Tapauksessa 1 voimme lisätä tauluun A sarakkeen, joka viittaa tauluun B, kuten teimme edellisen osion esimerkissä. Tapauksessa 2 tilanne on kuitenkin hankalampi, koska yksittäinen viittaus kummankaan taulun rivissä ei riittäisi. Ratkaisuna on luoda kolmas liitostaulu (join table), joka sisältää tiedot viittauksista.

Esimerkki

Tarkastellaan esimerkkinä tilannetta, jossa verkkokaupassa on tuotteita ja asiakkaita ja jokainen asiakas on valinnut tiettyjä tuotteita ostoskoriin. Tietyn asiakkaan korissa voi olla useita tuotteita, ja toisaalta tietty tuote voi olla usean asiakkaan korissa.

Rakennamme tietokannan, jossa on kolme taulua: Products, Customers ja Purchases. Liitostaulu Purchases ilmaisee, mitä tuotteita on kunkin asiakkaan ostoskorissa. Sen jokainen rivi esittää yhden parin muotoa “asiakkaan id korissa on tuote id”.

Oletetaan, että taulujen sisällöt ovat seuraavat:

id name price
1 retiisi 7
2 porkkana 5
3 nauris 4
4 lanttu 8
5 selleri 4
id name
1 Uolevi
2 Maija
3 Aapeli
customer_id product_id
1 2
1 5
2 1
2 4
2 5

Tämä tarkoittaa, että Uolevin korissa on porkkana ja selleri sekä Maijan korissa on retiisi, lanttu ja selleri. Aapelin korissa ei ole mitään tuotetta.

Nyt voimme hakea asiakkaat ja tuotteet seuraavasti:

SELECT
  Customers.name, Products.name
FROM
  Customers, Products, Purchases
WHERE
  Customers.id = Purchases.customer_id AND
  Products.id = Purchases.product_id;

Kyselyn ideana on hakea tauluista Customers ja Products taulun Purchases rivejä vastaavat tiedot. Jotta saamme mielekkäitä tuloksia, kytkemme rivit yhteen kahden ehdon avulla. Kysely tuottaa seuraavan tulostaulun:

name    name    
------  --------
Uolevi  porkkana
Uolevi  selleri 
Maija   retiisi 
Maija   lanttu  
Maija   selleri 

Miten kysely toimii?

Voimme taas tutkia kyselyn toimintaa hakemalla kaikki sarakkeet ja poistamalla ehdot:

SELECT * FROM Customers, Products, Purchases;

Tämän kyselyn tulostaulussa on kaikki tavat valita jokin asiakas, tuote ja ostokset. Tulostaulussa on 5 * 3 * 5 = 75 riviä ja se alkaa näin:

id  name    id  name      price  customer_id  product_id
--  ------  --  --------  -----  -----------  ----------
1   Uolevi  1   retiisi   7      1            2         
1   Uolevi  1   retiisi   7      1            5         
1   Uolevi  1   retiisi   7      2            1         
1   Uolevi  1   retiisi   7      2            4         
1   Uolevi  1   retiisi   7      2            5         
1   Uolevi  2   porkkana  5      1            2         
1   Uolevi  2   porkkana  5      1            5         
1   Uolevi  2   porkkana  5      2            1         
1   Uolevi  2   porkkana  5      2            4         
1   Uolevi  2   porkkana  5      2            5         
1   Uolevi  3   nauris    4      1            2         
1   Uolevi  3   nauris    4      1            5         
1   Uolevi  3   nauris    4      2            1         
1   Uolevi  3   nauris    4      2            4         
1   Uolevi  3   nauris    4      2            5         
...

Sitten kun lisäämme kyselyyn ehdot, saamme rajattua kiinnostavat rivit:

SELECT
  *
FROM
  Customers, Products, Purchases
WHERE
  Customers.id = Purchases.customer_id AND
  Products.id = Purchases.product_id;
id  name    id  name      price  customer_id  product_id
--  ------  --  --------  -----  -----------  ----------
1   Uolevi  2   porkkana  5      1            2         
1   Uolevi  5   selleri   4      1            5         
2   Maija   1   retiisi   7      2            1         
2   Maija   4   lanttu    8      2            4         
2   Maija   5   selleri   4      2            5         

Kun vielä määritämme halutut sarakkeet, tuloksena on lopullinen kysely:

SELECT
  Customers.name, Products.name
FROM
  Customers, Products, Purchases
WHERE
  Customers.id = Purchases.customer_id AND
  Products.id = Purchases.product_id;
name    name    
------  --------
Uolevi  porkkana
Uolevi  selleri 
Maija   retiisi 
Maija   lanttu  
Maija   selleri 

Lisää ehtoja kyselyyn

Voimme lisätä kyselyyn lisää ehtoja, jos haluamme saada selville muuta ostoskoreista. Esimerkiksi seuraava kysely hakee Maijan korissa olevat tuotteet:

SELECT
  Products.name
FROM
  Customers, Products, Purchases
WHERE
  Customers.id = Purchases.customer_id AND
  Products.id = Purchases.product_id AND
  Customers.name = 'Maija';
name
----------
retiisi   
lanttu    
selleri   

Seuraava kysely puolestaan kertoo, keiden korissa on selleri:

SELECT
  Customers.name
FROM
  Customers, Products, Purchases
WHERE
  Customers.id = Purchases.customer_id AND
  Products.id = Purchases.product_id AND
  Products.name = 'selleri';
name
----------
Uolevi    
Maija    

Yhteenveto tauluista

Voimme käyttää koostefunktioita ja ryhmittelyä myös usean taulun kyselyissä. Ne käsittelevät tulostaulua samalla periaatteella kuin yhden taulun kyselyissä.

Tarkastellaan edelleen tietokantaa, jossa on tuotteita, asiakkaita ja ostoksia:

id name price
1 retiisi 7
2 porkkana 5
3 nauris 4
4 lanttu 8
5 selleri 4
id name
1 Uolevi
2 Maija
3 Aapeli
customer_id product_id
1 2
1 5
2 1
2 4
2 5

Seuraava kysely luo yhteenvedon, joka näyttää jokaisesta asiakkaasta, montako tuotetta hänen ostoskorissaan on ja mikä on tuotteiden yhteishinta.

SELECT
  Customers.name, COUNT(Products.id), SUM(Products.price)
FROM
  Customers, Products, Purchases
WHERE
  Customers.id = Purchases.customer_id AND Products.id = Purchases.product_id
GROUP BY
  Customers.id;

Kyselyn tulos on seuraava:

name    COUNT(Products.id)  SUM(Products.price)
------  ------------------  -------------------
Uolevi  2                   9                  
Maija   3                   19                 

Uolevin korissa on siis 2 tavaraa, joiden yhteishinta on 9, ja Maijan korissa on 3 tavaraa, joiden yhteishinta on 19.

Miten kysely toimii?

Kyselyn perusta on tässä:

SELECT
  *
FROM
  Customers, Products, Purchases
WHERE
  Customers.id = Purchases.customer_id AND Products.id = Purchases.product_id;
id  name    id  name      price  customer_id  product_id
--  ------  --  --------  -----  -----------  ----------
1   Uolevi  2   porkkana  5      1            2         
1   Uolevi  5   selleri   4      1            5         
2   Maija   1   retiisi   7      2            1         
2   Maija   4   lanttu    8      2            4         
2   Maija   5   selleri   4      2            5         

Kun kyselyyn lisätään ryhmittely GROUP BY Customers.id, rivit jakautuvat kahteen ryhmään sarakkeen Customers.id mukaan:

Ryhmä 1:

id  name    id  name      price  customer_id  product_id
--  ------  --  --------  -----  -----------  ----------
1   Uolevi  2   porkkana  5      1            2         
1   Uolevi  5   selleri   4      1            5         

Ryhmä 2:

id  name   id  name     price  customer_id  product_id
--  -----  --  -------  -----  -----------  ----------
2   Maija  1   retiisi  7      2            1         
2   Maija  4   lanttu   8      2            4         
2   Maija  5   selleri  4      2            5         

Näille ryhmille lasketaan sitten tuotteiden määrä COUNT(Products.id) sekä ostosten yhteishinta SUM(Products.price).

Huomaa, että kyselyssä ryhmittely tapahtuu sarakkeen Customers.id mukaan, mutta kyselyssä haetaan sarake Customers.name. Tämä on sinänsä järkevää, koska sarake Customers.id määrää sarakkeen Customers.name. Tämä kysely toimii esimerkiksi SQLitessä, mutta jotkin tietokannat vaativat tässäkin tapauksessa, että sellaisenaan haettavan sarakkeen tulee esiintyä myös ryhmittelyssä. Tällöin ryhmittelyn tulisi olla GROUP BY Customers.id, Customers.name.

Puuttuvan rivin ongelma

Äskeinen kysely toimii sinänsä hyvin, mutta jotain puuttuu:

name    COUNT(Products.id)  SUM(Products.price)
------  ------------------  -------------------
Uolevi  2                   9                  
Maija   3                   19                 

Kyselyn puutteena on vielä, että tuloksissa ei ole lainkaan kolmatta tietokannassa olevaa asiakasta eli Aapelia. Koska Aapelin korissa ei ole mitään, Aapelin rivi ei yhdisty minkään muun rivin kanssa eikä pääse osaksi tulostaulua.

Olemme törmänneet ongelmaan, mutta onneksi löydämme siihen ratkaisun pian.

JOIN-syntaksi

Tähän mennessä olemme hakeneet tietoa tauluista listaamalla taulut kyselyn FROM-osassa, mikä toimii yleensä hyvin. Kuitenkin joskus on tarpeen JOIN-syntaksi, josta on hyötyä silloin, kun kyselyn tuloksesta näyttää “puuttuvan” tietoa.

Kyselytavat

Seuraavassa on kaksi tapaa toteuttaa sama kysely, ensin käyttäen ennestään tuttua tapaa ja sitten käyttäen JOIN-syntaksia.

SELECT
  Courses.name, Teachers.name
FROM
  Courses, Teachers
WHERE
  Courses.teacher_id = Teachers.id;
SELECT
  Courses.name, Teachers.name
FROM
  Courses JOIN Teachers ON Courses.teacher_id = Teachers.id;

JOIN-syntaksissa taulujen nimien välissä esiintyy sana JOIN ja lisäksi taulujen rivit toisiinsa kytkevä ehto annetaan erillisessä ON-osassa.

Tässä tapauksessa JOIN-syntaksi on vain vaihtoehtoinen tapa toteuttaa kysely eikä se tuo mitään uutta. Kuitenkin näemme seuraavaksi, miten voimme laajentaa syntaksia niin, että se antaa meille uusia mahdollisuuksia kyselyissä.

Esimerkki

Tarkastellaan esimerkkinä tilannetta, jossa tietokannassa on tutut taulut Courses ja Teachers, mutta taulussa Courses yhdeltä kurssilta puuttuu opettaja:

id  name              teacher_id
--  ----------------  ----------
1   Laskennan mallit  3         
2   Tietoverkot       1         
3   Graduseminaari    1         
4   PHP-ohjelmointi   NULL      
5   Neuroverkot       3         

Rivin 4 sarakkeessa teacher_id on arvo NULL, joten jos suoritamme jommankumman äskeisen kyselyn, ongelmaksi tulee, että rivi 4 ei täsmää mihinkään taulun Teachers riviin. Tämän seurauksena tulostauluun ei tule riviä kurssista PHP-ohjelmointi:

name              name   
----------------  -------
Laskennan mallit  Kivinen
Tietoverkot       Kaila  
Graduseminaari    Kaila  
Neuroverkot       Kivinen

Ratkaisu ongelmaan on käyttää LEFT JOIN -syntaksia, joka tarkoittaa, että mikäli jokin vasemman taulun rivi ei yhdisty mihinkään oikean taulun riviin, kyseinen vasemman taulun rivi pääsee silti mukaan yhdeksi riviksi tulostauluun. Kyseisellä rivillä jokaisen oikeaan tauluun perustuvan sarakkeen arvona on NULL.

Tässä tapauksessa voimme toteuttaa kyselyn näin:

SELECT
  Courses.name, Teachers.name
FROM
  Courses LEFT JOIN Teachers ON Courses.teacher_id = Teachers.id;

Nyt tulostauluun ilmestyy myös kurssi Tietokantojen perusteet ilman opettajaa:

name              name   
----------------  -------
Laskennan mallit  Kivinen
Tietoverkot       Kaila  
Graduseminaari    Kaila  
PHP-ohjelmointi   NULL   
Neuroverkot       Kivinen

Miten kysely toimii?

Jälleen hyvä tapa saada selkoa kyselystä on yksinkertaistaa sitä:

SELECT
  *
FROM
  Courses LEFT JOIN Teachers ON Courses.teacher_id = Teachers.id;
id  name              teacher_id  id    name   
--  ----------------  ----------  ----  -------
1   Laskennan mallit  3           3     Kivinen
2   Tietoverkot       1           1     Kaila  
3   Graduseminaari    1           1     Kaila  
4   PHP-ohjelmointi   NULL        NULL  NULL   
5   Neuroverkot       3           3     Kivinen

Tästä näkee, että koska vasemman taulun rivi 4 ei täsmää mihinkään oikean taulun riviin, niin kyseisestä rivistä tulee tulostauluun yksi rivi, jossa jokainen sarake oikean taulun osuudessa on NULL.

JOIN-kyselyperhe

Itse asiassa JOIN-kyselystä on olemassa peräti neljä eri muunnelmaa:

SQLiten rajoituksena on, että vain kaksi ensimmäistä kyselytapaa ovat mahdollisia. Onneksi LEFT JOIN on yleensä se, mitä haluamme.

ON vs. WHERE

Sana ON on oleellinen LEFT JOIN -kyselyssä, koska se asettaa ehdon niin, että mukaan otetaan myös vasemman taulun ylimääräiset rivit:

SELECT
  Courses.name, Teachers.name
FROM
  Courses LEFT JOIN Teachers ON Courses.teacher_id = Teachers.id;
name              name   
----------------  -------
Laskennan mallit  Kivinen
Tietoverkot       Kaila  
Graduseminaari    Kaila  
PHP-ohjelmointi   NULL   
Neuroverkot       Kivinen

Jos käytämme sen sijasta sanaa WHERE, vasemman taulun ylimääräiset rivit jäävät pois:

SELECT
  Courses.name, Teachers.name
FROM
  Courses LEFT JOIN Teachers
WHERE
  Courses.teacher_id = Teachers.id;
name              name   
----------------  -------
Laskennan mallit  Kivinen
Tietoverkot       Kaila  
Graduseminaari    Kaila  
Neuroverkot       Kivinen

Sinänsä kyselyssä voi esiintyä sekä ON että WHERE:

SELECT
  Courses.name, Teachers.name
FROM
  Courses LEFT JOIN Teachers ON Courses.teacher_id = Teachers.id
WHERE
  Courses.name <> 'Tietoverkot';

Tällöin ON-osa hoitaa taulujen yhdistämisen ja WHERE-osa rajaa tuloksia lisää:

name              name   
----------------  -------
Laskennan mallit  Kivinen
Graduseminaari    Kaila  
PHP-ohjelmointi   NULL   
Neuroverkot       Kivinen

Tässä tapauksessa WHERE-osan vaikutuksena on, että tulostaulussa ei ole riviä, jossa kurssina on Tietoverkot.

Jos molemmat ehdot ovatkin ON-osassa, kyselyn tulos muuttuu taas:

SELECT
  Courses.name, Teachers.name
FROM
  Courses LEFT JOIN Teachers
          ON Courses.teacher_id = Teachers.id AND
             Courses.name <> 'Tietoverkot';
name              name   
----------------  -------
Laskennan mallit  Kivinen
Tietoverkot       NULL   
Graduseminaari    Kaila  
PHP-ohjelmointi   NULL   
Neuroverkot       Kivinen

Tässä tapauksessa kursseja Tietoverkot ja PHP-ohjelmointi vastaavat rivit jäävät ylimääräisiksi vasemmassa taulussa, koska niitä ei voida liittää mihinkään oikean taulun riviin ON-ehdossa.

Yhteenveto toimivaksi

Nyt voimme pureutua aiempaan ongelmaan, jossa yhteenvetokyselystä puuttui tietoa. Tietokannassamme on edelleen seuraavat taulut:

id name price
1 retiisi 7
2 porkkana 5
3 nauris 4
4 lanttu 8
5 selleri 4
id name
1 Uolevi
2 Maija
3 Aapeli
customer_id product_id
1 2
1 5
2 1
2 4
2 5

Muodostimme yhteenvedon ostoskoreista seuraavalla kyselyllä:

SELECT
  Customers.name, COUNT(Products.id), SUM(Products.price)
FROM
  Customers, Products, Purchases
WHERE
  Customers.id = Purchases.customer_id AND Products.id = Purchases.product_id
GROUP BY
  Customers.id;

Kuitenkin ongelmaksi tuli, että Aapeli puuttuu yhteenvedosta:

name    COUNT(Products.id)  SUM(Products.price)
------  ------------------  -------------------
Uolevi  2                   9                  
Maija   3                   19                 

Ongelman syynä on, että Aapelin ostoskori on tyhjä eli kun kysely valitsee yhdistelmiä taulujen riveistä, ei ole mitään sellaista riviä, jolla esiintyisi Aapeli. Ratkaisu ongelmaan on käyttää LEFT JOIN -syntaksia näin:

SELECT
  Customers.name, COUNT(Products.id), SUM(Products.price)
FROM
  Customers LEFT JOIN Purchases ON Customers.id = Purchases.customer_id
            LEFT JOIN Products ON Products.id = Purchases.product_id
GROUP BY
  Customers.id;

Nyt myös Aapeli ilmestyy mukaan yhteenvetoon:

name    COUNT(Products.id)  SUM(Products.price)
------  ------------------  -------------------
Uolevi  2                   9                  
Maija   3                   19                 
Aapeli  0                   NULL               

Koska Aapelin ostoskorissa ei ole tuotteita, hintojen summaksi tulee NULL. Voimme vielä parantaa kyselyä IFNULL-funktion avulla:

SELECT
  Customers.name, COUNT(Products.id), IFNULL(SUM(Products.price), 0)
FROM
  Customers LEFT JOIN Purchases ON Customers.id = Purchases.customer_id
            LEFT JOIN Products ON Products.id = Purchases.product_id
GROUP BY
  Customers.id;

Tämän seurauksena mahdollinen NULL muuttuu arvoksi 0:

name    COUNT(Products.id)  IFNULL(SUM(Products.price), 0)
------  ------------------  ------------------------------
Uolevi  2                   9                             
Maija   3                   19                            
Aapeli  0                   0                             

Miten kysely toimii?

Kun kyselyssä on useita LEFT JOIN -osia, tulkintana on, että ne yhdistävät tauluja vasemmalta oikealle. Yllä olevassa kyselyssä voimme ajatella, että ensimmäinen vaihe yhdistää taulut Customers ja Purchases:

SELECT
  *
FROM
  Customers LEFT JOIN Purchases ON Customers.id = Purchases.customer_id;
id  name    customer_id  product_id
--  ------  -----------  ----------
1   Uolevi  1            2         
1   Uolevi  1            5         
2   Maija   2            1         
2   Maija   2            4         
2   Maija   2            5         
3   Aapeli  NULL         NULL      

Toinen vaihe puolestaan yhdistää yllä olevan tulostaulun ja taulun Products:

SELECT
  *
FROM
  Customers LEFT JOIN Purchases ON Customers.id = Purchases.customer_id
            LEFT JOIN Products ON Products.id = Purchases.product_id;
id  name    customer_id  product_id  id    name      price
--  ------  -----------  ----------  ----  --------  -----
1   Uolevi  1            2           2     porkkana  5    
1   Uolevi  1            5           5     selleri   4    
2   Maija   2            1           1     retiisi   7    
2   Maija   2            4           4     lanttu    8    
2   Maija   2            5           5     selleri   4    
3   Aapeli  NULL         NULL        NULL  NULL      NULL 

Molemmissa vaiheissa Aapeli pääsee osaksi tulostaulua, koska kyseinen rivi ei täsmää minkään oikean taulun rivin kanssa.

4. Lisää SQL-kielestä

Tyypit ja lausekkeet

SQL-kielessä esiintyy tyyppejä ja lausekkeita samaan tapaan kuin ohjelmoinnissa. Olemme jo nähneet monia esimerkkejä SQL-komennoista, mutta nyt on hyvä hetki tutustua syvällisemmin kielen rakenteeseen.

Jokainen tietokantajärjestelmä toteuttaa tyypit ja lausekkeet vähän omalla tavallaan, ja tietokantojen toiminnassa on paljon pieniä eroja. Niinpä aiheeseen liittyvät yksityiskohdat kannattaa tarkastaa käytetyn tietokannan dokumentaatiosta.

Tyypit

Taulun määrittelyssä jokaiselle sarakkeelle annetaan tyyppi:

CREATE TABLE Movies (
  id INTEGER PRIMARY KEY,
  name TEXT,
  release_year INTEGER
);

Tässä sarakkeen name tyyppi on TEXT (merkkijono) ja sarakkeen release_year tyyppi on INTEGER (kokonaisluku). Nämä ovat yleisimmät tyypit, jotka ovat saatavilla näillä nimillä monissa tietokannoissa. Esimerkkejä muista yleisistä tyypeistä ovat TIMESTAMP (ajanhetki), REAL (liukuluku) ja BLOB (raakadata).

TEXT vs. VARCHAR

Perinteikäs tapa tallentaa merkkijono SQL:ssä on käyttää tyyppiä VARCHAR, jossa annetaan suluissa merkkijonon maksimipituus. Esimerkiksi tyyppi VARCHAR(10) tarkoittaa, että merkkijonossa voi olla enintään 10 merkkiä.

Tämä on muistuma vanhan ajan ohjelmoinnista, jossa merkkijono saatettiin esittää kiinteän pituisena merkkitaulukkona. Tyyppi TEXT on kuitenkin mukavampi, koska siinä ei tarvitse keksiä maksimipituutta.

SQLiten tyypit

Erikoinen SQLiten piirre on, että taulun määrittelyssä esiintyvä tyyppi on vain ohje, mitä tyyppiä sarakkeessa tulisi olla. Voimme kuitenkin olla välittämättä ohjeesta ja vaikkapa tallentaa kokonaisluvun kohdalle merkkijonon:

INSERT INTO Movies (name, release_year) VALUES ('Lumikki', 'abc');

Lisäksi tyypin nimenä voi olla mikä tahansa merkkijono, vaikka SQLitessä ei olisi sellaista tyyppiä. Tämän avulla voimme esimerkiksi määritellä sarakkeen, johon on tarkoitus tallentaa ajanhetki:

CREATE TABLE Bookings (
  id INTEGER PRIMARY KEY,
  start_time TIMESTAMP,
  end_time TIMESTAMP,
  description TEXT
);

SQLitessä ei ole tyyppiä TIMESTAMP, vaan ajanhetkiä käsitellään merkkijonoina, mutta tässä kuitenkin sarakkeen tyyppi ilmaisee, mitä siihen on tarkoitus tallentaa.

Lausekkeet

Lauseke on SQL-komennon osa, jolla on tietty arvo. Esimerkiksi kyselyssä

SELECT price FROM Products WHERE name = 'retiisi';

on neljä lauseketta: price, name, 'retiisi' ja name = 'retiisi'. Lausekkeet price ja name saavat arvonsa rivin sarakkeesta, lauseke 'retiisi' on merkkijonovakio ja lauseke name = 'retiisi' on totuusarvoinen.

Voimme rakentaa monimutkaisempia lausekkeita samaan tapaan kuin ohjelmoinnissa. Esimerkiksi kysely

SELECT price * 5 FROM Products;

antaa jokaisen tuotteen hinnan viisinkertaisena ja kysely

SELECT name FROM Products WHERE price % 2 = 0;

hakee tuotteet, joiden hinta on parillinen.

Hyvä tapa testata SQL:n lausekkeiden toimintaa on keskustella tietokannan kanssa tekemällä kyselyitä, jotka eivät hae tietoa mistään taulusta vaan laskevat vain tietyn lausekkeen arvon. Keskustelu voi näyttää vaikkapa seuraavalta:

sqlite> SELECT 2 * (1 + 3);
8
sqlite> SELECT 'tes' || 'ti';
testi
sqlite> SELECT 3 < 5;
1

Ensimmäinen kysely laskee lausekkeen 2 * (1 + 3) arvon. Toinen kysely yhdistää ||-operaattorilla merkkijonot 'tes' ja 'ti' merkkijonoksi 'testi'. Kolmas kysely puolestaan määrittää ehtolausekkeen 3 < 5 arvon. Tästä näkee, että SQLitessä kokonaisluku ilmaisee totuusarvon: 1 on tosi ja 0 on epätosi.

Monet SQL:n lausekkeisiin liittyvät asiat ovat tuttuja ohjelmoinnista:

Näiden lisäksi SQL:ssä on kuitenkin myös erikoisempia ominaisuuksia, joiden tuntemisesta on välillä hyötyä. Seuraavassa on joitakin niistä:

BETWEEN

Lauseke x BETWEEN a AND b on tosi, jos x on vähintään a ja enintään b. Esimerkiksi kysely

SELECT * FROM Products WHERE price BETWEEN 4 AND 6;

hakee tuotteet, joiden hinta on vähintään 4 ja korkeintaan 6. Voimme toki kirjoittaa samalla tavalla toimivan kyselyn myös näin:

SELECT * FROM Products WHERE price >= 4 AND price <= 6;

CASE

Rakenne CASE mahdollistaa ehtolausekkeen tekemisen. Siinä voi olla yksi tai useampi WHEN-osa sekä mahdollinen ELSE-osa. Esimerkiksi kysely

SELECT
  name,
  CASE WHEN price > 5 THEN 'kallis' ELSE 'halpa' END
FROM
  Products;

hakee kunkin tuotteen nimen sekä tiedon siitä, onko tuote kallis vai halpa. Tässä tuote on kallis, jos sen hinta on yli 5, ja muuten halpa.

IN

Lauseke x IN (...) on tosi, jos x on jokin annetuista arvoista. Esimerkiksi kysely

SELECT
  SUM(price)
FROM
  Products
WHERE
  name IN ('lanttu', 'nauris', 'selleri');

hakee lantun, nauriin ja sellerin yhteishinnan.

LIKE

Lauseke s LIKE p on tosi, jos merkkijono s vastaa kuvausta p. Kuvauksessa voi käyttää erikoismerkkejä: merkki _ tarkoittaa mitä tahansa yksittäistä merkkiä ja merkki % tarkoittaa mitä tahansa määrää mitä tahansa merkkejä. Esimerkiksi kysely

SELECT * FROM Products WHERE name LIKE '%ri%';

hakee tuotteet, joiden nimen osana esiintyy merkkijono “ri” (kuten nauris ja selleri).

Funktiot

Lausekkeiden osana voi esiintyä funktioita samaan tapaan kuin ohjelmoinnissa. Tässä on esimerkkinä joitakin SQLiten funktioita:

Seuraava kysely hakee tuotteet, joiden nimessä on kuusi kirjainta (kuten lanttu ja nauris).

SELECT * FROM Products WHERE LENGTH(name) = 6;

Seuraava kysely ryhmittelee tuotteet ensimmäisen kirjaimen mukaan ja ilmoittaa kullakin kirjaimella alkavien tuotteiden määrät.

SELECT
  SUBSTR(name, 1, 1), COUNT(*)
FROM
  Products
GROUP BY
  SUBSTR(name, 1, 1);

Seuraava kysely antaa rivit satunnaisessa järjestyksessä, koska järjestys ei perustu minkään sarakkeen sisältöön vaan satunnaiseen arvoon.

SELECT * FROM Products ORDER BY RANDOM();

ORDER BY ja lausekkeet

Voisi kuvitella, että kyselyssä

SELECT * FROM Products ORDER BY 1;

rivit järjestetään lausekkeen 1 mukaan. Koska lausekkeen arvo on joka rivillä 1, tämä ei tuottaisi mitään erityistä järjestystä. Näin ei kuitenkaan ole, vaan 1 järjestää rivit ensimmäisen sarakkeen mukaan, 2 toisen sarakkeen mukaan, jne. Tämä on siis vaihtoehtoinen tapa ilmaista sarake, johon järjestys perustuu.

Kuitenkin jos ORDER BY -osassa oleva lauseke on jotain muuta kuin yksittäinen luku (kuten RANDOM()), rivit järjestetään kyseisen lausekkeen mukaisesti.

NULL-arvot

NULL on erityinen arvo, joka ilmaisee, että taulun sarakkeessa ei ole tietoa tai jokin kyselyn osa ei tuottanut tietoa. NULL on tietyissä tilanteissa kätevä, mutta voi aiheuttaa myös yllätyksiä.

Oletuksena SQLite-tulkki näyttää NULL-arvon tyhjänä:

sqlite> SELECT NULL;

Kuitenkin NULL-arvon saa näkymään tulkin komennolla .nullvalue:

sqlite> .nullvalue NULL
sqlite> SELECT NULL;
NULL

NULL on selkeästi eri asia kuin luku 0. Jos NULL esiintyy laskun osana, niin koko laskun tulokseksi tulee NULL.

sqlite> SELECT 5 + NULL;
NULL
sqlite> SELECT 2 * NULL + 1;
NULL

Myöskään tavallinen vertailu ei tuota tulosta, jos verrattavana on NULL:

sqlite> SELECT 5 = NULL;
NULL
sqlite> SELECT 5 <> NULL;
NULL

Tämä on yllättävää, koska yleensä lausekkeille a ja b pätee joko a = b tai a <> b. Voimme kuitenkin tutkia erityisen syntaksin IS NULL avulla, onko lausekkeen arvo NULL:

sqlite> SELECT 5 IS NULL;
0
sqlite> SELECT NULL IS NULL;
1

Sarakkeen puuttuva tieto

NULL-arvon yksi käyttötarkoitus on ilmaista, että jossain sarakkeessa ei ole tietoa. Esimerkiksi seuraavassa taulussa Movies Dumbon vuosi puuttuu, joten sen kohdalla on NULL:

id  name       release_year
--  ---------  ------------
1   Lumikki    1937        
2   Fantasia   1940        
3   Pinocchio  1940        
4   Dumbo      NULL        
5   Bambi      1942        

Kun haemme ensin vuoden 1940 elokuvat ja sitten kaikki elokuvat muilta vuosilta, saamme seuraavat tulokset:

SELECT * FROM Movies WHERE release_year = 1940;
id  name       release_year
--  ---------  ------------
2   Fantasia   1940        
3   Pinocchio  1940        
SELECT * FROM Movies WHERE release_year <> 1940;
id  name     release_year
--  -------  ------------
1   Lumikki  1937        
5   Bambi    1942        

Koska Dumbolla ei ole vuotta, emme saa sitä kummassakaan kyselyssä, mikä on yllättävä ilmiö. Voimme kuitenkin hakea näin elokuvat, joilla ei ole vuotta:

SELECT * FROM Movies WHERE release_year IS NULL;
id  name   release_year
--  -----  ------------
4   Dumbo  NULL        

NULL-arvo koostefunktiossa

Kun koostefunktion sisällä on lauseke (kuten sarakkeen arvo), riviä ei lasketa mukaan, jos lausekkeen arvo on NULL. Tarkastellaan esimerkkinä seuraavaa taulua Employees:

id  name      company  salary
--  --------  -------  ------
1   Anna      Google   8000  
2   Liisa     Google   7500  
3   Kaaleppi  Amazon   NULL  
4   Uolevi    Amazon   NULL  
5   Maija     Google   9500  

Taulussa Googlen työntekijöillä on ilmoitettu palkka, mutta Amazonin työntekijöillä ei. Koostefunktio COUNT(salary) laskee mukaan vain rivit, joissa palkka on ilmoitettu:

SELECT COUNT(salary) FROM Employees WHERE company = 'Google';
COUNT(salary)
-------------
3

SELECT COUNT(salary) FROM Employees WHERE company = 'Amazon';
COUNT(salary)
-------------
0

Kun sitten laskemme palkkojen summia koostefunktiolla SUM(salary), saamme seuraavat tulokset:

SELECT SUM(salary) FROM Employees WHERE company = 'Google';
SUM(salary)
-----------
25000      
SELECT SUM(salary) FROM Employees WHERE company = 'Amazon';
SUM(salary)
-----------
NULL

Tämä on vähän yllättävää, koska voisi myös odottaa tyhjän summan olevan 0 eikä NULL.

NULL-arvon muuttaminen

Funktio IFNULL(a, b) palauttaa arvon a, jos a ei ole NULL, ja muuten arvon b:

sqlite> SELECT IFNULL(5, 0);
IFNULL(5, 0)
------------
5          
sqlite> SELECT IFNULL(NULL, 0);
IFNULL(NULL, 0)
---------------
0

Yllä oleva tapa on tyypillinen tapa käyttää funktiota: kun toinen parametri on 0, niin funktio muuttaa mahdollisen NULL-arvon nollaksi. Tästä on hyötyä esimerkiksi LEFT JOIN -kyselyissä SUM-funktion kanssa.

Funktio IFNULL ei ole SQL-standardin mukainen funktio, eikä se toimi kaikissa tietokannoissa. Yleisempi standardin mukainen funktio on COALESCE(...), jolle annetaan lista arvoista. Funktio palauttaa listan ensimmäisen arvon, joka ei ole NULL, tai arvon NULL, jos jokainen arvo on NULL. Jos funktiolla on kaksi parametria, se toimii samoin kuin IFNULL.

sqlite> SELECT COALESCE(1, 2, 3);
COALESCE(1, 2, 3)
-----------------
1              
sqlite> SELECT COALESCE(NULL, 2, 3);
COALESCE(NULL, 2, 3)
--------------------
2                 
sqlite> SELECT COALESCE(NULL, NULL, 3);
COALESCE(NULL, NULL, 3)
-----------------------
3                    
sqlite> SELECT COALESCE(NULL, NULL, NULL);
COALESCE(NULL, NULL, NULL)
--------------------------
NULL

Alikyselyt

Alikysely on SQL-komennon osana oleva lauseke, jonka arvo syntyy jonkin kyselyn perusteella. Voimme rakentaa alikyselyjä samaan tapaan kuin varsinaisia kyselyjä ja toteuttaa niiden avulla hakuja, joita olisi vaikea saada aikaan muuten.

Esimerkki

Tarkastellaan esimerkkinä tilannetta, jossa tietokannassa on pelaajien tuloksia taulussa Results. Oletamme, että taulun sisältö on seuraava:

id  name      score
--  --------  -----
1   Uolevi    120  
2   Maija     80   
3   Liisa     120  
4   Aapeli    45   
5   Kaaleppi  115  

Haluamme nyt selvittää ne pelaajat, jotka ovat saavuttaneet korkeimman tuloksen, eli kyselyn tulisi palauttaa Uolevi ja Liisa. Saamme tämän aikaan alikyselyllä seuraavasti:

SELECT
  name, score
FROM
  Results
WHERE
  score = (SELECT MAX(score) FROM Results);

Kyselyn tuloksena on:

name    score
------  -----
Uolevi  120  
Liisa   120  

Tässä kyselyssä alikysely on SELECT MAX(score) FROM Results, joka antaa suurimman taulussa olevan tuloksen eli tässä tapauksessa arvon 120. Huomaa, että alikysely tulee kirjoittaa sulkujen sisään, jotta se ei sekoitu pääkyselyyn.

Alikyselyn laatiminen

Alikysely voi esiintyä melkein missä tahansa kohtaa kyselyssä, ja se voi tilanteesta riippuen palauttaa yksittäisen arvon, listan arvoista tai kokonaisen taulun.

Alikysely sarakkeessa

Seuraavassa kyselyssä alikyselyn avulla luodaan kolmas sarake, joka näyttää pelaajan tuloksen eron ennätystulokseen:

SELECT
  name, score, (SELECT MAX(score) FROM Results) - score
FROM
  Results;
name      score  (SELECT MAX(score) FROM Results) - score
--------  -----  ----------------------------------------
Uolevi    120    0                                     
Maija     80     40                                    
Liisa     120    0                                     
Aapeli    45     75                                    
Kaaleppi  115    5                                     

Koska tulostaulun sarakkeen tuottava lauseke on monimutkainen, tulostaulua voi selkeyttää nimeämällä sarakkeen uudestaan:

SELECT
  name,
  score,
  (SELECT MAX(score) FROM Results) - score AS difference
FROM
  Results;
name      score  difference
--------  -----  ----------
Uolevi    120    0         
Maija     80     40        
Liisa     120    0         
Aapeli    45     75        
Kaaleppi  115    5         

Alikysely tauluna

Seuraavassa kyselyssä alikysely luo taulun, jossa on kolme parasta tulosta. Näiden tulosten summa (120 + 120 + 115) lasketaan pääkyselyssä.

SELECT
  SUM(score)
FROM
  (SELECT * FROM Results ORDER BY score DESC LIMIT 3);
SUM(score)
----------
355

Tässä LIMIT rajaa tulostaulua niin, että siinä on vain kolme ensimmäistä riviä.

Huomaa, että ilman alikyselyä saisimme väärän tuloksen:

SELECT SUM(score) FROM Results ORDER BY score DESC LIMIT 3;
SUM(score)
----------
480     

Tässä tulostaulussa on vain yksi rivi, jossa on kaikkien tulosten summa (480). Niinpä kyselyn lopussa oleva LIMIT 3 ei vaikuta mitenkään tulokseen.

Alikysely listana

Seuraava kysely hakee pelaajat, joiden tulos kuuluu kolmen parhaimman joukkoon. Alikysely palauttaa listan tuloksista IN-lauseketta varten.

SELECT
  name
FROM
  Results
WHERE
  score IN (SELECT score FROM Results ORDER BY score DESC LIMIT 3);
name
----------
Uolevi    
Liisa     
Kaaleppi  

Riippuva alikysely

Alikysely on mahdollista toteuttaa myös niin, että sen toiminta riippuu pääkyselyssä käsiteltävästä rivistä. Näin on seuraavassa kyselyssä:

SELECT
  name,
  score,
  (SELECT COUNT(*) FROM Results WHERE score > R.score) AS better_count
FROM
  Results R;

Tämä kysely laskee jokaiselle pelaajalle, monenko pelaajan tulos on parempi kuin pelaajan oma tulos. Esimerkiksi Maijalle vastaus on 3, koska Uolevin, Liisan ja Kaalepin tulos on parempi. Kysely antaa seuraavan tuloksen:

name      score  better_count
--------  -----  ------------
Uolevi    120    0           
Maija     80     3           
Liisa     120    0           
Aapeli    45     4           
Kaaleppi  115    2           

Koska taulu Results esiintyy kahdessa roolissa alikyselyssä, pääkyselyn taululle on annettu nimi R. Tämän ansiosta alikyselyssä on selvää, että halutaan laskea rivejä, joiden tulos on parempi kuin pääkyselyssä käsiteltävän rivin tulos.

Tässä on vielä toinen esimerkki riippuvasta alikyselystä:

SELECT
  name
FROM
  Results R
WHERE
  (SELECT COUNT(*) FROM Results WHERE score < R.score) >= 1;

Tämä kysely etsii pelaajat, joilla on parempi tulos kuin jollakin toisella pelaajalla. Tässä alikysely laskee, monellako pelaajalla on huonompi tulos, ja kyselyn ehtona on, että alikyselyn tulos on vähintään yksi. Kyselyn tuloksena on:

name
----------
Uolevi    
Maija     
Liisa     
Kaaleppi  

Tässä tapauksessa kysely palauttaa kaikki pelaajat paitsi Aapelin, jonka tulos on huonoin.

SQL:ssä on myös avainsana EXISTS, joka ilmaisee, palauttaako alikysely vähintään yhden rivin. Tämän avulla äskeinen kysely voidaan kirjoittaa selkeämmin:

SELECT
  name
FROM
  Results R
WHERE
  EXISTS (SELECT * FROM Results WHERE score < R.score);

Milloin käyttää alikyselyä?

Melko usein alikysely on vaihtoehtoinen tapa toteuttaa kysely, jonka voisi tehdä jotenkin muutenkin. Esimerkiksi molemmat seuraavat kyselyt hakevat tuotteiden nimet asiakkaan 1 ostoskorissa:

SELECT
  Products.name
FROM
  Products, Purchases
WHERE
  Products.id = Purchases.product_id AND Purchases.customer_id = 1;
SELECT
  name
FROM
  Products
WHERE
  id IN (SELECT product_id FROM Purchases WHERE customer_id = 1);

Ensimmäinen kysely on tyypillinen kahden taulun kysely, kun taas toinen kysely valikoi tuotteet alikyselyn avulla. Kumpi kysely on parempi?

Ensimmäinen kysely on parempi, koska tämä on tarkoitettu tapa hakea SQL:ssä tietoa tauluista viittausten avulla. Toinen kysely toimii sinänsä, mutta se poikkeaa totutusta eikä tietokantajärjestelmä myöskään pysty ehkä suorittamaan sitä yhtä tehokkaasti.

Alikyselyä kannattaa käyttää vain silloin, kun siihen on todellinen syy. Jos kyselyn voi tehdä helposti usean taulun kyselyllä, tämä on yleensä parempi ratkaisu.

Lisää tekniikoita

Tässä osiossa on lisää näytteitä SQL:n mahdollisuuksista. Näistä tekniikoista on hyötyä joidenkin SQL Trainerin vaikeiden tehtävien ratkaisemisessa.

Kumulatiivinen summa

Hyödyllinen taito SQL:ssä on osata laskea kumulatiivinen summa eli jokaiselle riville summa sarakkeen luvuista kyseiselle riville asti. Tarkastellaan esimerkiksi seuraavaa taulua Items:

id  value
--  -----
1   200  
2   100  
3   400  
4   100  

Voimme laskea kumulatiivisen summan kahden taulun kyselyllä näin:

SELECT
  A.id, SUM(B.value)
FROM
  Items A, Items B
WHERE
  B.id <= A.id
GROUP BY
  A.id;
id  SUM(B.value)
--  ------------
1   200         
2   300         
3   700         
4   800         

Tässä on ideana, että summa lasketaan taulun A riville ja taulusta B haetaan kaikki rivit, joiden id on pienempi tai sama kuin taulun A rivillä. Halutut summat saadaan laskettua SUM-funktiolla ryhmittelyn jälkeen.

Vastaavaa tekniikkaa voi käyttää muissakin tilanteissa, jos haluamme laskea tuloksen, joka riippuu jotenkin kaikista “pienemmistä” riveistä taulussa.

Sisäkkäiset koosteet

Tarkastellaan tilannetta, jossa haluamme selvittää, mikä on suurin määrä elokuvia, jotka ovat ilmestyneet samana vuonna. Esimerkiksi seuraavassa taulussa Movies haluttu tulos on 2, koska vuonna 1940 ilmestyi kaksi elokuvaa.

id  name       release_year
--  ---------  ------------
1   Lumikki    1937        
2   Fantasia   1940        
3   Pinocchio  1940        
4   Dumbo      1941        
5   Bambi      1942        

Tämä on vähän hankalalta vaikuttava tilanne, koska meidän tulisi tehdä sisäkkäin kyselyt COUNT, joka laskee ilmestymismääriä, ja sitten MAX, joka hakee suurimman arvon. SQL ei salli kuitenkaan kyselyä SELECT MAX(COUNT(release_year)) tai vastaavaa.

Voimme ottaa kuitenkin lähtökohdaksi kyselyn, joka ryhmittelee elokuvat vuoden mukaan ja hakee jokaisesta ryhmästä elokuvien määrän:

SELECT COUNT(*) FROM Movies GROUP BY release_year;
COUNT(*)  
--------
1         
2         
1         
1       

Näistä luvuista pitää vielä saada haettua suurin, mikä onnistuu alikyselyn avulla. Tässä tapauksessa kätevä tapa on käyttää alikyselyä niin, että sen tulos on pääkyselyn FROM-osassa, jolloin alikysely luo taulun, josta pääkysely hakee tietoa:

SELECT MAX(year_count) FROM (
  SELECT COUNT(*) year_count FROM Movies GROUP BY release_year
);
MAX(year_count)
---------------
2       

Entä voisiko tehtävän ratkaista ilman alikyselyä? Kyllä, koska voimme järjestää tulokset suurimmasta pienimpään ja valita tulostaulun ensimmäisen rivin:

SELECT COUNT(*) AS year_count FROM Movies GROUP BY release_year
ORDER BY year_count DESC LIMIT 1;
year_count
----------
2          

Sijaluvut

Tarkastellaan vielä taulua Results, jossa on pelaajia ja heidän tuloksiaan:

id  name      score
--  --------  -----
1   Aapeli    45   
2   Kaaleppi  115  
3   Liisa     120  
4   Maija     80   
5   Uolevi    120  

Tavoitteena on hakea rivit järjestyksessä tuloksen mukaan suurimmasta pienempään ja ilmoittaa lisäksi kunkin rivin sijaluku. Yksi tapa toteuttaa tämä on tehdä alikysely, joka laskee, monellako rivillä tulos on parempi, jolloin sija on yhtä suurempi kuin alikyselyn tulos:

SELECT
  (SELECT COUNT(*) FROM Results WHERE score > R.score) + 1 AS place,
  name, score
FROM
  Results R
ORDER BY
  score DESC, name;
place  name      score
-----  --------  -----
1      Liisa     120  
1      Uolevi    120  
3      Kaaleppi  115  
4      Maija     80   
5      Aapeli    45   

Samalla idealla voidaan laskea sijaluvut myös niin, että jokaisella on eri sija ja yhtä suuren tuloksen tapauksessa aakkosjärjestys ratkaisee sijan:

SELECT
  (SELECT COUNT(*) FROM Results WHERE score > R.score OR
    (score = T.score AND name < T.name)) + 1 AS place,
  name, score
FROM
  Results R
ORDER BY
  score DESC, name;
place  name      score
-----  --------  -----
1      Liisa     120  
2      Uolevi    120  
3      Kaaleppi  115  
4      Maija     80   
5      Aapeli    45   

Vaihtoehtoinen tapa laskea sijalukuja on ikkunafunktio, jos käytetty tietokanta sallii sen. Esimerkiksi SQLiten uusissa versioissa ikkunafunktion RANK avulla voidaan laskea vastaavat sijaluvut kuin äskeisissä esimerkeissä.

SELECT
  RANK() OVER (ORDER BY score DESC) place, name, score
FROM
  Results
ORDER BY
  place, name;
place  name      score
-----  --------  -----
1      Liisa     120  
1      Uolevi    120  
3      Kaaleppi  115  
4      Maija     80   
5      Aapeli    45   
SELECT
  RANK() OVER (ORDER BY score DESC, name) place, name, score
FROM
  Results
ORDER BY
  place, name;
place  name      score
-----  --------  -----
1      Liisa     120  
2      Uolevi    120  
3      Kaaleppi  115  
4      Maija     80   
5      Aapeli    45   

Listojen vertailu

Tarkastellaan taulua Lists, johon on tallennettu listojen sisältö. Esimerkiksi seuraavassa taulussa lista 1 sisältää luvut [2, 4, 5], lista 2 sisältää luvut [3, 5] ja lista 3 sisältää luvut [2, 4, 5]:

id  list_id  value
--  -------  -----
1   1        2    
2   1        4    
3   1        5    
4   2        3    
5   2        5    
6   3        2    
7   3        4    
8   3        5    

Seuraava kysely laskee jokaiselle listaparille, montako yhteistä lukua niillä on:

SELECT
  A.list_id, B.list_id, COUNT(*)
FROM
  Lists A, Lists B
WHERE
  A.value = B.value
GROUP BY
  A.list_id, B.list_id;
list_id  list_id  COUNT(*)
-------  -------  --------
1        1        3       
1        2        1       
1        3        3       
2        1        1       
2        2        2       
2        3        1       
3        1        3       
3        2        1       
3        3        3       

Tästä selviää, että esimerkiksi listoilla 1 ja 2 on yksi yhteinen luku (5) ja listoilla 1 ja 3 on kolme yhteistä lukua (2, 4, 5). Tällaista kyselyä laajentamalla voidaan vaikkapa vertailla, onko kahdella listalla täysin sama sisältö. Näin on silloin, kun listoilla on yhtä monta lukua ja yhteisten lukujen määrä on yhtä suuri kuin yksittäisen listan lukujen määrä.

5. Tietokannat ohjelmoinnissa

Testitietokanta

Tämän luvun esimerkeissä ja kurssin tehtävissä käytetään testitietokantaa, joka sisältää tiedot kaupunkipyörillä tehdyistä matkoista vuonna 2024 Helsingin ja Espoon alueella. Tietokanta perustuu HSL:n julkaisemaan avoimen datan aineistoon, joka on muutettu tätä kurssia varten SQLite-tietokannaksi.

Voit kopioida tietokannan itsellesi tästä: bikes_2024.zip

Tietokannassa on seuraavat kaksi taulua:

CREATE TABLE Stations (
    id INTEGER PRIMARY KEY,
    name TEXT
);

CREATE TABLE Trips (
    id INTEGER PRIMARY KEY,
    start_time TEXT,
    end_time TEXT,
    start_station_id INTEGER REFERENCES Stations,
    end_station_id INTEGER REFERENCES Stations,
    distance INTEGER,
    duration INTEGER
);

Taulu Stations sisältää tiedot kaupunkipyöräasemista. Taulussa on kaksi saraketta: id (id-numero) sekä name (aseman nimi).

Taulu Trips sisältää tiedot matkoista. Taulussa on seuraavat sarakkeet:

Voimme tutkia tietokannan sisältöä SQLite-tulkin kautta seuraavaan tapaan:

$ sqlite3 bikes_2024.db
sqlite> .tables
Stations  Trips
sqlite> SELECT COUNT(*) FROM Stations;
458
sqlite> SELECT COUNT(*) FROM Trips;
2585668
sqlite> SELECT * FROM Stations LIMIT 10;
1|Kaivopuisto
2|Laivasillankatu
3|Kapteeninpuistikko
4|Viiskulma
5|Sepänkatu
6|Hietalahdentori
7|Designmuseo
8|Vanha kirkkopuisto
9|Erottajan aukio
10|Kasarmitori
sqlite> SELECT * FROM Trips WHERE id = 100;
100|2024-04-01T10:05:03|2024-04-01T10:26:19|119|259|4627|1271
sqlite> SELECT name FROM Stations WHERE id = 119;
Gebhardinaukio
sqlite> SELECT name FROM Stations WHERE id = 259;
Petter Wetterin tie
sqlite> .quit

Tämän perusteella tietokannassa on 458 asemaa ja 2585668 matkaa. Esimerkiksi matka id-numerolla 100 alkoi Gebhardinaukiolta ja päättyi Petter Wetterin tielle. Matkan pituus oli 4,6 kilometriä ja kesto reilut 21 minuuttia.

Katsotaan seuraavaksi, miten voimme käsitellä tätä tietokantaa Python- ja R-kielillä.

SQLite Pythonissa

Python-kielen standardikirjastossa on moduuli sqlite3, jonka avulla voidaan käyttää SQLite-tietokantaa. Seuraava koodi muodostaa yhteyden tietokantaan ja hakee taulusta Stations tietoa SQL-kyselyillä:

import sqlite3

db = sqlite3.connect("bikes_2024.db")

result = db.execute("SELECT id, name FROM Stations WHERE id = 5")
print(result.fetchone())

result = db.execute("SELECT id, name FROM Stations ORDER BY id LIMIT 10")
print(result.fetchall())

Koodin tulostus on seuraava:

(5, 'Sepänkatu')
[(1, 'Kaivopuisto'), (2, 'Laivasillankatu'), (3, 'Kapteeninpuistikko'), (4, 'Viiskulma'), (5, 'Sepänkatu'), (6, 'Hietalahdentori'), (7, 'Designmuseo'), (8, 'Vanha kirkkopuisto'), (9, 'Erottajan aukio'), (10, 'Kasarmitori')]

Tässä db on tietokantaolio, jonka kautta voidaan suorittaa SQL-komentoja metodilla execute. Tässä koodissa suoritetaan kaksi SELECT-komentoa.

Ensimmäinen SELECT-komento hakee taulusta Stations rivin, jonka id-numero on 5. Koska kysely palauttaa yhden rivin, käytetään metodia fetchone, joka palauttaa yhden rivin tuplena.

Toinen SELECT-komento hakee taulusta Stations kymmenen ensimmäistä riviä. Nyt käytetään metodia fetchall, joka palauttaa listan, jossa jokainen tuple vastaa yhtä tulostaulun riviä.

Tietokantatiedoston sijainti

Tyypillinen ongelma tietokantaa käyttävässä koodissa on, että tietokantatiedosto sijaitsee eri paikassa tietokoneella kuin koodi olettaa sen sijaitsevan. Jos et saa yllä olevaa koodia toimimaan, syy on luultavasti tässä.

Jos koodissa viitattua tietokantatiedostoa ei ole olemassa, koodi luo uuden tyhjän tietokantatiedoston. Koska tässä tietokannassa ei ole taulua Stations, yritys hakea tietoa taulusta epäonnistuu.

Esimerkki: Aseman nimi

Seuraava koodi kysyy käyttäjältä aseman id-numeroa ja hakee sen perusteella aseman nimen tietokannasta:

import sqlite3

db = sqlite3.connect("bikes_2024.db")

station_id = input("Anna aseman id-numero: ")
result = db.execute("SELECT name FROM Stations WHERE id = ?", [station_id])
station_name = result.fetchone()[0]
print("Aseman nimi:", station_name)

Koodin suoritus voi näyttää seuraavalta:

Anna aseman id-numero: 42
Aseman nimi: Haapaniemenkatu

Tässä kyselyn osana on parametri ?, jonka arvoksi tulee muuttujassa station_id oleva id-numero. Koodi näyttää, miten metodin execute kutsumisen yhteydessä annetaan parametrien arvot listana.

Tässä tapauksessa metodi fetchone palauttaa tuplen, jossa on yksi alkio. Tämän alkion sisältö saadaan muuttujaan []-syntaksilla, jossa 0 tarkoittaa tuplen ensimmäisen alkion hakemista.

Koodin heikkoutena on vielä, että se ei ota huomioon tilannetta, jossa tietokannassa ei ole asemaa annetulla id-numerolla:

Anna aseman id-numero: 666
Traceback (most recent call last):
  File "test.py", line 7, in <module>
    station_name = result.fetchone()[0]
                   ~~~~~~~~~~~~~~~~~^^^
TypeError: 'NoneType' object is not subscriptable

Tässä tilanteessa metodi fetchone palauttaa arvon None, jolloin ei ole mahdollista hakea tuplen ensimmäistä alkiota. Voimme käsitellä tämän tilanteen esimerkiksi seuraavasti try/except-rakenteen avulla:

import sqlite3

db = sqlite3.connect("bikes_2024.db")

station_id = input("Anna aseman id-numero: ")
result = db.execute("SELECT name FROM Stations WHERE id = ?", [station_id])

try:
    station_name = result.fetchone()[0]
    print("Aseman nimi:", station_name)
except TypeError:
    print("Asemaa ei löytynyt")

Nyt koodi antaa selkeän viestin siitä, ettei asemaa löytynyt:

Anna aseman id-numero: 666
Asemaa ei löytynyt

Tässä on vielä toinen toteutustapa, jossa tietokantahaku suoritetaan funktion find_station_name kautta. Tämä funktio palauttaa aseman nimen tai merkkijonon --, jos asemaa ei löytynyt.

import sqlite3

db = sqlite3.connect("bikes_2024.db")

def find_station_name(station_id):
    result = db.execute("SELECT name FROM Stations WHERE id = ?",
                        [station_id])
    result_row = result.fetchone()
    return result_row[0] if result_row else "--"

station_id = input("Anna aseman id-numero: ")

station_name = find_station_name(station_id)
print("Aseman nimi:", station_name)

Nyt koodi toimii seuraavasti:

Anna aseman id-numero: 42
Aseman nimi: Haapaniemenkatu
Anna aseman id-numero: 666
Aseman nimi: --

Esimerkki: Kohdeasemat

Seuraava koodi kysyy käyttäjältä lähtöaseman ja päivämäärän ja etsii sitten kaikki kohdeasemat, joihin päättyi lähtöasemalta alkanut matka annettuna päivänä.

import sqlite3

db = sqlite3.connect("bikes_2024.db")

def find_destinations(station_name, date):
    sql = """SELECT
                 DISTINCT B.name
             FROM
                 Stations AS A, Stations AS B, Trips AS T
             WHERE
                 T.start_station_id = A.id AND
                 T.end_station_id = B.id AND
                 A.name = ? AND
                 T.start_time LIKE ?
             ORDER BY
                 B.name"""
    result = db.execute(sql, [station_name, date + "%"])
    return [row[0] for row in result.fetchall()]

station_name = input("Anna aseman nimi: ")
date = input("Anna päivämäärä: ")

destinations = find_destinations(station_name, date)
print("Number of destinations:", len(destinations))
for destination in destinations:
    print(destination)

Tässä on esimerkki koodin toiminnasta:

Anna aseman nimi: Syystie
Anna päivämäärä: 2024-05-16
Number of destinations: 5
A.I. Virtasen aukio
Ala-Malmin tori
Huhtakuja
Pukinmäen asema
Vanha Tapanilantie

Tässä tarvittava SQL-komento on monimutkainen, minkä takia se on jaettu koodissa usealle riville. Tässä on kätevä Pythonin """-syntaksi, jonka avulla pystyy määrittelemään usean rivin merkkijonon.

Komennolle annetaan kaksi parametria, jotka sijoitetaan merkkien ? kohdalle siinä järjestyksessä kuin ne on annettu listassa. Listan ensimmäinen alkio menee ensimmäisen merkin ? kohdalle ja toinen alkio menee toisen merkin ? kohdalle. Koska parametrit ovat merkkijonoja, ne sijoitetaan '-merkkien sisään SQL:ssä.

Tiettynä päivänä alkavat matkat saadaan etsittyä LIKE-syntaksin avulla rajoittamalla hakua niin, että sarakkeen start_time arvon alussa tulee olla annettu päivämäärä. Merkki % ilmaisee, että päivämäärän jälkeen voi tulla mikä tahansa kellonaika.

SQLite R-kielessä

R:ssä SQLite-tietokantaa käytetään tyypillisesti kirjaston RSQLite avulla, jonka voi asentaa komennolla install.packages("RSQLite"). Seuraava koodi muodostaa yhteyden tietokantaan ja hakee taulusta Stations tietoa SQL-kyselyillä:

library(RSQLite)

db <- dbConnect(SQLite(), "bikes_2024.db")

result <- dbGetQuery(db, "SELECT id, name FROM Stations WHERE id = 5")
print(result)

result <- dbGetQuery(db, "SELECT id, name FROM Stations ORDER BY id LIMIT 10")
print(result)

Koodin tulostus on seuraava:

  id      name
1  5 Sepänkatu

   id               name
1   1        Kaivopuisto
2   2    Laivasillankatu
3   3 Kapteeninpuistikko
4   4          Viiskulma
5   5          Sepänkatu
6   6    Hietalahdentori
7   7        Designmuseo
8   8 Vanha kirkkopuisto
9   9    Erottajan aukio
10 10        Kasarmitori

Tässä db on tietokantaolio, jonka kautta voidaan suorittaa SQL-komentoja funktiolla dbGetQuery. Tässä koodissa suoritetaan kaksi SELECT-komentoa.

Ensimmäinen SELECT-komento hakee taulusta Stations rivin, jonka id-numero on 5. Toinen SELECT-komento hakee taulusta Stations kymmenen ensimmäistä riviä.

Tietokantatiedoston sijainti

Tyypillinen ongelma tietokantaa käyttävässä koodissa on, että tietokantatiedosto sijaitsee eri paikassa tietokoneella kuin koodi olettaa sen sijaitsevan. Jos et saa yllä olevaa koodia toimimaan, syy on luultavasti tässä.

Jos koodissa viitattua tietokantatiedostoa ei ole olemassa, koodi luo uuden tyhjän tietokantatiedoston. Koska tässä tietokannassa ei ole taulua Stations, yritys hakea tietoa taulusta epäonnistuu.

RStudiossa hakemiston voi määrittää valikosta Session -> Set Working Directory -> To Source File Location. Tämän jälkeen koodin pitäisi toimia, jos tietokantatiedosto on samassa hakemistossa kuin kooditiedosto.

Esimerkki: Aseman nimi

Seuraava koodi kysyy käyttäjältä aseman id-numeroa ja hakee sen perusteella aseman nimen tietokannasta:

library(RSQLite)

db <- dbConnect(SQLite(), "bikes_2024.db")

station_id <- readline(prompt = "Anna aseman id-numero: ")
result <- dbGetQuery(db,
                     "SELECT name FROM Stations WHERE id = ?",
                     params = list(station_id))

station_name <- result$name[1]
cat("Aseman nimi:", station_name, "\n")

Koodin suoritus voi näyttää seuraavalta:

Anna aseman id-numero: 42
Aseman nimi: Haapaniemenkatu

Tässä kyselyn osana on parametri ?, jonka arvoksi tulee muuttujassa station_id oleva id-numero. Koodi näyttää, miten funktion dbGetQuery kutsumisen yhteydessä annetaan parametrien arvot listana.

Tässä syntaksi result$name[1] tarkoittaa, että haetaan sarakkeen name arvo tulostaulun rivillä 1 eli tässä tapauksessa ainoalla rivillä.

Jos tietokannassa ei ole asemaa annetulla id-numerolla, koodi antaa seuraavan tuloksen (NA tarkoittaa puuttuvaa arvoa):

Anna aseman id-numero: 666
Aseman nimi: NA

Voimme antaa tässä tilanteessa selkeämmän viestin asiasta esimerkiksi tarkastamalla tulostaulun rivien määrän:

library(RSQLite)

db <- dbConnect(SQLite(), "bikes_2024.db")

station_id <- readline(prompt = "Anna aseman id-numero: ")

result <- dbGetQuery(db,
                     "SELECT name FROM Stations WHERE id = ?",
                     params = list(station_id))

if (nrow(result) == 1) {
  station_name <- result$name[1]
  cat("Aseman nimi:", station_name, "\n")
} else {
  cat("Asemaa ei löytynyt\n")
}

Nyt koodi antaa selkeän viestin siitä, ettei asemaa löytynyt:

Anna aseman id-numero: 666
Asemaa ei löytynyt

Tässä on vielä toinen toteutustapa, jossa tietokantahaku suoritetaan funktion find_station_name kautta. Tämä funktio palauttaa aseman nimen tai merkkijonon --, jos asemaa ei löytynyt.

library(RSQLite)

db <- dbConnect(SQLite(), "bikes_2024.db")

find_station_name <- function(station_id) {
  result <- dbGetQuery(db,
                       "SELECT name FROM Stations WHERE id = ?",
                       params = list(station_id))
  if (nrow(result) == 1) result$name[1] else "--"
}

station_id <- readline(prompt = "Anna aseman id-numero: ")
station_name <- find_station_name(station_id)
cat("Aseman nimi:", station_name, "\n")

Nyt koodi toimii seuraavasti:

Anna aseman id-numero: 42
Aseman nimi: Haapaniemenkatu
Anna aseman id-numero: 666
Aseman nimi: --

Esimerkki: Kohdeasemat

Seuraava koodi kysyy käyttäjältä lähtöaseman ja päivämäärän ja etsii sitten kaikki kohdeasemat, joihin päättyi lähtöasemalta alkanut matka annettuna päivänä.

library(RSQLite)

db <- dbConnect(SQLite(), "bikes_2024.db")

find_destinations <- function(station_name, date) {
  sql <- "
    SELECT DISTINCT B.name
    FROM Stations AS A, Stations AS B, Trips AS T
    WHERE
      T.start_station_id = A.id AND
      T.end_station_id = B.id AND
      A.name = ? AND
      T.start_time LIKE ?
    ORDER BY B.name
  "
  res <- dbGetQuery(db, sql, params = list(station_name, paste0(date, "%")))
  res$name
}

station_name <- readline(prompt = "Anna aseman nimi: ")
date <- readline(prompt = "Anna päivämäärä: ")

destinations <- find_destinations(station_name, date)
cat("Number of destinations:", length(destinations), "\n")
for (destination in destinations) {
  cat(destination, "\n")
}

Tässä on esimerkki koodin toiminnasta:

Anna aseman nimi: Syystie
Anna päivämäärä: 2024-05-16
Number of destinations: 5
A.I. Virtasen aukio
Ala-Malmin tori
Huhtakuja
Pukinmäen asema
Vanha Tapanilantie

Komennolle annetaan kaksi parametria, jotka sijoitetaan merkkien ? kohdalle siinä järjestyksessä kuin ne on annettu listassa. Listan ensimmäinen alkio menee ensimmäisen merkin ? kohdalle ja toinen alkio menee toisen merkin ? kohdalle. Koska parametrit ovat merkkijonoja, ne sijoitetaan '-merkkien sisään SQL:ssä.

Tiettynä päivänä alkavat matkat saadaan etsittyä LIKE-syntaksin avulla rajoittamalla hakua niin, että sarakkeen start_time arvon alussa tulee olla annettu päivämäärä. Funktio paste0 lisää päivämäärän perään merkin %, joka ilmaisee, että päivämäärän jälkeen voi tulla mikä tahansa kellonaika.

Mitä tehdä missäkin?

Tietokannan ja koodin puolella voi usein tehdä samantapaisia asioita. Esimerkiksi seuraavassa on kaksi tapaa etsiä pisin matka tietokannasta:

result = db.execute("SELECT MAX(distance) FROM Trips")
max_distance = result.fetchone()[0]
print(max_distance)
result = db.execute("SELECT distance FROM Trips")
max_distance = max(result.fetchall())[0]
print(max_distance)

Ensimmäisessä tavassa haetaan pisin matka tietokannan puolella SQL:n MAX-funktiolla. Toisessa tavassa haetaan tietokannasta kaikkien matkojen pituudet listaan ja etsitään sitten koodin puolella listan pisin matka Pythonin max-funktiolla.

Näistä kahdesta tavasta ensimmäinen tapa on selkeästi parempi: ei ole hyvä hakea turhaa tietoa koodin puolelle ja tehdä käsittelyä, jonka voi tehdä helposti myös tietokannassa.

Erityisesti kannattaa välttää tilannetta, jossa suoritetaan turhaan useita SQL-komentoja, vaikka vain yksi komento riittäisi. Esimerkiksi seuraavassa on huono tapa hakea tietokannasta jokaisesta asemasta matkojen määrä:

result = db.execute("SELECT id, name FROM Stations")
stations = result.fetchall()

for station_id, station_name in stations:
    result = db.execute("""SELECT COUNT(*) FROM Trips
                           WHERE start_station_id = ?""",
                        [station_id])
    trip_count = result.fetchone()[0]
    print(station_name, trip_count)

Koodi hakee ensin listaan kunkin aseman id-numeron ja nimen. Tämän jälkeen koodi hakee silmukassa jokaisesta asemasta erikseen niiden matkojen määrän, jotka alkoivat kyseiseltä asemalta. Koodi on kyllä toimiva mutta se tekee valtavasti turhaa työtä hakiessaan jokaisen tiedon erikseen. Parempi ratkaisu on muodostaa yksi kysely, joka hakee suoraan kaiken tarvittavan:

sql = """SELECT S.name, COUNT(*)
         FROM Stations AS S
           LEFT JOIN Trips AS T ON S.id = T.start_station_id
         GROUP BY S.id"""

data = db.execute(sql).fetchall()
for station_name, trip_count in data:
    print(station_name, trip_count)

Tuloksena oleva kysely on monimutkaisempi, mutta sen avulla tietokantajärjestelmä voi optimoida kokonaisuutena halutun tiedon hakemisen ja toimittaa tiedon mahdollisimman tehokkaasti koodille.

6. Tietokannan suunnittelu

Suunnittelun periaatteet

Tietokannan suunnittelussa tulee päättää tietokannan rakenne: mitä tauluja tietokannassa on sekä mitä sarakkeita kussakin taulussa on. Tähän on sinänsä suuri määrä mahdollisuuksia, mutta tuntemalla muutaman periaatteen pääsee pitkälle.

Hyvä tavoite suunnittelussa on, että tuloksena olevaa tietokantaa on mukavaa käyttää SQL-kielen avulla. Tietokannan rakenteen tulisi olla sellainen, että tietoa pystyy hakemaan ja muuttamaan näppärästi SQL-komennoilla.

Tietokannan suunnittelun periaatteet ovat hyödyllisiä ja johtavat usein toimiviin ratkaisuihin. Kuitenkin kannattaa miettiä, mikä periaatteissa on taustalla ja milloin kannattaa mahdollisesti tehdä toisin. Hyvä tavoite on luoda käyttötarkoitukseen sopiva tietokanta eikä noudattaa periaatteita ilman omaa ajattelua.

Taulu vs. luokka

Tietokannan taulun määrittely kuvaa, minkä tyyppistä tietoa tauluun voidaan tallentaa. Esimerkiksi seuraavassa taulussa Movies jokainen taulun rivi sisältää elokuvan nimen ja julkaisuvuoden:

CREATE TABLE Movies (
  id INTEGER PRIMARY KEY,
  name TEXT,
  release_year INTEGER
);

INSERT INTO Movies (name, release_year) VALUES ('Lumikki', 1937);
INSERT INTO Movies (name, release_year) VALUES ('Fantasia' 1940);
INSERT INTO Movies (name, release_year) VALUES ('Pinocchio', 1940);

Monissa ohjelmointikielissä luokan määrittely kuvaa, minkä tyyppistä tietoa oliot sisältävät. Esimerkiksi seuraava Python-koodi määrittelee luokan Movie, joka sisältää elokuvan nimen ja julkaisuvuoden. Tämän jälkeen koodi lisää olioita listaan.

@dataclass
class Movie:
    name: str
    release_year: int

movies = []
movies.append(Movie("Lumikki", 1937))
movies.append(Movie("Fantasia", 1940))
movies.append(Movie("Pinocchio", 1940))

Tietokannan taulun määrittely muistuttaa siis ohjelmoinnin luokkaa, ja yksittäinen taulun rivi on lähellä luokasta luotua oliota.

Yksi vai useita tauluja?

Ohjelmoinnissa kaikki saman tyyppiset oliot perustuvat samaan luokkaan, ja vastaavasti tietokannassa kaikki saman tyyppiset rivit ovat yhdessä taulussa. Tämän ansiosta voimme käsitellä rivejä kätevästi SQL-komennoilla.

Esimerkiksi jos tietokannassa on elokuvia, hyvä ratkaisu on tallentaa kaikki elokuvat samaan tauluun Movies:

id  name       release_year
--  ---------  ------------
1   Lumikki    1937        
2   Fantasia   1940        
3   Pinocchio  1940        
4   Dumbo      1941        
5   Bambi      1942        

Tästä taulusta voimme hakea esimerkiksi vuoden 1940 elokuvat näin:

SELECT name FROM Movies WHERE release_year = 1940;

Mutta mitä kävisi, jos jakaisimmekin elokuvat moneen tauluun? Esimerkiksi voisimme jakaa elokuvat tauluihin vuosien mukaan. Tällöin taulussa Movies1940 olisi vuoden 1940 elokuvat, ja voisimme hakea ne näin:

SELECT name FROM Movies1940;

Tällainen ratkaisu toimii niin kauan, kuin haluamme hakea vain tietyn vuoden elokuvia. Kuitenkin tietokanta muuttuu vaikeakäyttöiseksi heti, jos haluamme tehdä jotain muita hakuja. Esimerkiksi jos haluamme hakea kaikki elokuvat vuosilta 1940–1950, tarvitsemme useita kyselyjä:

SELECT name FROM Movies1940;
SELECT name FROM Movies1941;
SELECT name FROM Movies1942;
...
SELECT name FROM Movies1950;

Kuitenkin kun elokuvat ovat samassa taulussa, niin selviämme yhdellä kyselyllä:

SELECT name FROM Movies WHERE release_year BETWEEN 1940 AND 1950;

Kun elokuvat ovat yhdessä taulussa, pystymme käsittelemään niitä monipuolisesti yksittäisillä SQL-komennoilla, mikä ei olisi mahdollista, jos tauluja olisi useita.

Viittausten toteutus

Yksi moneen -suhde

Tarkastellaan tietokantaa, johon tallennetaan kursseja ja opettajia. Taulujen välillä on yksi moneen -suhde: jokaisella kurssilla on yksi opettaja, kun taas yhdellä opettajalla voi olla monta kurssia. Voimme luoda tietokannan taulut näin:

CREATE TABLE Teachers (
  id INTEGER PRIMARY KEY,
  name TEXT
);

CREATE TABLE Courses (
  id INTEGER PRIMARY KEY,
  name TEXT,
  teacher_id INTEGER REFERENCES Teachers
);

Taulussa Courses sarake teacher_id viittaa tauluun Teachers, eli siinä on jonkun opettajan id-numero. Viittaus ilmaistaan REFERENCES-määreellä, joka kertoo, että sarakkeessa oleva kokonaisluku viittaa tauluun Teachers.

Voisimme laittaa tauluihin tietoa vaikkapa seuraavasti:

INSERT INTO Teachers (name) VALUES ('Kaila');
INSERT INTO Teachers (name) VALUES ('Kivinen');
INSERT INTO Teachers (name) VALUES ('Laaksonen');

INSERT INTO Courses (name, teacher_id) VALUES ('Tietoverkot', 1);
INSERT INTO Courses (name, teacher_id) VALUES ('Graduseminaari', 1);
INSERT INTO Courses (name, teacher_id) VALUES ('PHP-ohjelmointi', 3);
INSERT INTO Courses (name, teacher_id) VALUES ('Neuroverkot', 2);

Monta moneen -suhde

Tarkastellaan sitten tilannetta, jossa useampi opettaja voi järjestää kurssin yhteisesti. Tällöin kyseessä on monta moneen -suhde, koska kurssilla voi olla monta opettajaa ja opettajalla voi olla monta kurssia.

Nyt taulun Teachers rivi voi liittyä useaan taulun Courses riviin, ja vastaavasti taulun Courses rivi voi liittyä useaan taulun Teachers riviin. Koska tietokannan rivi ei voi sisältää listaa viittauksista, emme voi lisätä viittauksia suoraan kumpaankaan tauluun, vaan meidän täytyy luoda uusi taulu viittauksille:

CREATE TABLE Teachers (
  id INTEGER PRIMARY KEY,
  name TEXT
);

CREATE TABLE Courses (
  id INTEGER PRIMARY KEY,
  name TEXT
);

CREATE TABLE CourseTeachers (
  course_id INTEGER REFERENCES Courses,
  teacher_id INTEGER REFERENCES Teachers
);

Muutoksena aiempaan on, että taulussa Courses ei ole enää viittausta tauluun Teachers, mutta sen sijaan tietokannassa on uusi taulu CourseTeachers, joka viittaa kumpaankin tauluun. Jokainen rivi tässä taulussa kuvaa yhden suhteen muotoa “kurssilla id opettaa opettaja id”.

Esimerkiksi voisimme ilmaista näin, että kurssilla on kaksi opettajaa:

INSERT INTO Teachers (name) VALUES ('Laaksonen');
INSERT INTO Teachers (name) VALUES ('Luukkainen');

INSERT INTO Courses (name) VALUES ('PHP-ohjelmointi');
INSERT INTO Courses (name) VALUES ('Neuroverkot');

INSERT INTO CourseTeachers VALUES (1, 1);
INSERT INTO CourseTeachers VALUES (1, 2);
INSERT INTO CourseTeachers VALUES (2, 1);

Tämä tarkoittaa, että Laaksonen ja Luukkainen opettavat kurssilla PHP-ohjelmointi. Lisäksi Laaksonen opettaa kurssilla Neuroverkot.

Huomaa, että voisimme käyttää tätä ratkaisua myös aiemmassa tilanteessa, jossa kurssilla on aina tasan yksi opettaja. Tässä tapauksessa tietokannassa olisi kuitenkin tavallaan turha taulu.

Tiedon atomisuus

Periaate: Tietokannan taulun jokaisessa sarakkeessa on yksittäinen eli atominen tieto, kuten yksi luku tai yksi merkkijono. Sarakkeessa ei saa olla listaa tiedoista.

Tämä periaate helpottaa tietokannan käsittelyä SQL-komentojen avulla: kun jokainen tieto on omassa sarakkeessaan, tietoon pystyy viittaamaan kätevästi.

Tietokantaan voidaan tallentaa lista luomalla taulu, jonka jokainen rivi on listan yksittäinen alkio, kuten äskeinen taulu CourseTeachers. Mutta miksi emme voisi vain tallentaa listaa yhteen sarakkeeseen? Seuraava esimerkki selventää asiaa.

Esimerkki

Vaihe 1

Haluamme tallentaa tietokantaan opiskelijoiden tenttituloksia. Tentissä on neljä tehtävää, joista voi saada 0–6 pistettä. Voisimme koettaa tallentaa pisteet näin:

student_id  points 
----------  -------
1           6,5,1,4
2           3,6,6,6
3           6,4,0,6

Ideana on, että sarakkeessa points on merkkijono, jossa on lista pisteistä pilkuilla erotettuina. Tämä ratkaisu kuitenkin rikkoo periaatetta, että jokaisessa sarakkeessa on yksittäinen tieto. Mitä vikaa ratkaisussa on?

Ratkaisun ongelmana on, että meidän on vaivalloista koettaa päästä pisteisiin käsiksi SQL-komennoissa, koska pisteet ovat merkkijonon sisällä. Esimerkiksi jos haluamme laskea jokaisen opiskelijan yhteispisteet, tarvitsemme seuraavan tapaisen kyselyn:

SELECT student_id,
       SUBSTR(points, 1, 1) + SUBSTR(points, 3, 1) +
       SUBSTR(points, 5, 1) + SUBSTR(points, 7, 1) AS total_points
FROM Results;

Tässä funktio SUBSTR erottaa merkkijonosta tietyssä kohdassa olevan osajonon. Kysely on kuitenkin hankala ja lisäksi toimii vain, kun pisteitä on tasan neljä ja ne ovat yksinumeroisia. Tarvitsemme paremman tavan tallentaa pisteet.

Vaihe 2

Seuraavassa taulussa pisteille on neljä saraketta, jolloin voimme käsitellä niitä yksitellen:

student_id  points1  points2  points3  points4
----------  -------  -------  -------  -------
1           6        5        1        4
2           3        6        6        6
3           6        4        0        6

Tämän ansiosta saamme jo toteutettua kyselyn mukavammin:

SELECT student_id,
       points1 + points2 + points3 + points4 AS total_points
FROM Results;

Tämä ratkaisu on selkeästi parempaan suuntaan, mutta siinä on edelleen ongelmia. Vaikka pisteet ovat eri sarakkeissa, oletuksena on edelleen, että tehtäviä on tasan neljä. Jos tehtävien määrä muuttuu, joudumme muuttamaan taulun rakennetta ja kaikkia pisteisiin liittyviä SQL-komentoja, mikä ei ole hyvä tilanne.

Vaihe 3

Kun haluamme tallentaa listan tietokantaan, hyvä ratkaisu on tallentaa jokainen listan alkio omalle rivilleen. Tämän esimerkin tapauksessa voimme luoda taulun, jonka jokainen rivi ilmaisee tietyn opiskelijan pisteet tietyssä tehtävässä:

student_id  task_id  points
----------  -------  ------
1           1        6     
1           2        5     
1           3        1     
1           4        4     
2           1        3     
2           2        6     
2           3        6     
2           4        6     
3           1        6     
3           2        4     
3           3        0     
3           4        6     

Nyt voimme hakea kunkin opiskelijan yhteispisteet näin:

SELECT student_id, SUM(points) AS total_points
FROM Results
GROUP BY student_id;

Tämä on yleiskäyttöinen kysely eli se toimii yhtä hyvin riippumatta tehtävien määrästä. Pystymme hyödyntämään summan laskemisessa funktiota SUM sen sijaan, että meidän tulisi luetella kaikki tehtävät itse.

Huomaa, että muutoksen seurauksena taulun rivien määrä kasvoi selvästi. Tätä ei kannata kuitenkaan hätkähtää: tietokantajärjestelmät on toteutettu niin, että ne toimivat hyvin, vaikka taulussa olisi paljon rivejä.

Mikä on atomista tietoa?

Atomisen tiedon käsite ei ole hyvin määritelty. Selkeästi lista ei ole atominen tieto, mutta onko sitten vaikka merkkijonokaan, jossa on useita sanoja?

Tarkastellaan esimerkkinä tilannetta, jossa taulun sarakkeessa on käyttäjän nimi. Onko tämä huonoa suunnittelua, koska samassa sarakkeessa on etu- ja sukunimi?

id  name          
--  --------------
1   Anna Virtanen 
2   Maija Korhonen
3   Pasi Lahtinen 

Voisimme myös tallentaa etu- ja sukunimen erikseen näin:

id  first_name  last_name
--  ----------  ---------
1   Anna        Virtanen 
2   Maija       Korhonen 
3   Pasi        Lahtinen 

Riippuu tilanteesta, kumpi taulu on parempi. Jos järjestelmässä on erityisesti tarvetta etsiä tietoa etu- tai sukunimen perusteella (esimerkiksi etsiä kaikki käyttäjät, joiden etunimi on Anna), jälkimmäinen taulu on parempi. Kuitenkaan usein ei ole näin eikä ole mitään pahaa tallentaa samaan sarakkeeseen etu- ja sukunimi.

Vastaavasti jos tietokantaan tallennetaan käyttäjän lähettämä viesti, siinä voi olla monia sanoja eli tavallaan viesti on lista sanoja, mutta on silti hyvä ratkaisu tallentaa koko viesti yhteen sarakkeeseen, koska viestiä käsitellään tietokannassa yhtenä kokonaisuutena. Olisi hyvin huono ratkaisu jakaa “atomisesti” viestin sanat omiin sarakkeisiin.

Kannattaakin ajatella asiaa niin, että jos jotain tietoa on tarvetta käsitellä erillisenä SQL-komennoissa, niin se on atominen tieto, jonka tulee olla omassa sarakkeessa. Jos taas tietoon ei viitata SQL-komennoissa, se voi olla sarakkeessa osana laajempaa kokonaisuutta.

Toisteinen tieto

Periaate: Jokainen tieto on tasan yhdessä paikassa tietokannassa. Tietokannassa ei ole tietoa, jonka voi laskea tai päätellä tietokannan muun sisällön perusteella.

Tätä periaatetta seuraamalla tietokannan sisällön päivittäminen on helppoa, koska päivitys riittää tehdä yhteen paikkaan eikä se vaikuta tietokannan muihin osiin.

Esimerkki 1

Tallennamme järjestelmään käyttäjien lähettämiä viestejä seuraavasti tauluun Messages:

id  user        message       
--  ----------  --------------
1   Anna123     Missä olet?   
2   Joulupukki  Bussissa vielä
3   Anna123     Meneekö kauan?
4   Joulupukki  5 min         

Tämä on muuten toimiva ratkaisu, mutta tietokannan sisältöä on hankalaa päivittää, jos käyttäjä päättää vaihtaa nimeään. Esimerkiksi jos Anna123 haluaa muuttaa nimeään, muutos täytyy tehdä jokaiseen viestiin, jonka hän on lähettänyt.

Parempi ratkaisu on toteuttaa tietokanta niin, että käyttäjän nimi on vain yhdessä paikassa. Luonteva paikka tälle on taulu Users, joka sisältää käyttäjät:

id  name      
--  ----------
1   Anna123   
2   Joulupukki

Muissa tauluissa on vain viitteenä käyttäjän id-numero, joka on muuttumaton tieto. Esimerkiksi taulu Messages näyttää nyt tältä:

id  user_id  message       
--  -------  --------------
1   1        Missä olet?   
2   2        Bussissa vielä
3   1        Meneekö kauan?
4   2        5 min         

Tämän jälkeen käyttäjän nimen muuttaminen on helppoa, koska muutos riittää tehdä taulun Users yhteen riviin ja muutos päivittyy heti kaikkialle, koska muissa tauluissa viitataan edelleen oikeaan riviin.

Tämä monimutkaistaa kyselyjä, koska tietoa täytyy hakea useista tauluista, mutta ratkaisu on kuitenkin kokonaisuuden kannalta hyvä.

Vieläkin toisteisuutta?

Äskeisestä muutoksesta huolimatta tietokannassa saattaa esiintyä edelleen toisteisuutta. Esimerkiksi seuraavassa tilanteessa käyttäjät lähettävät samanlaisen viestin “Hei!”. Pitäisikö tietokannan rakennetta parantaa?

id  user_id  message
--  -------  -------
1   1        Hei!   
2   2        Hei!   

Tässä tapauksessa ei olisi hyvä idea toteuttaa tietokantaa niin, että jos kaksi käyttäjää lähettää saman sisältöisen viestin, viestin sisältö tallennetaan vain yhteen paikkaan.

Vaikka viesteissä on sama sisältö, ne ovat erillisiä viestejä, joiden ei ole tarkoitus viitata samaan asiaan. Jos käyttäjä 1 muuttaa viestin sisältöä, muutoksen ei tule heijastua käyttäjän 2 viestiin, vaikka siinä sattuu olemaan tällä hetkellä sama sisältö.

Esimerkki 2

Tallennamme tietokantaan tietoa opiskelijoiden suorituksista. Tietokannasta voidaan kysyä, montako opintopistettä opiskelija on suorittanut.

Seuraavassa tietokannassa jokaisen opiskelijan yhteyteen on tallennettu tieto, montako opintopistettä hän on suorittanut. Taulun Students sisältönä on:

id  name    total_credits
--  ------  -------------
1   Maija   20           
2   Uolevi  10           

Taulussa Completions puolestaan on seuraavat rivit:

id  student_id  course_id  credits
--  ----------  ---------  -------
1   1           1          5      
2   1           2          5      
3   1           4          10     
4   2           1          5      
5   2           3          5      

Voimme hakea helposti opiskelijan opintopisteiden yhteismäärän näin:

SELECT total_credits FROM Students WHERE name = 'Maija';

Kuitenkin tietokannassa on toisteista tietoa: taulun Students sarakkeen total_credits sisältö voidaan laskea taulun Completions avulla. Esimerkiksi Maijan opintopisteiden määrä 20 taulussa Students voidaan myös laskea summana 5 + 5 + 10 taulusta Completions.

Ongelmana on, että suoritusta lisätessä täytyy sekä lisätä uusi rivi tauluun Completions että päivittää opintopisteiden yhteismäärä tauluun Students. Jos päivitys unohtuu tehdä tai epäonnistuu, tietokantaan tulee ristiriitaista tietoa.

Pääsemme eroon toisteisesta tiedosta poistamalla sarakkeen total_credits taulusta Students:

id  name  
--  ------
1   Maija 
2   Uolevi

Tämän muutoksen seurauksena on vaikeampaa selvittää opiskelijan opintopisteiden yhteismäärä, koska tieto täytyy laskea suorituksista lähtien:

SELECT SUM(Completions.credits) AS total_credits
FROM Completions, Students
WHERE Completions.student_id = Students.id AND Students.name = 'Maija';

Tämä on kuitenkin kokonaisuutena hyvä muutos, koska nyt voimme huoletta muuttaa suorituksia taulussa Completions ja luottaa siihen, että saamme aina laskettua ajantasaisen tiedon opiskelijan opintopisteistä.

Muutokset vs. kyselyt

Vaikka ihanteena on, että tietokannassa ei ole toisteista tietoa, joskus kuitenkin toisteista tietoa tarvitaan hakujen tehostamiseksi. Toisteinen tieto vaikeuttaa tietokannan muuttamista mutta helpottaa kyselyjen tekemistä.

Usein esiintyvä ilmiö tietojenkäsittelytieteessä on, että joudumme tasapainoilemaan sen kanssa, haluammeko muuttaa vai hakea tehokkaasti tietoa ja paljonko tilaa voimme käyttää. Tämä tulee tietokantojen lisäksi vastaan esimerkiksi algoritmien suunnittelussa.

Jos tietokannassa ei ole toisteista tietoa, muutokset ovat helppoja, koska jokainen tieto on vain yhdessä paikassa eli riittää muuttaa vain yhden taulun yhtä riviä. Toinen hyvä puoli on, että toisteinen tieto ei vie tilaa tietokannassa. Toisaalta kyselyt voivat olla monimutkaisia ja hitaita, koska halutut tiedot pitää kerätä kasaan eri puolilta tietokantaa.

Toisteista tietoa lisäämällä pystymme nopeuttamaan kyselyjä mutta toisaalta muutokset hankaloituvat, koska muutettu tieto pitää päivittää useaan paikkaan. Samaan aikaan myös tietokannan tilankäyttö kasvaa toisteisen tiedon takia.

Ei ole mitään yleistä sääntöä, paljonko toisteista tietoa kannattaa lisätä, vaan tämä riippuu tietokannan sisällöstä ja halutuista kyselyistä. Yksi hyvä tapa on aloittaa tilanteesta, jossa toisteista tietoa ei ole, ja lisätä sitten toisteista tietoa tarvittaessa, jos kyselyt eivät muuten ole riittävän tehokkaita.

Suunnitteluesimerkki

Tarkastellaan lopuksi laajempaa esimerkkiä, jossa tavoitteemme on suunnitella tietokanta yliopiston luentosalien varaamista varten. Tietokannan tulee mahdollistaa seuraavat toiminnot:

Suunnittelun vaiheet

Tietokannan suunnittelu etenee yleensä pikkuhiljaa niin, että tietokantaan lisätään uusia tauluja ja sarakkeita aina, kun uusissa toiminnoissa on tarvetta niille.

Seuraavaksi näemme, miten esimerkkitietokanta rakentuu vaihe vaiheelta vaadittujen toimintojen perusteella.

Kirjautuminen järjestelmään

Tämä on tavallinen toiminto, josta on hyvä aloittaa tietokannan suunnittelu. Tarvitsemme taulun, jossa on käyttäjän tunnus ja salasana:

CREATE TABLE Users (
  id INTEGER PRIMARY KEY,
  username TEXT,
  password TEXT
);

Koska järjestelmässä on kahdenlaisia käyttäjiä (peruskäyttäjät ja ylläpitäjät), tämä tieto tulee tallentaa myös tietokantaan. Kaksi mahdollista ratkaisua ovat:

Kokemus on osoittanut, että ratkaisu 1 on yleensä parempi tietokannan käyttämisen kannalta. Lisätään tauluun Users uusi sarake user_role, joka ilmaisee käyttäjän roolin (esim. 1 = peruskäyttäjä, 2 = ylläpitäjä):

CREATE TABLE Users (
  id INTEGER PRIMARY KEY,
  username TEXT,
  password TEXT,
  user_role INTEGER
);

Varattavan salin tiedot

Hyvä ratkaisu on luoda erilliset taulut Buildings ja Categories, joihin tallennetaan tiedot rakennuksista ja hintaluokista:

CREATE TABLE Buildings (
  id INTEGER PRIMARY KEY,
  name TEXT
);
CREATE TABLE Categories (
  id INTEGER PRIMARY KEY,
  name TEXT,
);

Tämän jälkeen voimme luoda vielä taulun Rooms, joka sisältää tiedot saleista. Tämä taulu viittaa tauluihin Buildings ja Categories.

CREATE TABLE Rooms (
  id INTEGER PRIMARY KEY,
  name TEXT,
  building_id INTEGER REFERENCES Buildings,
  seat_count INTEGER,
  category_id INTEGER REFERENCES Categories
);

Nyt voisimme lisätä järjestelmään seuraavasti tiedot salista A111:

INSERT INTO Buildings (name) VALUES ('Exactum');
INSERT INTO Categories (name) VALUES ('A10');
INSERT INTO Rooms (name, building_id, seat_count, category_id)
       VALUES ('A111', 1, 280, 1);

Varauksen kokonaishinta

Tätä toimintoa varten voimme lisätä tauluun Categories sarakkeen price_per_hour, joka ilmaisee hintaluokan tuntihinnan:

CREATE TABLE Categories (
  id INTEGER PRIMARY KEY,
  name TEXT,
  price_per_hour INTEGER
);

Tämän jälkeen voisimme laskea seuraavasti, paljonko maksaa neljän tunnin varaus saliin A111:

SELECT Categories.price_per_hour * 4 AS total_price
FROM Rooms, Categories
WHERE Rooms.category_id = Categories.id AND Rooms.name = 'A111';

Rakennusten aukioloajat

Rakennukset ovat yleensä avoinna arkipäivinä mutta eivät viikonloppuisin ja pyhäpäivinä. Lisäksi aukioloajat voivat muuttua esimerkiksi kesän ajaksi.

Käytännössä voisi olla vaikeaa määritellä kaikissa tapauksissa toimivaa säännöstöä, joka kertoisi, milloin tietty rakennus on auki. Sen sijaan hyvä ratkaisu on luoda taulu OpeningHours, joka sisältää rivin jokaisen rakennuksen jokaiselle aukiolopäivälle:

CREATE TABLE OpeningHours (
  id INTEGER PRIMARY KEY,
  building_id INTEGER REFERENCES Buildings,
  start_time TIMESTAMP,
  end_time TIMESTAMP
);

Esimerkiksi seuraavat komennot lisäävät tiedot, miten Exactum-rakennus on auki syyskuun ensimmäisellä viikolla vuonna 2025:

INSERT INTO OpeningHours (building_id, start_time, end_time)
       VALUES (1, '2025-09-01 08:00:00', '2025-09-01 20:00:00');
INSERT INTO OpeningHours (building_id, start_time, end_time)
       VALUES (1, '2025-09-02 08:00:00', '2025-09-02 20:00:00');
INSERT INTO OpeningHours (building_id, start_time, end_time)
       VALUES (1, '2025-09-03 08:00:00', '2025-09-03 20:00:00');
INSERT INTO OpeningHours (building_id, start_time, end_time)
       VALUES (1, '2025-09-04 08:00:00', '2025-09-04 20:00:00');
INSERT INTO OpeningHours (building_id, start_time, end_time)
       VALUES (1, '2025-09-05 08:00:00', '2025-09-05 18:00:00');

Sopivan salin etsiminen

Tämä toiminto onnistuu olemassa olevien taulujen avulla. Esimerkiksi seuraava komento etsii Exactumista salia, johon mahtuu ainakin 100 henkilöä:

SELECT Rooms.name
FROM Rooms, Buildings
WHERE Rooms.building_id = Buildings.id AND
      Rooms.seat_count >= 100 AND Buildings.name = 'Exactum';

Varauspyyntö ja vahvistus

Voimme tehdä seuraavan taulun Requests, johon tallennetaan varauspyynnöt. Jokaiseen pyyntöön liittyy käyttäjä, sali, varauksen alku- ja loppuaika sekä kommentti.

CREATE TABLE Requests (
  id INTEGER PRIMARY KEY
  user_id INTEGER REFERENCES Users,
  room_id INTEGER REFERENCES Rooms,
  start_time TIMESTAMP,
  end_time TIMESTAMP,
  comment TEXT
);

Mitä tapahtuu sitten, kun ylläpitäjä hyväksyy varauksen? Yksi ratkaisu olisi tehdä toinen taulu Reservations, joka sisältää hyväksytyt varaukset:

CREATE TABLE Reservations (
  id INTEGER PRIMARY KEY,
  user_id INTEGER REFERENCES Users,
  room_id INTEGER REFERENCES Rooms,
  start_time TIMESTAMP,
  end_time TIMESTAMP
);

Vaikka varauspyyntö ja vahvistettu varaus ovat sinänsä eri asioita, tässä tulisi kuitenkin ongelmaksi, että tauluissa Requests ja Reservations on hyvin samanlaista tietoa. Parempi ratkaisu voisi olla yhdistää nämä taulut esimerkiksi seuraavasti:

CREATE TABLE Reservations (
  id INTEGER PRIMARY KEY,
  user_id INTEGER REFERENCES Users,
  room_id INTEGER REFERENCES Rooms,
  start_time TIMESTAMP,
  end_time TIMESTAMP,
  status INTEGER
);

Tässä sarake status ilmaisee varauksen tilan. Voisimme esimerkiksi päättää, että tila 1 tarkoittaa varauspyyntöä ja tila 2 tarkoittaa vahvistettua varausta.

Tietokannan kuvaaminen

Tietokannan rakenteen kuvaamiseen on kaksi tavallista tapaa: graafinen tietokantakaavio, joka esittää taulujen suhteet, sekä SQL-skeema, jossa on taulujen luontikomennot.

Tietokantakaavio

Tietokantakaavio on tietokannan graafinen esitys, jossa jokainen tietokannan taulu on laatikko, joka sisältää taulun nimen ja sarakkeet listana. Rivien viittaukset toisiinsa esitetään laatikoiden välisinä yhteyksinä.

Tietokantakaavion piirtämiseen on monia vähän erilaisia tapoja. Seuraava kaavio on luotu netissä olevalla työkalulla dbdiagram.io:

SQL-skeema

SQL-skeema sisältää CREATE TABLE -komennot, joiden avulla tietokanta voidaan muodostaa. Seuraava SQL-skeema vastaa tietokantaamme:

CREATE TABLE Users (
  id INTEGER PRIMARY KEY,
  username TEXT,
  password TEXT,
  user_role INTEGER
);

CREATE TABLE Buildings (
  id INTEGER PRIMARY KEY,
  name TEXT
);

CREATE TABLE Categories (
  id INTEGER PRIMARY KEY,
  name TEXT,
  price_per_hour INTEGER
);

CREATE TABLE Rooms (
  id INTEGER PRIMARY KEY,
  name TEXT,
  building_id INTEGER REFERENCES Buildings,
  seat_count INTEGER,
  category_id INTEGER REFERENCES Categories
);

CREATE TABLE OpeningHours (
  id INTEGER PRIMARY KEY,
  building_id INTEGER REFERENCES Buildings,
  start_time TIMESTAMP,
  end_time TIMESTAMP
);

CREATE TABLE Reservations (
  id INTEGER PRIMARY KEY,
  user_id INTEGER REFERENCES Users,
  room_id INTEGER REFERENCES Rooms,
  start_time TIMESTAMP,
  end_time TIMESTAMP,
  status INTEGER
);

7. Tietokannan ominaisuudet

Tiedon eheys

Tiedon eheys tarkoittaa, että tietokannassa oleva tieto on paikkansa pitävää ja ristiriidatonta. Päävastuu tiedon laadusta on tietokantaa muuttavalla käyttäjällä tai sovelluksella, mutta myös tietokannan suunnittelija voi vaikuttaa asiaan lisäämällä tauluihin ehtoja, jotka valvovat tietokantaan syötettävää tietoa.

Sarakkeiden ehdot

Voimme lisätä taulun luonnin yhteydessä sarakkeisiin ehtoja, joita tietokantajärjestelmä valvoo tietoa lisättäessä ja muutettaessa. Näillä ehdoilla voi rajoittaa tietokantaan tulevaa tietoa. Tyypillisiä ehtoja ovat seuraavat:

UNIQUE

Ehto UNIQUE tarkoittaa, että sarakkeessa tulee olla eri arvo joka rivillä. Esimerkiksi seuraavassa taulussa vaatimuksena on, että joka tuotteella on eri nimi:

CREATE TABLE Products (
  id INTEGER PRIMARY KEY,
  name TEXT UNIQUE,
  price INTEGER
);

Ehto UNIQUE voi kohdistua myös useampaan sarakkeeseen, jolloin se merkitään erikseen sarakkeiden jälkeen:

CREATE TABLE Products (
  id INTEGER PRIMARY KEY,
  name TEXT,
  price INTEGER,
  UNIQUE(name, price)
);

Tämä tarkoittaa, että taulussa ei voi olla kahta riviä, joilla on sama nimi ja sama hinta.

NOT NULL ja DEFAULT

Ehto NOT NULL tarkoittaa, että sarakkeessa ei saa olla arvoa NULL. Esimerkiksi seuraavassa taulussa tuotteen hinta ei saa olla tyhjä:

CREATE TABLE Products (
  id INTEGER PRIMARY KEY,
  name TEXT,
  price INTEGER NOT NULL
);

Määre DEFAULT antaa sarakkeelle tietyn oletusarvon, jos sen arvo puuttuu rivin lisäämisessä. Esimerkiksi voimme määrittää hinnalle oletusarvon 0 näin:

CREATE TABLE Products (
  id INTEGER PRIMARY KEY,
  name TEXT,
  price INTEGER DEFAULT 0
);

CHECK

Yleisempi tapa luoda ehto on käyttää avainsanaa CHECK, jonka jälkeen voi kirjoittaa minkä tahansa ehtolausekkeen. Esimerkiksi seuraavassa taulussa ehto price >= 0 tarkoittaa, että hinta ei saa olla negatiivinen:

CREATE TABLE Products (
  id INTEGER PRIMARY KEY,
  name TEXT,
  price INTEGER,
  CHECK (price >= 0)
);

Ehtojen valvonta

Ehtojen hyötynä on, että tietokantajärjestelmä valvoo ehtoja ja kieltäytyy tekemästä niitä rikkovia lisäyksiä tai muutoksia. Seuraavassa on esimerkki tästä SQLitessä:

sqlite> CREATE TABLE Products (id INTEGER PRIMARY KEY,
   ...>                        name TEXT,
   ...>                        price INTEGER,
   ...>                        CHECK (price >= 0));
sqlite> INSERT INTO Products(name, price) VALUES ('retiisi', 4);
sqlite> INSERT INTO Products(name, price) VALUES ('selleri', 7);
sqlite> INSERT INTO Products(name, price) VALUES ('nauris', –2);
Error: CHECK constraint failed: Products
sqlite> SELECT * FROM Products;
1|retiisi|4
2|selleri|7
sqlite> UPDATE Products SET price = –2 WHERE id = 2;
Error: CHECK constraint failed: Products

Kun koetamme lisätä tauluun Products rivin, jossa hinta on negatiivinen, tämä rikkoo ehdon price >= 0 eikä SQLite salli rivin lisäämistä vaan antaa virheen CHECK constraint failed: Products. Samalla tavalla käy, jos koetamme muuttaa olemassa olevan rivin sarakkeen hinnan negatiiviseksi jälkeenpäin.

Ehdot ohjelmoinnissa

Seuraava esimerkki näyttää, miten taulussa olevaa ehtoa voidaan hyödyntää ohjelmoinnissa. Haluamme, että tietokannan jokaisella tuotteella on eri nimi, minkä vuoksi sarakkeessa name on ehtona UNIQUE:

CREATE TABLE Products (
  id INTEGER PRIMARY KEY,
  name TEXT UNIQUE,
  price INTEGER
);

Nyt tuotteen lisäämisen tietokantaan voi toteuttaa näin:

try:
    db.execute("""
               INSERT INTO Products (name, price) VALUES (?, ?)
               """, [name, price])
    print("Tuote lisätty tauluun")
except sqlite3.IntegrityError:
    print("Lisäys ei onnistunut, tuote valmiiksi taulussa")

Tässä tapauksessa komento INSERT epäonnistuu, jos taulussa on valmiina samanniminen tuote. Python-koodissa tilanteen voi tunnistaa siitä, että lisäysyritys aiheuttaa virheen sqlite3.IntegrityError. Niinpä koodi voi yrittää lisätä tuotetta tutkimatta, onko tuote jo valmiiksi taulussa, ja tästä syntyvä mahdollinen virhe ilmaisee, että tuote on lisätty aiemmin.

Tämä on selkeästi parempi toteutus kuin tutkia koodissa ennen lisäämistä SELECT-kyselyllä, onko tuotetta jo taulussa, koska UNIQUE-ehdon avulla tietokanta pitää luotettavasti huolen asiasta ja koodiakin tarvitaan vähemmän.

Jos taulussa ei olisi UNIQUE-ehtoa ja sovellus suorittaisi komennot SELECT ja INSERT, olisi mahdollista, että toinen tietokannan käyttäjä ehtisi lisätä välissä saman tuotteen tauluun, jolloin taulussa olisikin kaksi tuotetta samalla nimellä. Kuitenkaan UNIQUE-ehdon kanssa näin ei voi tapahtua mitenkään.

Viittausten ehdot

Tauluissa oleviin viitauksiin liittyvät ehdot pitävät huolen siitä, että viittaukset viittaavat todellisiin riveihin. Tarkastellaan esimerkkinä seuraavia tauluja:

CREATE TABLE Teachers (
  id INTEGER PRIMARY KEY,
  name TEXT
);

CREATE TABLE Courses (
  id INTEGER PRIMARY KEY,
  name TEXT,
  teacher_id INTEGER
);

Tässä tarkoituksena on, että taulun Courses sarake teacher_id viittaa taulun Teachers sarakkeeseen id. Kuitenkin tietokannan käyttäjä voi antaa sarakkeen teacher_id arvoksi mitä tahansa (esim. luvun 123), jolloin tietokannan sisältö muuttuu epämääräiseksi.

Voimme parantaa tilannetta kertomalla taulun Courses luonnissa REFERENCES-merkinnällä, että sarake teacher_id viittaa tauluun Teachers:

CREATE TABLE Courses (
  id INTEGER PRIMARY KEY,
  name TEXT,
  teacher_id INTEGER REFERENCES Teachers
);

Tämän jälkeen voimme luottaa siihen, että taulussa Courses sarakkeen teacher_id arvot viittaavat todellisiin riveihin taulussa Teachers.

Huomaa, että historiallisista syistä SQLite ei oletuksena valvo viittauksia, vaan meidän tulee ensin suorittaa seuraava komento:

sqlite> PRAGMA foreign_keys = ON;

Tämä on SQLiten erikoisuus, ja muissa tietokannoissa viittauksia valvotaan aina.

Tässä on esimerkki viittauksien valvomisesta:

sqlite> PRAGMA foreign_keys = ON;
sqlite> CREATE TABLE Teachers (id INTEGER PRIMARY KEY, name TEXT);
sqlite> CREATE TABLE Courses (id INTEGER PRIMARY KEY, name TEXT,
   ...>                       teacher_id INTEGER REFERENCES Teachers);
sqlite> INSERT INTO Teachers (name) VALUES ('Kaila');
sqlite> INSERT INTO Teachers (name) VALUES ('Kivinen');
sqlite> SELECT * FROM Teachers;
1|Kaila
2|Kivinen
sqlite> INSERT INTO Courses (name, teacher_id)
   ...>             VALUES ('Laskennan mallit', 2);
sqlite> INSERT INTO Courses (name, teacher_id)
   ...>             VALUES ('PHP-ohjelmointi', 123);
Error: FOREIGN KEY constraint failed   

Taulussa Teachers on kaksi opettajaa, joiden id-numerot ovat 1 ja 2. Niinpä kun koetamme lisätä tauluun Courses rivin, jossa teacher_id on 123, SQLite ei salli tätä vaan saamme virheilmoituksen FOREIGN KEY constraint failed.

Viittaukset ja poistot

Viittausten ehtoihin liittyy tavallisia sarakkeiden ehtoja mutkikkaampia tilanteita, koska viittaukset ovat kahden taulun välisiä. Erityisesti mitä tapahtuu, jos taulusta yritetään poistaa rivi, johon viitataan toisessa taulussa?

Yleensä oletuksena tietokannoissa riviä ei voi poistaa, jos siihen on viittaus muualta. Esimerkiksi jos koetamme äskeisen esimerkin päätteeksi poistaa taulusta Teachers rivin 2, tämä ei onnistu, koska siihen viitataan taulussa Courses:

sqlite> DELETE FROM Teachers WHERE id = 2;
Error: FOREIGN KEY constraint failed

Halutessamme voimme kuitenkin määrittää taulun luonnissa tarkemmin, mitä tällaisessa tilanteessa tapahtuu. Yksi vaihtoehto on ON DELETE CASCADE, mikä tarkoittaa, että rivin poistuessa myös siihen viittaavat rivit poistetaan:

CREATE TABLE Courses (
  id INTEGER PRIMARY KEY,
  name TEXT,
  teacher_id INTEGER REFERENCES Teachers ON DELETE CASCADE
);

Nyt jos tietokannasta poistetaan opettaja, niin samalla poistetaan automaattisesti kaikki kurssit, joita hän opettaa. Tämä voi kuitenkin olla kyseenalainen vaihtoehto, koska tämän seurauksena tietokannan tauluista voi kadota yllättäen tietoa.

Mahdollisia vaihtoehtoja ON DELETE -osassa ovat:

Hämmentävä seikka on, että myös oletusvaihtoehto NO ACTION estää rivin poistamisen, vaikka nimestä voisi päätellä muuta. Vaihtoehdot NO ACTION ja RESTRICT toimivat käytännössä lähes samalla tavalla, mutta tietokannasta riippuen niiden toiminnassa voi olla eroja joissain erikoistilanteissa.

Transaktiot

Transaktio on joukko peräkkäisiä SQL-komentoja, jotka tietokantajärjestelmä lupaa suorittaa yhtenä kokonaisuutena. Tietokannan käyttäjä voi luottaa siihen, että joko (1) kaikki komennot suoritetaan ja muutokset jäävät pysyvästi tietokantaan tai (2) transaktio keskeytyy eivätkä komennot aiheuta mitään muutoksia tietokantaan.

Transaktioiden yhteydessä esiintyy usein ihanteena kirjainyhdistelmä ACID, joka tulee seuraavista sanoista:

Transaktion vaiheet

Itse asiassa transaktio on hyvin arkipäiväinen asia tietokannoissa, sillä oletuksena jokainen suoritettava SQL-komento on oma transaktionsa. Tarkastellaan esimerkiksi seuraavaa komentoa, joka kasvattaa jokaisen tuotteen hintaa yhdellä:

UPDATE Products SET price = price + 1;

Koska komento suoritetaan transaktiona, voimme luottaa siihen, että joko jokaisen tuotteen hinta todella kasvaa yhdellä tai sitten minkään tuotteen hinta ei muutu. Jälkimmäinen voi tapahtua esimerkiksi silloin, kun sähköt katkeavat kesken päivityksen. Silloinkaan ei siis voi käydä niin, että vain osa hinnoista muuttuu.

Usein kuitenkin sana transaktio viittaa erityisesti siihen, että suoritettavaan kokonaisuuteen kuuluu useampi SQL-komento. Tällöin annamme ensin komennon BEGIN, joka aloittaa transaktion, sitten kaikki transaktioon kuuluvat komennot tavalliseen tapaan ja lopuksi komennon COMMIT, joka päättää transaktion.

Klassinen esimerkki transaktiosta on tilanne, jossa pankissa siirretään rahaa tililtä toiselle. Esimerkiksi seuraava transaktio siirtää 100 euroa Maijan tililtä Uolevin tilille:

BEGIN;
UPDATE Accounts SET balance = balance - 100 WHERE customer = 'Maija';
UPDATE Accounts SET balance = balance + 100 WHERE customer = 'Uolevi';
COMMIT;

Transaktion ideana on, että mitään pysyvää muutosta ei tapahdu ennen komentoa COMMIT. Niinpä yllä olevassa esimerkissä ei ole mahdollista, että Maija menettäisi 100 euroa mutta Uolevi ei saisi mitään. Joko kummankin tilin saldo muuttuu ja rahat siirtyvät onnistuneesti tai molemmat saldot säilyvät entisellään.

Jos transaktio keskeytyy jostain syystä ennen komentoa COMMIT, kaikki transaktiossa tehdyt muutokset peruuntuvat. Yksi syy transaktion keskeytymiseen on jokin häiriö tietokoneen toiminnassa (kuten sähköjen katkeaminen), mutta voimme myös itse halutessamme keskeyttää transaktion antamalla komennon ROLLBACK.

Transaktioiden kokeilu

Hyvä tapa saada ymmärrystä transaktioista on kokeilla käytännössä, miten ne toimivat. Tässä on esimerkkinä yksi keskustelu SQLiten kanssa:

sqlite> CREATE TABLE Accounts (id INTEGER PRIMARY KEY,
   ...>                        customer TEXT, balance INTEGER);
sqlite> INSERT INTO Accounts (customer, balance) VALUES ('Uolevi', 350);
sqlite> INSERT INTO Accounts (customer, balance) VALUES ('Maija', 600);
sqlite> SELECT * FROM Accounts;
1|Uolevi|350
2|Maija|600
sqlite> BEGIN;
sqlite> UPDATE Accounts SET balance = balance - 100 WHERE customer = 'Maija';
sqlite> SELECT * FROM Accounts;
1|Uolevi|350
2|Maija|500
sqlite> ROLLBACK;
sqlite> SELECT * FROM Accounts;
1|Uolevi|350
2|Maija|600
sqlite> BEGIN;
sqlite> UPDATE Accounts SET balance = balance - 100 WHERE customer = 'Maija';
sqlite> UPDATE Accounts SET balance = balance + 100 WHERE customer = 'Uolevi';
sqlite> COMMIT;
sqlite> SELECT * FROM Accounts;
1|Uolevi|450
2|Maija|500

Alkutilanteessa Uolevin tilillä on 350 euroa ja Maijan tilillä on 600 euroa. Ensimmäisessä transaktiossa poistamme ensin Maijan tililtä 100 euroa, mutta sen jälkeen tulemme toisiin ajatuksiin ja keskeytämme transaktion. Niinpä transaktiossa tehty muutos peruuntuu ja tilien saldot ovat samat kuin alkutilanteessa. Toisessa transaktiossa viemme kuitenkin transaktion loppuun, minkä seurauksena Uolevin tilillä on 450 euroa ja Maijan tilillä on 500 euroa.

Huomaa, että transaktion sisällä muutokset kyllä näkyvät, vaikka niitä ei olisi tehty vielä pysyvästi tietokantaan. Esimerkiksi ensimmäisen transaktion SELECT-kysely antaa Maijan tilin saldoksi 500 euroa, koska edellinen UPDATE-komento muutti saldoa.

Transaktiot ohjelmoinnissa

Transaktiokomentoja (BEGIN, COMMIT, jne.) voi suorittaa ohjelmoinnissa samaan tapaan kuin muitakin SQL-komentoja. Esimerkiksi seuraava koodi lisää tauluun Products tuhat riviä for-silmukassa yhden transaktion sisällä:

db.execute("BEGIN")

for i in range(1000):
    db.execute("""
               INSERT INTO Products (name, price) VALUES (?, ?)
               """, ["product" + str(i), 1])

db.execute("COMMIT")

Koska koodi on transaktion sisällä, koodi joko lisää kaikki rivit tietokantaan tai ei yhtään riviä, jos transaktio epäonnistuu jostain syystä.

Tässä tapauksessa transaktion käyttäminen myös nopeuttaa koodia, koska jokaista riviä ei lisätä erillisen transaktion sisällä vaan lisäys tapahtuu kokonaisuutena. Tämä auttaa tietokantaa toteuttamaan rivien lisäämisen tehokkaammin.

Rinnakkaiset transaktiot

Lisämaustetta transaktioiden käsittelyyn tuo, että tietokannalla voi olla useita käyttäjiä, joilla on meneillään samanaikaisia transaktioita. Missä määrin eri käyttäjien transaktiot tulisi eristää toisistaan?

Tämä on kysymys, johon ei ole yhtä oikeaa vastausta, vaan vastaus riippuu käyttötilanteesta ja myös tietokannan ominaisuuksista. Tavallaan paras ratkaisu olisi eristää transaktiot täydellisesti toisistaan, mutta toisaalta tämä voi haitata tietokannan käyttämistä.

SQL-standardi määrittelee transaktioiden eristystasot seuraavasti:

Taso 1 (read uncommitted)

On sallittua, että transaktio pystyy näkemään toisen transaktion tekemän muutoksen, vaikka toista transaktiota ei ole viety loppuun.

Taso 2 (read committed)

Toisin kuin tasolla 1, transaktio saa nähdä toisen transaktion tekemän muutoksen vain, jos toinen transaktio on viety loppuun.

Taso 3 (repeatable read)

Tason 2 vaatimus ja lisäksi jos transaktion aikana luetaan saman rivin sisältö useita kertoja, joka kerralla saadaan sama sisältö.

Taso 4 (serializable)

Transaktiot ovat täysin eristettyjä ja komennot käyttäytyvät samoin kuin jos transaktiot olisi suoritettu peräkkäin yksi kerrallaan jossain järjestyksessä.

Esimerkki

Tarkastellaan tilannetta, jossa tuotteen 1 hinta on aluksi 8 ja kaksi käyttäjää (K1 ja K2) suorittaa samaan aikaan komentoja transaktioiden sisällä:

K1: BEGIN;

K2: BEGIN;
K2: UPDATE Products SET price = 5 WHERE id = 1;

K1: SELECT price FROM Products WHERE id = 1;

K2: UPDATE Products SET price = 7 WHERE id = 1;
K2: COMMIT;

K1: SELECT price FROM Products WHERE id = 1;
K1: COMMIT;

Tasolla 1 käyttäjä 1 voi saada kyselyistä tulokset 5 ja 7, koska käyttäjän 2 tekemät muutokset voivat tulla näkyviin heti, vaikka käyttäjän 2 transaktioita ei ole viety loppuun.

Tasolla 2 käyttäjä 1 voi saada kyselyistä tulokset 8 ja 7, koska ensimmäisen kyselyn kohdalla toista transaktiota ei ole viety loppuun, kun taas toisen kyselyn kohdalla se on viety loppuun.

Tasoilla 3 ja 4 käyttäjä 1 saa kyselyistä tulokset 8 ja 8, koska tämä on tilanne ennen transaktion alkua eikä välissä loppuun viety transaktio saa muuttaa luettua rivin sisältöä.

Transaktiot käytännössä

Transaktioiden toteutustavat ja saatavilla olevat eristystasot riippuvat käytetystä tietokantajärjestelmästä. Esimerkiksi SQLitessä ainoa mahdollinen taso on 4, kun taas PostgreSQL toteuttaa tasot 2–4 ja oletuksena käytössä on taso 2.

Eristystaso 4 on tavallaan paras, koska silloin transaktioiden muutokset eivät voi näkyä mitenkään toisilleen. Miksi edes muut tasot ovat olemassa ja miksi esimerkiksi PostgreSQL:n oletustaso on 2?

Hyvän eristämisen hintana on, että se voi hidastaa tai estää transaktioiden suorittamista, koska transaktion vieminen loppuun voisi aiheuttaa ristiriitaisen tilanteen. Toisaalta monissa käytännön tilanteissa riittää mainiosti heikompikin eristys, kunhan tietokannan käyttäjä on siitä tietoinen.

Hyvää tietoa rinnakkaisten transaktioiden toiminnasta saa perehtymällä käytetyn tietokannan dokumentaatioon sekä testailemalla asioita itse käytännössä. Esimerkiksi voimme käynnistää itse kaksi SQLite-tulkkia, avata niillä saman tietokannan ja sen jälkeen kirjoittaa transaktioita sisältäviä komentoja ja tehdä havaintoja.

Seuraava keskustelu näyttää edellisen esimerkin tuloksen kahdessa rinnakkain käynnissä olevassa SQLite-tulkissa:

K1: BEGIN;
                                         
K2: BEGIN;
K2: UPDATE Products SET price = 5 WHERE id = 1;

K1: SELECT price FROM Products WHERE id = 1;
8

K2: UPDATE Products SET price = 7 WHERE id = 1;
K2: COMMIT;
Error: database is locked

K1: SELECT price FROM Products WHERE id=1;
8
K1: COMMIT;

Tästä näkee, että ensimmäinen transaktio tosiaan saa kummastakin kyselystä tuloksen 8. Toista transaktiota ei sen sijaan saada vietyä loppuun, vaan tulee virheviesti Error: database is locked, koska tietokanta on lukittuna samanaikaisen transaktion takia. Eristys toimii siis hyvin, mutta toista transaktiota pitäisi yrittää viedä loppuun uudestaan.

Vertailun vuoksi tässä on vastaava keskustelu PostgreSQL-tulkeissa (tasolla 2):

K1: BEGIN;

K2: BEGIN;
K2: UPDATE Products SET price = 5 WHERE id = 1;

K1: SELECT price FROM Products WHERE id = 1;
8

K2: UPDATE Products SET price = 7 WHERE id = 1;
K2: COMMIT;

K1: SELECT price FROM Products WHERE id = 1;
7
K1: COMMIT;

Nyt toisen transaktion muuttama arvo 7 ilmestyy ensimmäiseen transaktioon, mutta toisaalta molemmat transaktiot saadaan vietyä loppuun ongelmitta.

Miten transaktiot toimivat?

Transaktioiden toteuttaminen on kiehtova tekninen haaste tietokannoissa. Tavallaan transaktion tulee tehdä muutoksia tietokantaan, koska komennot voivat riippua edellisistä komennoista, mutta toisaalta mitään ei saa muuttaa pysyvästi ennen transaktion viemistä loppuun.

Yksi keskeinen ajatus tietokantojen taustalla on tallentaa muutoksia kahdella tavalla. Ensin kuvaus muutoksesta kirjataan lokitiedostoon (write-ahead log), jota voi ajatella luettelona suoritetuista komennoista. Vasta tämän jälkeen muutokset tehdään tietokannan varsinaisiin tietorakenteisiin. Nyt jos jälkimmäisessä vaiheessa sattuu jotain yllättävää, muutokset ovat jo tallessa lokitiedostossa ja ne voidaan suorittaa myöhemmin uudestaan.

Transaktioiden yhteydessä tietokantajärjestelmän täytyy myös pitää kirjaa siitä, mitkä muutokset ovat minkäkin meneillään olevan transaktion tekemiä. Käytännössä tauluihin voidaan tallentaa rivimuutoksia, jotka näkyvät vain tietyille transaktioille. Sitten jos transaktio pääsee loppuun asti, nämä muutokset liitetään taulun pysyväksi sisällöksi.

Kyselyjen suoritus

SQL-kielen taustalla oleva idea on, että tietokannan käyttäjän riittää kuvata, mitä tietoa hän haluaa hakea, ja tietokantajärjestelmä hoitaa loput. Niinpä tietokantajärjestelmän on tärkeää pystyä löytämään jokin tehokas tapa toteuttaa käyttäjän antama kysely ja toimittaa kyselyn tulokset käyttäjälle.

Kyselyn suunnitelma

Monet tietokantajärjestelmät kertovat pyydettäessä suunnitelmansa, miten annettu kysely aiotaan suorittaa. Tämän avulla voidaan tutkia tietokantajärjestelmän sisäistä toimintaa.

Tarkastellaan esimerkkinä kyselyä, joka hakee retiisin tiedot taulusta Products:

SELECT * FROM Products WHERE name = 'retiisi';

Kun laitamme SQLitessä kyselyn eteen sanan EXPLAIN, saamme seuraavan tapaisen selostuksen suunnitelmasta:

sqlite> EXPLAIN SELECT * FROM Products WHERE name = 'retiisi';
addr  opcode         p1    p2    p3    p4             p5  comment      
----  -------------  ----  ----  ----  -------------  --  -------------
0     Init           0     12    0                    00  Start at 12  
1     OpenRead       0     2     0     3              00  root=2 iDb=0; Products
2     Rewind         0     10    0                    00               
3       Column         0     1     1                    00  r[1]=Products.name
4       Ne             2     9     1     (BINARY)       52  if r[2]!=r[1] goto 9
5       Rowid          0     3     0                    00  r[3]=rowid   
6       Copy           1     4     0                    00  r[4]=r[1]    
7       Column         0     2     5                    00  r[5]=Products.price
8       ResultRow      3     3     0                    00  output=r[3..5]
9     Next           0     3     0                    01               
10    Close          0     0     0                    00               
11    Halt           0     0     0                    00               
12    Transaction    0     0     1     0              01  usesStmtJournal=0
13    TableLock      0     2     0     Products       00  iDb=0 root=2 write=0
14    String8        0     2     0     retiisi        00  r[2]='retiisi'
15    Goto           0     1     0                    00 

SQLite muuttaa kyselyn tietokannan sisäiseksi ohjelmaksi, joka hakee tietoa tauluista. Tässä tapauksessa ohjelman suoritus alkaa riviltä 12, jossa alkaa transaktio, ja sitten rivillä 14 rekisteriin 2 sijoitetaan hakuehdossa oleva merkkijono “retiisi”. Tämän jälkeen suoritus siirtyy riville 1, jossa aloitetaan taulun Products käsittely, ja rivit 2–9 muodostavat silmukan, joka etsii hakuehtoa vastaavat rivit taulusta.

Voimme myös pyytää tiiviimmän suunnitelman laittamalla kyselyn eteen sanat EXPLAIN QUERY PLAN. Tällöin tulos voi olla seuraava:

sqlite> EXPLAIN QUERY PLAN SELECT * FROM Products WHERE name = 'retiisi';
0|0|0|SCAN TABLE Products

Tässä SCAN TABLE Products tarkoittaa, että kysely käy läpi taulun Products rivit.

Kyselyn optimointi

Jos kyselyssä haetaan tietoa vain yhdestä taulusta, kysely on yleensä helppo suorittaa, mutta todelliset haasteet tulevat vastaan usean taulun kyselyissä. Tällöin tietokantajärjestelmän tulee osata optimoida kyselyn suorittamista eli muodostaa hyvä suunnitelma, jonka avulla halutut tiedot saadaan kerättyä tehokkaasti tauluista.

Tarkastellaan esimerkkinä seuraavaa kyselyä, joka listaa kurssien ja opettajien nimet:

SELECT Courses.name, Teachers.name
FROM Courses, Teachers
WHERE Courses.teacher_id = Teachers.id;

Koska kysely kohdistuu kahteen tauluun, kyselyn voi ajatella muodostavan ensin kaikki rivien yhdistelmät tauluista Courses ja Teachers ja valitsevan sitten ne rivit, joilla pätee ehto Courses.teacher_id = Teachers.id. Tämä on hyvä ajattelutapa, mutta tämä ei vastaa sitä, miten kunnollinen tietokantajärjestelmä toimii.

Ongelmana on, että tauluissa Courses ja Teachers voi molemmissa olla suuri määrä rivejä. Esimerkiksi jos kummassakin taulussa on miljoona riviä, rivien yhdistelmiä olisi miljoona miljoonaa ja veisi valtavasti aikaa muodostaa ja käydä läpi kaikki yhdistelmät.

Tässä tilanteessa tietokantajärjestelmän pitääkin ymmärtää, mitä käyttäjä oikeastaan on hakemassa ja miten kyselyssä annettu ehto rajoittaa tulosrivejä. Käytännössä riittää käydä läpi kaikki taulun Courses rivit ja etsiä jokaisen rivin kohdalla jotenkin tehokkaasti yksittäinen haluttu rivi taulusta Teachers.

Voimme taas pyytää SQLiteä selittämään kyselyn suunnitelman:

sqlite> EXPLAIN QUERY PLAN SELECT Courses.name, Teachers.name FROM Courses, Teachers WHERE Courses.teacher_id = Teachers.id;
0|0|0|SCAN TABLE Courses
0|1|1|SEARCH TABLE Teachers USING INTEGER PRIMARY KEY (rowid=?)

Tämä kysely käy läpi taulun Courses rivit (SCAN TABLE Courses) ja hakee tietoa taulusta Teachers pääavaimen avulla (SEARCH TABLE Teachers). Jälkimmäinen tarkoittaa, että kun käsittelyssä on tietty taulun Courses rivi, kysely hakee tehokkaasti taulusta Teachers rivin, jossa Teachers.id on sama kuin Courses.teacher_id.

Mutta miten käytännössä taulusta Teachers voi hakea tehokkaasti? Tämä onnistuu käyttämällä taulun yhteydessä olevaa indeksiä.

Indeksit

Indeksi on tietokannan taulun yhteyteen tallennettu hakemistorakenne, jonka tavoitteena on tehostaa tauluun liittyvien kyselyiden suorittamista. Indeksin avulla tietokantajärjestelmä voi selvittää tehokkaasti, missä päin taulua on rivejä, jotka täsmäävät tiettyyn hakuehtoon.

Indeksiä voi ajatella samalla tavalla kuin kirjan lopussa olevaa hakemistoa, jossa kerrotaan hakusanoista, millä kirjan sivuilla ne esiintyvät. Hakemiston avulla löydämme tietyn sanan sijainnit paljon nopeammin kuin lukemalla koko kirjan läpi.

Pääavaimen indeksi

Kun tietokantaan luodaan taulu, sen pääavain saa automaattisesti indeksin. Tämän ansiosta voidaan suorittaa tehokkaasti hakuja, joissa ehto liittyy pääavaimeen.

Esimerkiksi kun luomme SQLitessä taulun

CREATE TABLE Products (
  id INTEGER PRIMARY KEY,
  name TEXT,
  price INTEGER
);

niin taululle luodaan indeksi sarakkeelle id ja voimme etsiä tehokkaasti tuotteita id-numeron perusteella. Tämän ansiosta esimerkiksi seuraava kysely toimii tehokkaasti:

SELECT price FROM Products WHERE id = 3;

Voimme varmistaa tämän kysymällä kyselyn suunnitelman:

sqlite> EXPLAIN QUERY PLAN SELECT price FROM Products WHERE id = 3;
selectid    order       from        detail                                                   
----------  ----------  ----------  ---------------------------------------------------------
0           0           0           SEARCH TABLE Products USING INTEGER PRIMARY KEY (rowid=?)

Suunnitelmassa näkyy SEARCH TABLE, mikä tarkoittaa, että kysely pystyy hakemaan taulusta tietoa tehokkaasti indeksin avulla.

Indeksin luominen

Pääavaimen indeksi on kätevä, mutta voimme haluta myös etsiä tietoa jonkin muun sarakkeen perusteella. Esimerkiksi seuraava kysely hakee rivit sarakkeen price perusteella:

SELECT name FROM Products WHERE price = 4;

Tämä kysely ei ole oletuksena tehokas, koska sarakkeelle price ei ole indeksiä. Näemme tämän pyytämällä taas selitystä kyselystä:

sqlite> EXPLAIN QUERY PLAN SELECT name FROM Products WHERE price = 4;
selectid    order       from        detail             
----------  ----------  ----------  -------------------
0           0           0           SCAN TABLE Products

Nyt suunnitelmassa näkyy SCAN TABLE, mikä tarkoittaa, että kysely joutuu käymään läpi taulun kaikki rivit. Tämä on hidasta, jos taulussa on paljon rivejä.

Voimme kuitenkin luoda uuden indeksin, joka tehostaa saraketta price käyttäviä kyselyitä. Saamme luotua indeksin komennolla CREATE INDEX näin:

CREATE INDEX idx_price ON Products (price);

Tässä idx_price on indeksin nimi, jolla voimme viitata siihen myöhemmin. Indeksi toimii luonnin jälkeen täysin automaattisesti, eli tietokantajärjestelmä osaa käyttää sitä kyselyissä ja huolehtii sen päivittämisestä.

Indeksin luomisen jälkeen voimme kysyä uudestaan kyselyn suunnitelmaa:

sqlite> EXPLAIN QUERY PLAN SELECT name FROM Products WHERE price = 4;
selectid    order       from        detail                                               
----------  ----------  ----------  -----------------------------------------------------
0           0           0           SEARCH TABLE Products USING INDEX idx_price (price=?)

Indeksin ansiosta suunnitelmassa ei lue enää SCAN TABLE vaan SEARCH TABLE. Suunnitelmassa näkyy myös, että aikomuksena on hyödyntää indeksiä idx_price.

Lisää käyttötapoja

Voimme käyttää indeksiä myös kyselyissä, joissa haemme pienempiä tai suurempia arvoja. Esimerkiksi sarakkeelle price luodun indeksin avulla voimme etsiä vaikkapa rivejä, joille pätee ehto price < 3 tai price >= 8.

Indeksi on myös mahdollista luoda usean sarakkeen perusteella. Esimerkiksi voisimme luoda indeksin näin:

CREATE INDEX idx_price ON Products (price, name);

Tässä indeksissä rivit on järjestetty ensisijaisesti hinnan ja toissijaisesti nimen mukaan. Indeksi tehostaa hakuja, joissa hakuperusteena on joko pelkkä hinta tai yhdessä hinta ja nimi. Kuitenkaan indeksi ei tehosta hakuja, joissa hakuperusteena on pelkkä nimi.

Miten indeksi toimii?

Indeksi tarvitsee tuekseen hakemistorakenteen, josta voi hakea tehokkaasti rivejä sarakkeen arvon perusteella. Tämä voidaan toteuttaa esimerkiksi puurakenteena, jonka avaimina on sarakkeiden arvoja.

Asiaan liittyvää teoriaa käsitellään tarkemmin kurssilla Tietorakenteet ja algoritmit binäärihakupuiden yhteydessä. Tyypillisiä tietokantojen yhteydessä käytettäviä puurakenteita ovat B-puu ja sen muunnelmat.

Milloin luoda indeksi?

Periaatteessa voisi ajatella, että taulun jokaiselle sarakkeelle kannattaa luoda indeksi, jolloin monenlaiset kyselyt ovat nopeita. Tämä ei ole kuitenkaan käytännössä hyvä idea.

Vaikka indeksit tehostavat kyselyitä, niissä on myös kaksi ongelmaa: indeksi vie tilaa ja hidastaa myös tiedon lisäämistä ja muuttamista. Jälkimmäinen johtuu siitä, että kun taulun sisältö muuttuu, muutos täytyy päivittää kaikkiin tauluun liittyviin indekseihin. Indeksiä ei siis kannata luoda huvin vuoksi.

Hyvä syy indeksin luontiin on, että haluamme suorittaa usein tietynlaisia kyselyitä ja ne toimivat hitaasti, koska tietokantajärjestelmä joutuu käymään läpi turhaan jonkin taulun kaikki rivit kyselyn aikana. Tällöin voimme lisätä taululle sopivan indeksin, jonka avulla tällaiset kyselyt toimivat jatkossa tehokkaasti.

Indekseillä on käytännössä suuri vaikutus tietokantojen tehokkuuteen. Moni tietokanta toimii hitaasti sen takia, että siitä puuttuu oleellisia indeksejä.

Indeksit ovat myös yksi esimerkki siitä, miten toisteinen tieto voi tehostaa kyselyjä. Indekseissä kuitenkaan toisteista tietoa ei tallenneta tauluun vaan taulun ulkopuolelle erilliseen hakemistorakenteeseen.

8. Tietokantojen teoria

Tämä materiaalin luku on päivittymässä, ja uusi versio ilmestyy syyskuun puoliväliin mennessä.