Das größte Open-Source-Projekt der bevuta IT ist Pepa, eine webbasierte Software zur Verwaltung und Archivierung von Dokumenten. Pepa ist in Clojure und ClojureScript geschrieben. Es nutzt PostgreSQL um alle Daten zu speichern, einschließlich der hochgeladenen Dateien und der gerenderten Bilder.

PostgreSQL Enum

Wir haben uns für PostgreSQL entschieden, weil es eine Open-Source-Datenbank ist und wegen der guten Erfahrungen, die wir mit dieser Datenbank gemacht haben. Die Unterstützung in Clojure ist (über clojure.java.jdbc) sehr gut und leichtgewichtig.

Ein häufig genutzter Datentyp ist der Aufzählungstyp. Typische Beispiele sind Anwesenheitsinformationen (available, busy, away), Wochentage (monday, tuesday, …) oder Statusinformationen (success, failure, pending).

PostgreSQL unterstützt dies in Form des ENUM-Datentyps:

CREATE TYPE PROCESSING_STATUS AS ENUM ('pending', 'failed', 'processed');

Dieses Stück DDL erzeugt einen neuen Typen namens PROCESSING_STATUS~, welcher die möglichen Werte pending, failed oder processed annehmen kann.

Zwar ist ENUM der Datentyp der Wahl, die Nutzung in Clojure ist aber nicht trivial:

CREATE TABLE files (
  name TEXT,
  status PROCESSING_STATUS
);

Wir definieren hier eine neue Datenbanktabelle namens files mit zwei Spalten: name (einen String) und status (vom vorher definierten Datentyp PROCESSING_STATUS). Mit mit rohem SQL ist es sehr leicht, Daten in diese Tabelle zu schreiben:

INSERT INTO files VALUES ('my-file.txt', 'pending');
-- INSERT 0 1

Wenn wir versuchen, ungültige Werte einzutragen, schlägt dies wie erwartet fehl:

INSERT INTO files VALUES ('my-other-file.txt', 'invalid_status');
-- ERROR:  invalid input value for enum processing_status: "invalid_status"

Leider funktioniert es aus Clojure heraus nicht so einfach:

(insert! pg-db :files {:name "my-file.txt", :status "pending"})
;; PSQLException ERROR: column "status" is of type processing_status but expression is of type character varying

Die Ursache liegt in der eher strengen Typverarbeitung von JDBC: wir versuchen einen String einzufügen wo ein ENUM erwartet wird. SQL selber wandelt das automatisch um, JDBC tut dies nicht.

Ein typischer Weg, dies zu umgehen, ist auf den ENUM zu verzichten und direkt Strings zu verwenden, jedoch bietet dies nicht die gleichen Konsistenzgarantie und ist damit eine häufige Ursache für Fehler.

Eine weitere ähnlich zweifelhafte Notlösung ist, stringtype=unspecified in der JDBC-URL zu übergeben. Dies sorgt dafür, dass Strings immer als Wert vom Typ 'unknown' übergeben werden, was es PostgreSQL erlaubt, automatisch die passendste Umwandlung vorzunehmen.

Noch eine weitere Möglichkeit ist das Erstellen eines PGobject vom korrekten Typen und dies an insert! zu übergeben:

(let [status (doto (PGobject.)
               (.setType "processing_status")
               (.setValue "pending"))]
  (insert! pg-db :files {:name "my-file.txt", :status status}))

Zwar ist das korrekt und typsicher, aber sehr umständlich. Es gibt einen besseren Weg.

clojure.java.jdbc erweitern

clojure.java.jdbc bietet ein Protokoll an, um Typkonvertierung zu erledigen: ISQLValue. Dieses Protokoll enthält eine Funktion sql-value, welche genutzt wird, um jeden Wert in ein PGobject zu verwandeln.

In Clojure werden üblicherweise keywords (mit oder ohne Namespace-Präfix) genutzt, um Aufzählungen zu realisieren. Im zweiten Fall sähe der PROCESSING_STATUS pending vielleicht so aus: :processing-status/pending.

Der Weg vom keyword zu einem equivalenten PGobject ist recht einfach:

(defn kw->pgenum [kw]
  (let [type (-> (namespace kw)
                 (s/replace "-" "_"))
        value (name kw)]
    (doto (PGobject.)
      (.setType type)
      (.setValue value))))

Jetzt müssen nur noch ISQLValue für clojure.lang.Keyword implementieren:

(extend-type clojure.lang.Keyword
  jdbc/ISQLValue
  (sql-value [kw]
    (kw->pgenum kw)))

Das erlaubt uns jetzt unser insert! wie folgt zu schreiben:

(insert! pg-db :files {:name "meine-datei.txt", :status :processing-status/pending})

Was noch bleibt, ist den umgekehrten Weg zu implementieren. Wenn wir einen PROCESSING_STATUS abfragen, bekommen wir immer noch einen String zurückgeliefert und nicht unser keyword mit Namespace-Präfix. Das bedeutet, dass wir es mit einem komplett anderen Typen zu tun haben als wir eingefügt haben. Undenkbar!

Glücklicherweise bietet uns clojure.java.jdbc ein Protokoll an, um auch damit korrekt umzugehen: IResultSetReadColum.

Es enthält eine Funktion, result-set-read-column, welche einmalig für jede Spalte einer abgerufenen Zeile der Datenbank aufgerufen wird. Die Argumente sind die Werte der Spalte, ein Objekt, welches Informationen über die aktuelle Zeile enthält und den Index der aktuellen Spalte.

Damit können wir anhand einer Liste von definierten ENUMs das Folgende schreiben:

(def +schema-enums+
  "A set of all PostgreSQL enums in schema.sql. Used to convert
  enum-values back into Clojure keywords."
  #{"processing_status"})

(extend-type String
  jdbc/IResultSetReadColumn
  (result-set-read-column [val rsmeta idx]
    (let [type (.getColumnTypeName rsmeta idx)]
      (if (contains? +schema-enums+ type)
        (keyword (s/replace type "_" "-") val)
        val))))

Wenn der Name eines Typen in der Spalte in unserer Menge von ENUMs ist, wird dieser Wert in ein keyword mit Namespace-Präfix umgewandelt. Wir können jetzt erfolgreich einen Wert aus der Datenbank abfragen:

(query (:db user/system) ["SELECT * FROM files"])
;=> ({:status :processing_status/pending, :name "my-file.txt"})

PostgreSQL-Enums und Clojure - Fazit

Wieder einmal hat Clojure seine Flexibilität unter Beweis gestellt, wenn es darum geht, mit unterschiedlichen Arten von Daten zu arbeiten. Einige wenige Zeilen in Clojure erlauben es uns, die Fähigkeiten von clojure.java.jdbc zu erweitern, um problemlos einen neuen Datentyp korrekt zu vearbeiten.

Natürlich ist das nicht auf ENUM begrenzt: PostgreSQL hat viele sehr praktische Datentypen, wie z.B. Typen für räumliche Informationen (POINT, LINE, POLYGON, …), Geldeinheiten (MONEY) und Netzwerkadressen (INET, MACADDR). Es ist nicht schwer, sich vorzustellen, wie die hier vorgestellte Methode angepasst werden kann, um diese ebenfalls in native Clojuretypen umzuwandeln.

Ein Nachteil der vorgestellten Implementierung ist, dass wir die Menge von ENUM~s manuell pflegen müssen. Idealerweise würde sie sich initial alle bekannten ~ENUM~s aus der Datenbank holen und diese in einem ~atom speichern.