Where parallels cross

Interesting bits of life

Going through Lacinia tutorial to get familiar with GraphQL

This is my work through the very cool tutorial by Howard Lewis Ship.

Let's start!

The repo with the complete source is here:

git clone https://github.com/walmartlabs/clojure-game-geek

This is a project about board games and feedback. First the template project

lein new lacinia-tutorial

The project has to change slightly:

(defproject lacinia-tutorial "0.1.0-SNAPSHOT"
  :description "FIXME: write description"
  :url "http://example.com/FIXME"
  :license {:name "Eclipse Public License"
            :url "http://www.eclipse.org/legal/epl-v10.html"}
  :dependencies [[org.clojure/clojure "1.8.0"]
                 [com.walmartlabs/lacinia "0.21.0"]])

Lacinia uses third party libraries to parse the GraphQL schemas and store in ordered maps.

Schemas have the edn format:

{:objects
 {:BoardGame
  {:description "A physical or virtual board game."
   :fields
   {:id {:type (non-null ID)} ;; ID is a GraphQL symbol and a Lacinia
                              ;; base type, the non-null bit is a
                              ;; Lacinia constraint: this is likely
                              ;; treated as clojure.spec
    :name {:type (non-null String)}
    :summary {:type String
              :description "A one-line summary of the game."}
    :description {:type String
                  :description "A long-form description of the game."}
    :min_players {:type Int ;; lacinia works only with underscores due to problems with clojure.spec
                  :description "The minimum number of players the game supports."}
    :max_players {:type Int
                  :description "The maximum number of players the game supports."}
    :play_time {:type Int
                :description "Play time, in minutes, for a typical game."}}}}

 :queries
 {:game_by_id
  {:type :BoardGame
   :description "Access a BoardGame by its unique id, if it exists."
   :args
   {:id {:type ID}}
   :resolve :query/game-by-id}}} ; this is the GraphQL query resolver

Now we need an interpreter:

(ns lacinia-tutorial.schema
  "Contains custom resolvers and a function to provide the full schema."
  (:require
    [clojure.java.io :as io]
    [com.walmartlabs.lacinia.util :as util]
    [com.walmartlabs.lacinia.schema :as schema]
    [clojure.edn :as edn]))

(defn resolver-map
  []
  {:query/game-by-id (fn [context args value] ;; this is our resolver implementation
                       nil)})

(defn load-schema
  []
  (-> (io/resource "cgg-schema.edn") ;; load text from resource directory
      slurp
      edn/read-string ;; get the data as a clojure object
      (util/attach-resolvers (resolver-map)) ;; apply the resolver
      schema/compile)) ;; this should produce the GraphQL api?

For REPL development it can be useful to setup the user namespace:

(ns user
  (:require
    [lacinia-tutorial.schema :as s]
    [com.walmartlabs.lacinia :as lacinia]))

(def schema (s/load-schema))

(defn q
  [query-string]
  (lacinia/execute schema query-string nil nil)) ;; this is a quick
                                                 ;; way to query our
                                                 ;; GraphQL API
                                                 ;; without starting
                                                 ;; a server

This should now let us use the REPL:

(q "{ game_by_id(id: \"foo\") { id name summary }}")

Naturally this resolves to empty because the resolver is programmed so.

Let's now create some data that respect the schema:

{:games
 [{:id "1234"
   :name "Zertz"
   :summary "Two player abstract with forced moves and shrinking board"
   :min_players 2
   :max_players 2
   }
  {:id "1235"
   :name "Dominion"
   :summary "Created the deck-building genre; zillions of expansions"
   :min_players 2}
  {:id "1236"
   :name "Tiny Epic Galaxies"
   :summary "Fast dice-based sci-fi space game with a bit of chaos"
   :min_players 1
   :max_players 4}
  {:id "1237"
   :name "7 Wonders: Duel"
   :summary "Tense, quick card game of developing civilizations"
   :min_players 2
   :max_players 2}]}

And let's now use the data in the code:

(ns lacinia-tutorial.schema
  "Contains custom resolvers and a function to provide the full schema."
  (:require
    [clojure.java.io :as io]
    [com.walmartlabs.lacinia.util :as util]
    [com.walmartlabs.lacinia.schema :as schema]
    [clojure.edn :as edn]))

(defn resolve-game-by-id
  [games-map context args value] ; the last 3 attributes are provided by the resolver
  (let [{:keys [id]} args]
    (get games-map id)))

(defn resolver-map
  []
  (let [cgg-data (-> (io/resource "cgg-data.edn")
                     slurp
                     edn/read-string)
        games-map (->> cgg-data
                       :games ; taking the games from the data
                       (reduce #(assoc %1 (:id %2) %2) {}))]
    {:query/game-by-id (partial resolve-game-by-id games-map)}))

(defn load-schema
  []
  (-> (io/resource "cgg-schema.edn") ;; load text from resource directory
      slurp
      edn/read-string ;; get the data as a clojure object
      (util/attach-resolvers (resolver-map)) ;; apply the resolver by substituting the :query keywords with the functions
      schema/compile)) ;; this should produce the GraphQL api?

Let's fix up our development file to use Clojure maps:

(ns user
  (:require
    [lacinia-tutorial.schema :as s]
    [com.walmartlabs.lacinia :as lacinia]
    [clojure.walk :as walk])
    (:import (clojure.lang IPersistentMap)))

(def schema (s/load-schema))

(defn simplify
  "Converts all ordered maps nested within the map into standard hash maps, and
   sequences into vectors, which makes for easier constants in the tests, and eliminates ordering problems."
  [m]
  (walk/postwalk
    (fn [node]
      (cond
        (instance? IPersistentMap node) (into {} node)
        (seq? node) (vec node)
        :else
        node))
    m))

(defn q
  [query-string]
  (-> (lacinia/execute schema query-string nil nil)
      simplify))

Now the query shows a normal map:

(use 'user :reload) ; to reload the user ns
(q "{ game_by_id(id: \"anything\") { id name summary }}")

And we can also do a query according to the data we have:

(use 'user :reload-all) ; to reload all the ns
(q "{ game_by_id(id: \"1237\") {name summary min_players max_players}}")

Let's now add a field in the data we got:

{:games
 [{:id "1234"
   :name "Zertz"
   :summary "Two player abstract with forced moves and shrinking board"
   :min_players 2
   :max_players 2
   :designers #{"200"}} ;; reference to the designer id. I think this is interpreted as a set of designer ids by the resolver
  {:id "1235"
   :name "Dominion"
   :summary "Created the deck-building genre; zillions of expansions"
   :designers #{"204"}
   :min_players 2}
  {:id "1236"
   :name "Tiny Epic Galaxies"
   :summary "Fast dice-based sci-fi space game with a bit of chaos"
   :designers #{"203"}
   :min_players 1
   :max_players 4}
  {:id "1237"
   :name "7 Wonders: Duel"
   :summary "Tense, quick card game of developing civilizations"
   :designers #{"201" "202"}
   :min_players 2
   :max_players 2}]

 :designers
 [{:id "200"
   :name "Kris Burm"
   :url "http://www.gipf.com/project_gipf/burm/burm.html"}
  {:id "201"
   :name "Antoine Bauza"
   :url "http://www.antoinebauza.fr/"}
  {:id "202"
   :name "Bruno Cathala"
   :url "http://www.brunocathala.com/"}
  {:id "203"
   :name "Scott Almes"}
  {:id "204"
   :name "Donald X. Vaccarino"}]}

By not doing anything the query on designers fails:

(use 'user :reload-all) ; to reload all the ns
(q "{ game_by_id(id: \"1237\") {designers}}")

The reason is that our schema does not contain such data. No schema no party:

{:objects
 {:BoardGame
  {:description "A physical or virtual board game."
   :fields
   {:id {:type (non-null ID)} ;; ID is a GraphQL symbol and a Lacinia
                              ;; base type, the non-null bit is a
                              ;; Lacinia constraint: this is likely
                              ;; treated as clojure.spec
    :name {:type (non-null String)}
    :summary {:type String
              :description "A one-line summary of the game."}
    :description {:type String
                  :description "A long-form description of the game."}
    :designers {:type (non-null (list ID))
                  :description "A long-form description of the game."}
    :min_players {:type Int ;; lacinia works only with underscores due to problems with clojure.spec
                  :description "The minimum number of players the game supports."}
    :max_players {:type Int
                  :description "The maximum number of players the game supports."}
    :play_time {:type Int
                :description "Play time, in minutes, for a typical game."}}}}

 :queries
 {:game_by_id
  {:type :BoardGame
   :description "Access a BoardGame by its unique id, if it exists."
   :args
   {:id {:type ID}}
   :resolve :query/game-by-id}}} ; this is the GraphQL query resolver

Now the query works:

(use 'user :reload-all) ; to reload all the ns
(q "{ game_by_id(id: \"1237\") {designers}}")

However we want to interpret the ids to a designer object. Let's change the schema again:

{:objects
 {:BoardGame
  {:description "A physical or virtual board game."
   :fields
   {:id {:type (non-null ID)}
    :name {:type (non-null String)}
    :summary {:type String
              :description "A one-line summary of the game."}
    :description {:type String
                  :description "A long-form description of the game."}
    :designers {:type (non-null (list :Designer))
                :description "Designers who contributed to the game."
                :resolve :BoardGame/designers}
    :min_players {:type Int
                  :description "The minimum number of players the game supports."}
    :max_players {:type Int
                  :description "The maximum number of players the game supports."}
    :play_time {:type Int
                :description "Play time, in minutes, for a typical game."}}}

  :Designer
  {:description "A person who may have contributed to a board game design."
   :fields
   {:id {:type (non-null ID)}
    :name {:type (non-null String)}
    :url {:type String
          :description "Home page URL, if known."}
    :games {:type (non-null (list :BoardGame))
            :description "Games designed by this designer."
            :resolve :Designer/games}}}}

 :queries
 {:game_by_id
  {:type :BoardGame
   :description "Access a BoardGame by its unique id, if it exists."
   :args
   {:id {:type ID}}
   :resolve :query/game-by-id}}}

Now we are missing a resolver!

(use 'user :reload-all) ; to reload all the ns
(q "{ game_by_id(id: \"1237\") {designers}}")

Let's define the designer resolver:

(ns lacinia-tutorial.schema
  "Contains custom resolvers and a function to provide the full schema."
  (:require
    [clojure.java.io :as io]
    [com.walmartlabs.lacinia.util :as util]
    [com.walmartlabs.lacinia.schema :as schema]
    [clojure.edn :as edn]))

(defn resolve-game-by-id
  [games-map context args value] ; the last 3 attributes are provided by the resolver
  (let [{:keys [id]} args]
    (get games-map id)))

(defn resolve-board-game-designers
  [designers-map context args board-game]
  (->> board-game
       :designers
       (map designers-map)))

(defn resolve-designer-games
  [games-map context args designer]
  (let [{:keys [id]} designer]
    (->> games-map
         vals
         (filter #(-> % :designers (contains? id))))))

(defn entity-map
  [data k]
  (reduce #(assoc %1 (:id %2) %2)
          {}
          (get data k)))

(defn resolver-map
  []
  (let [cgg-data (-> (io/resource "cgg-data.edn")
                     slurp
                     edn/read-string)
        games-map (entity-map cgg-data :games)
        designers-map (entity-map cgg-data :designers)]
    {:query/game-by-id (partial resolve-game-by-id games-map)
     :BoardGame/designers (partial resolve-board-game-designers designers-map)
     :Designer/games (partial resolve-designer-games games-map)}))

(defn load-schema
  []
  (-> (io/resource "cgg-schema.edn") ;; load text from resource directory
      slurp
      edn/read-string ;; get the data as a clojure object
      (util/attach-resolvers (resolver-map)) ;; apply the resolver
      schema/compile)) ;; this should produce the GraphQL api?

And now a nested query:

(use 'user :reload-all) ; to reload all the ns
(q "{ game_by_id(id: \"1237\") { name designers { name }}}")

Now queries need to be nested otherwise we get an error:

(use 'user :reload-all) ; to reload all the ns
(q "{ game_by_id(id: \"1237\") { name designers }}")

And since we have defined an isomorphism between the data (from designer to board and viz), we can show the graph side of this query language:

(use 'user :reload-all) ; to reload all the ns
(q "{ game_by_id(id: \"1234\") { name designers { name games { name }}}}") ;; this query uses the isomorphism

Now that we have a working REPL, let's move on to the web interface:

(defproject clojure-game-geek "0.1.0-SNAPSHOT"
  :description "A tiny BoardGameGeek clone written in Clojure with Lacinia"
  :url "https://github.com/walmartlabs/clojure-game-geek"
  :license {:name "Eclipse Public License"
            :url "http://www.eclipse.org/legal/epl-v10.html"}
  :dependencies [[org.clojure/clojure "1.8.0"]
                 [com.walmartlabs/lacinia-pedestal "0.5.0"]
                 [io.aviso/logging "0.2.0"]])

lacinia-pedestal is the web layer based on jetty. We can now setup logging with a Logback library configuration file:

<configuration scan="true" scanPeriod="1 seconds">

  <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
    <encoder>
      <pattern>%-5level %logger - %msg%n</pattern>
    </encoder>
  </appender>

  <root level="warn">
    <appender-ref ref="STDOUT"/>
  </root>

</configuration>

And now let's improve our REPL development tools:

(ns user
  (:require
    [lacinia-tutorial.schema :as s]
    [com.walmartlabs.lacinia :as lacinia]
    [com.walmartlabs.lacinia.pedestal :as lp]
    [io.pedestal.http :as http]
    [clojure.java.browse :refer [browse-url]]
    [clojure.walk :as walk])
  (:import (clojure.lang IPersistentMap)))

(def schema (s/load-schema))

(defn simplify
  "Converts all ordered maps nested within the map into standard hash maps, and
   sequences into vectors, which makes for easier constants in the tests, and eliminates ordering problems."
  [m]
  (walk/postwalk
    (fn [node]
      (cond
        (instance? IPersistentMap node)
        (into {} node)

        (seq? node)
        (vec node)

        :else
        node))
    m))

(defn q
  [query-string]
  (-> (lacinia/execute schema query-string nil nil)
      simplify))

(defonce server nil)

(defn start-server
  [_]
  (let [server (-> schema
                   (lp/service-map {:graphiql true})
                   http/create-server
                   http/start)]
    (browse-url "http://localhost:8888/")
    server))

(defn stop-server
  [server]
  (http/stop server)
  nil)

(defn start
  []
  (alter-var-root #'server start-server)
  :started)

(defn stop
  []
  (alter-var-root #'server stop-server)
  :stopped)

We have enabled graphiql to have at disposal the interactive REPL of GraphQL. This should not be enabled in PRD.

Now we can start the server:

(start)

The GraphIQL interface is cool: the Docs button is very useful to explore the schema available.

Let's handle state with the component library:

(defproject clojure-game-geek "0.1.0-SNAPSHOT"
  :description "A tiny BoardGameGeek clone written in Clojure with Lacinia"
  :url "https://github.com/walmartlabs/clojure-game-geek"
  :license {:name "Eclipse Public License"
            :url "http://www.eclipse.org/legal/epl-v10.html"}
  :dependencies [[org.clojure/clojure "1.8.0"]
                 [com.walmartlabs/lacinia-pedestal "0.5.0"]
                 [com.stuartsierra/component "0.3.2"]
                 [io.aviso/logging "0.2.0"]])

We will have two component: the server and the schema provider. We know that the server depends on the schema provider (no schema no party XD).

The schema provider:

(ns lacinia-tutorial.schema
  "Contains custom resolvers and a function to provide the full schema."
  (:require
    [clojure.java.io :as io]
    [com.walmartlabs.lacinia.util :as util]
    [com.walmartlabs.lacinia.schema :as schema]
    [com.stuartsierra.component :as component]
    [clojure.edn :as edn]))

(defn resolve-game-by-id
  [games-map context args value]
  (let [{:keys [id]} args]
    (get games-map id)))

(defn resolve-board-game-designers
  [designers-map context args board-game]
  (->> board-game
       :designers
       (map designers-map)))

(defn resolve-designer-games
  [games-map context args designer]
  (let [{:keys [id]} designer]
    (->> games-map
         vals
         (filter #(-> % :designers (contains? id))))))

(defn entity-map
  [data k]
  (reduce #(assoc %1 (:id %2) %2)
          {}
          (get data k)))

(defn resolver-map
  [component] ; now this function depends on a component
  (let [cgg-data (-> (io/resource "cgg-data.edn")
                     slurp
                     edn/read-string)
        games-map (entity-map cgg-data :games)
        designers-map (entity-map cgg-data :designers)]
    {:query/game-by-id (partial resolve-game-by-id games-map)
     :BoardGame/designers (partial resolve-board-game-designers designers-map)
     :Designer/games (partial resolve-designer-games games-map)}))

(defn load-schema
  [component]
  (-> (io/resource "cgg-schema.edn")
      slurp
      edn/read-string
      (util/attach-resolvers (resolver-map component))
      schema/compile))

(defrecord SchemaProvider [schema]

  component/Lifecycle

  (start [this]
    (assoc this :schema (load-schema this))) ; a record can override methods

  (stop [this]
    (assoc this :schema nil)))

(defn new-schema-provider ;; a constructor for the SchemaProvider
  []
  {:schema-provider (map->SchemaProvider {})})

And the server component:

(ns lacinia-tutorial.server
  (:require [com.stuartsierra.component :as component]
            [com.walmartlabs.lacinia.pedestal :as lp]
            [io.pedestal.http :as http]))

(defrecord Server [schema-provider server] ; this is what we had in the user.clj before

  component/Lifecycle
  (start [this]
    (assoc this :server (-> schema-provider
                            :schema
                            (lp/service-map {:graphiql true})
                            http/create-server
                            http/start)))

  (stop [this]
    (http/stop server)
    (assoc this :server nil)))

(defn new-server
  []
  {:server (component/using (map->Server {})   ;; here the dependency on the :schema-provider component
                            [:schema-provider])})

And to have a high level view of the components:

(ns lacinia-tutorial.system
  (:require
    [com.stuartsierra.component :as component]
    [lacinia-tutorial.schema :as schema]
    [lacinia-tutorial.server :as server]))

(defn new-system
  []
  (merge (component/system-map)
         (server/new-server)
         (schema/new-schema-provider)))

Finally the user.clj has to change:

(ns user
  (:require
    [com.walmartlabs.lacinia :as lacinia]
    [clojure.java.browse :refer [browse-url]]
    [lacinia-tutorial.system :as system]
    [clojure.walk :as walk]
    [com.stuartsierra.component :as component])
  (:import (clojure.lang IPersistentMap)))

(defn simplify
  "Converts all ordered maps nested within the map into standard hash maps, and
   sequences into vectors, which makes for easier constants in the tests, and eliminates ordering problems."
  [m]
  (walk/postwalk
    (fn [node]
      (cond
        (instance? IPersistentMap node)
        (into {} node)

        (seq? node)
        (vec node)

        :else
        node))
    m))

(defonce system (system/new-system))

(defn q
  [query-string]
  (-> system ; here we are deconstructing our system
      :schema-provider
      :schema
      (lacinia/execute query-string nil nil)
      simplify))

(defn start
  []
  (alter-var-root #'system component/start-system) ; here we change our system atom
  (browse-url "http://localhost:8888/")
  :started)

(defn stop
  []
  (alter-var-root #'system component/stop-system)
  :stopped)

And it works:

(start)

We are missing some information from our data schema that could be useful:

{:objects
 {:BoardGame
  {:description "A physical or virtual board game."
   :fields
   {:id {:type (non-null ID)}
    :name {:type (non-null String)}
    :rating_summary {:type (non-null :GameRatingSummary) ; allow people to add rating of a game
                     :resolve :BoardGame/rating-summary} ; this will use the :GameRating
    :summary {:type String
              :description "A one-line summary of the game."}
    :description {:type String
                  :description "A long-form description of the game."}
    :designers {:type (non-null (list :Designer))
                :description "Designers who contributed to the game."
                :resolve :BoardGame/designers}
    :min_players {:type Int
                  :description "The minimum number of players the game supports."}
    :max_players {:type Int
                  :description "The maximum number of players the game supports."}
    :play_time {:type Int
                :description "Play time, in minutes, for a typical game."}}}

  :GameRatingSummary
  {:description "Summary of ratings for a single game."
   :fields
   {:count {:type (non-null Int) ; so we cannot add constraints on the range of symbols? Weird
            :description "Number of ratings provided for the game.  Ratings are 1 to 5 stars."}
    :average {:type (non-null Float)
              :description "The average value of all ratings, or 0 if never rated."}}}

  :Member
  {:description "A member of Clojure Game Geek.  Members can rate games."
   :fields
   {:id {:type (non-null ID)}
    :member_name {:type (non-null String)
                  :description "Unique name of member."}
    :ratings {:type (list :GameRating)
              :description "List of games and ratings provided by this member."
              :resolve :Member/ratings}}} ; this will link members and ratings

  :GameRating
  {:description "A member's rating of a particular game."
   :fields
   {:game {:type (non-null :BoardGame)
           :description "The Game rated by the member."
           :resolve :GameRating/game} ; this will create an isomorphism
    :rating {:type (non-null Int)
             :description "The rating as 1 to 5 stars."}}}

  :Designer
  {:description "A person who may have contributed to a board game design."
   :fields
   {:id {:type (non-null ID)}
    :name {:type (non-null String)}
    :url {:type String
          :description "Home page URL, if known."}
    :games {:type (non-null (list :BoardGame))
            :description "Games designed by this designer."
            :resolve :Designer/games}}}}

 :queries
 {:game_by_id
  {:type :BoardGame
   :description "Select a BoardGame by its unique id, if it exists."
   :args
   {:id {:type (non-null ID)}}
   :resolve :query/game-by-id}

  :member_by_id
  {:type :Member
   :description "Select a ClojureGameGeek Member by their unique id, if it exists."
   :args
   {:id {:type (non-null ID)}}
   :resolve :query/member-by-id}}}

As we are adding a required value our data has to change:

{:games
 [{:id "1234"
   :name "Zertz"
   :summary "Two player abstract with forced moves and shrinking board"
   :min_players 2
   :max_players 2
   :designers #{"200"}}
  {:id "1235"
   :name "Dominion"
   :summary "Created the deck-building genre; zillions of expansions"
   :designers #{"204"}
   :min_players 2}
  {:id "1236"
   :name "Tiny Epic Galaxies"
   :summary "Fast dice-based sci-fi space game with a bit of chaos"
   :designers #{"203"}
   :min_players 1
   :max_players 4}
  {:id "1237"
   :name "7 Wonders: Duel"
   :summary "Tense, quick card game of developing civilizations"
   :designers #{"201" "202"}
   :min_players 2
   :max_players 2}]

 :members
 [{:id "37"
   :member_name "curiousattemptbunny"}
  {:id "1410"
   :member_name "bleedingedge"}
  {:id "2812"
   :member_name "missyo"}]

 :ratings
 [{:member_id "37" :game_id "1234" :rating 3}
  {:member_id "1410" :game_id "1234" :rating 5}
  {:member_id "1410" :game_id "1236" :rating 4}
  {:member_id "1410" :game_id "1237" :rating 4}
  {:member_id "2812" :game_id "1237" :rating 4}
  {:member_id "37" :game_id "1237" :rating 5}]

 :designers
 [{:id "200"
   :name "Kris Burm"
   :url "http://www.gipf.com/project_gipf/burm/burm.html"}
  {:id "201"
   :name "Antoine Bauza"
   :url "http://www.antoinebauza.fr/"}
  {:id "202"
   :name "Bruno Cathala"
   :url "http://www.brunocathala.com/"}
  {:id "203"
   :name "Scott Almes"}
  {:id "204"
   :name "Donald X. Vaccarino"}]}


And so we change our schema.clj

(ns lacinia-tutorial.schema
  "Contains custom resolvers and a function to provide the full schema."
  (:require
    [clojure.java.io :as io]
    [com.walmartlabs.lacinia.util :as util]
    [com.walmartlabs.lacinia.schema :as schema]
    [com.stuartsierra.component :as component]
    [clojure.edn :as edn]))

(defn resolve-element-by-id
  [element-map context args value]
  (let [{:keys [id]} args]
    (get element-map id)))

(defn resolve-board-game-designers
  [designers-map context args board-game]
  (->> board-game
       :designers
       (map designers-map)))

(defn resolve-designer-games
  [games-map context args designer]
  (let [{:keys [id]} designer]
    (->> games-map
         vals
         (filter #(-> % :designers (contains? id))))))

(defn entity-map
  [data k]
  (reduce #(assoc %1 (:id %2) %2)
          {}
          (get data k)))

(defn rating-summary
  [cgg-data]
  (fn [_ _ board-game]
    (let [id (:id board-game)
          ratings (->> cgg-data
                       :ratings
                       (filter #(= id (:game_id %)))
                       (map :rating))
          n (count ratings)]
      {:count n
       :average (if (zero? n)
                  0
                  (/ (apply + ratings)
                     (float n)))})))

(defn member-ratings
  [ratings-map]
  (fn [_ _ member]
    (let [id (:id member)]
      (filter #(= id (:member_id %)) ratings-map))))

(defn game-rating->game
  [games-map]
  (fn [_ _ game-rating]
    (get games-map (:game_id game-rating))))

(defn resolver-map
  [component]
  (let [cgg-data (-> (io/resource "cgg-data.edn")
                     slurp
                     edn/read-string)
        games-map (entity-map cgg-data :games)
        members-map (entity-map cgg-data :members)
        designers-map (entity-map cgg-data :designers)]
    {:query/game-by-id (partial resolve-element-by-id games-map) ;; isn't this becoming a litle to long?
     :query/member-by-id (partial resolve-element-by-id members-map)
     :BoardGame/designers (partial resolve-board-game-designers designers-map)
     :BoardGame/rating-summary (rating-summary cgg-data)
     :GameRating/game (game-rating->game games-map)
     :Designer/games (partial resolve-designer-games games-map)
     :Member/ratings (member-ratings (:ratings cgg-data))}))

(defn load-schema
  [component]
  (-> (io/resource "cgg-schema.edn")
      slurp
      edn/read-string
      (util/attach-resolvers (resolver-map component))
      schema/compile))

(defrecord SchemaProvider [schema]

  component/Lifecycle

  (start [this]
    (assoc this :schema (load-schema this)))

  (stop [this]
    (assoc this :schema nil)))

(defn new-schema-provider
  []
  {:schema-provider (map->SchemaProvider {})})

Let's try:

(start)
(q "{ game_by_id(id: \"1237\") { name rating_summary { count average }}}")
(q "{ member_by_id(id: \"1410\") { member_name ratings { game { name } rating }}}")

The cool thing about GraphQL is that it allows to modify data as well! So far our resolvers were just reading data. In GraphQL a mutation allows to alter existing data. We will need to set up a mutable data structure: a database!

A database is another component: our schema provider will depend on it.

(ns lacinia-tutorial.db
  (:require
    [clojure.edn :as edn]
    [clojure.java.io :as io]
    [com.stuartsierra.component :as component]))

(defrecord ClojureGameGeekDb [data]

  component/Lifecycle

  (start [this]
    (assoc this :data (-> (io/resource "cgg-data.edn")
                          slurp
                          edn/read-string
                          atom)))

  (stop [this]
    (assoc this :data nil)))

(defn new-db
  []
  {:db (map->ClojureGameGeekDb {})})

(defn find-game-by-id
  [db game-id]
  (->> db
       :data
       deref
       :games
       (filter #(= game-id (:id %)))
       first))

(defn find-member-by-id
  [db member-id]
  (->> db
       :data
       deref
       :members
       (filter #(= member-id (:id %)))
       first))

(defn list-designers-for-game
  [db game-id]
  (let [designers (:designers (find-game-by-id db game-id))]
    (->> db
         :data
         deref
         :designers
         (filter #(contains? designers (:id %))))))

(defn list-games-for-designer
  [db designer-id]
  (->> db
       :data
       deref
       :games
       (filter #(-> % :designers (contains? designer-id)))))

(defn list-ratings-for-game
  [db game-id]
  (->> db
       :data
       deref
       :ratings
       (filter #(= game-id (:game_id %)))))

(defn list-ratings-for-member
  [db member-id]
  (->> db
       :data
       deref
       :ratings
       (filter #(= member-id (:member_id %)))))

We essentially just embed the db in a component. Notice the :data atom: this is our mutable data strucutre.

Again the system will change:

(ns lacinia-tutorial.system
  (:require
    [com.stuartsierra.component :as component]
    [lacinia-tutorial.schema :as schema]
    [lacinia-tutorial.server :as server]
    [lacinia-tutorial.db :as db]))

(defn new-system
  []
  (merge (component/system-map)
         (server/new-server)
         (schema/new-schema-provider)
         (db/new-db)))

Now we have to enforce the dependency on the schema provider:

(ns lacinia-tutorial.schema
  "Contains custom resolvers and a function to provide the full schema."
  (:require
    [clojure.java.io :as io]
    [com.walmartlabs.lacinia.util :as util]
    [com.walmartlabs.lacinia.schema :as schema]
    [com.stuartsierra.component :as component]
    [lacinia-tutorial.db :as db]
    [clojure.edn :as edn]))

(defn game-by-id
  [db]
  (fn [_ args _]
    (db/find-game-by-id db (:id args))))

(defn member-by-id
  [db]
  (fn [_ args _]
    (db/find-member-by-id db (:id args))))

(defn board-game-designers
  [db]
  (fn [_ _ board-game]
    (db/list-designers-for-game db (:id board-game))))

(defn designer-games
  [db]
  (fn [_ _ designer]
    (db/list-games-for-designer db (:id designer))))

(defn rating-summary
  [db]
  (fn [_ _ board-game]
    (let [ratings (map :rating (db/list-ratings-for-game db (:id board-game)))
          n (count ratings)]
      {:count n
       :average (if (zero? n)
                  0
                  (/ (apply + ratings)
                     (float n)))})))

(defn member-ratings
  [db]
  (fn [_ _ member]
    (db/list-ratings-for-member db (:id member))))

(defn game-rating->game
  [db]
  (fn [_ _ game-rating]
    (db/find-game-by-id db (:game_id game-rating))))

(defn resolver-map
  [component]
  (let [db (:db component)]
    {:query/game-by-id (game-by-id db)
     :query/member-by-id (member-by-id db)
     :BoardGame/designers (board-game-designers db)
     :BoardGame/rating-summary (rating-summary db)
     :GameRating/game (game-rating->game db)
     :Designer/games (designer-games db)
     :Member/ratings (member-ratings db)}))

(defn load-schema
  [component]
  (-> (io/resource "cgg-schema.edn")
      slurp
      edn/read-string
      (util/attach-resolvers (resolver-map component))
      schema/compile))

(defrecord SchemaProvider [schema]

  component/Lifecycle

  (start [this]
    (assoc this :schema (load-schema this)))

  (stop [this]
    (assoc this :schema nil)))

(defn new-schema-provider
  []
  {:schema-provider (-> {}
                        map->SchemaProvider
                        (component/using [:db]))})

Now we can test our GraphQL again:

(start)
(q "{ member_by_id(id: \"1410\") { member_name ratings { game { name rating_summary { count average } designers { name  games { name }}} rating }}}")

All this setup for enabling mutations finally made us ready to change some rating data:

(ns lacinia-tutorial.db
  (:require
    [clojure.edn :as edn]
    [clojure.java.io :as io]
    [com.stuartsierra.component :as component]))

(defrecord ClojureGameGeekDb [data]

  component/Lifecycle

  (start [this]
    (assoc this :data (-> (io/resource "cgg-data.edn")
                          slurp
                          edn/read-string
                          atom)))

  (stop [this]
    (assoc this :data nil)))

(defn new-db
  []
  {:db (map->ClojureGameGeekDb {})})

(defn find-game-by-id
  [db game-id]
  (->> db
       :data
       deref
       :games
       (filter #(= game-id (:id %)))
       first))

(defn find-member-by-id
  [db member-id]
  (->> db
       :data
       deref
       :members
       (filter #(= member-id (:id %)))
       first))

(defn list-designers-for-game
  [db game-id]
  (let [designers (:designers (find-game-by-id db game-id))]
    (->> db
         :data
         deref
         :designers
         (filter #(contains? designers (:id %))))))

(defn list-games-for-designer
  [db designer-id]
  (->> db
       :data
       deref
       :games
       (filter #(-> % :designers (contains? designer-id)))))

(defn list-ratings-for-game
  [db game-id]
  (->> db
       :data
       deref
       :ratings
       (filter #(= game-id (:game_id %)))))

(defn list-ratings-for-member
  [db member-id]
  (->> db
       :data
       deref
       :ratings
       (filter #(= member-id (:member_id %)))))

(defn ^:private apply-game-rating
  [game-ratings game-id member-id rating]
  (->> game-ratings
       (remove #(and (= game-id (:game_id %))
                     (= member-id (:member_id %))))
       (cons {:game_id game-id
              :member_id member-id
              :rating rating})))

(defn upsert-game-rating
  "Adds a new game rating, or changes the value of an existing game rating."
  [db game-id member-id rating]
  (-> db
      :data
      (swap! update :ratings apply-game-rating game-id member-id rating)))

Then let's make space in the schema for a mutation:

{:objects
 {:BoardGame
  {:description "A physical or virtual board game."
   :fields
   {:id {:type (non-null ID)}
    :name {:type (non-null String)}
    :rating_summary {:type (non-null :GameRatingSummary) ; allow people to add rating of a game
                     :resolve :BoardGame/rating-summary} ; this will use the :GameRating
    :summary {:type String
              :description "A one-line summary of the game."}
    :description {:type String
                  :description "A long-form description of the game."}
    :designers {:type (non-null (list :Designer))
                :description "Designers who contributed to the game."
                :resolve :BoardGame/designers}
    :min_players {:type Int
                  :description "The minimum number of players the game supports."}
    :max_players {:type Int
                  :description "The maximum number of players the game supports."}
    :play_time {:type Int
                :description "Play time, in minutes, for a typical game."}}}

  :GameRatingSummary
  {:description "Summary of ratings for a single game."
   :fields
   {:count {:type (non-null Int) ; so we cannot add constraints on the range of symbols? Weird
            :description "Number of ratings provided for the game.  Ratings are 1 to 5 stars."}
    :average {:type (non-null Float)
              :description "The average value of all ratings, or 0 if never rated."}}}

  :Member
  {:description "A member of Clojure Game Geek.  Members can rate games."
   :fields
   {:id {:type (non-null ID)}
    :member_name {:type (non-null String)
                  :description "Unique name of member."}
    :ratings {:type (list :GameRating)
              :description "List of games and ratings provided by this member."
              :resolve :Member/ratings}}} ; this will link members and ratings

  :GameRating
  {:description "A member's rating of a particular game."
   :fields
   {:game {:type (non-null :BoardGame)
           :description "The Game rated by the member."
           :resolve :GameRating/game} ; this will create an isomorphism
    :rating {:type (non-null Int)
             :description "The rating as 1 to 5 stars."}}}

  :Designer
  {:description "A person who may have contributed to a board game design."
   :fields
   {:id {:type (non-null ID)}
    :name {:type (non-null String)}
    :url {:type String
          :description "Home page URL, if known."}
    :games {:type (non-null (list :BoardGame))
            :description "Games designed by this designer."
            :resolve :Designer/games}}}}

 :queries
 {:game_by_id
  {:type :BoardGame
   :description "Select a BoardGame by its unique id, if it exists."
   :args
   {:id {:type (non-null ID)}}
   :resolve :query/game-by-id}

  :member_by_id
  {:type :Member
   :description "Select a ClojureGameGeek Member by their unique id, if it exists."
   :args
   {:id {:type (non-null ID)}}
   :resolve :query/member-by-id}}

 :mutations ; the mutations!
 {:rate_game
  {:type :BoardGame
   :description "Establishes a rating of a board game, by a Member.

   On success (the game and member both exist), selects the BoardGame.
   Otherwise, selects nil and an error." ; errors do not have a type in GraphQL!! The rationale is that any resolver can return errors.
   :args
   {:game_id {:type (non-null ID)}
    :member_id {:type (non-null ID)}
    :rating {:type (non-null Int)
             :description "Game rating as a number between 1 and 5."}}
   :resolve :mutation/rate-game}}}}

Errors do not have a typep in GraphQL!! The rationale is that any resolver can return errors.

It remains to implement the mutation:

(ns lacinia-tutorial.schema
  "Contains custom resolvers and a function to provide the full schema."
  (:require
    [clojure.java.io :as io]
    [com.walmartlabs.lacinia.util :as util]
    [com.walmartlabs.lacinia.schema :as schema]
    [com.walmartlabs.lacinia.resolve :refer [resolve-as]]
    [com.stuartsierra.component :as component]
    [lacinia-tutorial.db :as db]
    [clojure.edn :as edn]))

(defn game-by-id
  [db]
  (fn [_ args _]
    (db/find-game-by-id db (:id args))))

(defn member-by-id
  [db]
  (fn [_ args _]
    (db/find-member-by-id db (:id args))))

(defn rate-game
  [db]
  (fn [_ args _]
    (let [{game-id :game_id
           member-id :member_id
           rating :rating} args
          game (db/find-game-by-id db game-id)
          member (db/find-member-by-id db member-id)]
      (cond
        (nil? game)
        (resolve-as nil {:message "Game not found." ; this are the errors
                         :status 404})

        (nil? member)
        (resolve-as nil {:message "Member not found."
                         :status 404})

        (not (<= 1 rating 5))
        (resolve-as nil {:message "Rating must be between 1 and 5."
                         :status 400})

        :else  ; the success
        (do
          (db/upsert-game-rating db game-id member-id rating)
          game)))))

(defn board-game-designers
  [db]
  (fn [_ _ board-game]
    (db/list-designers-for-game db (:id board-game))))

(defn designer-games
  [db]
  (fn [_ _ designer]
    (db/list-games-for-designer db (:id designer))))

(defn rating-summary
  [db]
  (fn [_ _ board-game]
    (let [ratings (map :rating (db/list-ratings-for-game db (:id board-game)))
          n (count ratings)]
      {:count n
       :average (if (zero? n)
                  0
                  (/ (apply + ratings)
                     (float n)))})))

(defn member-ratings
  [db]
  (fn [_ _ member]
    (db/list-ratings-for-member db (:id member))))

(defn game-rating->game
  [db]
  (fn [_ _ game-rating]
    (db/find-game-by-id db (:game_id game-rating))))

(defn resolver-map
  [component]
  (let [db (:db component)]
    {:query/game-by-id (game-by-id db)
     :query/member-by-id (member-by-id db)
     :mutation/rate-game (rate-game db)
     :BoardGame/designers (board-game-designers db)
     :BoardGame/rating-summary (rating-summary db)
     :GameRating/game (game-rating->game db)
     :Designer/games (designer-games db)
     :Member/ratings (member-ratings db)}))

(defn load-schema
  [component]
  (-> (io/resource "cgg-schema.edn")
      slurp
      edn/read-string
      (util/attach-resolvers (resolver-map component))
      schema/compile))

(defrecord SchemaProvider [schema]

  component/Lifecycle

  (start [this]
    (assoc this :schema (load-schema this)))

  (stop [this]
    (assoc this :schema nil)))

(defn new-schema-provider
  []
  {:schema-provider (-> {}
                        map->SchemaProvider
                        (component/using [:db]))})

resolve-as should return nil with errors.

Let's try out our first mutation. We first read the data:

(start)
(q "{ member_by_id(id: \"1410\") { member_name ratings { game { id name } rating }}}")

Then we modify the data:

(q "mutation { rate_game(member_id: \"1410\", game_id: \"1236\", rating: 3) { rating_summary { count average }}}")

And we check that the result was persisted:

(q "{ member_by_id(id: \"1410\") { member_name ratings { game { id name } rating }}}")

Also let's note the difference between an expected error

(q "mutation { rate_game(member_id: \"1410\", game_id: \"9999\", rating: 4) { name rating_summary { count average }}}")

and an unexpected one:

(q "mutation { rate_game(member_id: \"1410\", game_id: \"9999\") { name rating_summary { count average }}}")

After all of this mutating, we really should think about a serious database. Let's use PostgreSQL!

(defproject clojure-game-geek "0.1.0-SNAPSHOT"
  :description "A tiny BoardGameGeek clone written in Clojure with Lacinia"
  :url "https://github.com/walmartlabs/clojure-game-geek"
  :license {:name "Eclipse Public License"
            :url "http://www.eclipse.org/legal/epl-v10.html"}
  :dependencies [[org.clojure/clojure "1.9.0"]
                 [com.stuartsierra/component "0.3.2"]
                 [com.walmartlabs/lacinia "0.30.0"]
                 [com.walmartlabs/lacinia-pedestal "0.10.0"]
                 [org.clojure/java.jdbc "0.7.8"]
                 [org.postgresql/postgresql "42.2.5.jre7"]
                 [com.mchange/c3p0 "0.9.5.2"]
                 [io.aviso/logging "0.3.1"]])

jdbc is our wrapper to connect with database. c3p0 will care about grouping our connections efficiently. postgresql will provide a wrapper to communicate with the db.

Let's setup the docker environment that will guest our DB:

version: '3'
services:
  db:
    ports:
    - 25432:5432
    image: postgres:10.2-alpine

A script to start docker will simplify matters:

#!/usr/bin/env bash

docker-compose -p cgg up -d

And one to stop docker:

#!/usr/bin/env bash

docker-compose -p cgg down

And another to start a sql shell from the container:

#!/usr/bin/env bash

docker exec -ti --user postgres cgg_db_1 psql -Ucgg_role cggdb

And a final one to fill the DB within the container:

#!/usr/bin/env bash

docker exec -i --user postgres cgg_db_1 createdb cggdb

docker exec -i --user postgres cgg_db_1 psql cggdb -a  <<__END
create user cgg_role password 'lacinia';
__END

docker exec -i cgg_db_1 psql -Ucgg_role cggdb -a <<__END
drop table if exists designer_to_game;
drop table if exists game_rating;
drop table if exists member;
drop table if exists board_game;
drop table if exists designer;

CREATE OR REPLACE FUNCTION mantain_updated_at()
RETURNS TRIGGER AS \$\$
BEGIN
   NEW.updated_at = now();
   RETURN NEW;
END;
\$\$ language 'plpgsql';

create table member (
  member_id int generated by default as identity primary key,
  name text not null,
  created_at timestamp not null default current_timestamp,
  updated_at timestamp not null default current_timestamp);

create trigger member_updated_at before update
on member for each row execute procedure
mantain_updated_at();

create table board_game (
  game_id int generated by default as identity primary key,
  name text not null,
  summary text,
  min_players integer,
  max_players integer,
  created_at timestamp not null default current_timestamp,
  updated_at timestamp not null default current_timestamp);

create trigger board_game_updated_at before update
on board_game for each row execute procedure
mantain_updated_at();

create table designer (
  designer_id int generated by default as identity primary key,
  name text not null,
  uri text,
  created_at timestamp not null default current_timestamp,
  updated_at timestamp not null default current_timestamp);

create trigger designer_updated_at before update
on designer for each row execute procedure
mantain_updated_at();

create table game_rating (
  game_id int references board_game(game_id),
  member_id int references member(member_id),
  rating integer not null,
  created_at timestamp not null default current_timestamp,
  updated_at timestamp not null default current_timestamp);

create trigger game_rating_updated_at before update
on game_rating for each row execute procedure
mantain_updated_at();

create table designer_to_game (
  designer_id int  references designer(designer_id),
  game_id int  references board_game(game_id),
  primary key (designer_id, game_id));

insert into board_game (game_id, name, summary, min_players, max_players) values
  (1234, 'Zertz', 'Two player abstract with forced moves and shrinking board', 2, 2),
  (1235, 'Dominion', 'Created the deck-building genre; zillions of expansions', 2, null),
  (1236, 'Tiny Epic Galaxies', 'Fast dice-based sci-fi space game with a bit of chaos', 1, 4),
  (1237, '7 Wonders: Duel', 'Tense, quick card game of developing civilizations', 2, 2);

alter table board_game alter column game_id restart with 1300;

insert into member (member_id, name) values
  (37, 'curiousattemptbunny'),
  (1410, 'bleedingedge'),
  (2812, 'missyo');

alter table member alter column member_id restart with 2900;

insert into designer (designer_id, name, uri) values
  (200, 'Kris Burm', 'http://www.gipf.com/project_gipf/burm/burm.html'),
  (201, 'Antoine Bauza', 'http://www.antoinebauza.fr/'),
  (202, 'Bruno Cathala', 'http://www.brunocathala.com/'),
  (203, 'Scott Almes', null),
  (204, 'Donald X. Vaccarino', null);

alter table designer alter column designer_id restart with 300;

insert into designer_to_game (designer_id, game_id) values
  (200, 1234),
  (201, 1237),
  (204, 1235),
  (203, 1236),
  (202, 1237);

insert into game_rating (game_id, member_id, rating) values
  (1234, 37, 3),
  (1234, 1410, 5),
  (1236, 1410, 4),
  (1237, 1410, 4),
  (1237, 2812, 4),
  (1237, 37, 5);
__END

With this final script we are moving towards a world of generated numeric identifiers. This means that from now on our schema is different:

{:objects
 {:BoardGame
  {:description "A physical or virtual board game."
   :fields
   {:game_id {:type (non-null Int)}
    :name {:type (non-null String)}
    :rating_summary {:type (non-null :GameRatingSummary) ; allow people to add rating of a game
                     :resolve :BoardGame/rating-summary} ; this will use the :GameRating
    :summary {:type String
              :description "A one-line summary of the game."}
    :description {:type String
                  :description "A long-form description of the game."}
    :designers {:type (non-null (list :Designer))
                :description "Designers who contributed to the game."
                :resolve :BoardGame/designers}
    :min_players {:type Int
                  :description "The minimum number of players the game supports."}
    :max_players {:type Int
                  :description "The maximum number of players the game supports."}
    :play_time {:type Int
                :description "Play time, in minutes, for a typical game."}}}

  :GameRatingSummary
  {:description "Summary of ratings for a single game."
   :fields
   {:count {:type (non-null Int) ; so we cannot add constraints on the range of symbols? Weird
            :description "Number of ratings provided for the game.  Ratings are 1 to 5 stars."}
    :average {:type (non-null Float)
              :description "The average value of all ratings, or 0 if never rated."}}}

  :Member
  {:description "A member of Clojure Game Geek.  Members can rate games."
   :fields
   {:member_id {:type (non-null Int)}
    :member_name {:type (non-null String)
                  :description "Unique name of member."}
    :ratings {:type (list :GameRating)
              :description "List of games and ratings provided by this member."
              :resolve :Member/ratings}}} ; this will link members and ratings

  :GameRating
  {:description "A member's rating of a particular game."
   :fields
   {:game {:type (non-null :BoardGame)
           :description "The Game rated by the member."
           :resolve :GameRating/game} ; this will create an isomorphism
    :rating {:type (non-null Int)
             :description "The rating as 1 to 5 stars."}}}

  :Designer
  {:description "A person who may have contributed to a board game design."
   :fields
   {:designer_id {:type (non-null Int)}
    :name {:type (non-null String)}
    :url {:type String
          :description "Home page URL, if known."}
    :games {:type (non-null (list :BoardGame))
            :description "Games designed by this designer."
            :resolve :Designer/games}}}}

 :queries
 {:game_by_id
  {:type :BoardGame
   :description "Select a BoardGame by its unique id, if it exists."
   :args
   {:id {:type (non-null Int)}}
   :resolve :query/game-by-id}

  :member_by_id
  {:type :Member
   :description "Select a ClojureGameGeek Member by their unique id, if it exists."
   :args
   {:id {:type (non-null Int)}}
   :resolve :query/member-by-id}}

 :mutations ; the mutations!
 {:rate_game
  {:type :BoardGame
   :description "Establishes a rating of a board game, by a Member.

   On success (the game and member both exist), selects the BoardGame.
   Otherwise, selects nil and an error." ; errors do not have a type in GraphQL!! The rationale is that any resolver can return errors.
   :args
   {:game_id {:type (non-null Int)}
    :member_id {:type (non-null Int)}
    :rating {:type (non-null Int)
             :description "Game rating as a number between 1 and 5."}}
   :resolve :mutation/rate-game}}}}

Note kebab case is invalid in GraphQL schema. Note: JDBC defaults to a connection for operation. This is why we use an external library to handle pooling.

(ns lacinia-tutorial.db
  (:require
    [com.stuartsierra.component :as component]
    [clojure.java.jdbc :as jdbc])
  (:import (com.mchange.v2.c3p0 ComboPooledDataSource)))

(defn ^:private pooled-data-source
  [host dbname user password port]
  {:datasource
   (doto (ComboPooledDataSource.)
     (.setDriverClass "org.postgresql.Driver" )
     (.setJdbcUrl (str "jdbc:postgresql://" host ":" port "/" dbname))
     (.setUser user)
     (.setPassword password))})

(defrecord ClojureGameGeekDb [ds]

  component/Lifecycle

  (start [this]
    (assoc this
           :ds (pooled-data-source "localhost" "cggdb" "cgg_role" "lacinia" 25432)))

  (stop [this]
    (-> ds :datasource .close)
    (assoc this :ds nil)))

(defn new-db
  []
  {:db (map->ClojureGameGeekDb {})})


(defn find-game-by-id
  [component game-id]
  (first
    (jdbc/query (:ds component)
                ["select game_id, name, summary, min_players, max_players, created_at, updated_at
               from board_game where game_id = ?" game-id])))

(defn find-member-by-id
  [component member-id]
  (->> component
       :db
       deref
       :members
       (filter #(= member-id (:id %)))
       first))

(defn list-designers-for-game
  [component game-id]
  (let [designers (:designers (find-game-by-id component game-id))]
    (->> component
         :db
         deref
         :designers
         (filter #(contains? designers (:id %))))))

(defn list-games-for-designer
  [component designer-id]
  (->> component
       :db
       deref
       :games
       (filter #(-> % :designers (contains? designer-id)))))

(defn list-ratings-for-game
  [component game-id]
  (->> component
       :db
       deref
       :ratings
       (filter #(= game-id (:game_id %)))))

(defn list-ratings-for-member
  [component member-id]
  (->> component
       :db
       deref
       :ratings
       (filter #(= member-id (:member_id %)))))

(defn ^:private apply-game-rating
  [game-ratings game-id member-id rating]
  (->> game-ratings
       (remove #(and (= game-id (:game_id %))
                     (= member-id (:member_id %))))
       (cons {:game_id game-id
              :member_id member-id
              :rating rating})))

(defn upsert-game-rating
  "Adds a new game rating, or changes the value of an existing game rating."
  [db game-id member-id rating]
  (-> db
      :db
      (swap! update :ratings apply-game-rating game-id member-id rating)))

We basically setup pooling in the start of the component, and we modify our code that retrieved data to query these through SQL.

./docker-up.sh; ./setup-db.sh./docker-up.sh

Now let's try again our query:

(start)
(q "{ game_by_id(id: 1234) { game_id name summary min_players max_players }}")

Let's improve the user.clj:

(ns user
  (:require
    [com.walmartlabs.lacinia :as lacinia]
    [clojure.java.browse :refer [browse-url]]
    [lacinia-tutorial.system :as system]
    [clojure.walk :as walk]
    [com.stuartsierra.component :as component])
  (:import (clojure.lang IPersistentMap)))

(defn simplify
  "Converts all ordered maps nested within the map into standard hash maps, and
   sequences into vectors, which makes for easier constants in the tests, and eliminates ordering problems."
  [m]
  (walk/postwalk
    (fn [node]
      (cond
        (instance? IPersistentMap node)
        (into {} node)

        (seq? node)
        (vec node)

        :else
        node))
    m))

(defonce system nil)

(defn q
  [query-string]
  (-> system ; here we are deconstructing our system
      :schema-provider
      :schema
      (lacinia/execute query-string nil nil)
      simplify))

(defn start
  []
  (alter-var-root #'system (fn [_]
                             (-> (system/new-system)
                                 component/start-system)))
  (browse-url "http://localhost:8888/")
  :started)

(defn stop
  []
  (when (some? system)
    (component/stop-system system)
    (alter-var-root #'system (constantly nil)))
  :stopped)

(comment
  (start)
  (stop)
  )

Let's harder what we got with some tests.

(ns lacinia-tutorial.server
  (:require [com.stuartsierra.component :as component]
            [com.walmartlabs.lacinia.pedestal :as lp]
            [io.pedestal.http :as http]))

(defrecord Server [schema-provider server port] ; this is what we had in the user.clj before

  component/Lifecycle
  (start [this]
    (assoc this :server (-> schema-provider
                            :schema
                            (lp/service-map {:graphiql true
                                             :port port})
                            http/create-server
                            http/start)))

  (stop [this]
    (http/stop server)
    (assoc this :server nil)))

(defn new-server
  []
  {:server (component/using (map->Server {:port 8888})
                            [:schema-provider])})

This is enough configuration to configure a different server port for tests.

Then we extract utility functions:

(ns user
  (:require
    [com.walmartlabs.lacinia :as lacinia]
    [clojure.java.browse :refer [browse-url]]
    [lacinia-tutorial.system :as system]
    [lacinia-tutorial.test-utils :as tu]
    [com.stuartsierra.component :as component])
  (:import (clojure.lang IPersistentMap)))

(defonce system nil)

(defn q
  [query-string]
  (-> system ; here we are deconstructing our system
      :schema-provider
      :schema
      (lacinia/execute query-string nil nil)
      tu/simplify))

(defn start
  []
  (alter-var-root #'system (fn [_]
                             (-> (system/new-system)
                                 component/start-system)))
  (browse-url "http://localhost:8888/")
  :started)

(defn stop
  []
  (when (some? system)
    (component/stop-system system)
    (alter-var-root #'system (constantly nil)))
  :stopped)

(comment
  (start)
  (stop)
  )

(ns lacinia-tutorial.test-utils
  (:require
    [clojure.walk :as walk])
  (:import
    (clojure.lang IPersistentMap)))

(defn simplify
  "Converts all ordered maps nested within the map into standard hash maps, and
   sequences into vectors, which makes for easier constants in the tests, and eliminates ordering problems."
  [m]
  (walk/postwalk
    (fn [node]
      (cond
        (instance? IPersistentMap node)
        (into {} node)

        (seq? node)
        (vec node)

        :else
        node))
    m))

And let's add an integration test:

(ns lacinia-tutorial.system-tests
  (:require
    [clojure.test :refer [deftest is]]
    [lacinia-tutorial.system :as system]
    [lacinia-tutorial.test-utils :refer [simplify]]
    [com.stuartsierra.component :as component]
    [com.walmartlabs.lacinia :as lacinia]))

(defn ^:private test-system
  "Creates a new system suitable for testing, and ensures that
  the HTTP port won't conflict with a default running system."
  []
  (-> (system/new-system)
      (assoc-in [:server :port] 8989)))

(defn ^:private q
  "Extracts the compiled schema and executes a query."
  [system query variables]
  (-> system
      (get-in [:schema-provider :schema])
      (lacinia/execute query variables nil)
      simplify))

(deftest can-read-board-game
  (let [system (component/start-system (test-system))
        results (q system
                   "{ game_by_id(id: 1234) { name summary min_players max_players play_time }}"
                   nil)]
    (is (= {:data {:game_by_id {:max_players 2
                                :min_players 2
                                :name "Zertz"
                                :play_time nil
                                :summary "Two player abstract with forced moves and shrinking board"}}}
           results))
    (component/stop-system system)))

And let's run the test:

rm /tmp/lacinia-tutorial/test/lacinia_tutorial/core_test.clj
lein test

And with this we have seen a lot of what we can do with Lacinia!

Comments

comments powered by Disqus