Datomic

Unsere Programme sollen möglichst keinen mutable State haben.

=> Nur möglichst wenige Atome.

Oft haben wir aber viele veränderliche Fakten, die wir regelmäßig updaten müssen.

  • User Profile
  • Kontobewegungen
  • Lagerhaus Inventar
  • ...

Datenbanken sind Programme der Hauptaufgabe die Verwaltung von State ist

  • PostgreSQL
  • MySQL
  • SQLite
  • Reddis
  • MongoDB
  • Cassandra
  • ...

Unsere Anwendung sollte mutable State soweit wir möglich an eine Datenbank abgeben.

In der Clojure Welt ist Datomic eine populäre Datenbank.

Datomic ist komplett immutable

  • Jeder alte Zustand bleibt verfügbar
  • Auditability
  • Fehler sind korrigierbar

Datomic skaliert gut mit Lese- und schlecht mit Schreiboperationen

Für die Beispiele binden wir eine lokale Datomic Version ein:


          :dependencies [[org.clojure/clojure "1.11.1"]
                         [com.datomic/client-cloud "1.0.125"]
                         [com.datomic/local "1.0.277"]]
        

Für ein produktives Deployment sollte man einen ordentlich konfigurierten Server aufsetzen.

Einen Client erzeugen wir mit der Konfiguration der Datenbank


          (require '[datomic.client.api :as d])

          (def client (d/client {:server-type :datomic-local
                                 :storage-dir :mem
                                 :system      "exampledb"}))

        

Wenn noch nicht geschehen, müssen wir auf dem verbundenen Server eine neue Datenbank erzeugen


          (d/create-database client {:db-name "exampledb"})
        

Für eine existierende Datenbank können wir nun eine Connection aufbauen


          (def conn (d/connect client {:db-name "exampledb"}))
        

Wir können der Connection nun Transaktionen mitgeben. Jede Transaktion gibt Datomic eine Liste von Fakten mit.


          (d/transact conn {:tx-data [{:db/ident :red}
                                      {:db/ident :green}
                                      {:db/ident :blue}
                                      {:db/ident :yellow}]})
        

Jede Farbe ist ein eigenes "Datom"

:db/ident ist ein namespaced Keyword

Für Keywords, mit breiter Anwendung (z.B. in Libraries oder als Datenbank Keys) können diese sehr nützlich sein (um Name Collisions zu vermeiden).

Wenn jeder Key einer Map ein namespaced Keyword im gleichen Namespace ist, gibt es eine Kurzsyntax:


          #:db{:ident :red
               :valueType :db.type/string}
        

ist äquivalent zu


          {:db/ident :red
           :db/valueType :db.type/string}
        

:db/ident wird verwendet, um Enum Werte zu spezifizieren.

Analog können wir nun auch noch die Größe und den Typ unseres Inventars spezifizieren.

Durch kleine Funktionen können wir uns dabei die Arbeit leichter machen:


          (defn make-idents
            [x]
            (mapv #(hash-map :db/ident %) x))

          (d/transact conn {:tx-data (make-idents [:small :medium :large :xlarge])})
          (d/transact conn {:tx-data (make-idents [:shirt :pants :dress :hat])})
        

In der Regel wollen wir komplexere Objekte speichern, zum Beispiel unser Inventar:


          {:inv/sku   "sku-1234"
           :inv/color :green
           :inv/size  :small
           :inv/type  :dress}
        

Für komplexe Objekte müssen wir Datomic zuerst ein Schema mitgeben


          (d/transact conn {:tx-data
                            [{:db/ident :inv/sku
                              :db/valueType :db.type/string
                              :db/unique :db.unique/identity
                              :db/cardinality :db.cardinality/one}
                             {:db/ident :inv/color
                              :db/valueType :db.type/ref
                              :db/cardinality :db.cardinality/one}
                             {:db/ident :inv/size
                              :db/valueType :db.type/ref
                              :db/cardinality :db.cardinality/one}
                             {:db/ident :inv/type
                              :db/valueType :db.type/ref
                              :db/cardinality :db.cardinality/one}]})
          

Jeder Eintrag definiert eine Eigenschaft, die ein Objekt haben kann

  • :db/ident ist der Key der Eigenschaft
  • :db/valueType ist der Typ der Eigenschaft
  • :db/unique gibt an, ob der Wert der Eigenschaft in der Datenbank einmalig ist
  • :db/cardinality gibt an, wie viele Werte die Eigenschaft haben kann

          {:db/ident :inv/sku
           :db/valueType :db.type/string
           :db/unique :db.unique/identity
           :db/cardinality :db.cardinality/one}
        

SKUs sind global eindeutige Strings und jedes Objekt kann nur eine davon haben


          {:db/ident :inv/color
           :db/valueType :db.type/ref
           :db/cardinality :db.cardinality/one}
        

Jedes Objekt kann auch nur eine Farbe haben. Diese ist eine Referenz auf ein anderes Datom
(in diesem Fall eines der Enum Datome, die wir zuvor definiert haben)


          {:db/ident :inv/size
           :db/valueType :db.type/ref
           :db/cardinality :db.cardinality/one}
        

          {:db/ident :inv/type
           :db/valueType :db.type/ref
           :db/cardinality :db.cardinality/one}
        

Das gleiche gilt für die Größe und Typ

Wir können nun ein Objekt in die Datenbank schreiben


          (d/transact conn {:tx-data
                            [#:inv{:sku   "sku-1234"
                                   :color :green
                                   :size  :small
                                   :type  :dress}]})
        

Wir können existierende Fakten aus der Datenbank laden. Mit pull bekommen wir ausgewählte Eigenschaften zu einem eindeutigen Objekt


          (d/pull (d/db conn)
                  [{:inv/color [:db/ident]}
                   {:inv/size [:db/ident]}
                   {:inv/type [:db/ident]}]
                  [:inv/sku "SKU-42"])
        

Parameter 2 gibt an, was wir genau zu dem Objekt bekommen wollen. Bei Referenzen können wir auch sagen, was wir von diesen wollen.

Mit '[*] bkeommen wir alle Eigenschafte.

Parameter 3 gibt an, wie wir das Objekt finden können. Entweder über die ID (falls wir die kennen) oder eine unique Eigenschaft

Parameter 1 gibt an, welchen Zustand der Datenbank zum suchen nehmen wollen.

(d/db conn) liefert den aktuellen Zustand der Datenbank.

Transaktionen haben einen umfangreichen Rückgabewert

  • :db-before: Zustand der Datenbank vor der Transaktion
  • :db-after: Zustand der Datenbank nach der Transaktion
  • :tx-data: Die Transaktion selbst

          (let [inventory-insertion
                (d/transact conn {:tx-data (create-sample-data colors sizes types)})]
            (println (d/pull (:db-before inventory-insertion)
                             '[*]
                             [:inv/sku "SKU-42"]))
            (println (d/pull (:db-after inventory-insertion)
                             '[*]
                             [:inv/sku "SKU-42"])))
        

Jede Transaktion erzeugt einen neuen Zustand, den wir später noch abrufen können.

Indem eine "Transaktionszeitachse" abgebildet wird, kann die Datenbank immutable sein.

Mit pull suchen wir genau eine Entity. Mit q können wir Queries schreiben.

Datomic verwendet Datalog als Query Sprache.

Beispiel: Finde alle SKUs, mit der gleichen Farbe wie SKU-42


          (d/q
            '[:find ?sku
              :where
              [?e :inv/sku "SKU-42"]
              [?e :inv/color ?color]
              [?e2 :inv/color ?color]
              [?e2 :inv/sku ?sku]]
            (d/db conn))
        
  • Jedes Symbol mit ? ist ein Platzhalter
  • Jede :where Zeile ist eine Bedingung
    • Der erste Eintrag ist eine Entity
    • Der zweite Eintrag ist eine Eigenschaft
    • Der dritte Eintrag ist der Wert der Eigenschaft

Datalog Queries sind ähnlich mächtig, wie SQL mit Rekursion.

Pattern: Nicht sagen, wie gesucht wird, sondern was das Ergebnis erfüllen soll.

Wir können auch Aggregations verwenden


          (d/q
            '[:find (count ?sku)
              :where
              [?e :inv/color :green]
              [?e :inv/sku ?sku]]
            (d/db conn))
        

In Queries können wir beliebige Clojure Core Funktionen verwenden


        (d/q
          '[:find ?sku
            :where
            [?e :inv/color :blue]
            [?e :inv/sku ?sku]
            [(re-find #"SKU-4" ?sku)]]
          (d/db conn))
        

Auch Java Methoden funktionieren (zum Beispiel .contains von Strings)

Applikationen entwickeln sich weiter. Wir können unser Schema analog erweitern.


          (d/transact conn {:tx-data
            [{:db/ident :inv/count
              :db/valueType :db.type/long
              :db/cardinality :db.cardinality/one}]})
        

Einer schon existierenden Entity können wir mit add neue Attribute hinzufügen.


    (d/transact conn {:tx-data
                      [[:db/add [:inv/sku "SKU-21"] :inv/count 7]
                       [:db/add [:inv/sku "SKU-22"] :inv/count 7]
                       [:db/add [:inv/sku "SKU-42"] :inv/count 100]]})
        

Analog können wir Attribute wieder entfernen:


          (d/transact conn {:tx-data
                            [[:db/retract [:inv/sku "SKU-22"] :inv/count]]})
        

Mit retractEntity retracten wir die ganze Entity.

Die retracteten Information bleiben in alten Zuständen der Datenbank weiterhin erhalten.

Wir können keine Attribute eines Schemas retracten, nur die Attribute der Entities selber.

Ansonsten würden ja alte Entities ungültig.

Stattdessen: Lieber neue Attribute mit neuem Namen.
Daher sind Namespaced Keywords hier wichtig.

Wenn wir ein existierendes Attribut überschreiben, wird der alte Wert automatisch retracted.


          (let [update-42 (d/transact conn
                            {:tx-data
                             [[:db/add [:inv/sku "SKU-42"] :inv/count 101]]})]
            (println (d/pull (:db-before update-42)
                             '[:inv/count]
                             [:inv/sku "SKU-42"]))
            (println (d/pull (:db-after update-42)
                             '[:inv/count]
                             [:inv/sku "SKU-42"])))
        

Wenn wir neue Werte basierend auf alten zuweisen wollen müssen wir auf Race-Conditions achten.

Datomic hat mit der cas Funktion Support für optimistic Concurrency


          (let [{current-count :inv/count} (d/pull (d/db conn)
                                                   '[:inv/count]
                                                   [:inv/sku "SKU-42"])
                new-count (- current-count 10)]

            (d/transact conn
                        {:tx-data
                         [[:db/cas [:inv/sku "SKU-42"]
                                   :inv/count
                                   current-count
                                   new-count]]}))
        

Falls der alte Wert sich geändert hat, so wirft transact eine Exception
(eher Clojure untypisch)

Clojure hat, wie Java, eine try - catch Logik:


          (try (d/transact conn {:tx-data
                                 [[:db/cas [:inv/sku "SKU-42"]
                                           :inv/count
                                           current-count
                                           new-count]]})
            (catch clojure.lang.ExceptionInfo e
              (if (= :cognitect.anomalies/conflict (-> e ex-data :cognitect.anomalies/category))
                (println "CAS conflict")
                (println "Unexpected error"))))
        

Wir können eigene Clojure Funktionen in Datomic registrieren, wenn wir komplexere Logik in einer Transaktion abbilden wollen.

Datomic hat noch eine ganze Reihe weiterer Features, die den Rahmen dieses Kapitels sprengen würden.