So wird’s gemacht: Typsichere Datenbank-Anbindung

In der Datenbank des E-Learning-Systems autotool steht, welcher Student welche Aufgabe wann gelöst hat, sowie weitere Verwaltungsinformationen, z.B. für jede Aufgabe der Bearbeitungszeitraum und zu welcher Vorlesung sie gehört.

Für die typsichere Verarbeitung benutzen wir

Das ist alles hier sehr schön erklärt. Ich zeige im Folgenden, wie das im autotool angewendet wird.

Rohform der Daten

Das DB-Schema wurde ursprünglich (ca. 2000) direkt in SQL beschrieben. Ein Beispiel für eine Tabellen-Deklaration ist:

CREATE TABLE `aufgabe` (
  `ANr` int(11) NOT NULL AUTO_INCREMENT,
  `Name` text CHARACTER SET utf8 NOT NULL,
  `VNr` bigint(20) NOT NULL,
  `Highscore` text CHARACTER SET utf8 NOT NULL,
  `Von` datetime NOT NULL,
  `Bis` datetime NOT NULL,
  `Config` text CHARACTER SET utf8 NOT NULL,
  `Remark` text,
  `Typ` text CHARACTER SET utf8 NOT NULL,
  `Status` text CHARACTER SET utf8 NOT NULL,
  `server` text CHARACTER SET utf8 NOT NULL,
  `signature` text CHARACTER SET utf8 NOT NULL,
  PRIMARY KEY (`ANr`),
  KEY `aufgabe_VNr_fkey` (`VNr`)
) ENGINE=MyISAM AUTO_INCREMENT=2592 DEFAULT CHARSET=latin1 COMMENT='aufgabe';

Haskell-Deklaration der Form der Daten

Die persistent-Verbindung wurde später hinzugefügt. Das DB-Schema wurde dabei nicht geändert.

Die Datei config/models enthält u.a. diese (von Hand geschriebenen) Deklarationen:

Aufgabe sql=aufgabe
  Id                                     sql=ANr
  name              Text                 sql=Name
  vorlesungId       VorlesungId          sql=VNr
  highscore         HiLo                 sql=Highscore
  von               UTCTime              sql=Von
  bis               UTCTime              sql=Bis
  konfiguration     AufgabeKonfiguration sql=Config
  hinweis           Text Maybe           sql=Remark
  typ               AufgabeTyp           sql=Typ
  status            Status               sql=Status
  server            ServerUrl            sql=server
  signatur          Text                 sql=signature

Daraus werden Typdeklarationen abgeleitet,

data Aufgabe
  = Aufgabe {aufgabeName :: !Text,
             aufgabeVorlesungId :: !Key Vorlesung,
             aufgabeHighscore :: !Control.Types.HiLo,
             aufgabeVon :: !UTCTime,
         ... }
data instance EntityField Aufgabe typ where
  ...
  AufgabeVorlesungId
     :: typ ~ Key Vorlesung => EntityField Aufgabe typ
  AufgabeHighscore
     :: typ ~ Control.Types.HiLo => EntityField Aufgabe typ
  AufgabeVon
     :: typ ~ UTCTime => EntityField Aufgabe typ

die die Beziehungen zwischen Tabellen und ihren Spalten beschreiben. Anhand dieser Beschreibung werden Queries statisch typisiert und damit fehlerhafte Queries bereits zur Übersetzungszeit erkannt.

Diese Deklarationen werden während der Kompilation durch den Aufruf von persistFileWith im Modul Models erzeugt. Den dabei entstehenden Quelltext sieht man also nicht, aber die Wirkung kann man in ghci so betrachten:

cd yesod
stack repl autotool-yesod:lib
:i Aufgabe

Für neue hinzugefügte Tabellen genügt es, eine Deklaration in config/models anzugeben. Beim nächsten Systemstart wird die Tabelle in der DB erzeugt.

Das bedeutet: wenn man ein Projekt neu beginnt, deklariert man alle Tabellen so und braucht überhaupt kein SQL zu schreiben.

Randbemerkung: Routing

Alle Routen, die der Service beantwortet, sind hier verzeichnet: config/routes, z.B.

...
/vorlesung/#VorlesungId/aufgaben AufgabenR GET !tutor !einschreibung

Der URL https://autotool.imn.htwk-leipzig.de/new/vorlesung/236/aufgaben wird demnach abgebildet in einen Aufruf der Funktion

getAufgabenR :: VorlesungId -> Handler Html

aus dem Modul Handler.Aufgaben

Benutzung der Daten

Die Liste aller Aufgaben für die gewählte Vorlesung wird so bestimmt:

aufgabenListe :: Set TimeStatus -> VorlesungId -> Handler Html
aufgabenListe disp vorlesung = do
  ...
  aufgaben <- runDB $ selectList [AufgabeVorlesungId ==. vorlesung] []
  ...

Das ist statisch typsicher: aus dem Typ von AufgabeVorlesungId wird bestimmt, daß es sich um die Tabelle Aufgabe handelt. Es ist garantiert, daß die Spalte vorlesungId in dieser Tabelle existiert und die Einträge tatsächlich Schlüssel für die Tabelle Vorlesung sind. Es ist statisch garantiert, daß in dieser Anfrage die Werte mit Schlüsseln von Vorlesungen verglichen werden, denn der Vergleichsoperator hat diesen Typ:

(==.) :: PersistField typ => EntityField v typ -> typ -> Filter v

dabei stimmt die Typvariable typ im ersten und zweiten Argument überein.

Die Query mit selectList kann ergänzt werden durch Spezifikationen zum Ordnen und Abschneiden der Resultate, zum Beispiel:

getLogR :: Handler Html
getLogR = do
  entries <- runDB $ selectList [ ] [ Desc EventId , LimitTo 100 ]
  ...

Insbesondere für Anfragen, die JOIN benutzen, wird esqueleto als eine zusätzliche Abstraktionsschicht oberhalb von persistent empfohlen. Wird derzeit im autotool nicht benutzt, wäre aber an einigen Stellen sinnvoll, vgl. Issue 316.

Zusammenfassung

Die Bibliothek persistent beschreibt eine tiefe Einbettung der Datenbankabfragesprache (SQL) in die Gastsprache (Haskell).

Jede Tabelle, jede Zeile, jeder Spalte, jede Anfrage, aber auch jeder Teil-Ausdruck einer Anfrage wird durch einen Ausdruck der Gastsprache beschrieben.

Dadurch kann man bei der Programmierung von DB-Zugriffen

  • Abstraktionsmechanismen (Unterprogramme, Modulsystem usw.) und
  • Sicherheitsmechanismen (statische Bindung von Namen, statisches Typsystem)

der Gastsprache direkt anwenden.

Das ist Lichtjahre besser als die Benutzung von SQL-Query-Strings in Programmtexten. Das sieht z.B. so aus

public List findWithName(String name) {
return em.createQuery(
 "SELECT c FROM Customer c WHERE c.name LIKE :custName")
 .setParameter("custName", name)
 .setMaxResults(10).getResultList();  }

und bietet eine Unzahl von Fehlermöglichkeiten, die alle erst zur Laufzeit festgestellt werden können, u.a.

  • SQL-Syntaxfehler: "SELCT c FROM ..."
  • fehlende Tabelle: "SELECT c FROM Cstomer ...
  • fehlende Spalte: "SELECT c FROM Customer where c.nme ...
  • falscher Spaltentyp
  • falsche Parameterzuweisung: ".setParameter("cstName", ..)

und manche Fehler werden auch zur Laufzeit nicht erkannt:

  • Umgehen der Parameterzuweisung: wenn man eine Benutzereingabe direkt in den Query-String einfügt, wird es spannend.

Vgl. dazu auch Folien aus VL Fortgeschrittene Programmierung