PostgreSQL-Enums in Clojure nutzen
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.