clojure-north-2020

0.1.0-SNAPSHOT


A self-guided workshop presented at Clojure/north 2020

dependencies

org.clojure/clojure
1.10.1
ring/ring-jetty-adapter
1.8.0
metosin/ring-http-response
0.9.1
hiccup
1.0.5
metosin/reitit
0.4.2
ring
1.8.0
integrant
0.8.0
funcool/cuerdas
2.1.0
clojure-csv/clojure-csv
2.0.2
datascript
0.18.11
datascript-transit
0.3.0
io.replikativ/datahike
0.2.1
com.taoensso/timbre
4.10.0



(this space intentionally left almost blank)

namespaces

 

Overview

This workshop is organized in numerical order of progression. Start with ch01 and move through each exercise (x0n) all the way through chapter 04/x04.

Have fun!!

(ns clojure-north-2020.core)
 
(ns clojure-north-2020.ch01-data.x01-data)

A Clojure Application Starts as Data

Perhaps the most overlooked and powerful aspect of Clojure is that it is a first-class data oriented language. That, combined with functional programming, sets it aside from nearly any other language.

In this namespace, were going to consider the first step you should undertake when writing any Clojure program - modeling your domain as data.

Forget your functions, code, etc. Instead just think about your domain. How might you model it as data?

In Clojure you have 4 key data structures:

  • Vectors
    • [] ;Empty vector
    • [1 2 3 :a :b :c] ;A vector literal with heterogeneous contents.
  • Map
    • {} ;Empty map
    • {:a 1 "b" 2.0} ;A map literal with heterogeneous contents.
  • Sets
    • #{} ;Empty set
    • #{1 2 3 :a :b :c} ;A set literal with heterogeneous contents.
  • Lists
    • () ;Empty list
    • '(1 2 3 :a :b :c) ;A list literal with heterogeneous contents.
    • Lists are evaluated and are generally not used for data modeling.

Given these simple literal data structures you should be able to model any problem in any domain. Model actual cases. This is a far more powerful technique than starting with a schema or class hierarchy.

Consider how you might model the following domains:

For this project, we'll be modeling superheroes. Consider how you might model a superhero with attributes such as name, alias, powers, weapons, etc.

One interesting aspect of this problem is how we reference other entities, such as villains/nemeses. These are also top-level items, so perhaps it makes sense to ID them by name vs. a plain old string.

Exercise: Model at least 2 superheros as data.

For example, Batman may have the following characteristics:

  • His name is "Batman"
  • His alias is "Bruce Wayne"
  • His powers are that he is rich
  • His weapons are his utility belt and a kryptonite spear
  • His alignment is "Chaotic Good"
  • His nemeses include the Joker and Penguin

How might you model referential relations, such as nemeses, alliances, or familial relationships?

(def data
  [])
 
(ns clojure-north-2020.ch01-data.x01-data-solutions)
(def data
  [{:name      "Batman"
    :alias     "Bruce Wayne"
    :powers    #{"Rich"}
    :weapons   #{"Utility Belt" "Kryptonite Spear"}
    :alignment "Chaotic Good"
    :nemesis   [{:name "Joker"}
                {:name "Penguin"}]}
   {:name      "Superman"
    :alias     "Clark Kent"
    :powers    #{"Strength" "Flight" "Bullet Immunity"}
    :alignment "Lawful Good"
    :nemesis   [{:name "Lex Luthor"}
                {:name "Zod"}
                {:name "Faora"}]}
   {:name      "Wonder Woman"
    :alias     "Diana Prince"
    :powers    #{"Strength" "Flight"}
    :weapons   #{"Lasso of Truth" "Bracers"}
    :alignment "Lawful Good"
    :nemesis   [{:name "Ares"}]}
   {:name      "Shazam"
    :alias     "Billy Batson"
    :powers    #{"Strength" "Bullet Immunity"}
    :alignment "Neutral Good"
    :nemesis   [{:name "Dr. Thaddeus Sivana"}
                {:name "Pride"}
                {:name "Envy"}
                {:name "Greed"}
                {:name "Wrath"}
                {:name "Sloth"}
                {:name "Gluttony"}
                {:name "Lust"}]}
   {:name      "Joker"
    :alias     "Jack Napier"
    :alignment "Chaotic Evil"
    :nemesis   [{:name "Batman"}]
    }
   ])
 
(ns clojure-north-2020.ch01-data.x02-functions
  (:require [clojure-csv.core :as csv]
            [clojure-north-2020.ch01-data.x01-data :refer [data]]
            [clojure-north-2020.ch01-data.x02-functions-solutions :as x02-soln]
            [clojure.string :as cs]
            [cuerdas.core :as cc]))

Manipulating Data and Building APIs with Functions

As a language, Clojure is great at working with data. It's very common once you establish a data model to work with it interactively to build domain API functions or just better understand the data. As you explore and find useful relations it is common to "lift" a form into a function.

Exercise: Interact with our data

Explore the data generated in the last ns and write a useful function. Examples:

  • Find everyone with a given power.
  • List the names of all characters that are the nemesis of someone with a given power.
  • Find the alias of the nemesis of everyone with an alias.
  • Lift one of these explorations to a function.

Some Utility Functions

It is common to develop an data model and then bring in data from an external source such as a csv file. You will then wish to conform this csv data to your created internal model. We'll be doing this in the next few exercises so that we don't have to create data for hundreds of supers by hand.

We will be reading in our data from csv files. Let's write some functions for converting our raw files into our data as we've modeled it previously. One of the great things about Clojure is that is effectively a DSL for data, so the functions we create now will be domain agnostic and of general utility in the future since we're just dealing with data.

Exercise: Write a csv->data function

  • Write a function that consumes a csv file and produces a sequence of maps for each entry in the file.
  • Modify the function to convert column names to keywords. Tip: Clojure has the keyword function and the cuerdas library (included) has an even better one.
  • Modify the function to trim string values.
  • Modify the function to remove "garbage" values. If a key is empty or nil or a value is empty, nil, or "-", remove it from the map.

Step 1: Inspect the data. We can see that the first row is the column names and the rest are data. We also see some data quality issues:

  • The first column (index?) has no name
  • Nonexistent values are "-"
  • Nothing is done to parse non-string values
(comment
  (->> "resources/heroes_information.csv"
       slurp
       csv/parse-csv
       ;Just look a a sampling of the data
       (take 4)))

Remove entries in a seq of pairs for which any of the following are true:

  • The first item (key) is nil
  • The second item (value) is nil, "", or "-"
(defn remove-bad-entries [m]
  ;TODO - implement)
(comment
  (= {:c "OK"}
     (remove-bad-entries {nil "A" :a nil :b "" :c "OK"}))
  (= [[:c "OK"]]
     (remove-bad-entries [[nil "A"] [:a nil] [:b ""] [:c "OK"]])))

Convert a sequence of vectors into a sequence of maps, assuming the first row of the vectors is a header row.

(defn table->maps [[headers & cols]]
  ;TODO - implement)
(comment
  (= [{:id 1 :name "Mark" :age 42}
      {:id 2 :name "Sue" :age 12 :phone "123-456-7890"}
      {:id 3 :name "Pat" :age 18}]
     (table->maps
       [["" "ID" "Name" "Age" "Phone"]
        [0 1 "Mark" 42 "-"]
        [1 2 "Sue" 12 "123-456-7890"]
        [2 3 "Pat" 18 nil]])))
(defn csv-file->maps [f]
  (-> f
      slurp
      csv/parse-csv
      ;Once you get your solution in place, remove the external solution
      x02-soln/table->maps))

Other utility functions

This won't be an exercise, but here are a few more utility functions that we will be using to parse our data.

Keywordize strings by:

  1. replacing all sequences of nonword characters with a space
  2. Removing all single quotes
  3. Turning the string into a keyword using the Cuerdas library.
(defn kwize [s]
  (-> s (cs/replace #"\W+" " ") (cs/replace #"'" ) cc/keyword))

Update key k in map m with function f if there is a value in m for k.

(defn maybe-update [m k f]
  (cond-> m (some? (m k)) (update k f)))

Bulk update several keys ks in map m with function f

(defn maybe-bulk-update [m ks f]
  (reduce (fn [m k] (maybe-update m k f)) m ks))
(comment
  (maybe-bulk-update
    {:width "2.1" :height "45.53" :name "Bob"}
    [:width :height]
    #(Double/parseDouble %)))
 
(ns clojure-north-2020.ch01-data.x02-functions-solutions
  (:require [clojure-north-2020.ch01-data.x01-data-solutions :refer [data]]
            [clojure.string :as cs]
            [cuerdas.core :as cc]))
(comment
  (->> data
       (filter (fn [{:keys [powers]}] (get powers "Bullet Immunity")))
       (mapcat :nemesis))

  ;; Find the alias of the nemesis of everyone with an alias.
  (let [d (->> data (filter :alias) (mapcat :nemesis) (map :name) set)]
    (->> data
         (filter (fn [{:keys [name]}] (d name)))
         (map :alias)))
  )

Remove entries in a seq of pairs for which any of the following are true:

  • The first item (key) is nil
  • The second item (value) is nil, "", or "-"
(defn remove-bad-entries [m]
  (into (empty m)
        (remove (fn [[k v]] (or (nil? k) (contains? #{"-"  nil} v))) m)))
(comment
  (remove-bad-entries
    {nil "A" :a nil :b "" :c "OK"}))

Convert a sequence of vectors into a sequence of maps, assuming the first row of the vectors is a header row.

(defn table->maps [[headers & cols]]
  (let [h (map cc/keyword headers)]
    (->> cols
         (map (fn [col] (zipmap h (map #(cond-> % (string? %) cs/trim) col))))
         (map remove-bad-entries))))
 

Create the Primary Hero Data Set

We are going to parse the kaggle files found at the following links:

  • Super Heroes Dataset containing two files:
    • heroes_information.csv: Basic information on over 700 superheroes
    • superheropowers.csv: Powers attached to all superheroes.
  • https://www.kaggle.com/thec03u5/complete-superhero-dataset

This exercise demonstrates the power of Clojure as a data-first language. Many other languages have libraries or DSLs for processing data, but Clojure is inherently a DSL for data. Data is generally loaded straight from a file, normalized into a desired format, and worked with directly.

(ns clojure-north-2020.ch01-data.x03-hero-data
  (:require [clojure-csv.core :as csv]
            [clojure-north-2020.ch01-data.x02-functions :refer
             [csv-file->maps kwize maybe-bulk-update]]))

Process heroes_information.csv to get basic superhero data

(defn normalize [m]
  (let [dbl-fields [:height :weight]
        kw-fields [:gender :alignment :hair-color :skin-color :eye-color :race]]
    (-> m
        (maybe-bulk-update dbl-fields #(Double/parseDouble %))
        (maybe-bulk-update kw-fields kwize))))
(comment
  (normalize
    {:publisher  "DC Comics"
     :race       "Martian"
     :name       "Martian Manhunter"
     :alignment  "good"
     :weight     "135.0"
     :hair-color "No Hair"
     :skin-color "green"
     :eye-color  "red"
     :gender     "Male"
     :height     "201.0"}))
(defn heroes-data []
  (let [filename "resources/heroes_information.csv"]
    (->> filename
         csv-file->maps
         (map normalize))))

Exercise: Investigate Data Quality

Determine the following:

  • We are going to treat the heroes' names as a primary key.
    • Do we have duplicates?
    • If so, what are the frequencies of the duplicates?
  • Given a hero by name (e.g. "Spider-Man"), determine what fields are duplicated and what the distinct values are. Note that there are data quality issues. We are going to just accept the situation and move on.
(comment
  (take 10 (heroes-data))
  ;Compute the frequency of names if the name occurs > 1 time.
  (->> (heroes-data)
       ;...)
  ;Given a hero name, determine the duplicate values associated with
  ; non-distinct keys. You may want to use the test data from below.
  (->> (heroes-data)
       ;...)
  ; Hint:
  (do
    (use 'clojure.repl)
    (doc merge-with))
  ;TODO - Promote to the function "dupes"
  (defn dupes [maps]
    ;Extract by promoting the previous exercise)
  ;We've build a generally useful function
  (= {:age [12 14 16]
      :height [100 101]}
     (dupes
       [{:name "Mark" :age 12}
        {:name "Mark" :age 12 :height 100}
        {:name "Mark" :age 14 :height 100}
        {:name "Mark" :age 16 :height 101}])))
 
(ns clojure-north-2020.ch01-data.x04-hero-data-solutions)
(comment
  (require '[clojure-north-2020.ch01-data.x03-hero-data :refer [heroes-data]])
  (take 10 (heroes-data))
  ;Compute the frequency of names if the name occurs > 1 time.
  (->> (heroes-data)
       (map :name)
       frequencies
       (filter (fn [[_ v]] (> v 1)))
       (into {}))
  ;Given a hero name, determine the duplicate values associated with
  ; non-distinct keys
  (->> (heroes-data)
       (filter (comp #{"Spider-Man"} :name))
       (apply merge-with (fn [a b] (if (= a b) a (flatten (vector a b)))))
       (filter (fn [[_ v]] (seq? v)))
       (into {}))
  (defn dupes [m]
    (->> m
         (apply merge-with (fn [a b] (if (= a b) [a] (vector a b))))
         (map (fn [[k v]] [k (distinct (flatten v))]))
         (filter (fn [[_ [_ s]]] s))
         (into {})))
  (->> (heroes-data)
       (filter (comp #{"Spider-Man"} :name))
       dupes)
  (dupes
    [{:name "Mark" :age 12}
     {:name "Mark" :age 12 :height 100}
     {:name "Mark" :age 14 :height 100}]))
 
(ns clojure-north-2020.ch01-data.x04-hero-powers-data
  (:require [clojure-csv.core :as csv]
            [clojure-north-2020.ch01-data.x02-functions :refer [csv-file->maps table->maps]]))

Get Powers Data

We'll process super_hero_powers.csv to get powers data.

Convert map of {:hero-name "name" :powerx "True|False"} to map of {:name "name" :powers #{:set :of :powers}}.

(defn normalize [raw-hero-map]
  (letfn [(f [m [k v]]
            (cond
              (= k :hero-names) (assoc m :name v)
              (= v "True") (update m :powers (comp set conj) k)
              :else m))]
    (reduce f {} raw-hero-map)))

Note that the dataset is a seq of maps with the key :hero-names (the name) and a boolean (as string) key for each power. Write a function that normalizes our data into a map with the hero's name as :name and powers as a set of keywords (e.g. #{:super-strength :flight}).

(defn powers-data []
  (let [filename "resources/super_hero_powers.csv"]
    (->> filename
         csv-file->maps
         (map normalize))))
(comment
  ;Non-reduced powers
  (let [filename "resources/super_hero_powers.csv"]
    (->> filename
         csv-file->maps
         (take 2)))

  ; Reduced powers
  (take 2 (powers-data))

  ; See who has the powers flight, super-strength, and vision-x-ray?
  ; Before trying it out, who might you expect to have these powers?
  (->> (powers-data)
       (filter (fn [{:keys [name powers]}]
                 (and (powers :flight)
                      (powers :super-strength)
                      (powers :vision-x-ray))))
       (map :name))
  )
 
(ns clojure-north-2020.ch01-data.x05-supplemental-hero-data
  (:require [clojure-csv.core :as csv]
            [clojure-north-2020.ch01-data.x02-functions :refer
             [csv-file->maps kwize maybe-bulk-update maybe-update table->maps]]
            [clojure.string :as cs]))

Supplemental Hero Data

Process SuperheroDataset.csv to get additional source data. This is a fairly ugly file and requires extra processing.

We are not going to do any exercises here, but it is worthwhile to scroll to the bottom of the file and view the rich comments.

(defn remove-trash-fields [m]
  (let [trash-values #{"No team connections added yet." "No alter egos found."}]
    (into {} (remove (fn [[_ v]] (trash-values v)) m))))
(defn process-team-affiliations [s]
  (let [teams (map cs/trim (cs/split s #","))
        parser (partial re-matches #"(Formerly:)?([^\(]+)(?:\(([^\)]+)\))?")]
    (loop [[team & r] teams former? false res []]
      (if team
        (let [[_ f n l] (parser team)
              former? (or former? (some? f))
              team-data {:team/name    (cs/trim n)
                         :team/leader? (some? l)
                         :team/former? former?}]
          (recur r former? (conj res team-data)))
        res))))
(defn captrim-all [v] (map (comp cs/capitalize cs/trim) v))
(defn process-occupations [s] (captrim-all (cs/split s #"[,;]")))
(defn process-bases [s] (captrim-all (cs/split s #";")))
(defn process-alter-egos [s] (captrim-all (cs/split s #",")))
(defn process-units-field [s]
  (let [multipliers {"ton" 1000 "meter" 100}
        [mag units] (-> s (cs/split #"//") last cs/trim (cs/split #"\s+"))]
    (* (Double/parseDouble (cs/replace mag #"," ))
       (multipliers units 1))))
(defn process-aliases [s]
  (map cs/trim (cs/split s #",")))
(defn process-relatives [s]
  (for [[_ names relation] (re-seq #";?([^;\(]*)\(([^\)]+)\)" s)
        name (->> (cs/split names #",") (map cs/trim) (filter seq))]
    {:relative     {:name (cs/trim name)}
     :relationship (kwize (cs/trim relation))}))
(defn gather-stats [m]
  (let [attrs [:combat :durability :intelligence :power :speed :strength :total-power :unnamed-0]
        stats (select-keys m attrs)]
    (assoc
      (apply dissoc m attrs)
      :stats (map (fn [[k v]] {:stat/name k :stat/value v}) stats))))
(defn normalize [m]
  (let [dbl-fields [:speed :intelligence :unnamed-0 :power :durability :strength
                    :total-power :combat]
        kw-fields [:alignment :hair-color :eye-color :gender :skin-color :race]
        unit-fields [:weight :height]]
    (-> m
        (maybe-bulk-update dbl-fields #(Double/parseDouble %))
        (maybe-bulk-update kw-fields kwize)
        (maybe-bulk-update unit-fields process-units-field)
        remove-trash-fields
        (maybe-update :team-affiliation process-team-affiliations)
        (maybe-update :aliases process-aliases)
        (maybe-update :alter-egos process-alter-egos)
        (maybe-update :occupation process-occupations)
        (maybe-update :relatives process-relatives)
        (maybe-update :base process-bases)
        gather-stats)))
(defn supplemental-hero-data []
  (let [filename "resources/SuperheroDataset.csv"]
    (->> filename csv-file->maps (map normalize))))
(comment
  (take 10 (supplemental-hero-data))

  (let [filename "resources/SuperheroDataset.csv"]
    (->> filename
         csv-file->maps
         (map normalize)
         (mapcat :relatives)
         (map first)
         (filter identity)
         distinct))
  )
 
(ns clojure-north-2020.ch01-data.x06-yoda-quotes
  (:require [clojure-csv.core :as csv]
            [clojure-north-2020.ch01-data.x02-functions :refer [csv-file->maps]]))

Not used today :( Sorry! However, you can inspect it with our generic csv-file->maps function.

(comment
  (csv-file->maps "resources/yoda-corpus.csv"))
 

A few utility functions for working with Datahike

Nothing special to see here.

(ns clojure-north-2020.ch02-datalog.datahike-utils
  (:require [clojure.edn :as edn]
            [clojure.java.io :as io]
            [datahike.api :as d]))

Create a datahike connection backed by the given directory name.

(defn conn-from-dirname [dirname]
  (let [db-dir (doto (io/file dirname) io/make-parents)
        uri (str "datahike:" (io/as-url db-dir))
        _ (when-not (d/database-exists? uri)
            (d/create-database uri))
        conn (d/connect uri)]
    (alter-meta! conn assoc :uri uri)
    conn))

Release a datahike connection and delete the database.

(defn cleanup [conn]
  (let [{:keys [uri]} (meta conn)]
    (d/release conn)
    (when uri (d/delete-database uri))))

read an edn resource to data.

(defn read-edn [resource]
  (->> (io/resource resource) slurp edn/read-string))
(comment
  (conn-from-dirname "tmp/abc")
  )
 
(ns clojure-north-2020.ch02-datalog.x00-basics
  (:require [datascript.core :as d]))

Datascript (and Datahike, Datomic, and Crux) are Fact Stores

(def db
  (d/db-with
    (d/empty-db)
    [{:name "Mark" :favorite-food "Pizza"}
     {:name "Pat" :favorite-food "Ice Cream" :age 42}
     {:name "Chloe" :favorite-food "Chips"}]))

"Facts" are Datoms

Evaluate the above code in a REPL. db explodes to the following set of entries:

[[1 :favorite-food "Pizza" 536870913]
 [1 :name "Mark" 536870913]
 [2 :age 42 536870913]
 [2 :favorite-food "Ice Cream" 536870913]
 [2 :name "Pat" 536870913]
 [3 :favorite-food "Chips" 536870913]
 [3 :name "Chloe" 536870913]]

These entries are [E A V T] Datoms where:

  • E is the Entity ID of an entity in the database
  • A is the Attribute being described
  • V is the Value associated with the attribute
  • T is the Transaction ID or Time associated with the tuple

Datoms are often called "Facts" and the DBs themselves considered "Fact Stores." In plain terms, a fact is a statement made about something for some point in time and a datom describes such facts.

Note that some implementations may also have additional fields such as a boolean indicating whether the fact was asserted or retracted.

Queries

Queries are done by unifying facts

(d/q
  '[:find ?n
    :in $
    :where
    [?e :name ?n]]
  db)
 
(ns clojure-north-2020.ch02-datalog.x01-schemas
  (:require [clojure-north-2020.ch02-datalog.datahike-utils :as du]
            [datahike.api :as dh]
            [datascript.core :as ds]))

Schemas

A key concept in data modeling with datalog databases is that schema is specified at the attribute level.

  • SQL Databases: Schema is at the record level
  • Document Databases: Documents are schema-free unless indexes are specified
  • Datalog Databases: Schema is at the field/attribute level

Take a moment to consider this. This is a very, very powerful concept.

Schema-free Datascript

Datascript is very forgiving. You can pretty much dump whatever you want into a Datascript database. You get full query powers, but not all of your entities explode as expected and not all queries are as efficient.

Execute the following in a REPL. How many entities are created?

(ds/db-with
  (ds/empty-db)
  [{:name       "Batman"
    :alias      "Bruce Wayne"
    :powers     #{"Rich"}
    :weapons    #{"Utility Belt" "Kryptonite Spear"}
    :hair-color :black
    :alignment  "Chaotic Good"
    :nemesis    [{:name "Joker"}
                 {:name "Penguin"}]}
   ;Try with and without this. What happens?
   ;{:name "Batman" :alias "Bruce"}
   ])

Datascript Schemas

Datascript schemas are a map in which keys are the schema keys in the database and the values describe the keys.

(def datascript-schema
  {:name    {:db/unique :db.unique/identity}
   :alias   {:db/unique      :db.unique/identity
             :db/cardinality :db.cardinality/many}
   :powers  {:db/cardinality :db.cardinality/many}
   :weapons {:db/cardinality :db.cardinality/many}
   :nemesis {:db/valueType   :db.type/ref
             :db/cardinality :db.cardinality/many}})

Datascript with Schema

Now evaluate the db with the full schema.

(ds/db-with
  (ds/empty-db
    datascript-schema)
  [{:name       "Batman"
    :alias      "Bruce Wayne"
    :powers     #{"Rich"}
    :weapons    #{"Utility Belt" "Kryptonite Spear"}
    :hair-color :black
    :alignment  "Chaotic Good"
    :nemesis    [{:name "Joker"}
                 {:name "Penguin"}]}
   {:name  "Batman"
    :alias "Bruce"}])

What happens when a key is missing?

(ds/db-with
  (ds/empty-db
    ; Note that we're removing the alias cardinality/many attribute
    (dissoc datascript-schema :alias))
  [{:name       "Batman"
    :alias      "Bruce Wayne"
    :powers     #{"Rich"}
    :weapons    #{"Utility Belt" "Kryptonite Spear"}
    :hair-color :green
    :alignment  "Chaotic Good"
    :nemesis    [{:name "Joker"}
                 {:name "Penguin"}]}
   ; Observe that alias and hair color are overwritten
   {:name       "Batman"
    :alias      "Bruce"
    :hair-color :black}])

Datahike/Datomic Schemas

Datahike (and Datomic) schemas are a vector of maps in which each map describes a single attribute. Unlike Datascript, a schema must exist for every attribute to be transacted.

(def schema
  [{:db/ident       :name
    :db/valueType   :db.type/string
    :db/unique      :db.unique/identity
    :db/cardinality :db.cardinality/one}
   {:db/ident       :alias
    :db/valueType   :db.type/string
    :db/unique      :db.unique/identity
    :db/cardinality :db.cardinality/many}
   {:db/ident       :alignment
    :db/valueType   :db.type/string
    :db/cardinality :db.cardinality/one}
   {:db/ident       :powers
    :db/valueType   :db.type/string
    :db/cardinality :db.cardinality/many}
   {:db/ident       :weapons
    :db/valueType   :db.type/string
    :db/cardinality :db.cardinality/many}
   {:db/ident       :nemesis
    :db/valueType   :db.type/ref
    :db/cardinality :db.cardinality/many}])
(comment
  (def conn (du/conn-from-dirname "tmp/x01-schemas"))
  (dh/transact conn schema)
  ;What happens when we transact this? How do we fix it?
  ;Exercise - Fix the a schema.
  (dh/transact
    conn
    [{:name       "Batman"
      :alias      "Bruce Wayne"
      :powers     #{"Rich"}
      :weapons    #{"Utility Belt" "Kryptonite Spear"}
      :hair-color :black
      :alignment  "Chaotic Good"
      :nemesis    [{:name "Joker"}
                   {:name "Penguin"}]}
     ])
  (du/cleanup conn))
 
(ns clojure-north-2020.ch02-datalog.x02-queries
  (:require [clojure-north-2020.ch02-datalog.x01-schemas :refer [datascript-schema]]
            [datahike.api :as d]
            [datascript.core :as ds]))

Queries and Data Access

In this section we discuss the key ways to get data from a db. For Datascript and Datahike the APIs are the same except for time-travel functions. Other features are not identical across all implementations. For example, the entity API is not available in Datomic Cloud and as-of is not avaiable for Datascript.

We'll just use Datascript for this example as the APIs are identical for what is being demonstrated.

(def dsdb
  (ds/db-with
    (ds/empty-db
      datascript-schema)
    [{:name       "Batman"
      :alias      "Bruce Wayne"
      :powers     #{"Rich"}
      :weapons    #{"Utility Belt" "Kryptonite Spear"}
      :hair-color :black
      :alignment  "Chaotic Good"
      :nemesis    [{:name "Joker"}
                   {:name "Penguin"}]}
     {:name  "Batman"
      :alias "Bruce"}]))

Exercise: Try the following in a REPL to understand the query methods.

The Pull API

This allows you to 'pull' facts straight from a db given an identity.

(ds/pull dsdb '[*] 1)

You can use 'lookup refs' for any unique identity (entity or value).

(ds/pull dsdb '[*] [:name "Batman"])

You can specify certain keys as well as do more complex attribute specs.

(ds/pull dsdb '[:name {:nemesis [:name]}] [:name "Batman"])

The Entity API

When you get an entity it's like situating yourself at a node in a graph db.

(:name (ds/entity dsdb [:name "Batman"]))

You can navigate any way you want. This is a forward reference.

(->> (ds/entity dsdb [:name "Batman"])
     :nemesis)

You can also do 'backrefs' to walk the link backwards.

(->> (ds/entity dsdb [:name "Joker"])
     :_nemesis)

Exercise: How might you modify the last two queries to get the names of the nemeses? This is much more straightforward than our brute-force seq function driven approach.

The Query API

This is the most powerful and most commonly used API.

Queries have a powerful datalog syntax. Note that queries are data. It is common to inline them with the q function, but they can be stored up as standalone items in a file, db, etc.

(def nemeses-query
  '[:find [?enemy-name ...]
    :in $ ?name
    :where
    [?e :name ?name]
    [?e :nemesis ?n]
    [?n :name ?enemy-name]])

Get the nemeses of Batman

(ds/q nemeses-query dsdb "Batman")

Exercise - Write a query to list the name and alignment of all individuals in the database.

(comment
  (ds/q
    '[:find ...]
    ;;Add some more facts to make it interesting
    (ds/db-with
      dsdb
      [{:name "Joker" :alignment "Chaotic Evil"}
       {:name "Darth Vader" :alignment "Lawful Evil"}])))
 
(ns clojure-north-2020.ch02-datalog.x02-queries-solutions)
(comment
  (require '[datascript.core :as ds])
  (require '[clojure-north-2020.ch02-datalog.x02-queries :refer [dsdb]])
  (ds/q
    '[:find ?name ?alignment
      :in $
      :where
      [?e :name ?name]
      [?e :alignment ?alignment]]
    (ds/db-with
      dsdb
      [{:name "Joker" :alignment "Chaotic Evil"}
       {:name "Darth Vader" :alignment "Lawful Evil"}]))
  )
 
(ns clojure-north-2020.ch02-datalog.x03-hero-schema
  (:require [clojure-north-2020.ch02-datalog.datahike-utils :as du]))

Loading the Hero Schema and Normalizing the Data

Here we are loading in our pre-created basic hero schema.

(def schema (du/read-edn "schemas/datahike/hero-schema.edn"))

It is common to have a function that transforms the data into a schema compliant format. This can also be achieved with database or transaction functions, which are beyond the scope of this workshop.

(defn hero->dh-format [{:keys [publisher] :as hero}]
  (cond-> hero publisher (update :publisher (fn [p] {:name p}))))
(comment
  (require
    '[clojure-north-2020.ch01-data.x03-hero-data :as hd]
    '[datahike.api :as d])
  (def conn (du/conn-from-dirname "tmp/hero-data-schema"))
  (d/transact conn schema)
  (count (d/transact conn (mapv hero->dh-format (hd/heroes-data))))
  (d/pull @conn '[*] [:name "Spider-Man"])
  (du/cleanup conn))
 
(ns clojure-north-2020.ch02-datalog.x04-hero-powers-schema
  (:require [clojure-north-2020.ch02-datalog.datahike-utils :as du]))

Loading the Hero Powers Schema

No modification of data is required.

(def schema (du/read-edn "schemas/datahike/hero-powers-schema.edn"))
(comment
  (require
    '[clojure-north-2020.ch01-data.x04-hero-powers-data :as hpd]
    '[datahike.api :as d])
  (def conn (du/conn-from-dirname "tmp/hero-powers-schema"))
  (d/transact conn schema)
  (count (d/transact conn (vec (hpd/powers-data))))
  (d/pull @conn '[*] [:name "Spider-Man"])
  ;;NOTE! - The attribute is :powers, not :power!!!
  ;; ## Exercise: Who has the power :levitation?
  (d/q
    '[...]
    @conn :levitation)
  ;; ## Exercise: Who has the same powers as the named super? Return a map of
  ;; power to sequence of names of hero with shared power.
  (let [hero-name "Yoda"]
    (->> (d/q
           '[...]
           @conn hero-name)
         (reduce (fn [m [n p]] (update m p conj n)) {})))
  ;; ## Exercise: List heroes by number of powers. Who has the most?
  (sort-by
    second
    (d/q
      '[...]
      @conn))
  (du/cleanup conn))
 
(ns clojure-north-2020.ch02-datalog.x04-hero-powers-schema-solutions)
(comment
  ;; ## Exercise: Who has the power :levitation?
  (d/q
    '[:find [?name ...]
      :in $ ?power
      :where
      [?e :name ?name]
      [?e :powers ?power]]
    @conn :levitation)

  ;; ## Exercise: Who has the same powers as the named super? Return a map of
  ;; power to sequence of names of hero with shared power.
  (let [hero-name "Yoda"]
    (->> (d/q
           '[:find ?that-name ?power
             :in $ ?name
             :where
             [?e :name ?name]
             [?e :powers ?power]
             [?f :powers ?power]
             [?f :name ?that-name]
             [(not= ?e ?f)]]
           @conn hero-name)
         (reduce (fn [m [n p]] (update m p conj n)) {})))

  ;; ## Exercise: List heroes by number of powers. Who has the most?
  (sort-by
    second
    (d/q
      '[:find ?name (count ?power)
        :in $
        :where
        [?e :name ?name]
        [?e :powers ?power]]
      @conn)))
 
(ns clojure-north-2020.ch02-datalog.x05-supplemental-hero-data-schema
  (:require [clojure-north-2020.ch02-datalog.datahike-utils :as du]))

Loading the Supplemental Hero Schema and Normalizing the Data

(def schema (du/read-edn "schemas/datahike/supplemental-hero-data-schema.edn"))
(defn add-stat-ids [{hero-name :name :as hero}]
  (letfn [(add-stat-id [stats]
            (map (fn [{stat-name :stat/name :as stat}]
                   (assoc stat :hero.stat/id (str hero-name "/" (name stat-name))))
                 stats))]
    (update hero :stats add-stat-id)))
(defn add-relative-ids [{hero-name :name :as hero}]
  (letfn [(add-relative-id [relatives]
            (map (fn [{:keys [relative] :as r}]
                   (assoc r :hero.relative/id (str hero-name "/" (:name relative))))
                 relatives))]
    (update hero :relatives add-relative-id)))
(defn add-team-affiliations [{hero-name :name :as hero}]
  (letfn [(add-team-affiliation [team-affiliation]
            (map (fn [{tn :team/name :as r}]
                   (assoc r :hero.team/id (str hero-name "/" tn)))
                 team-affiliation))]
    (update hero :team-affiliation add-team-affiliation)))
(defn creator-name->creator-ref [{:keys [creator] :as hero}]
  (cond-> hero creator (update :creator (fn [p] {:name p}))))
(defn hero->dh-format [hero]
  (-> hero
      add-stat-ids
      add-relative-ids
      add-team-affiliations
      creator-name->creator-ref))
(comment
  (require
    '[clojure-north-2020.ch01-data.x05-supplemental-hero-data :as shd]
    '[datahike.api :as d])
  (def conn (du/conn-from-dirname "tmp/supplemental-hero-data-schema"))
  (d/transact conn schema)
  (count (d/transact conn (mapv hero->dh-format (shd/supplemental-hero-data))))
  ;;Execute the following to see what data is provided by this dataset.
  (d/pull @conn '[*] [:name "Spider-Man"])
  (du/cleanup conn))
 
(ns clojure-north-2020.ch02-datalog.x06-the-ultimate-db
  (:require [clojure-north-2020.ch01-data.x03-hero-data :as x03d]
            [clojure-north-2020.ch01-data.x04-hero-powers-data :as x04d]
            [clojure-north-2020.ch01-data.x05-supplemental-hero-data :as x05d]
            [clojure-north-2020.ch02-datalog.datahike-utils :as du]
            [clojure-north-2020.ch02-datalog.x03-hero-schema :as x03]
            [clojure-north-2020.ch02-datalog.x04-hero-powers-schema :as x04]
            [clojure-north-2020.ch02-datalog.x05-supplemental-hero-data-schema :as x05]
            [datahike.api :as d]))

Create our Final Schema

Now that we have our 3 data sets with their corresponding schemas we can transact all of the data into one unified dataset. The schema can be combined into one as shown here or each can be transacted independently as seen in the comment block below.

The truly powerful thing about this family of databases is the fact that no special logic, tables, schemas, etc. were needed to join the data into a unified data set. The existence of shared unique attributes provides implicit joins for a very interesting data set.

(def schema
  (vec (distinct (concat x03/schema x04/schema x05/schema))))
(comment
  (def conn (du/conn-from-dirname "tmp/the-ultimate-db"))
  (count @conn)
  (d/transact conn x03/schema)
  (d/transact conn x04/schema)
  (d/transact conn x05/schema)
  (keys (d/transact conn (mapv x03/hero->dh-format (x03d/heroes-data))))
  (keys (d/transact conn (vec (x04d/powers-data))))
  (keys (d/transact conn (mapv x05/hero->dh-format (x05d/supplemental-hero-data))))
  (count @conn)
  ;;Once you load the above, try the following out.
  ;;
  ;; We now have a very cool set of information about our heroes, with
  ;; attributes such as teams, powers, occupations, stats, alter egos, and more.
  (d/pull @conn '[*] [:name "Spider-Man"])
  ;; Note that this will destroy the db.
  (du/cleanup conn))
 
(ns clojure-north-2020.ch02-datalog.x07-queries
  (:require [datahike.api :as d]))

Queries

We can now build a library of useful queries. Note that these are all data. You can store these as edn if desired, or maintain a ns or nses of queries. If care is taken, queries can be db-independent. All of these should work with both datascript and datahike.

find the name and alignment for all entities in the db.

(def alignment-query
  '[:find ?name ?alignment
    :in $
    :where
    [?e :name ?name]
    [?e :alignment ?alignment]])

find the distinct alignments in db.

(def distinct-alignments-query
  '[:find [?alignment ...]
    :in $
    :where
    [_ :alignment ?alignment]])

find the nemeses of a given hero.

(def nemeses-query
  '[:find [?nemesis-name ...]
    :in $ ?name
    :where
    [?e :name ?name]
    [?e :nemesis ?nemesis]
    [?nemesis :name ?nemesis-name]])

Find powers shared between this super and others

(def shared-powers-query
  '[:find ?that-name ?power
    :in $ ?this-name
    :where
    [?e :name ?this-name]
    [?e :powers ?power]
    [?f :powers ?power]
    [?f :name ?that-name]
    [(not= ?e ?f)]])

Return all supers by name and power count

(def powers-by-count-query
  '[:find ?name (count ?powers)
    :in $
    :where
    [?e :name ?name]
    [?e :powers ?powers]])

Exercise

Return a set of 3-tuples (name, race, power) of all heroes in the db of the same race as the input hero.

(def shared-powers-by-race-query
  [])
(def schema-query
  '[:find [(pull ?e [*]) ...]
    :in $
    :where
    [?e :db/ident ?ident]])
(def name-query
  '[:find [?name ...]
    :in $
    :where
    [?e :name ?name]])

Determine the set of valid values for each keyword (enum) type in the db.

Exercise

Write a query that determines all values for attributes where the type is schema. For example, what are the extant eye-colors or genders in the db?

(def distinct-ident-keywords-query
  [])
(comment
  (require '[clojure-north-2020.ch02-datalog.datahike-utils :as du]
           '[datahike.api :as d]
           '[clojure.set :refer [intersection]])
  ;;Use the previous "ultimate" db (don't clean it up yet).
  (def conn (du/conn-from-dirname "tmp/the-ultimate-db"))
  ;;Reality check
  (count @conn)
  ;; We now have a very cool set of information about our heroes, with
  ;; attributes such as teams, powers, occupations, stats, alter egos, and more.
  (d/pull @conn '[*] [:name "Spider-Man"])
  (d/pull @conn '[*] [:name "Odin"])
  (d/pull @conn '[*] [:name "Thor"])
  (d/pull @conn '[*] [:name "Spectre"])
  (d/pull @conn '[*] [:name "Superman"])
  (d/pull @conn '[*] [:name "Faora"])
  (->> (d/q powers-by-count-query @conn)
       (sort-by second))
  ;; ## Exercise
  ;; Determine the shared powers of heroes of a given race using only
  ;; their name. Using this query, determine the powers of Kryptonians.
  (defn shared-powers-by-race [hero-name]
    (->> (d/q
           ;Fill this in above. Might want to inline for the exercise.
           shared-powers-by-race-query
           @conn hero-name)
         (group-by (fn [[n r]] [n r]))
         (map (fn [[k v]]
                (let [m (zipmap [:name :race] k)]
                  m (assoc m :powers (set (map last v))))))))
  (shared-powers-by-race "Superman")
  (shared-powers-by-race "Spider-Man")
  (shared-powers-by-race "Thor")
  ;;Given the above result, determine all powers common to Kryptonians
  ;;and Asgardians by starting with an example of one.
  ;;Note - Data quality issue.
  (:race (d/entity @conn [:name "Odin"]))
  (:race (d/entity @conn [:name "Thor"]))
  ;; We can query the db using the schemas as well. The
  ;; distinct-ident-keywords-query should return the attribute-value
  ;; combinations for all idents in the db where the type is keyword.
  (->> (d/q distinct-ident-keywords-query @conn)
       (group-by first)
       (map (fn [[k v]] [k (set (map second v))]))
       (into {}))
  (du/cleanup conn))
 
(ns clojure-north-2020.ch02-datalog.x07-queries-solutions)
(def shared-powers-by-race-query
  '[:find ?name ?race ?powers
    :in $ ?n
    :where
    [?e :name ?n]
    [?e :race ?race]
    [?e :powers ?powers]
    [?f :race ?race]
    [?f :name ?name]
    [?f :powers ?powers]
    [(not= ?e ?f)]])

Determine the set of valid values for each keyword (enum) type in the db.

(comment
  ;;All powers shared by Kryptonians
  (->> "Superman"
       shared-powers-by-race
       (map :powers)
       (apply intersection))
  ;;All powers shared by Asgardians
  (->> "Thor"
       shared-powers-by-race
       (map :powers)
       (apply intersection))
  (def distinct-ident-keywords-query
    '[:find ?ident ?v
      :in $
      :where
      [?e :db/ident ?ident]
      [?e :db/valueType :db.type/keyword]
      [_ ?ident ?v]]))
 
(ns clojure-north-2020.ch03-web.x01-basic
  (:require [clojure.pprint :as pp]
            [ring.adapter.jetty :as jetty]))

Clojure Web App Basics

Now that we have a cool database, let's build a backend application that allows us to interact with it via RESTful service. We'll start with a simple explanation of what and how web apps work in Clojure. Once we understand how web apps work we'll wire in our database. Finally, we'll look at a technique for "pushing state to the edges" of our application for a truly functional app that still has state.

The Basic Web App

Here we have the most basic Clojure application possible, consisting of exactly two things:

  1. A jetty web server (Add [ring/ring-jetty-adapter "1.8.0"] to your dependencies.)
  2. A handler - a single function that takes a request (a map) and returns a response (also a map)
(defn hello-handler [_request]
  {:status 200
   :body   "Hello Clojure!"})
(defn request-dump-handler
  [request]
  {:status 200
   :body   (with-out-str (pp/pprint request))})

Exercise - Change the handler to request dump handler.

This can be done as simply as evaluating (def handler request-dump-handler) in the REPL and refreshing your browser.

(def handler hello-handler)

Our one and only web server. Notice that the hander is "var quoted" using #'. This means the handler will resolve to the handler function when called rather than being evaluated to the value of the handler when the server is created. This allows for live code reloading.

(defonce server (jetty/run-jetty #'handler {:host  "0.0.0.0"
                                            :port  3000
                                            :join? false}))
(comment
  (require '[clojure.java.browse :refer [browse-url]])
  (browse-url "http://localhost:3000")
  (.stop server))
 
(ns clojure-north-2020.ch03-web.x02-routes
  (:require [clojure.pprint :as pp]
            [ring.adapter.jetty :as jetty]))

Handler Roles/Concerns

Handlers have 3 main concerns:

  • Routing - what subhandler/business logic is to be executed
  • Business Logic - The actual logic you want to execute
  • Response - Transform the BI result into an appropriate HTTP response

In this namespace we split out our API, handler, and routing logic.

Protip - Separate logic from handlers

Business logic should know nothing about the calling context. If you are returning http response codes from or passing in web concepts to your business logic you are complecting your application. This particular "API" is contrived, but as we'll see in the future we can use completely independent API logic in our servers without the logic knowing anything about its surrounding context.

(defn greet [greetee]
  (format "Hello, %s!" (or greetee "Clojurian")))

Protip - Separate handlers from routing

A well written handler contains very little, if any business logic. It should simply parse a request, invoke external business logic, and format a response, including setting proper response codes for non-happy-path execution.

(defn hello-handler [_request]
  {:status 200
   :body   (greet nil)})
(defn request-dump-handler [request]
  {:status 200
   :body   (with-out-str (pp/pprint request))})

The Global Handler

Here we have some very simple routing logic based on case matching the uri from the request. Aside from demonstrating the concept that handlers are nothing more than a request->return map function you probably want to use a real routing library in real life like Reitit or Compojure.

Protip - Only perform routing in the global handler

Defer handling of specific routes to their own functions. This facilitates independent testing of each handler and recomposition of routes.

(defn handler [{:keys [uri] :as request}]
  (case uri
    ;; ### Exercise - modify the hello handler to greet the invoker by name if
    ;; the "name" query param is set. E.g. http://localhost:3000/hello?name=Mark
    ;; should return "Hello, Mark!" Tip - Use the request-dump-handler to
    ;; understand how to parse the request map.
    "/hello" (hello-handler request)
    "/dump" (request-dump-handler request)
    {:status 404
     :body "Sorry, I only understand hello and dump"}))
(defonce server (jetty/run-jetty #'handler {:host  "0.0.0.0"
                                            :port  3000
                                            :join? false}))
(comment
  (require '[clojure.java.browse :refer [browse-url]])
  (browse-url "http://localhost:3000")
  (.stop server))
 
(ns clojure-north-2020.ch03-web.x02-routes-solutions)
(defn greet [greetee]
  (format "Hello, %s!" (or greetee "Clojurian")))
(defn hello-handler [{:keys [query-string] :as _request}]
  (let [[_ greetee] (some->> query-string (re-matches #"name=(.+)"))]
    {:status 200
     :body   (greet greetee)}))
 
(ns clojure-north-2020.ch03-web.x03-responses
  (:require [clojure.pprint :as pp]
            [ring.adapter.jetty :as jetty]
            [ring.util.http-response :refer [content-type header not-found ok]]))

Use ring.util.http-response

In this namespace we introduce the ring.util.http-response library. It is added to your project with the [metosin/ring-http-response "0.9.1"] dependency.

We can now formulate responses using simple functions instead of hand-rolling maps for each endpoint.

Note that this library does nothing more than modify maps.

With the exception of response formatting, everything here is identical to the previous exercise.

Business Logic

(defn greet [greetee]
  (format "Hello, %s!" (or greetee "Clojurian")))

"Local" handlers

(defn hello-handler [{:keys [query-string] :as request}]
  (let [[_ greetee] (some->> query-string (re-matches #"name=(.+)"))]
    (ok (greet greetee))))
(defn request-dump-handler [request]
  (ok (with-out-str (pp/pprint request))))

"Global" handler which is mostly routing to local handlers

(defn handler [{:keys [uri] :as request}]
  (case uri
    "/hello" (hello-handler request)
    "/dump" (request-dump-handler request)
    (not-found "Sorry, I don't understand that path.")))
(defonce server (jetty/run-jetty #'handler {:host  "0.0.0.0"
                                            :port  3000
                                            :join? false}))

Exercise

Evaluate the following forms to see that these helper functions are nothing more than simple data manipulators.

(comment
  ;Basic responses
  (ok "All I do is modify maps.")
  (not-found "You did't find me.")
  ;Use a threading macro with a basic response + response modifiers
  (-> (ok "Watch this")
      (content-type "text/plain")
      (header "Content-Disposition" "attachment; filename=\"foo.txt\""))

  (require '[clojure.java.browse :refer [browse-url]])
  (browse-url "http://localhost:3000")
  (.stop server)
  )
 
(ns clojure-north-2020.ch03-web.x04-reitit
  (:require [clojure.pprint :as pp]
            [muuntaja.core :as m]
            [reitit.coercion.spec]
            [reitit.ring :as ring]
            [reitit.ring.coercion :as coercion]
            [reitit.ring.middleware.exception :as exception]
            [reitit.ring.middleware.multipart :as multipart]
            [reitit.ring.middleware.muuntaja :as muuntaja]
            [reitit.ring.middleware.parameters :as parameters]
            [reitit.swagger :as swagger]
            [reitit.swagger-ui :as swagger-ui]
            [ring.adapter.jetty :as jetty]
            [ring.util.http-response :refer [content-type header not-found ok]]))

Reitit: A library for routing + Swagger

The details behind this ns are beyond the scope of this workshop, but the key detail to remember is that one of the concerns of a handler is routing. Until now we've just used a case statement on a request to do routing. This ns expands on that idea with the following additions:

  1. We use the reitit library to create data-driven routes. You should, by inspection, be able to determine how to add new routes.
  2. Reitit also adds several "middlewares" to the request that transforms the handler to do additional logic such as parameter extraction.
  3. We add a swagger ui handler to create a convenient Swagger UI.

Business Logic

(defn greet [greetee]
  (format "Hello, %s!" (or greetee "Clojurian")))

"Local" handlers

(defn hello-handler [{:keys [params] :as request}]
  (ok (greet (params "name"))))
(defn request-dump-handler [request]
  (ok (with-out-str (pp/pprint request))))

"Global" handler which is mostly routing to local handlers

(def router
  (ring/router
    [["/swagger.json"
      {:get {:no-doc  true
             :swagger {:info     {:title "my-api"}
                       :basePath "/"}
             :handler (swagger/create-swagger-handler)}}]
     ["/basic"
      {:swagger {:tags ["Basic Routes"]}}
      ["/hello"
       {:get {:summary    "Say hello"
              :parameters {:query {:name string?}}
              :responses  {200 {:body string?}}
              :handler    hello-handler}}]
      ["/dump"
       {:get {:summary    "Dump the request"
              :parameters {:query {:query-param string?}}
              :responses  {200 {:body string?}}
              :handler    request-dump-handler}}]]]
    {:data {:coercion   reitit.coercion.spec/coercion
            :muuntaja   m/instance
            :middleware [parameters/parameters-middleware
                         muuntaja/format-negotiate-middleware
                         muuntaja/format-response-middleware
                         exception/exception-middleware
                         muuntaja/format-request-middleware
                         coercion/coerce-response-middleware
                         coercion/coerce-request-middleware
                         multipart/multipart-middleware]}}))
(def handler
  (ring/ring-handler
    router
    (ring/routes
      (swagger-ui/create-swagger-ui-handler {:path "/"})
      (ring/create-default-handler))))
(defonce server (jetty/run-jetty #'handler {:host  "0.0.0.0"
                                            :port  3000
                                            :join? false}))
(comment
  (require '[clojure.java.browse :refer [browse-url]])
  (browse-url "http://localhost:3000")
  (.stop server)
  )
 
(ns clojure-north-2020.ch03-web.x05-state
  (:require [clojure-north-2020.ch01-data.x03-hero-data :as x03d]
            [clojure-north-2020.ch01-data.x04-hero-powers-data :as x04d]
            [clojure-north-2020.ch01-data.x05-supplemental-hero-data :as x05d]
            [clojure-north-2020.ch02-datalog.datahike-utils :as du]
            [clojure-north-2020.ch02-datalog.x03-hero-schema :as x03]
            [clojure-north-2020.ch02-datalog.x04-hero-powers-schema :as x04]
            [clojure-north-2020.ch02-datalog.x05-supplemental-hero-data-schema :as x05]
            [clojure-north-2020.ch02-datalog.x07-queries :as x07]
            [clojure.edn :as edn]
            [clojure.pprint :as pp]
            [datahike.api :as d]
            [muuntaja.core :as m]
            [reitit.coercion.spec]
            [reitit.ring :as ring]
            [reitit.ring.coercion :as coercion]
            [reitit.ring.middleware.exception :as exception]
            [reitit.ring.middleware.multipart :as multipart]
            [reitit.ring.middleware.muuntaja :as muuntaja]
            [reitit.ring.middleware.parameters :as parameters]
            [reitit.swagger :as swagger]
            [reitit.swagger-ui :as swagger-ui]
            [ring.adapter.jetty :as jetty]
            [ring.util.http-response :refer [bad-request not-found ok]]))

State: Let's make our app do something

Any useful app will have some sort of backend state, usually a database. At this point we are finally going to pull in our really cool datahike db and add some cool queries and other interactions.

Here we create a global link to our ultimate db. This is actually terrible in practice, but is often done by inexperienced Clojurians as they aren't really sure how to handle stateful interactions as they try to develop functional apps. What we want to do is "push our state to the edges" as is often said by more advanced Clojurians. We'll discuss this concept in the next chapter and implement the concept.

Despite the ugliness of how we're wiring in our db, it is a beautiful thing that we can arrive at this state from a completely bottom-up, data-driven design. We modeled our system as data, built a schema around it, created a few normalization functions as needed, and loaded our data into a database. Queries were data driven and reusable. At each stage of this process we built something useful and did not need to go back and modify our designs to wire in future stages of our system. With the exception of the db, everything until now has been purely functional. The db itself is a value when the current state is retrieved for use in queries.

(defonce conn (du/conn-from-dirname "tmp/the-ultimate-db"))

Business Logic

(defn greet [greetee]
  (format "Hello, %s!" (or greetee "Clojurian")))
(defn load-schemas []
  (d/transact conn x03/schema)
  (d/transact conn x04/schema)
  (d/transact conn x05/schema))
(defn load-data []
  (let [before (count @conn)
        _ (d/transact conn (mapv x03/hero->dh-format (x03d/heroes-data)))
        _ (d/transact conn (vec (x04d/powers-data)))
        _ (d/transact conn (mapv x05/hero->dh-format (x05d/supplemental-hero-data)))
        after (count @conn)]
    {:datoms-before before
     :datoms-after  after
     :datoms-added  (- after before)}))

"Local" handlers

(defn hello-handler [{:keys [params] :as _request}]
  (ok (greet (params "name"))))
(defn request-dump-handler [request]
  (ok (with-out-str (pp/pprint request))))
(defn load-schemas-handler [_request]
  (load-schemas)
  (ok "Schemas Loaded"))
(defn load-data-handler [_request]
  (ok (load-data)))
(defn hero-data-handler [{:keys [params] :as _request}]
  (let [n (params "name")]
    (try
      (ok (d/pull @conn '[*] [:name n]))
      (catch Throwable e
        (not-found (format "Superhero \"%s\" not found." n))))))
(defn datom-count-handler [_request]
  (ok {:datoms (count @conn)}))
(defn schema-handler [_request]
  (ok (map
        #(dissoc % :db/id)
        (d/q x07/schema-query @conn))))
(defn hero-names-handler [_request]
  (ok (sort (d/q x07/name-query @conn))))
(defn add-hero-handler [{:keys [body-params] :as _request}]
  (try
    (let [{:keys [tempids]} (d/transact conn [body-params])]
      (ok tempids))
    (catch Exception e
      (bad-request (.getMessage e)))))

"Global" handler which is mostly routing to local handlers

(def router
  (ring/router
    [["/swagger.json"
      {:get {:no-doc  true
             :swagger {:info     {:title "my-api"}
                       :basePath "/"}
             :handler (swagger/create-swagger-handler)}}]
     ["/query"
      {:swagger {:tags ["Query Routes"]}}
      ["/load-schemas"
       {:get {:summary   "Load the schemas in the db."
              :responses {200 {:body string?}}
              :handler   load-schemas-handler}}]
      ["/load-data"
       {:get {:summary   "Load the data in the db."
              :responses {200 {:body {:datoms-before int?
                                      :datoms-after  int?
                                      :datoms-added  int?}}}
              :handler   load-data-handler}}]
      ["/hero"
       {:get {:summary    "Get data about a hero."
              :parameters {:query {:name string?}}
              :responses  {200 {:body {}}
                           404 {:body string?}}
              :handler    hero-data-handler}}]
      ["/datom-count"
       {:get {:summary   "Get the number of datoms in the system."
              :responses {200 {:body {:datoms int?}}}
              :handler   datom-count-handler}}]
      ["/schema"
       {:get {:summary   "Get the schema from the db."
              :responses {200 {:body [{}]}}
              :handler   schema-handler}}]
      ["/names"
       {:get {:summary   "Get all superhero names"
              :responses {200 {:body [string?]}}
              :handler   hero-names-handler}}]
      ["/add"
       {:post {:summary    "Add a new superhero"
               :responses  {200 {:body {}}}
               :parameters {:body {:name string?}}
               :handler    add-hero-handler}}]
      ;; ## Exercise: Add an endpoint
      ]
     ["/basic"
      {:swagger {:tags ["Basic Routes"]}}
      ["/hello"
       {:get {:summary    "Say hello"
              :parameters {:query {:name string?}}
              :responses  {200 {:body string?}}
              :handler    hello-handler}}]
      ["/dump"
       {:get {:summary   "Dump the request"
              :responses {200 {:body string?}}
              :handler   request-dump-handler}}]]]
    {:data {:coercion   reitit.coercion.spec/coercion
            :muuntaja   m/instance
            :middleware [parameters/parameters-middleware
                         muuntaja/format-negotiate-middleware
                         muuntaja/format-response-middleware
                         exception/exception-middleware
                         muuntaja/format-request-middleware
                         coercion/coerce-response-middleware
                         coercion/coerce-request-middleware
                         multipart/multipart-middleware]}}))
(def handler
  (ring/ring-handler
    router
    (ring/routes
      (swagger-ui/create-swagger-ui-handler {:path "/"})
      (ring/create-default-handler))))
(comment
  (defonce server (jetty/run-jetty #'handler {:host  "0.0.0.0"
                                              :port  3000
                                              :join? false}))
  (require '[clojure.java.browse :refer [browse-url]])
  (browse-url "http://localhost:3000")
  (.stop server)
  (du/cleanup conn))
 
(ns clojure-north-2020.ch03-web.x05-state-solutions
  (:require [clojure.edn :as edn]
            [datahike.api :as d]
            [ring.util.http-response :refer [bad-request ok]]))
(comment
  (defn q-handler [{:keys [body-params] :as _request}]
    (try
      (let [q (edn/read-string (:query body-params))]
        (ok (d/q q @conn)))
      (catch Exception e
        (bad-request (.getMessage e)))))
  ["/q"
   {:post {:summary    "Invoke a generic query on the db."
           :parameters {:body {:query string?}}
           :handler    q-handler}}]
  ;Example request with content type of application/edn
  {:query "[:find [(pull ?e [*]) ...]
    :in $
    :where
    [?e :db/ident ?ident]]"})
 

Don't look at this until we get to "x03-the-final-system." All will be revealed.

(ns clojure-north-2020.ch04-application.parts.datahike
  (:require [clojure.java.io :as io]
            [clojure.pprint :as pp]
            [datahike.api :as d]
            [integrant.core :as ig]
            [taoensso.timbre :as timbre]))
(defn file->datahike-db-uri [file]
  (let [f (io/file file)
        _ (io/make-parents f)]
    (str "datahike:" (io/as-url f))))
(defmethod ig/init-key ::database
  [_ {:keys [db-uri db-file initial-tx schema-on-read temporal-index]
      :as   config}]
  (if-some [uri (or db-uri (file->datahike-db-uri db-file))]
    (let [args (cond-> [uri]
                       initial-tx (conj :initial-tx initial-tx)
                       schema-on-read (conj :schema-on-read schema-on-read)
                       temporal-index (conj :temporal-index temporal-index))]
      (timbre/debug "Ensuring Datahike DB database.")
      (when-not (d/database-exists? uri)
        (timbre/debugf "Datahike DB database does not exist... Creating %s." uri)
        (apply d/create-database args))
      (timbre/debugf "Datahike DB uri: %s" uri)
      (assoc config :db-uri uri))
    (timbre/error "No uri provided for database")))
(defmethod ig/halt-key! ::database [_ {:keys [db-uri delete-on-halt?]}]
  (when delete-on-halt?
    (timbre/debug "Deleting Datahike DB database.")
    (d/delete-database db-uri)))
(defmethod ig/init-key ::connection [_ {:keys [db-uri db-config]}]
  (if-some [uri (or db-uri (:db-uri db-config))]
    (do
      (timbre/debug "Creating Datahike DB connection.")
      (d/connect uri))
    (timbre/error "No db-uri provided for Datahike connection")))
(defmethod ig/halt-key! ::connection [_ connection]
  (if connection
    (do
      (timbre/debug "Releasing Datahike DB connection.")
      (d/release connection))
    (timbre/warn "No Datahike connection to release!")))
 

Don't look at this until we get to "x03-the-final-system." All will be revealed.

(ns clojure-north-2020.ch04-application.parts.jetty
  (:require [integrant.core :as ig]
            [ring.adapter.jetty :as jetty]
            [taoensso.timbre :as timbre])
  (:import (org.eclipse.jetty.server Server)))

This is a 'middleware' - a function that takes a handler and returns a new handler. For ring middlewares, both inbound handler and new hander take a request and return a response.

(defn wrap-component [handler component]
  (fn [request] (handler (into component request))))
(defmethod ig/init-key ::server [_ {:keys [handler] :as m}]
  (timbre/debug "Launching Jetty web server.")
  (jetty/run-jetty (wrap-component handler m) m))
(defmethod ig/halt-key! ::server [_ ^Server server]
  (timbre/debug "Stopping Jetty web server.")
  (.stop server))
 
(ns clojure-north-2020.ch04-application.x01-integrant
  (:require [clojure-north-2020.ch03-web.x05-state :as x05-state]
            [integrant.core :as ig]
            [ring.adapter.jetty :as jetty]
            [taoensso.timbre :as timbre])
  (:import (org.eclipse.jetty.server Server)))

Building Reloadable Systems with Integrant

Until now we've just used global, stateful components in our systems, namely a jetty web server and a datahike db as needed. This leaves lots of fragmented, global state sprinked about out program. There are also decoupled methods scattered about for initializing and cleaning up each component in the system.

It's time to fix this by "pushing our state to the edges of our system." This means we want all stateful components in one controlled location that we can feed into one side of the system (the ingress edge) and treat as values at the other edge of the system (the egress side) such that the system appears functional to the user. Outside of the setup of the system (initialization) and the leaf functions/methods of the system where the actual components are used the entire system behaves as a collection of functions.

One great aspect of our design so far is that we really are only a couple of steps away from achieving this design already. With the exception of our two stateful components (web server and connection) the entire api is nothing but functions and data. We don't need to adapt any of these building blocks to our new design. We'll just use our bottom-up set of functions as-is.

Step 1: Make Centralized, Reloadable Components

Integrant (aliased as ig) is a library for describing stateful resource configurations as maps. Upon initialization, each val in the config map is exchanged for a stateful thing using the config data in each key's value. The following example will illustrate the concept.

Note that other good libraries exist for handling state, such as Component and Mount.

Web Handlers

In this ns we've just got our single OK handler.

(defn handler [_request]
  {:status 200 :body "OK"})

Exercise - Change the handler

Once you've started the system (below), swap out the handler for the one we've already developed. Note that we're decoupling yet another concern - system state management. You can just pull in existing functions and use them (However, this handler does have issues that we'll fix later). (def handler x05-state/handler)

Configuration

Here is our config map. It has one key, ::server, which has configuration options for a ring-jetty adapter. Recall that when ig/init is called the vals of this map will be exchanged for a stateful component. In this case, we will go from the map {::server config-map} to {::server web-server-using-the-config-map}.

(def config
  {::server {:host    "0.0.0.0"
             :port    3000
             :join?   false
             :handler #'handler}})

Integrant Implementation

To use Integrant, implement the ig/init-key and ig/halt-key (optional) multimethods so that when the system is initialized logic is registered for each key.

(defmethod ig/init-key ::server [_ {:keys [handler] :as m}]
  (timbre/debug "Launching Jetty web server.")
  (jetty/run-jetty handler m))
(defmethod ig/halt-key! ::server [_ ^Server server]
  (timbre/debug "Stopping Jetty web server.")
  (.stop server))

System Boilerplate

We'll now wrap the ig/init and ig/halt! methods in some standard boilerplate to create a restartable system with the following self-explanatory functions:

  1. (start) - Start the system
  2. (stop) - Stop the system
  3. (restart) - Restart the system
  4. (system) - Get a handle to the system

Exercise - Try them out.

What we've done so far may not seem awesome, but we've done a few useful things:

  • Created a method to centrally contain and manage stateful items
  • Created a facility to centrally and cleanly manage system states
(defonce ^:dynamic *system* nil)
(defn system [] *system*)
(defn start []
  (alter-var-root #'*system* (fn [s] (if-not s (ig/init config) s))))
(defn stop []
  (alter-var-root #'*system* (fn [s] (when s (ig/halt! s) nil))))
(defn restart [] (stop) (start))
 
(ns clojure-north-2020.ch04-application.x02-the-final-handler
  (:require [clojure-north-2020.ch01-data.x03-hero-data :as x03d]
            [clojure-north-2020.ch01-data.x04-hero-powers-data :as x04d]
            [clojure-north-2020.ch01-data.x05-supplemental-hero-data :as x05d]
            [clojure-north-2020.ch02-datalog.x03-hero-schema :as x03]
            [clojure-north-2020.ch02-datalog.x04-hero-powers-schema :as x04]
            [clojure-north-2020.ch02-datalog.x05-supplemental-hero-data-schema :as x05]
            [clojure-north-2020.ch02-datalog.x07-queries :as x07]
            [clojure.pprint :as pp]
            [datahike.api :as d]
            [muuntaja.core :as m]
            [reitit.coercion.spec]
            [reitit.ring :as ring]
            [reitit.ring.coercion :as coercion]
            [reitit.ring.middleware.exception :as exception]
            [reitit.ring.middleware.multipart :as multipart]
            [reitit.ring.middleware.muuntaja :as muuntaja]
            [reitit.ring.middleware.parameters :as parameters]
            [reitit.swagger :as swagger]
            [reitit.swagger-ui :as swagger-ui]
            [ring.util.http-response :refer [bad-request not-found ok]]
            [clojure.edn :as edn]))

Threading State: A Purely Functional API

We are now going to modify our API in a very simple way - We remove the global reference to the datahike connection and instead add it as a function parameter in our request. This may seem odd, but recall that Clojure handlers are nothing more than a function that takes a request and returns a response. All we need to do is inject our connection into the request to thread it through the handler. We'll do this in the next section.

This approach has several advantages:

  • To the extent that the parameters are values, the function is pure
  • There is no global state floating around
  • It is trivial to synthesize inputs to the handler
  • It is trivial to consistently manage arguments for different environments (e.g. test, dev, staging, etc.)

Business Logic

(defn greet [greetee]
  (format "Hello, %s!" (or greetee "Clojurian")))
(defn load-schemas [conn]
  (d/transact conn x03/schema)
  (d/transact conn x04/schema)
  (d/transact conn x05/schema))
(defn load-data [conn]
  (let [before (count @conn)
        _ (d/transact conn (mapv x03/hero->dh-format (x03d/heroes-data)))
        _ (d/transact conn (vec (x04d/powers-data)))
        _ (d/transact conn (mapv x05/hero->dh-format (x05d/supplemental-hero-data)))
        after (count @conn)]
    {:datoms-before before
     :datoms-after  after
     :datoms-added  (- after before)}))

"Local" handlers

(defn hello-handler [{:keys [params] :as _request}]
  (ok (greet (params "name"))))
(defn request-dump-handler [request]
  (ok (with-out-str (pp/pprint request))))

Note that dh-conn is now in the request map vs. being a global variable.

(defn load-schemas-handler [{:keys [dh-conn] :as _request}]
  (load-schemas dh-conn)
  (ok "Schemas Loaded"))
(defn load-data-handler [{:keys [dh-conn] :as _request}]
  (ok (load-data dh-conn)))
(defn hero-data-handler [{:keys [params dh-conn] :as _request}]
  (let [n (params "name")]
    (try
      (ok (d/pull @dh-conn '[*] [:name n]))
      (catch Throwable e
        (not-found (format "Superhero \"%s\" not found." n))))))
(defn datom-count-handler [{:keys [dh-conn] :as _request}]
  (ok {:datoms (count @dh-conn)}))
(defn schema-handler [{:keys [dh-conn] :as _request}]
  (ok (map
        #(dissoc % :db/id)
        (d/q x07/schema-query @dh-conn))))
(defn hero-names-handler [{:keys [dh-conn] :as _request}]
  (ok (sort (d/q x07/name-query @dh-conn))))
(defn add-hero-handler [{:keys [body-params dh-conn] :as _request}]
  (try
    (let [{:keys [tempids]} (d/transact dh-conn [body-params])]
      (ok tempids))
    (catch Exception e
      (bad-request (.getMessage e)))))
(defn q-handler [{:keys [body-params dh-conn] :as _request}]
  (try
    (let [q (edn/read-string (:query body-params))]
      (ok (d/q q @dh-conn)))
    (catch Exception e
      (bad-request (.getMessage e)))))

"Global" handler which is mostly routing to local handlers. Note that this DID NOT CHANGE AT ALL from the previous example. The state is at the edges, just being piped through. You could even put the hander/router in one ns, the function defs in another, and use an alias or import to swap out the handlers.

(def router
  (ring/router
    [["/swagger.json"
      {:get {:no-doc  true
             :swagger {:info     {:title "my-api"}
                       :basePath "/"}
             :handler (swagger/create-swagger-handler)}}]
     ["/query"
      {:swagger {:tags ["Query Routes"]}}
      ["/load-schemas"
       {:get {:summary   "Load the schemas in the db."
              :responses {200 {:body string?}}
              :handler   load-schemas-handler}}]
      ["/load-data"
       {:get {:summary   "Load the data in the db."
              :responses {200 {:body {:datoms-before int?
                                      :datoms-after  int?
                                      :datoms-added  int?}}}
              :handler   load-data-handler}}]
      ["/hero"
       {:get {:summary    "Get data about a hero."
              :parameters {:query {:name string?}}
              :responses  {200 {:body {}}
                           404 {:body string?}}
              :handler    hero-data-handler}}]
      ["/datom-count"
       {:get {:summary   "Get the number of datoms in the system."
              :responses {200 {:body {:datoms int?}}}
              :handler   datom-count-handler}}]
      ["/schema"
       {:get {:summary   "Get the schema from the db."
              :responses {200 {:body [{}]}}
              :handler   schema-handler}}]
      ["/names"
       {:get {:summary   "Get all superhero names"
              :responses {200 {:body [string?]}}
              :handler   hero-names-handler}}]
      ["/add"
       {:post {:summary    "Add a new superhero"
               :responses  {200 {:body {}}}
               :parameters {:body {:name string?}}
               :handler    add-hero-handler}}]
      ;; ## Exercise: Add an endpoint
      ]
     ["/basic"
      {:swagger {:tags ["Basic Routes"]}}
      ["/hello"
       {:get {:summary    "Say hello"
              :parameters {:query {:name string?}}
              :responses  {200 {:body string?}}
              :handler    hello-handler}}]
      ["/dump"
       {:get {:summary   "Dump the request"
              :responses {200 {:body string?}}
              :handler   request-dump-handler}}]]]
    {:data {:coercion   reitit.coercion.spec/coercion
            :muuntaja   m/instance
            :middleware [parameters/parameters-middleware
                         muuntaja/format-negotiate-middleware
                         muuntaja/format-response-middleware
                         exception/exception-middleware
                         muuntaja/format-request-middleware
                         coercion/coerce-response-middleware
                         coercion/coerce-request-middleware
                         multipart/multipart-middleware]}}))
(def handler
  (ring/ring-handler
    router
    (ring/routes
      (swagger-ui/create-swagger-ui-handler {:path "/"})
      (ring/create-default-handler))))
 
(ns clojure-north-2020.ch04-application.x03-the-final-system
  (:require [clojure-north-2020.ch02-datalog.x06-the-ultimate-db :as x06]
            [clojure-north-2020.ch04-application.parts.datahike :as datahike]
            [clojure-north-2020.ch04-application.parts.jetty :as jetty]
            [clojure-north-2020.ch04-application.x02-the-final-handler :as tfh]
            [integrant.core :as ig]))

Building a Library of Restartable Components

In a prior section we saw how to create a restartable system that contains all of our stateful system components. These components are started and stopped via the init-key and halt-key! multimethods.

We also saw how to create a handler that behaves as a function in which all stateful components (or even stateless components such as db as value) are injected into the handler function.

Now it's time to combine these concepts to put everything together.

Creating a library of Components

To make things more modular and reusable, we are going to move our multimethods to their own namespace to create a library of components. Take a look at the following:

  • clojure-north-2020.ch04-application.parts.jetty - This contains the same multimethod used in the previous namespace with one important change that we'll discuss in a moment.
  • clojure-north-2020.ch04-application.parts.datahike - This contains Integrant multimethods for creating both a datahike database and connection.

The wrap-component Middleware

The clojure-north-2020.ch04-application.parts.jetty ns now has this function, which wraps the inbound handler from the configuration map:

` (defn wrap-component [handler component] (fn [request] (handler (into component request)))) `

This middleware takes a request and pours the configured component into it prior to passing the request into the handler. Note that it is only the server component from the config that gets poured into the request, not the entire system.

Protip: Build a library of components

Whether you use Integrant, Component, or Mount, you will likely find yourself using the same boilerplate code for a given library (e.g. Jetty or Datahike). Write a library to collect your implementations or use an existing one. Mine, called "Partsbin," can be found here.

Configuring the Components

Here is our config map. It now has 3 components:

  • ::datahike/database - Configuration required to create a datahike database.
  • ::datahike/connection - Configuration required to create a datahike connection. Note that the connection requires a database, which is referred to using ig/ref but aliased locally as db-config.
  • ::jetty/server - Config for the web server, which also declares a dependency on the datahike connection as dh-conn.

Recall that our new functional handler needs a dh-conn in each request to invoke datahike-aware functions. When ig/init is called the config map exchanges the values of the map for started instances of each value and the wrap-component middleware add the configured ::jetty/server keys to its own requests. This completes the threading of the stateful component through the handler. Another great advantage to this approach is that the handler knows NOTHING about the config, integrant, or anything else about its calling context. You can just as easily fabricate a request map and assoc a dh-conn into it and feed that into the handler.

(def config
  {::jetty/server        {:host    "0.0.0.0"
                          :port    3000
                          :join?   false
                          :handler #'tfh/handler
                          :dh-conn (ig/ref ::datahike/connection)}
   ::datahike/database   {:db-file         "tmp/the-ultimate-db"
                          :initial-tx      x06/schema}
   ;::datahike/database   {:db-file         "tmp/the-final-dhdb"
   ;                       :delete-on-halt? true
   ;                       :initial-tx      x06/schema}
   ::datahike/connection {:db-config (ig/ref ::datahike/database)}})

System Boilerplate

Same as before

(defonce ^:dynamic *system* nil)
(defn system [] *system*)
(defn start []
  (alter-var-root #'*system* (fn [s] (if-not s (ig/init config) s))))
(defn stop []
  (alter-var-root #'*system* (fn [s] (when s (ig/halt! s) nil))))
(defn restart [] (stop) (start))
(comment
  (keys (system))
  (let [conn (::datahike/connection (system))]
    (count @conn))

  (use '[clojure.repl])
  (require '[clojure.java.browse :refer [browse-url]])
  (browse-url "http://localhost:3000")
  )
 
(ns clojure-north-2020.ch04-application.x04-parting-thoughts)

In Conclusion

In this workshop, we've learned some key concepts that should help you in your Clojure journey from novice to full-stack, stateful system developer:

  1. Always start with your data. Model the data, think about the data. Don't write any code until you understand the data.
  2. Manipulate your data using Clojure's baked in functions. Clojure truly is a data DSL. As transformations become useful, elevate them to functions.
  3. Datascript/hike, Datomic, and similar are powerful, data-oriented databases. These allow you to model domains at the attribute level rather than the record level, giving a much more powerful and flexible way to work with your data.
  4. Stateful systems can be converted into functional systems by pushing all stateful components to the edge of the system. One powerful library for doing this is Integrant. Once this is done, all stateful components are bundled at one edge of the system, all logic is functions, and needed aspects of the various components are threaded through the functions to be used as system-agnostic parameters.

Additional Material

If you liked this workshop and want to learn more about any of the topics, feel free to check out these projects:

  • datascript-playground
  • This is a somewhat disorganized project of mine that explores datascript, topics, but it has a lot of examples that I've worked through to understand how to model data in Datascript.
  • Datascript and Datomic: Data Modeling for Heroes - A talk I gave on data modeling with Datascript and Datomic. One correction: At the time I misunderstood the db.unique/value schema type so ignore the brief statements I made on that.
  • Partsbin - A project I maintain that provides read-made components for use with Integrant. It also has a good explanation of how to build composable systems such that each part remains simple and decoupled.
  • Defeating the Four Horsemen of the Coding Apocalypse
    • My Clojure/conj 2019 talk in which I discuss 4 coding challenges we face, one of which is Complexity, which is tied to this presentation.
  • Bottom Up vs Top Down Design in Clojure
    • My Clojure/conj 2015 talk in which I describe building an application using many of the techniques discussed today - start with data, build functions, work your way up to an application.
  • Web Development with Clojure - An authoritative guide to building web apps with Clojure from the author of the Luminus framework.

Feedback

I hope you enjoyed this workshop. I value any feedback you may have, positive or otherwise. Please feel free to reach our to me on twitter (@mark_bastian), gmail (markbastian@gmail.com), or linkedin.